plugin.ts 8.8 KB

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