| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118 |
- /**
- * `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.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 = /^(?<prefix>(?:file|link):)?(?<path>\.{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
- }
|