mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
- 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.
112 lines
4.1 KiB
TypeScript
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
|
|
}
|