Files
deepseek-harness/apps/cli/src/plugin.ts
Turtle 925daf141b fix: address ds-review-bot round — insert-aliasing clones, settlement gates, closure module fallback
- Clone patch lists per generation (boot + composeLive): the include pushes
  insert rows by reference and mutates them in place, so a reused object
  baked user overrides into bundle rows and removal could not revert; the
  built-bin hot-reload e2e now asserts an override AND its removal reverting.
- The headless runner awaits Loader settlement before prompting (its inject
  gate covers only apiProxy/httpServer) and abandons cleanly when the tree
  died during the wait.
- healProfilesModuleFallback walks the app's full dependency+peer closure:
  out-of-tree plugins import seam packages (dsh-compact, dsh-subprocess, ...)
  that only implementations reach, and peers are how seams are declared.
- Profile init writes pnpm-workspace.yaml (nodeLinker: hoisted), not .npmrc
  — pnpm >=10 reads settings from the workspace manifest.
- Web dumps reject boot-only flags instead of printing a tree that differs
  from the same invocation's boot; --port validates at the flag;
  --dump-default-config no longer parses the (possibly broken) user layer;
  trustedHosts flag derivation merges over the composed value instead of
  replacing it; web-runtime gains surfaceContext (headless disables the GUI
  prompt/bash-vars the old -p never mounted); 'node_modules' is a reserved
  profile name; plugin-warning names the recovery step; client AGENTS.md
  registration surfaces point at the web-app bundle.
- Ship session-reference/tmux-context/tool-ask-user as app dependencies for
  terminal front-door patch layers (turtle-ui), same stance as mcp-client.
2026-08-06 09:27:44 +08:00

112 lines
4.1 KiB
TypeScript

/**
* `dsh plugin --profile <name> <args...>` — profile plugin management as a
* thin pnpm forwarder: initialize the profile on first use, run
* `pnpm <args...>` in the profile directory, then reconcile the `dsh.plugins`
* bundle-layer list from the manifest's dependency diff (a package exporting
* a `dsh.patch` joins the layer stack; one without only warns — it is a plain
* library dependency; a removed dependency leaves the stack).
* @module @deepseek-ai/dsh/plugin
*/
import { spawnSync } from 'node:child_process'
import { existsSync } from 'node:fs'
import { join } from 'node:path'
import {
DEFAULT_PROFILE_PLUGINS,
initProfile,
PROFILE_TEMPLATES,
readProfileManifest,
resolveBundleDir,
resolveProfileDir,
writeProfileManifest,
type ProfileManifest,
} from '@deepseek-ai/dsh-app-boot'
import { INSTALL_ANCHOR } from './profile-boot.ts'
const NAME = 'dsh'
/**
* Whether a resolved dependency exports a profile patch, i.e. is a bundle.
* @param packageName - the dependency's package name.
* @param profileDir - the profile directory (resolution anchor).
* @returns true when the package manifest declares `dsh.patch`.
*/
function exportsPatch(packageName: string, profileDir: string): boolean {
let dir: string
try {
dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
} catch {
return false // pnpm reported success yet the package is unresolvable — treat as plain
}
const manifest = readProfileManifest(NAME, dir)
return manifest.dsh?.patch !== undefined
}
/**
* Reconcile `dsh.plugins` against the manifest's dependency diff: pnpm has
* already written the real installed names, so a git/path/tarball/alias spec
* on the command line reconciles by its true package name. Added bundle
* dependencies append (in dependency order); removed dependencies drop.
*/
function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
const after = readProfileManifest(NAME, profileDir)
const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
const afterDeps = Object.keys(after.dependencies ?? {})
const plugins = after.dsh?.plugins ?? []
let changed = false
for (const packageName of afterDeps) {
if (beforeDeps.has(packageName) || plugins.includes(packageName)) continue
if (!exportsPatch(packageName, profileDir)) {
process.stderr.write(
`${NAME}: warning: ${packageName} declares no dsh.patch — installed as a plain dependency, not a profile layer `
+ '(if it gains one later, add it to dsh.plugins in the profile\'s package.json)\n',
)
continue
}
plugins.push(packageName)
changed = true
}
const afterSet = new Set(afterDeps)
for (const packageName of beforeDeps) {
if (afterSet.has(packageName) || !plugins.includes(packageName)) continue
plugins.splice(plugins.indexOf(packageName), 1)
changed = true
}
if (!changed) return
after.dsh = { ...after.dsh, plugins }
writeProfileManifest(profileDir, after)
}
/**
* Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
* @param profile - the profile name.
* @param args - pnpm arguments, verbatim.
* @returns the pnpm exit code.
*/
export function runPlugin(profile: string, args: readonly string[]): number {
const dir = resolveProfileDir(profile)
if (!existsSync(join(dir, 'package.json'))) {
initProfile(dir, PROFILE_TEMPLATES[profile] ?? DEFAULT_PROFILE_PLUGINS)
process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
}
const before = readProfileManifest(NAME, dir)
// Windows resolves pnpm through its .cmd shim, which spawn() refuses
// without a shell since the CVE-2024-27980 hardening.
const result = spawnSync('pnpm', [...args], {
cwd: dir,
stdio: 'inherit',
shell: process.platform === 'win32',
})
if (result.error !== undefined) {
const code = (result.error as NodeJS.ErrnoException).code
if (code === 'ENOENT') {
process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
return 127
}
throw result.error
}
const exitCode = result.status ?? 1
if (exitCode === 0) reconcilePlugins(before, dir)
return exitCode
}