plugin.ts 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211
  1. /**
  2. * `dsh plugin --profile <name> <args...>` — profile plugin management from
  3. * the terminal. `add <spec...>` and `remove <name...>` go through the plugin
  4. * installer the Web host shares: pnpm runs in the profile directory, every
  5. * new package is probed, a package that declares neither a bundle nor a
  6. * plugin module (or whose row id a composed layer already owns) is removed
  7. * again with the reason printed, and every newly installed bundle joins the
  8. * layer list — the CLI's install-and-enable semantics. Every other pnpm verb
  9. * is forwarded verbatim and followed by a reconcile of the
  10. * `dsh.profile.bundles` layer list against the installed state, so `update`
  11. * activates a package that gained its `dsh.bundle` declaration in a newer
  12. * version. Nothing here boots the profile: the plugins being managed never
  13. * start.
  14. * @module @deepseek-ai/dsh/plugin
  15. */
  16. import { spawnSync } from 'node:child_process'
  17. import { existsSync } from 'node:fs'
  18. import { join, resolve } from 'node:path'
  19. import {
  20. DEFAULT_PROFILE_BUNDLES,
  21. enableBundle,
  22. initProfile,
  23. loadProfile,
  24. PROFILE_TEMPLATES,
  25. readProfileManifest,
  26. reconcileInstalledBundles,
  27. resolveProfileDir,
  28. type probePackage,
  29. type ProfileManifest,
  30. } from '@deepseek-ai/dsh-app-boot'
  31. import {
  32. PluginInstaller, pluginOperationFailureOf, type PluginInstallOutcome, type PluginToolingConfig, type SpawnLike,
  33. } from '@deepseek-ai/dsh-plugin-manager'
  34. import { INSTALL_ANCHOR } from './profile-boot.ts'
  35. const NAME = 'dsh'
  36. /** The tooling bounds the command runs with; the Web host reads the same values from its config. */
  37. const TOOLING: PluginToolingConfig = { pnpmCommand: 'pnpm', installTimeoutMs: 600_000, probeTimeoutMs: 20_000, installLogTailBytes: 16_384 }
  38. /** Test seams: the child spawner and the package probe, so no pnpm or probe child runs. */
  39. export interface PluginCommandInternals {
  40. spawn?: SpawnLike
  41. probe?: typeof probePackage
  42. }
  43. /**
  44. * Reconcile `dsh.profile.bundles` against the installed state with the CLI's
  45. * install-and-enable semantics after a forwarded pnpm verb: pnpm has already
  46. * written the real installed names and materialized the packages, and every
  47. * newly installed bundle joins the layer stack. Warns once per newly-added
  48. * bundle-less dependency (a plain library or plugin module is fine; the
  49. * warning is orientation).
  50. */
  51. function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
  52. const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true })
  53. warnPlain(outcome.plain)
  54. }
  55. function warnPlain(plain: readonly string[]): void {
  56. for (const packageName of plain) {
  57. process.stderr.write(
  58. `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
  59. + '(a later update that gains one activates it automatically)\n',
  60. )
  61. }
  62. }
  63. /**
  64. * Rewrite relative filesystem specs against the user's invoking directory.
  65. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
  66. * (or their `file:`/`link:` forms) would silently resolve inside the profile
  67. * — `add .` from a plugin checkout would self-link the profile. Absolute
  68. * specs, registry names, and every other pnpm argument pass through
  69. * untouched.
  70. * @param argument - one pnpm argument, verbatim from argv.
  71. * @param cwd - the directory `dsh` was invoked from.
  72. * @returns the argument with a relative path spec anchored to `cwd`.
  73. */
  74. function anchorPathSpec(argument: string, cwd: string): string {
  75. const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
  76. if (match?.groups?.path === undefined) return argument
  77. // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
  78. // link-vs-copy semantics differ between `file:` and a plain directory
  79. // path, and the anchor must not change which one the user asked for.
  80. const prefix = match.groups.prefix ?? ''
  81. return `${prefix}${resolve(cwd, match.groups.path)}`
  82. }
  83. /** Whether the arguments are an `add` or `remove` of plain specs, which the installer handles. */
  84. function managedVerb(args: readonly string[]): 'add' | 'remove' | undefined {
  85. const [verb, ...rest] = args
  86. if ((verb !== 'add' && verb !== 'remove') || rest.length === 0 || rest.some(argument => argument.startsWith('-'))) return undefined
  87. return verb
  88. }
  89. /**
  90. * Run one `dsh plugin` invocation: init if needed, then install or remove
  91. * through the shared installer, or forward to pnpm and reconcile.
  92. * @param profile - the profile name.
  93. * @param args - pnpm arguments with relative path specs anchored to the invoking directory.
  94. * @param internals - test seams.
  95. * @returns the exit code.
  96. */
  97. export async function runPlugin(profile: string, args: readonly string[], internals: PluginCommandInternals = {}): Promise<number> {
  98. const dir = resolveProfileDir(profile)
  99. if (!existsSync(join(dir, 'package.json'))) {
  100. const template = PROFILE_TEMPLATES[profile]
  101. initProfile(
  102. dir,
  103. template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
  104. template?.patchReload,
  105. )
  106. process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
  107. }
  108. const verb = managedVerb(args)
  109. if (verb !== undefined) return runManaged(profile, dir, verb, args.slice(1), internals)
  110. return forwardToPnpm(dir, args)
  111. }
  112. /** Install or remove through the installer, enabling every newly installed bundle as the CLI always has. */
  113. async function runManaged(
  114. profile: string,
  115. dir: string,
  116. verb: 'add' | 'remove',
  117. specs: readonly string[],
  118. internals: PluginCommandInternals,
  119. ): Promise<number> {
  120. const installer = new PluginInstaller({
  121. profileDir: dir,
  122. profileName: profile,
  123. installAnchor: INSTALL_ANCHOR,
  124. loadProfile: () => loadProfile(NAME, profile, INSTALL_ANCHOR, undefined, { userLayer: false }),
  125. config: TOOLING,
  126. installLog: (chunk) => { (chunk.stream === 'stdout' ? process.stdout : process.stderr).write(chunk.text) },
  127. // pnpm's colours reach the terminal the user is looking at, never a redirected file.
  128. color: process.stdout.isTTY,
  129. ...internals,
  130. })
  131. for (const argument of specs) {
  132. const spec = anchorPathSpec(argument, process.cwd())
  133. try {
  134. if (verb === 'remove') {
  135. await installer.remove(spec)
  136. continue
  137. }
  138. report(dir, await installer.add(spec))
  139. } catch (error) {
  140. const failure = pluginOperationFailureOf(error)
  141. if (failure?.code !== 'plugins/install-failed') throw error
  142. return explainFailure(dir, spec, failure.details.exitCode, failure.cause)
  143. }
  144. }
  145. return 0
  146. }
  147. /** Enable what the run installed, and say what it removed again and what it left as a plain dependency. */
  148. function report(dir: string, outcome: PluginInstallOutcome): void {
  149. for (const name of outcome.installedOnly) enableBundle(NAME, dir, INSTALL_ANCHOR, name)
  150. for (const rejection of outcome.removed) {
  151. process.stderr.write(`${NAME}: removed ${rejection.name} again: ${rejection.reason}\n`)
  152. }
  153. warnPlain(outcome.plain)
  154. }
  155. /** The exit code and the orientation a failed pnpm run leaves the user with. */
  156. function explainFailure(dir: string, spec: string, exitCode: number | null, cause: unknown): number {
  157. if ((cause as NodeJS.ErrnoException | undefined)?.code === 'ENOENT') {
  158. process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
  159. return 127
  160. }
  161. // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
  162. // one; the profile owns it, and the commonest failure here is pnpm ≥10
  163. // blocking a git dependency's prepare (build) script until allowlisted.
  164. process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
  165. if (/^git\+|^github:|\.git(?:#|$)/.test(spec)) {
  166. process.stderr.write(
  167. `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
  168. + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
  169. )
  170. }
  171. return exitCode ?? 1
  172. }
  173. /** Forward any other pnpm verb verbatim, then reconcile the layer list. */
  174. function forwardToPnpm(dir: string, args: readonly string[]): number {
  175. const before = readProfileManifest(NAME, dir)
  176. // Windows resolves pnpm through its .cmd shim, which spawn() refuses
  177. // without a shell since the CVE-2024-27980 hardening.
  178. const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
  179. cwd: dir,
  180. stdio: 'inherit',
  181. shell: process.platform === 'win32',
  182. })
  183. if (result.error !== undefined) {
  184. const code = (result.error as NodeJS.ErrnoException).code
  185. if (code === 'ENOENT') {
  186. process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
  187. return 127
  188. }
  189. throw result.error
  190. }
  191. const exitCode = result.status ?? 1
  192. if (exitCode === 0) {
  193. reconcilePlugins(before, dir)
  194. } else {
  195. process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
  196. }
  197. return exitCode
  198. }