Files
deepseek-harness/packages/boot/plugin-manager/src/installer.ts
T
Yichen Jiang e0841bec01 refactor(plugin-manager): split the manager module by responsibility
`src/index.ts` held the installer, the manager, the view fold, and every
helper in one 1100-line file. It is now the package API alone, re-exporting
from `installer.ts` (pnpm runs, the probe cache, post-install checks),
`manager.ts` (every operation over the booted profile and the mutex),
`view.ts` (the package view fold and the row-ownership walk the dependents
query shares), `modules.ts` (declared-module row naming and views), and
`helpers.ts` (the diagnostic prefix, tooling bounds, spawn seam, and manifest
readers). The public surface, the tests, and the failure codes are unchanged.
2026-09-07 19:30:44 +08:00

289 lines
12 KiB
TypeScript

/**
* Installing and removing packages in one profile with pnpm, and probing
* them, over the profile on disk: nothing here touches a running tree, which
* is what lets the `dsh plugin` command install before any plugin starts.
* @module @deepseek-ai/dsh-plugin-manager/installer
*/
import { spawn as spawnChild } from 'node:child_process'
import { randomUUID } from 'node:crypto'
import { readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join } from 'node:path'
import {
awaitChildClose,
claimLayerIds,
healProfilesModuleFallback,
PLUGIN_PROBE_DIR,
probePackage,
readProbeCache,
readProfileManifest,
reconcileInstalledBundles,
resolveBundleDir,
resolveProfileLayer,
writeProbeCache,
type PluginProbe,
type Profile,
type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { PluginOperationError } from './errors.ts'
import { dependenciesOf, messageOf, NAME, type PluginToolingConfig, type SpawnLike } from './helpers.ts'
import type { PluginInstallLogChunk, PluginInstallRejection, PluginInstallResult } from './types.ts'
/** What one install run changed before any newly installed bundle was enabled. */
export type PluginInstallOutcome = Omit<PluginInstallResult, 'enabled'>
/** What {@link PluginInstaller} needs: the profile on disk, the tooling bounds, and where pnpm's output goes. */
export interface PluginInstallerOptions {
/** The profile directory pnpm runs in and whose manifest records installs. */
readonly profileDir: string
/** The profile name, for diagnostics. */
readonly profileName: string
/** Absolute path of the dsh app's package.json: the first resolution anchor for installed packages. */
readonly installAnchor: string
/** The profile's composed layers, read when a new bundle's row ids are checked against them. */
readonly loadProfile: () => Profile
readonly config: PluginToolingConfig
/** Receives every chunk of a pnpm run's output, in order; the last chunk carries the exit code. */
readonly installLog: (chunk: PluginInstallLogChunk) => void
/** Test seam: the child spawner; defaults to `node:child_process`. */
readonly spawn?: SpawnLike
/** Test seam: the package probe; defaults to app-boot's. */
readonly probe?: typeof probePackage
}
/** The installed package's manifest slice the view reads. */
export type InstalledManifest = ProfileManifest & { description?: string; dsh?: ProfileManifest['dsh'] & { title?: string } }
/**
* Installs and removes packages in one profile with pnpm, and probes them.
*
* Every run reads the profile manifest afresh and writes it through the
* app-boot helpers the `dsh plugin` command uses, so the CLI and the Web
* host never disagree on the file: `dependencies` records what is installed,
* `dsh.profile.bundles` what is enabled. Nothing here touches a running
* tree, which is what lets the CLI install before any plugin starts.
*/
export class PluginInstaller {
private readonly spawn: SpawnLike
private readonly probeRunner: typeof probePackage
/**
* @param options - the profile, the tooling bounds, and the output sink.
*/
constructor(private readonly options: PluginInstallerOptions) {
this.spawn = options.spawn ?? spawnChild
this.probeRunner = options.probe ?? probePackage
}
/**
* Whether the package is a profile dependency.
* @param packageName - the package.
* @returns true when the manifest lists it.
*/
isInstalled(packageName: string): boolean {
return packageName in dependenciesOf(readProfileManifest(NAME, this.options.profileDir))
}
/**
* Refuse a package the profile does not have.
* @param packageName - the package.
* @throws {PluginOperationError} `plugins/not-installed`.
*/
assertInstalled(packageName: string): void {
if (this.isInstalled(packageName)) return
throw new PluginOperationError(
'plugins/not-installed',
`${NAME}: ${packageName} is not installed in profile ${this.options.profileName}`,
{ packageName },
)
}
/**
* The installed package's manifest.
* @param packageName - the package.
* @returns the manifest, or undefined when the package cannot be resolved from the profile.
*/
readInstalledManifest(packageName: string): InstalledManifest | undefined {
let dir: string
try {
dir = resolveBundleDir(NAME, packageName, this.options.installAnchor, this.options.profileDir)
} catch {
// A dependency pnpm removed underneath the manifest, or one that never
// materialized; the caller reports what the manifest still says.
return undefined
}
// resolveBundleDir answers only a directory holding a package.json.
return JSON.parse(readFileSync(join(dir, 'package.json'), 'utf8')) as InstalledManifest
}
/**
* The package's probe record, from the profile cache or a fresh probe
* written to it. A record for another installed version is not reused.
* @param packageName - the installed package.
* @returns the record.
*/
async probe(packageName: string): Promise<PluginProbe> {
const version = this.readInstalledManifest(packageName)?.version
const cached = readProbeCache(this.options.profileDir, packageName, version)
if (cached !== undefined) return cached
const probe = await this.probeRunner({
binName: NAME,
profileDir: this.options.profileDir,
installAnchor: this.options.installAnchor,
packageName,
timeoutMs: this.options.config.probeTimeoutMs,
})
writeProbeCache(this.options.profileDir, probe)
return probe
}
/**
* Install a package with pnpm, probe every package the run added, and
* remove what has no place in a profile. New bundles are left disabled.
* @param spec - what to install, in pnpm's own vocabulary: a registry
* name, a `github:` or git URL, a tarball, or an absolute path.
* @returns what the run installed and what it removed again.
* @throws {PluginOperationError} `plugins/bad-request` for an empty spec,
* `plugins/install-failed` when pnpm exits non-zero, cannot be spawned, or times out.
*/
async add(spec: string): Promise<PluginInstallOutcome> {
if (spec.trim().length === 0) {
throw new PluginOperationError('plugins/bad-request', `${NAME}: the package spec must not be empty`, {})
}
const { profileDir, installAnchor } = this.options
const manifestPath = join(profileDir, 'package.json')
const snapshot = readFileSync(manifestPath, 'utf8')
const before = readProfileManifest(NAME, profileDir)
let jobId: string
try {
jobId = await this.runPnpm(['add', spec], spec)
} catch (error) {
// pnpm may have written the manifest before failing; the profile keeps
// the manifest it had, and what pnpm left under node_modules is not a
// dependency until a manifest names it.
if (readFileSync(manifestPath, 'utf8') !== snapshot) writeFileSync(manifestPath, snapshot)
throw error
}
const outcome = reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
const after = readProfileManifest(NAME, profileDir)
const added = Object.keys(dependenciesOf(after)).filter(name => !(name in dependenciesOf(before)))
await healProfilesModuleFallback({ installAnchor, profile: this.options.loadProfile() })
const installed: string[] = []
const removed: PluginInstallRejection[] = []
for (const name of added) {
const reason = await this.rejection(name)
if (reason === undefined) {
installed.push(name)
continue
}
await this.remove(name)
removed.push({ name, reason })
}
const kept = new Set(installed)
return {
installed,
removed,
installedOnly: outcome.installedOnly.filter(name => kept.has(name)),
plain: outcome.plain.filter(name => kept.has(name)),
jobId,
}
}
/**
* Run `pnpm remove`, reconcile the layer list, and forget the probe record.
* @param packageName - the dependency to remove.
* @throws {PluginOperationError} `plugins/install-failed` when pnpm fails.
*/
async remove(packageName: string): Promise<void> {
const { profileDir, installAnchor } = this.options
const before = readProfileManifest(NAME, profileDir)
await this.runPnpm(['remove', packageName], packageName)
reconcileInstalledBundles(NAME, profileDir, installAnchor, before, { autoEnable: false })
rmSync(join(profileDir, PLUGIN_PROBE_DIR, `${packageName.replaceAll('/', '__')}.json`), { force: true })
}
/**
* The post-install check of one package pnpm added: a package that is
* neither a bundle nor a plugin module has no place in a profile, and a
* bundle whose row id another layer already owns could only mount as a
* conflict record. A package the probe cannot run stays installed: the
* view reports it as not enableable with the probe's own reason.
* @returns why the package is removed again, or undefined to keep it.
*/
private async rejection(packageName: string): Promise<string | undefined> {
let probe: PluginProbe
try {
probe = await this.probe(packageName)
} catch {
return undefined // the failure is re-derived on every list and shown there
}
// A refused probe (an import that throws, another cordis copy) keeps the
// package: the view shows it as not enableable with the probe's reason.
if (!probe.ok) return undefined
if (probe.kind === 'library') return 'declares neither a dsh bundle nor a plugin module'
if (probe.kind !== 'bundle') return undefined
const { profileDir, installAnchor } = this.options
try {
const layer = resolveProfileLayer(NAME, readProfileManifest(NAME, profileDir), packageName, installAnchor, profileDir)
const lost = claimLayerIds([...this.options.loadProfile().layers, layer]).skipped.get(packageName)
if (lost === undefined) return undefined
return lost.map(conflict => `row ${JSON.stringify(conflict.rowId)} is already declared by ${conflict.declaredBy}`).join('; ')
} catch (error) {
return messageOf(error)
}
}
/**
* Run one pnpm command in the profile directory, streaming its output as
* install-log chunks.
* @returns the run's job id.
* @throws {PluginOperationError} `plugins/install-failed` on a non-zero exit, a signal, or the timeout.
*/
private async runPnpm(args: readonly string[], spec: string): Promise<string> {
const { config, profileDir } = this.options
const jobId = randomUUID()
const tail: string[] = []
let tailBytes = 0
const record = (stream: 'stdout' | 'stderr', text: string): void => {
tail.push(text)
tailBytes += Buffer.byteLength(text)
while (tailBytes > config.installLogTailBytes && tail.length > 1) {
tailBytes -= Buffer.byteLength(tail.shift() as string)
}
this.options.installLog({ jobId, spec, stream, text })
}
// Windows resolves pnpm through its .cmd shim, which spawn() refuses
// without a shell since the CVE-2024-27980 hardening. The parent
// environment is passed whole, as the `dsh plugin` command does: pnpm
// needs the user's registry, proxy, and auth settings.
const child = this.spawn(config.pnpmCommand, args, {
cwd: profileDir,
stdio: ['ignore', 'pipe', 'pipe'],
shell: process.platform === 'win32',
env: process.env,
})
child.stdout?.setEncoding('utf8')
child.stderr?.setEncoding('utf8')
child.stdout?.on('data', (text: string) => { record('stdout', text) })
child.stderr?.on('data', (text: string) => { record('stderr', text) })
const exitCode = await awaitChildClose(
child, config.installTimeoutMs,
() => new Error(`${NAME}: pnpm ${args.join(' ')} timed out after ${String(config.installTimeoutMs)}ms`),
).catch((error: unknown) => {
const message = messageOf(error)
record('stderr', `${message}\n`)
this.options.installLog({ jobId, spec, stream: 'stderr', text: '', exitCode: null })
throw new PluginOperationError('plugins/install-failed', `${NAME}: ${message}`, { spec, exitCode: null, log: tail.join('') }, { cause: error })
})
this.options.installLog({ jobId, spec, stream: 'stdout', text: '', exitCode })
if (exitCode !== 0) {
throw new PluginOperationError(
'plugins/install-failed',
`${NAME}: pnpm ${args.join(' ')} exited with ${String(exitCode)} in ${profileDir}`,
{ spec, exitCode, log: tail.join('') },
)
}
return jobId
}
}