plugin.ts 9.0 KB

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