fix(repository-plugin): make GitHub source preparation self-contained
This commit is contained in:
20 files changed
+454
-64
No files matched your search
@@ -14,6 +14,8 @@ export const PREPARED_ENTRY_FILENAME = 'dsh-plugin.mjs'
|
||||
export const PREPARED_ASSET_DIRECTORY = 'dsh-plugin-assets'
|
||||
/** Loader builtin used by every generated import-free wrapper. */
|
||||
export const REPOSITORY_PLUGIN_BUILTIN = 'dsh-repository-plugin'
|
||||
/** Exact host-owned command required by the repository package `prepack` lifecycle. */
|
||||
export const REPOSITORY_PLUGIN_PREPARE_COMMAND = 'dsh-plugin-prepare'
|
||||
|
||||
const sourceMetadataSchema = z.object({
|
||||
skills: z.array(z.string().min(1)).default([]),
|
||||
@@ -23,6 +25,9 @@ const sourceMetadataSchema = z.object({
|
||||
})
|
||||
const sourcePackageSchema = z.looseObject({
|
||||
name: z.string().min(1),
|
||||
scripts: z.looseObject({
|
||||
prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND),
|
||||
}),
|
||||
dsh: sourceMetadataSchema,
|
||||
})
|
||||
const preparedManifestSchema = z.object({
|
||||
@@ -148,7 +153,7 @@ export async function prepareDshPlugin(directory: string = process.cwd()): Promi
|
||||
throw new Error(`failed to read DSH plugin package metadata in ${pluginDirectory}`, { cause })
|
||||
}
|
||||
const parsed = sourcePackageSchema.safeParse(packageValue)
|
||||
if (!parsed.success) throw formatZodError('invalid package.json#dsh', parsed.error)
|
||||
if (!parsed.success) throw formatZodError('invalid DSH plugin package.json', parsed.error)
|
||||
|
||||
const sourceRoot = await realpath(dirname(pluginDirectory))
|
||||
const skillSources: string[] = []
|
||||
|
||||
@@ -20,6 +20,7 @@ import {
|
||||
} from './format.ts'
|
||||
import { parseMcpDocument, resolveMcpServers } from './mcp.ts'
|
||||
import {
|
||||
createRepositoryPrepareCommand,
|
||||
loadPreparedRepository,
|
||||
resolveRepositoryCacheDirectory,
|
||||
resolveRepositorySpecifier,
|
||||
@@ -29,6 +30,7 @@ export {
|
||||
PREPARED_ASSET_DIRECTORY,
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_BUILTIN,
|
||||
REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
prepareDshPlugin,
|
||||
type PreparedPluginManifest,
|
||||
} from './format.ts'
|
||||
@@ -129,17 +131,24 @@ 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 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)
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
for (const repository of repositories) {
|
||||
const plugin = await loadPreparedRepository(ctx, cache, repository)
|
||||
yield plugin.dispose
|
||||
}
|
||||
}, 'repository-plugin runtime and sources')
|
||||
for (const repository of repositories) {
|
||||
const plugin = await loadPreparedRepository(ctx, cache, repository)
|
||||
yield plugin.dispose
|
||||
}
|
||||
}, 'repository-plugin runtime and sources')
|
||||
} finally {
|
||||
await prepareCommand?.dispose()
|
||||
}
|
||||
}
|
||||
@@ -3,12 +3,18 @@
|
||||
* @module
|
||||
*/
|
||||
|
||||
import { mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join, resolve } from 'node:path'
|
||||
import { pathToFileURL } from 'node:url'
|
||||
import { fileURLToPath, 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'
|
||||
import { PREPARED_ENTRY_FILENAME } from './format.ts'
|
||||
import { z } from 'zod'
|
||||
import {
|
||||
PREPARED_ENTRY_FILENAME,
|
||||
REPOSITORY_PLUGIN_PREPARE_COMMAND,
|
||||
} from './format.ts'
|
||||
|
||||
// Value mirror: Cordis's const enum has no runtime object to import. Keep
|
||||
// aligned with `packages/self-modification/tool-cordis/src/fiber-state.ts`.
|
||||
@@ -17,11 +23,66 @@ 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
|
||||
// resolvable point').
|
||||
const GITHUB_SOURCE_PATTERN = /^github:([^/\s#&]+)\/([^/\s#&]+)#([^\s#&]+)(?:&path:(\/[^\s&]+))?$/
|
||||
const installedPackageSchema = z.looseObject({
|
||||
scripts: z.looseObject({
|
||||
prepack: z.literal(REPOSITORY_PLUGIN_PREPARE_COMMAND),
|
||||
}),
|
||||
})
|
||||
|
||||
function validPluginPath(path: string): boolean {
|
||||
const segments = path.split('/').slice(1)
|
||||
@@ -57,6 +118,19 @@ export function resolveRepositoryCacheDirectory(configured: string | undefined):
|
||||
return resolve(configured ?? join(resolveDshHome(), 'cache', DEFAULT_REPOSITORY_CACHE_DIRECTORY))
|
||||
}
|
||||
|
||||
async function assertInstalledPackageMetadata(directory: string): Promise<void> {
|
||||
let value: unknown
|
||||
try {
|
||||
value = JSON.parse(await readFile(join(directory, 'package.json'), 'utf8')) as unknown
|
||||
} catch (cause) {
|
||||
throw new Error(`failed to read installed DSH plugin package metadata in ${directory}`, { cause })
|
||||
}
|
||||
const result = installedPackageSchema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new Error(`installed DSH plugin package must declare scripts.prepack as ${JSON.stringify(REPOSITORY_PLUGIN_PREPARE_COMMAND)}:\n${z.prettifyError(result.error)}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load one exact repository generation's generated wrapper as a child Cordis fiber.
|
||||
* @param ctx - repository runtime context that owns the child.
|
||||
@@ -73,6 +147,7 @@ export async function loadPreparedRepository(
|
||||
const directory = await cache.resolve(specifier)
|
||||
const filename = join(directory, PREPARED_ENTRY_FILENAME)
|
||||
try {
|
||||
await assertInstalledPackageMetadata(directory)
|
||||
const plugin = await import(/* @vite-ignore */pathToFileURL(filename).href) as Plugin
|
||||
const fiber = ctx.plugin(plugin)
|
||||
await fiber
|
||||
|
||||
Reference in New Issue
Block a user