Files
deepseek-harness/packages/boot/plugin-manager/src/operations.ts
T
Yichen Jiang cb2750019a fix(plugin-manager): read pnpm's own registry, its stdout refusals, and lay failures where they belong
Review of #4539. A refusal pnpm view prints as JSON on stdout was read
as unknown, so no registry was ever asked after a dead one; stderr and
stdout are now read alike, and a lookup killed at its bound is a
network refusal. pnpm's own registry joins the fallback plan only once
pnpm config get registry names npm's own or a configured fallback, so
a company registry in .npmrc is never followed by a public one, and a
registry pnpm already names is asked once. The Host lays each failure
at the registry or the spec's own host, reading the host off error
lines only, and reports it as ChangeResult.failedAt; a stop that lands
between attempts is cancelled, not failed. The package publishes its
registry rule through ./registry so the dialog plans with the Host's
own function, starts from the registry the Host asks first until one
is remembered, tags that one as the default, labels pnpm's own by the
host it names, and words and offers a change of registry from the
Host's attribution. ERR_PNPM_NO_OFFLINE_META is no longer a network
failure.
2026-09-18 17:03:04 +08:00

270 lines
13 KiB
TypeScript

/** Shared profile package operations used by dsh plugin and the running manager. */
import { existsSync } from 'node:fs'
import { mkdir, mkdtemp, open } from 'node:fs/promises'
import { join, resolve } from 'node:path'
import { execa } from 'execa'
import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
import {
DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest,
resolveBundleDir, resolveProfileDir, loadOverlayPatches, type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
import type { PackageResult, Registry } from './types.ts'
/** Profile and invocation locations supplied by the launcher. */
export interface PackageOperationContext {
profile: string
/** Explicit directory for an application-owned profile; named CLI profiles resolve under home. */
dir?: string
installAnchor: string
cwd: string
home?: string
}
/** Output and cancellation policy for one pnpm operation. */
export interface PackageOperationOptions {
/** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. Defaults to `pnpm`. */
command?: string
/** Prefix arguments for an application-owned executable. */
args?: readonly string[]
/** Application runtime environment, applied only to this package operation. */
env?: Readonly<Record<string, string>>
/** CLI inherits authentication and terminal descriptors; service scrubs secrets and captures output. */
execution: 'cli' | 'service'
signal?: AbortSignal
outputBytes: number
onOutput?: (text: string, stream: 'stdout' | 'stderr') => void
activateNewBundles?: boolean
lockWaitMs?: number
}
/** Resolve relative package specs against the caller's directory.
* @param argument One pnpm argument.
* @param cwd Invocation directory, never the profile directory.
* @returns Anchored argument.
*/
export function anchorPathSpec(argument: string, cwd: string): string {
const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
if (match?.groups?.path === undefined) return argument
return `${match.groups.prefix ?? ''}${resolve(cwd, match.groups.path)}`
}
/** Read bundle metadata without loading its JavaScript.
* @param name Installed dependency or installation-owned package name.
* @param dir Profile directory.
* @param anchor Installation manifest.
* @returns Resolved metadata, or undefined for packages without bundle metadata.
*/
export function bundleManifest(name: string, dir: string, anchor: string): ProfileManifest | undefined {
const packageDir = resolveBundleDir('dsh', name, anchor, dir)
const manifest = readProfileManifest('dsh', packageDir)
return manifest.dsh?.bundle?.patch === undefined ? undefined : manifest
}
/** Atomically save a profile manifest while retaining unrelated fields.
* @param dir Profile directory.
* @param manifest Updated document.
*/
export async function saveManifest(dir: string, manifest: ProfileManifest): Promise<void> {
await writeFileAtomic(join(dir, 'package.json'), JSON.stringify(manifest, undefined, 2) + '\n', { mode: 0o600 })
}
/** Reconcile package removals and newly installed bundles without re-enabling retained dependencies. */
async function reconcile(before: ProfileManifest, dir: string, anchor: string, options: PackageOperationOptions): Promise<void> {
const after = readProfileManifest('dsh', dir)
const dependencies = Object.keys(after.dependencies ?? {})
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
const previous = after.dsh?.profile?.bundles ?? []
const bundles = previous.filter((name) => {
if (!beforeDeps.has(name) && !dependencies.includes(name)) return true
return dependencies.includes(name) && bundleManifest(name, dir, anchor) !== undefined
})
for (const name of dependencies) {
if (beforeDeps.has(name)) continue
const metadata = bundleManifest(name, dir, anchor)
if (metadata?.dsh?.bundle === undefined) {
options.onOutput?.(`dsh: warning: ${name} declares no dsh.bundle — installed as a plain dependency, not a profile layer\n`, 'stderr')
continue
}
loadOverlayPatches('dsh', join(resolveBundleDir('dsh', name, anchor, dir), metadata.dsh.bundle.patch))
if (!bundles.includes(name)) {
bundles.push(name)
}
}
if (JSON.stringify(previous) === JSON.stringify(bundles)) return
after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles } }
await saveManifest(dir, after)
}
/** Execute pnpm inside a profile whose caller already holds the profile write lock.
* @param context Launcher-owned profile and resolution locations.
* @param args Pnpm arguments, before relative path anchoring.
* @param options Output, activation and cancellation policy.
* @returns Exit status and diagnostic path; service output is bounded, CLI output uses inherited descriptors.
*/
export async function runProfilePnpm(
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
): Promise<PackageResult> {
const dir = context.dir ?? resolveProfileDir(context.profile, context.home)
const before = readProfileManifest('dsh', dir)
const logRoot = join(dir, '.plugin-manager', 'logs')
await mkdir(logRoot, { recursive: true, mode: 0o700 })
const logDir = await mkdtemp(join(logRoot, 'operation-'))
const logPath = join(logDir, 'pnpm.log')
const log = await open(logPath, 'wx', 0o600)
let output = Buffer.alloc(0)
let truncated = false
const cancellation = new AbortController()
const child = execa(options.command ?? 'pnpm', [...options.args ?? [], ...args.map(arg => anchorPathSpec(arg, context.cwd))], {
cwd: dir, env: { ...(options.execution === 'cli' ? process.env : scrubbedParentEnv()), ...options.env }, extendEnv: false, reject: false,
stdout: options.execution === 'cli' ? 'inherit' : 'pipe',
stderr: options.execution === 'cli' ? 'inherit' : 'pipe',
buffer: false, stdin: options.execution === 'cli' ? 'inherit' : 'ignore', cancelSignal: options.signal === undefined
? cancellation.signal : AbortSignal.any([cancellation.signal, options.signal]),
})
let writes = Promise.resolve()
const collect = async (stream: AsyncIterable<Buffer | string>, kind: 'stdout' | 'stderr') => {
try {
for await (const chunk of stream) {
const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)
writes = writes.then(async () => { await log.write(bytes) })
await writes
options.onOutput?.(bytes.toString('utf8'), kind)
output = Buffer.concat([output, bytes])
if (output.length > options.outputBytes) {
truncated = true
output = output.subarray(output.length - options.outputBytes)
}
}
} catch (error) {
cancellation.abort()
throw error
}
}
let exitCode: number
try {
const [completion, ...streams] = await Promise.allSettled([child,
...child.stdout === null ? [] : [collect(child.stdout, 'stdout')],
...child.stderr === null ? [] : [collect(child.stderr, 'stderr')],
])
for (const stream of streams) if (stream.status === 'rejected') throw stream.reason
if (completion.status === 'rejected') throw completion.reason
const result = completion.value
exitCode = result.exitCode ?? (result.code === 'ENOENT' ? 127 : 1)
if (result.failed && output.length === 0) {
const diagnostic = result.shortMessage ?? 'pnpm failed'
await log.write(diagnostic)
truncated = Buffer.byteLength(diagnostic) > options.outputBytes
output = Buffer.from(diagnostic).subarray(0, options.outputBytes)
}
if (exitCode === 0 && options.activateNewBundles !== false) await reconcile(before, dir, context.installAnchor, options)
} finally {
await log.close()
}
return { exitCode, output: output.toString('utf8'), truncated, logPath }
}
/** Initialize and run the dsh plugin command with the same write lock as the service.
* @param context Launcher-owned locations.
* @param args Pnpm arguments.
* @param options Output and cancellation policy.
* @returns Completed package-manager result.
*/
export async function runPluginCommand(
context: PackageOperationContext, args: readonly string[], options: PackageOperationOptions,
): Promise<PackageResult> {
const dir = context.dir ?? resolveProfileDir(context.profile, context.home)
await mkdir(dir, { recursive: true })
return withFileLock(join(dir, 'package.json'), async () => {
if (!existsSync(join(dir, 'package.json'))) {
const template = PROFILE_TEMPLATES[context.profile]
initProfile(dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES)
options.onOutput?.(`dsh: initialized profile ${context.profile} at ${dir}\n`, 'stderr')
}
return runProfilePnpm(context, args, options)
}, options.lockWaitMs === undefined ? undefined : { waitMs: options.lockWaitMs })
}
/** What one registry lookup answered. */
export interface PackageViewResult {
/** pnpm's exit code, null when it ended without one or never started. */
exitCode: number | null
stdout: string
stderr: string
/** The lookup ran past its bound and was killed. */
timedOut: boolean
/** The failure of starting pnpm at all, when that is what happened. */
cause?: unknown
}
/** Bounds of one registry lookup. */
export interface PackageViewOptions {
/** The pnpm executable name or path. Defaults to `pnpm`. */
command?: string
/** Prefix arguments for an application-owned executable. */
args?: readonly string[]
/** Application runtime environment, applied only to this package operation. */
env?: Readonly<Record<string, string>>
/** Ends the lookup early; the caller's signal, when it has one. */
signal?: AbortSignal
/** Bound on the lookup, in milliseconds. */
timeoutMs: number
/** The registry asked; null asks the one pnpm's own configuration names. */
registry?: Registry
}
/**
* Read the registry pnpm's own configuration names in the profile: its `.npmrc` chain and workspace settings,
* as `pnpm config get registry` resolves them.
* @param dir Profile directory.
* @param options The pnpm executable and the time bound.
* @returns The registry URL as pnpm printed it, or null when pnpm did not answer with one.
*/
export async function readProfileRegistry(
dir: string, options: { command?: string; args?: readonly string[]; env?: Readonly<Record<string, string>>; timeoutMs: number },
): Promise<string | null> {
const result = await execa(options.command ?? 'pnpm', [...options.args ?? [], 'config', 'get', 'registry'], {
cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore', timeout: options.timeoutMs,
})
// The registry is the last line: pnpm may print a notice before it.
const answer = result.exitCode === 0 ? result.stdout.trim().replace(/^[\s\S]*\n/, '').trim() : ''
return /^https?:\/\/\S+$/.test(answer) ? answer : null
}
/**
* The argument that sends one pnpm command to a registry.
* @param registry - the registry, or null for the one pnpm's own configuration names.
* @returns `--registry=<url>` for a URL; nothing for null.
*/
export function registryArguments(registry: Registry): string[] {
return registry === null ? [] : [`--registry=${registry}`]
}
/**
* Ask the registry what a spec names through `pnpm view`, run in the profile
* directory so the registry, proxy, and authentication settings of an install
* apply. The lookup makes one request without pnpm's own retries: a registry
* that does not answer is reported within `timeoutMs`, and the registries
* configured after it are the retry.
* @param dir Profile directory.
* @param spec One registry spec: a package name with an optional range.
* @param options The registry, cancellation, and the time bound.
* @returns pnpm's exit, output, and how the lookup ended.
*/
export async function viewProfilePackage(dir: string, spec: string, options: PackageViewOptions): Promise<PackageViewResult> {
const result = await execa(options.command ?? 'pnpm', [
...options.args ?? [], 'view', spec, 'name', 'version', 'description', 'dsh', '--json',
...registryArguments(options.registry ?? null), '--config.fetch-retries=0',
], {
cwd: dir, env: { ...scrubbedParentEnv(), ...options.env }, extendEnv: false, reject: false, stdin: 'ignore',
timeout: options.timeoutMs, ...options.signal === undefined ? {} : { cancelSignal: options.signal },
})
const cause = result.exitCode === undefined && !result.timedOut && !result.isCanceled
? Object.assign(new Error(result.shortMessage), { code: result.code })
: undefined
return {
exitCode: result.exitCode ?? null, stdout: result.stdout, stderr: result.stderr, timedOut: result.timedOut,
...cause === undefined ? {} : { cause },
}
}