plugin.ts 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163
  1. /**
  2. * `dsh plugin --profile <name> <args...>` — profile plugin management as a
  3. * thin pnpm forwarder: initialize the profile on first use, run
  4. * `pnpm <args...>` in the profile directory, then reconcile the
  5. * `dsh.profile.bundles` layer list against the installed state (a dependency
  6. * resolving to a package that declares `dsh.bundle` joins the layer stack; a
  7. * removed or bundle-less dependency leaves it). Reconciling by installed
  8. * state, not by dependency diff, means `update` activates a package that
  9. * gained its `dsh.bundle` declaration in a newer version.
  10. * @module @deepseek-ai/dsh/plugin
  11. */
  12. import { spawnSync } from 'node:child_process'
  13. import { existsSync } from 'node:fs'
  14. import { join, resolve } from 'node:path'
  15. import {
  16. DEFAULT_PROFILE_BUNDLES,
  17. initProfile,
  18. PROFILE_TEMPLATES,
  19. readProfileManifest,
  20. resolveBundleDir,
  21. resolveProfileDir,
  22. writeProfileManifest,
  23. type ProfileManifest,
  24. } from '@deepseek-ai/dsh-app-boot'
  25. import { INSTALL_ANCHOR } from './profile-boot.ts'
  26. const NAME = 'dsh'
  27. /**
  28. * Whether a resolved dependency exports a profile patch, i.e. is a bundle.
  29. * @param packageName - the dependency's package name.
  30. * @param profileDir - the profile directory (resolution anchor).
  31. * @returns true when the package manifest declares `dsh.bundle`.
  32. */
  33. function exportsPatch(packageName: string, profileDir: string): boolean {
  34. let dir: string
  35. try {
  36. dir = resolveBundleDir(NAME, packageName, INSTALL_ANCHOR, profileDir)
  37. } catch {
  38. return false // pnpm reported success yet the package is unresolvable — treat as plain
  39. }
  40. const manifest = readProfileManifest(NAME, dir)
  41. return manifest.dsh?.bundle?.patch !== undefined
  42. }
  43. /**
  44. * Reconcile `dsh.profile.bundles` against the installed state: pnpm has
  45. * already written the real installed names (so a git/path/tarball/alias spec
  46. * on the command line reconciles by its true package name) and materialized
  47. * the packages. A dependency that resolves to a `dsh.bundle`-declaring
  48. * package joins the layer stack (appended in dependency order); a
  49. * dependency-listed name that no longer does — removed, or the installed
  50. * version dropped the declaration — leaves it. In-box bundles from the
  51. * profile template are not dependencies and are never touched. Warns once
  52. * per newly-added bundle-less dependency (a plain library is fine; the
  53. * warning is orientation).
  54. */
  55. function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
  56. const after = readProfileManifest(NAME, profileDir)
  57. const beforeDeps = new Set(Object.keys(before.dependencies ?? {}))
  58. const dependencies = Object.keys(after.dependencies ?? {})
  59. const plugins = after.dsh?.profile?.bundles ?? []
  60. let changed = false
  61. for (const packageName of dependencies) {
  62. const isBundle = exportsPatch(packageName, profileDir)
  63. if (isBundle && !plugins.includes(packageName)) {
  64. plugins.push(packageName)
  65. changed = true
  66. } else if (!isBundle && !beforeDeps.has(packageName)) {
  67. process.stderr.write(
  68. `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
  69. + '(a later update that gains one activates it automatically)\n',
  70. )
  71. }
  72. }
  73. const dependencySet = new Set(dependencies)
  74. for (const packageName of [...plugins]) {
  75. // Only dependency-managed entries are subject to removal; template
  76. // bundles (dsh-base and friends) are not dependencies.
  77. const wasDependency = beforeDeps.has(packageName) || dependencySet.has(packageName)
  78. const stillBundle = dependencySet.has(packageName) && exportsPatch(packageName, profileDir)
  79. if (wasDependency && !stillBundle) {
  80. plugins.splice(plugins.indexOf(packageName), 1)
  81. changed = true
  82. }
  83. }
  84. if (!changed) return
  85. after.dsh = { ...after.dsh, profile: { ...after.dsh?.profile, bundles: plugins } }
  86. writeProfileManifest(profileDir, after)
  87. }
  88. /**
  89. * Rewrite relative filesystem specs against the user's invoking directory.
  90. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
  91. * (or their `file:`/`link:` forms) would silently resolve inside the profile
  92. * — `add .` from a plugin checkout would self-link the profile. Absolute
  93. * specs, registry names, and every other pnpm argument pass through
  94. * untouched.
  95. * @param argument - one pnpm argument, verbatim from argv.
  96. * @param cwd - the directory `dsh` was invoked from.
  97. * @returns the argument with a relative path spec anchored to `cwd`.
  98. */
  99. function anchorPathSpec(argument: string, cwd: string): string {
  100. const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
  101. if (match?.groups?.path === undefined) return argument
  102. // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
  103. // link-vs-copy semantics differ between `file:` and a plain directory
  104. // path, and the anchor must not change which one the user asked for.
  105. const prefix = match.groups.prefix ?? ''
  106. return `${prefix}${resolve(cwd, match.groups.path)}`
  107. }
  108. /**
  109. * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
  110. * @param profile - the profile name.
  111. * @param args - pnpm arguments with relative path specs anchored to the invoking directory.
  112. * @returns the pnpm exit code.
  113. */
  114. export function runPlugin(profile: string, args: readonly string[]): number {
  115. const dir = resolveProfileDir(profile)
  116. if (!existsSync(join(dir, 'package.json'))) {
  117. const template = PROFILE_TEMPLATES[profile]
  118. initProfile(
  119. dir,
  120. template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
  121. template?.patchReload,
  122. )
  123. process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
  124. }
  125. const before = readProfileManifest(NAME, dir)
  126. // Windows resolves pnpm through its .cmd shim, which spawn() refuses
  127. // without a shell since the CVE-2024-27980 hardening.
  128. const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
  129. cwd: dir,
  130. stdio: 'inherit',
  131. shell: process.platform === 'win32',
  132. })
  133. if (result.error !== undefined) {
  134. const code = (result.error as NodeJS.ErrnoException).code
  135. if (code === 'ENOENT') {
  136. process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
  137. return 127
  138. }
  139. throw result.error
  140. }
  141. const exitCode = result.status ?? 1
  142. if (exitCode === 0) {
  143. reconcilePlugins(before, dir)
  144. } else {
  145. // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
  146. // one; the profile owns it, and the commonest failure here is pnpm ≥10
  147. // blocking a git dependency's prepare (build) script until allowlisted.
  148. process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
  149. if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
  150. process.stderr.write(
  151. `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
  152. + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
  153. )
  154. }
  155. }
  156. return exitCode
  157. }