fix(repository-plugin): require published prepare dependency

This commit is contained in:
Tianyi Cui
2026-08-09 00:16:14 +08:00
parent e91ff6d499
commit 29b3e3fa84
26 changed files with 319 additions and 243 deletions
@@ -14,11 +14,11 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
/** Loader builtin used by every generated repository wrapper. */
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
/** Host-owned command that repository package `prepack` lifecycles must invoke. */
/** Dependency-provided command that repository package `prepack` lifecycles must invoke. */
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
/**
* Whether a package lifecycle declaration names the host preparation helper.
* Whether a package lifecycle declaration names the preparation dependency's helper.
* @param script - package-authored lifecycle command.
* @returns true when the required helper command is present.
*/
@@ -20,7 +20,6 @@ import {
} from './format.ts'
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
import {
createRepositoryPrepareCommand,
loadPreparedRepository,
resolveRepositoryCacheDirectory,
resolveRepositorySpecifier,
@@ -131,24 +130,17 @@ export async function apply(ctx: Context, config: Config = {}): Promise<void> {
if (new Set(repositories).size !== repositories.length) {
throw new Error('repository sources must resolve to unique exact specifiers')
}
const prepareCommand = repositories.length === 0 ? undefined : await createRepositoryPrepareCommand()
try {
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir), {
executableDirectories: prepareCommand === undefined ? [] : [prepareCommand.directory],
})
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
const cache = new RepositoryCache(resolveRepositoryCacheDirectory(config.cacheDir))
await ctx.effect(async function* () {
ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] = preparedRuntime
yield () => {
if (ctx.loader.builtins[REPOSITORY_PLUGIN_BUILTIN] === preparedRuntime) {
Reflect.deleteProperty(ctx.loader.builtins, REPOSITORY_PLUGIN_BUILTIN)
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
} finally {
await prepareCommand?.dispose()
}
}
for (const repository of repositories) {
const plugin = await loadPreparedRepository(ctx, cache, repository)
yield plugin.dispose
}
}, 'repository-plugin runtime and sources')
}
@@ -3,10 +3,9 @@
* @module
*/
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { readFile } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { fileURLToPath, pathToFileURL } from 'node:url'
import { pathToFileURL } from 'node:url'
import type { Context, Fiber, FiberState, Plugin } from 'cordis'
import type { RepositoryCache } from '@cordisjs/plugin-loader/repository'
import { resolveDshHome } from '@deepseek-ai/dsh-paths'
@@ -24,56 +23,6 @@ const FIBER_ACTIVE = 2 as FiberState.ACTIVE
/** Directory under the Harness home containing immutable repository generations. */
export const DEFAULT_REPOSITORY_CACHE_DIRECTORY = 'repository-plugins'
/** Temporary host command supplied to repository package lifecycle scripts. */
export interface RepositoryPrepareCommand {
/** Absolute directory to prepend to the isolated install's executable search path. */
directory: string
/** Remove the temporary command directory. */
dispose(): Promise<void>
}
function shellQuote(value: string): string {
return `'${value.replaceAll("'", "'\\''")}'`
}
function batchQuote(value: string): string {
return `"${value.replaceAll('%', '%%')}"`
}
/**
* Materialize the DSH-owned prepare executable used only while pnpm packs Git source.
* @returns a command directory and its idempotent cleanup operation.
*/
export async function createRepositoryPrepareCommand(): Promise<RepositoryPrepareCommand> {
const directory = await mkdtemp(join(tmpdir(), 'dsh-repository-plugin-bin-'))
const target = fileURLToPath(new URL('../lib/bin.js', import.meta.url))
try {
await Promise.all([
writeFile(join(directory, REPOSITORY_PLUGIN_PREPARE_COMMAND), [
'#!/bin/sh',
`exec ${shellQuote(process.execPath)} ${shellQuote(target)} "$@"`,
'',
].join('\n'), { mode: 0o700 }),
writeFile(join(directory, `${REPOSITORY_PLUGIN_PREPARE_COMMAND}.cmd`), [
'@echo off',
`${batchQuote(process.execPath)} ${batchQuote(target)} %*`,
'',
].join('\r\n'), { mode: 0o700 }),
])
} catch (cause) {
/* v8 ignore next -- requires a host filesystem failure after mkdtemp; cleanup semantics are the contract under test. */
await rm(directory, { recursive: true, force: true })
/* v8 ignore next -- preserves that unstageable host failure after best-effort cleanup. */
throw cause
}
return {
directory,
async dispose() {
await rm(directory, { recursive: true, force: true })
},
}
}
// The ref segment excludes `#` so `github:o/r#a#b` fails here — at the config
// parser, with the syntax the error message promises — instead of inside the
// cache's pnpm install ('misconfiguration fails loud at the earliest