/** * `dsh plugin --profile ` — profile plugin management as a * thin pnpm forwarder: initialize the profile on first use, run * `pnpm ` in the profile directory, then reconcile the * `dsh.profile.bundles` layer list against the installed state (a dependency * resolving to a package that declares `dsh.bundle` joins the layer stack; a * removed or bundle-less dependency leaves it). Reconciling by installed * state, not by dependency diff, means `update` activates a package that * gained its `dsh.bundle` declaration in a newer version. * @module @deepseek-ai/dsh/plugin */ import { spawnSync } from 'node:child_process' import { existsSync } from 'node:fs' import { join, resolve } from 'node:path' import { DEFAULT_PROFILE_BUNDLES, initProfile, PROFILE_TEMPLATES, readProfileManifest, reconcileInstalledBundles, resolveProfileDir, type ProfileManifest, } from '@deepseek-ai/dsh-app-boot' import { INSTALL_ANCHOR } from './profile-boot.ts' const NAME = 'dsh' /** * Reconcile `dsh.profile.bundles` against the installed state with the CLI's * install-and-enable semantics: 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) and materialized the packages, and every newly * installed bundle joins the layer stack. Warns once per newly-added * bundle-less dependency (a plain library or plugin module is fine; the * warning is orientation). */ function reconcilePlugins(before: ProfileManifest, profileDir: string): void { const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true }) for (const packageName of outcome.plain) { process.stderr.write( `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer ` + '(a later update that gains one activates it automatically)\n', ) } } /** * Rewrite relative filesystem specs against the user's invoking directory. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin` * (or their `file:`/`link:` forms) would silently resolve inside the profile * — `add .` from a plugin checkout would self-link the profile. Absolute * specs, registry names, and every other pnpm argument pass through * untouched. * @param argument - one pnpm argument, verbatim from argv. * @param cwd - the directory `dsh` was invoked from. * @returns the argument with a relative path spec anchored to `cwd`. */ function anchorPathSpec(argument: string, cwd: string): string { const match = /^(?(?:file|link):)?(?\.{1,2}(?:[/\\].*)?)$/.exec(argument) if (match?.groups?.path === undefined) return argument // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's // link-vs-copy semantics differ between `file:` and a plain directory // path, and the anchor must not change which one the user asked for. const prefix = match.groups.prefix ?? '' return `${prefix}${resolve(cwd, match.groups.path)}` } /** * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile. * @param profile - the profile name. * @param args - pnpm arguments with relative path specs anchored to the invoking directory. * @returns the pnpm exit code. */ export function runPlugin(profile: string, args: readonly string[]): number { const dir = resolveProfileDir(profile) if (!existsSync(join(dir, 'package.json'))) { const template = PROFILE_TEMPLATES[profile] initProfile( dir, template?.bundles ?? DEFAULT_PROFILE_BUNDLES, template?.patchReload, ) 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.map(argument => anchorPathSpec(argument, process.cwd())), { 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) } else { // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH // one; the profile owns it, and the commonest failure here is pnpm ≥10 // blocking a git dependency's prepare (build) script until allowlisted. process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`) if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) { process.stderr.write( `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — ` + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`, ) } } return exitCode }