Review direction (tianyicui, PR #660): in a stacked PR, change all other process-running places to use the new service. - lsp-local: LspConnection spawns through ctx.subprocess (piped protocol streams + a no-spill collected stderr tail); its private process-tree helpers (POSIX group signalling, Windows taskkill, liveness polling) are deleted in favor of the seam's handle verbs, and its buildChildEnv now rides scrubbedParentEnv (LSP children also stop inheriting stale DSH_*). The plugin injects 'subprocess'; compositions/tests mount dsh-subprocess-local. - subagent-acp: the ACP child spawns through the seam (piped ndjson streams, inherited stderr); spawn failure surfaces through done-rejection into the same startup race; disposal is handle.dispose with the plugin's configured graces. dsh-subagent-subprocess is DELETED — its dispose ladder and scrub are the seam's, and the isolated-config-dir helper had no consumer. - mcp-client, pty-local, sdk-helper: adopt scrubbedParentEnv as the one scrub definition (their spawns stay put by ownership: the MCP SDK and node-pty own those calls; the SDK wizard runs outside any composition). - Coverage: per-file 100% over every touched src file, with each v8 ignore carrying a platform or contract reason; new suites cover stdio dispositions, the dispose ladder tiers, injected-win32 tree semantics, waitForExit, settled-kill/terminate no-ops, and spawn-failure disposal. - Docs: consumer-migration Agent Note (en; zh follows in this PR), seam note updated in place, subprocess.md rewritten for the reshaped vocabulary (type-equiv re-registered), READMEs and SERVICE_ROLES updated, taskkill added to knip ignoreBinaries.
333 lines
12 KiB
TypeScript
333 lines
12 KiB
TypeScript
/**
|
|
* Package-manager strategies for SDK project workspaces and child commands.
|
|
*
|
|
* @module @deepseek-ai/dsh-helper/package-managers/package-manager
|
|
*/
|
|
|
|
import { execFile, spawn } from 'node:child_process'
|
|
import { scrubbedParentEnv } from '@deepseek-ai/dsh-subprocess'
|
|
import { promisify } from 'node:util'
|
|
import type { PackageJsonFile } from '../documents/package-json-file.ts'
|
|
import { PnpmWorkspaceFile } from '../documents/pnpm-workspace-file.ts'
|
|
import type { ProjectFile } from '../documents/project-file.ts'
|
|
|
|
/** Supported generated-project package managers. */
|
|
export type PackageManagerName = 'npm' | 'pnpm' | 'yarn'
|
|
|
|
/** Result from one child package-manager process. */
|
|
export interface CommandResult {
|
|
exitCode: number | null
|
|
signal: NodeJS.Signals | null
|
|
}
|
|
|
|
/** Injectable subprocess boundary used by package-manager strategies. */
|
|
export interface CommandRunner {
|
|
/** Run one executable without a shell and await process exit. */
|
|
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult>
|
|
}
|
|
|
|
/** Injectable package-manager version probe used by project creation. */
|
|
export type PackageManagerVersionProbe = (name: PackageManagerName, cwd: string) => Promise<string>
|
|
|
|
const execFileAsync = promisify(execFile)
|
|
|
|
/**
|
|
* Read a manager version without forwarding ambient credentials.
|
|
* @param name - package-manager executable.
|
|
* @param cwd - working directory used for resolution.
|
|
* @returns trimmed version output.
|
|
*/
|
|
export async function probePackageManagerVersion(name: PackageManagerName, cwd: string): Promise<string> {
|
|
try {
|
|
const { stdout } = await execFileAsync(name, ['--version'], {
|
|
cwd,
|
|
env: scrubEnvironment(),
|
|
encoding: 'utf8',
|
|
})
|
|
const version = stdout.trim()
|
|
if (!version) throw new Error('empty version output')
|
|
return version
|
|
} catch (error) {
|
|
throw new Error(`cannot run ${name} --version: ${String(error)}`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Remove credential-shaped environment variables from spawned commands.
|
|
* @param environment - source environment (injectable for tests); the default
|
|
* path shares the subprocess seam's scrub so every harness spawner drops the
|
|
* same names.
|
|
*/
|
|
export function scrubEnvironment(environment?: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
|
|
if (environment === undefined) return scrubbedParentEnv()
|
|
return Object.fromEntries(Object.entries(environment).filter(([name]) => !/(?:KEY|SECRET|TOKEN)/i.test(name)))
|
|
}
|
|
|
|
/** Node child-process command runner with inherited stdio and quiescent completion. */
|
|
export class NodeCommandRunner implements CommandRunner {
|
|
private readonly output: NodeJS.WritableStream | undefined
|
|
|
|
/**
|
|
* @param output - redirect target for child stdout+stderr; the child inherits
|
|
* this process's stdio when absent. Callers whose own stdout carries a machine
|
|
* protocol (create-sdk --json NDJSON) redirect child output to keep the
|
|
* protocol stream pure.
|
|
*/
|
|
constructor(output?: NodeJS.WritableStream) {
|
|
this.output = output
|
|
}
|
|
|
|
/** Spawn one child and settle only after exit, with redirected stdio drained. */
|
|
run(command: string, args: readonly string[], cwd: string): Promise<CommandResult> {
|
|
return new Promise((resolve, reject) => {
|
|
const output = this.output
|
|
if (output === undefined) {
|
|
const child = spawn(command, [...args], { cwd, env: scrubEnvironment(), stdio: 'inherit', shell: false })
|
|
child.once('error', reject)
|
|
child.once('exit', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
|
return
|
|
}
|
|
const child = spawn(command, [...args], {
|
|
cwd,
|
|
env: scrubEnvironment(),
|
|
stdio: ['inherit', 'pipe', 'pipe'],
|
|
shell: false,
|
|
})
|
|
child.stdout.pipe(output, { end: false })
|
|
child.stderr.pipe(output, { end: false })
|
|
child.once('error', reject)
|
|
child.once('close', (exitCode, signal) => { resolve({ exitCode, signal }) })
|
|
})
|
|
}
|
|
}
|
|
|
|
function major(version: string): number {
|
|
const match = /^(\d+)/.exec(version)
|
|
if (!match?.[1]) throw new Error(`invalid package manager version: ${JSON.stringify(version)}`)
|
|
return Number(match[1])
|
|
}
|
|
|
|
/** Behavior owned by one generated-project package manager. */
|
|
export abstract class PackageManager {
|
|
/** Manager executable and project identity. */
|
|
abstract readonly name: PackageManagerName
|
|
|
|
/** Detected concrete manager version. */
|
|
readonly version: string
|
|
|
|
constructor(version: string) {
|
|
this.version = version
|
|
}
|
|
|
|
/** Validate the detected version against this SDK's supported floor. */
|
|
abstract validateVersion(): void
|
|
|
|
/**
|
|
* Configure root manifest fields and return manager-specific files.
|
|
* @param manifest - generated root manifest to update.
|
|
* @returns manager-specific companion documents.
|
|
*/
|
|
abstract configureWorkspace(manifest: PackageJsonFile): ProjectFile[]
|
|
|
|
/**
|
|
* Build the NPM dependency spec for a local workspace plugin.
|
|
* @returns manager-specific local NPM dependency spec.
|
|
*/
|
|
abstract localPluginSpec(): string
|
|
|
|
/**
|
|
* Resolve a repository live-link NPM dependency.
|
|
* @param relativePath - relative path from generated project to package.
|
|
* @returns manager-specific NPM dependency spec.
|
|
*/
|
|
abstract linkSpec(relativePath: string): string
|
|
|
|
/**
|
|
* Build install command arguments.
|
|
* @returns arguments following the manager executable.
|
|
*/
|
|
installCommand(): readonly string[] {
|
|
return ['install']
|
|
}
|
|
|
|
/**
|
|
* Build project-build command arguments.
|
|
* @returns arguments following the manager executable.
|
|
*/
|
|
buildCommand(): readonly string[] {
|
|
return ['run', 'build']
|
|
}
|
|
|
|
/**
|
|
* Run NPM dependency installation and fail on non-zero or signalled exit.
|
|
* @param cwd - generated project directory.
|
|
* @param runner - optional subprocess boundary.
|
|
*/
|
|
async install(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
|
await this.runChecked(runner, this.installCommand(), cwd, 'install')
|
|
}
|
|
|
|
/**
|
|
* Run the project build and fail on non-zero or signalled exit.
|
|
* @param cwd - generated project directory.
|
|
* @param runner - optional subprocess boundary.
|
|
*/
|
|
async build(cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
|
await this.runChecked(runner, this.buildCommand(), cwd, 'build')
|
|
}
|
|
|
|
/**
|
|
* Build add-dependency command arguments for one already-normalized source spec.
|
|
* @param spec - a package-manager-native dependency source (`pkg@version` or `github:owner/repo#ref`).
|
|
* @returns arguments following the manager executable.
|
|
*/
|
|
addCommand(spec: string): readonly string[] {
|
|
return ['add', spec]
|
|
}
|
|
|
|
/**
|
|
* Add one dependency from a native source spec and fail on non-zero or signalled exit.
|
|
* @param spec - a package-manager-native dependency source.
|
|
* @param cwd - project directory.
|
|
* @param runner - optional subprocess boundary.
|
|
*/
|
|
async add(spec: string, cwd: string, runner: CommandRunner = new NodeCommandRunner()): Promise<void> {
|
|
await this.runChecked(runner, this.addCommand(spec), cwd, 'add')
|
|
}
|
|
|
|
private async runChecked(runner: CommandRunner, args: readonly string[], cwd: string, operation: string): Promise<void> {
|
|
const result = await runner.run(this.name, args, cwd)
|
|
if (result.signal !== null) {
|
|
throw new Error(`${this.name} ${operation} was killed by ${result.signal}`)
|
|
}
|
|
if (result.exitCode !== 0) {
|
|
throw new Error(`${this.name} ${operation} exited with code ${String(result.exitCode)}`)
|
|
}
|
|
}
|
|
}
|
|
|
|
/** npm workspace behavior. */
|
|
export class NpmPackageManager extends PackageManager {
|
|
override readonly name = 'npm'
|
|
|
|
/** npm 10 is the supported floor at the repository's Node floor. */
|
|
override validateVersion(): void {
|
|
if (major(this.version) < 10) throw new Error(`npm >=10 is required, got ${this.version}`)
|
|
}
|
|
|
|
/** Configure package.json workspaces; npm needs no companion file. */
|
|
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
|
manifest.addWorkspace('plugins/*')
|
|
manifest.setPackageManager(undefined)
|
|
return []
|
|
}
|
|
|
|
/** npm resolves workspace packages through its ordinary wildcard. */
|
|
override localPluginSpec(): string {
|
|
return '*'
|
|
}
|
|
|
|
/** npm live links use file NPM dependencies. */
|
|
override linkSpec(relativePath: string): string {
|
|
return `file:${relativePath}`
|
|
}
|
|
|
|
/** npm adds a dependency through `install <spec>` rather than an `add` verb. */
|
|
override addCommand(spec: string): readonly string[] {
|
|
return ['install', spec]
|
|
}
|
|
}
|
|
|
|
/** pnpm workspace behavior. */
|
|
export class PnpmPackageManager extends PackageManager {
|
|
override readonly name = 'pnpm'
|
|
|
|
/** pnpm 10 is the supported floor for strict NPM dependency-build policy. */
|
|
override validateVersion(): void {
|
|
if (major(this.version) < 10) throw new Error(`pnpm >=10 is required, got ${this.version}`)
|
|
}
|
|
|
|
/** Configure packageManager and a structured pnpm workspace file. */
|
|
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
|
manifest.setPackageManager(`pnpm@${this.version}`)
|
|
const workspace = PnpmWorkspaceFile.create()
|
|
workspace.addPackage('plugins/*')
|
|
return [workspace]
|
|
}
|
|
|
|
/** pnpm uses its explicit workspace protocol. */
|
|
override localPluginSpec(): string {
|
|
return 'workspace:*'
|
|
}
|
|
|
|
/** pnpm live links use link NPM dependencies. */
|
|
override linkSpec(relativePath: string): string {
|
|
return `link:${relativePath}`
|
|
}
|
|
}
|
|
|
|
/** Yarn Berry-compatible workspace behavior. */
|
|
export class YarnPackageManager extends PackageManager {
|
|
override readonly name = 'yarn'
|
|
|
|
/** Yarn classic is excluded because the generated project relies on modern workspaces. */
|
|
override validateVersion(): void {
|
|
if (major(this.version) < 2) throw new Error(`Yarn >=2 is required, got ${this.version}`)
|
|
}
|
|
|
|
/** Configure packageManager and package.json workspaces. */
|
|
override configureWorkspace(manifest: PackageJsonFile): ProjectFile[] {
|
|
manifest.addWorkspace('plugins/*')
|
|
manifest.setPackageManager(`yarn@${this.version}`)
|
|
return []
|
|
}
|
|
|
|
/** Modern Yarn uses the workspace protocol. */
|
|
override localPluginSpec(): string {
|
|
return 'workspace:*'
|
|
}
|
|
|
|
/** Yarn live links use portal NPM dependencies to preserve package identity. */
|
|
override linkSpec(relativePath: string): string {
|
|
return `portal:${relativePath}`
|
|
}
|
|
|
|
/** Yarn runs scripts without the `run` token. */
|
|
override buildCommand(): readonly string[] {
|
|
return ['build']
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Construct and validate one package-manager strategy.
|
|
* @param name - selected manager.
|
|
* @param version - detected concrete version.
|
|
* @returns validated strategy.
|
|
*/
|
|
export function createPackageManager(name: PackageManagerName, version: string): PackageManager {
|
|
let manager: PackageManager
|
|
switch (name) {
|
|
case 'npm': manager = new NpmPackageManager(version); break
|
|
case 'pnpm': manager = new PnpmPackageManager(version); break
|
|
case 'yarn': manager = new YarnPackageManager(version); break
|
|
}
|
|
manager.validateVersion()
|
|
return manager
|
|
}
|
|
|
|
/**
|
|
* Infer a package manager from an explicit choice or npm user-agent value.
|
|
* @param explicit - explicit CLI selection.
|
|
* @param userAgent - npm-compatible user-agent string.
|
|
* @returns selected or inferred manager name.
|
|
*/
|
|
export function inferPackageManagerName(
|
|
explicit: PackageManagerName | undefined,
|
|
userAgent: string | undefined = process.env.npm_config_user_agent,
|
|
): PackageManagerName | undefined {
|
|
if (explicit) return explicit
|
|
const token = userAgent?.split(' ')[0]?.split('/')[0]
|
|
if (token === 'npm' || token === 'pnpm' || token === 'yarn') return token
|
|
return undefined
|
|
}
|