| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163 |
- /**
- * `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,
- 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.bundle`.
- */
- 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?.bundle?.patch !== undefined
- }
- /**
- * Reconcile `dsh.profile.bundles` against the installed state: 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. A dependency that resolves to a `dsh.bundle`-declaring
- * package joins the layer stack (appended in dependency order); a
- * dependency-listed name that no longer does — removed, or the installed
- * version dropped the declaration — leaves it. In-box bundles from the
- * profile template are not dependencies and are never touched. Warns once
- * per newly-added bundle-less dependency (a plain library is fine; the
- * warning is orientation).
- */
- function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
- const after = readProfileManifest(NAME, profileDir)
- const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
- const dependencies = Object.keys(after.dependencies ?? {})
- const plugins = after.dsh?.profile?.bundles ?? []
- let changed = false
- for (const packageName of dependencies) {
- const isBundle = exportsPatch(packageName, profileDir)
- if (isBundle && !plugins.includes(packageName)) {
- plugins.push(packageName)
- changed = true
- } else if (!isBundle && !beforeDeps.has(packageName)) {
- 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',
- )
- }
- }
- const dependencySet = new Set(dependencies)
- for (const packageName of [...plugins]) {
- // Only dependency-managed entries are subject to removal; template
- // bundles (dsh-base and friends) are not dependencies.
- const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
- const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
- if (wasDependency && !stillBundle) {
- plugins.splice(plugins.indexOf(packageName), 1)
- changed = true
- }
- }
- if (!changed) return
- after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
- writeProfileManifest(profileDir, after)
- }
- /**
- * 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
- }
|