dsh --profile <name> replaces the fixed entry modes: --config and -p are removed, --patch adds overlays over the composed profile, a positional task selects one-shot mode (requires the headless-runner row), and dsh web stays as the alias for --profile web carrying the Web flag family as patches. dsh plugin --profile <name> forwards verbatim to pnpm in the profile directory, initializes on first use, and reconciles the dsh.plugins layer list after add/remove (patch-less packages warn and stay plain dependencies). Config dumps and the keyless web e2e scaffold compose the same bundle layers over the same empty root as the boot.
109 lines
4.0 KiB
TypeScript
109 lines
4.0 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\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
|
|
}
|