plugin.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118
  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. reconcileInstalledBundles,
  21. resolveProfileDir,
  22. type ProfileManifest,
  23. } from '@deepseek-ai/dsh-app-boot'
  24. import { INSTALL_ANCHOR } from './profile-boot.ts'
  25. const NAME = 'dsh'
  26. /**
  27. * Reconcile `dsh.profile.bundles` against the installed state with the CLI's
  28. * install-and-enable semantics: pnpm has already written the real installed
  29. * names (so a git/path/tarball/alias spec on the command line reconciles by
  30. * its true package name) and materialized the packages, and every newly
  31. * installed bundle joins the layer stack. Warns once per newly-added
  32. * bundle-less dependency (a plain library or plugin module is fine; the
  33. * warning is orientation).
  34. */
  35. function reconcilePlugins(before: ProfileManifest, profileDir: string): void {
  36. const outcome = reconcileInstalledBundles(NAME, profileDir, INSTALL_ANCHOR, before, { autoEnable: true })
  37. for (const packageName of outcome.plain) {
  38. process.stderr.write(
  39. `${NAME}: warning: ${packageName} declares no dsh.bundle — installed as a plain dependency, not a profile layer `
  40. + '(a later update that gains one activates it automatically)\n',
  41. )
  42. }
  43. }
  44. /**
  45. * Rewrite relative filesystem specs against the user's invoking directory.
  46. * pnpm runs with cwd = the profile directory, so a bare `.` or `../plugin`
  47. * (or their `file:`/`link:` forms) would silently resolve inside the profile
  48. * — `add .` from a plugin checkout would self-link the profile. Absolute
  49. * specs, registry names, and every other pnpm argument pass through
  50. * untouched.
  51. * @param argument - one pnpm argument, verbatim from argv.
  52. * @param cwd - the directory `dsh` was invoked from.
  53. * @returns the argument with a relative path spec anchored to `cwd`.
  54. */
  55. function anchorPathSpec(argument: string, cwd: string): string {
  56. const match = /^(?<prefix>(?:file|link):)?(?<path>\.{1,2}(?:[/\\].*)?)$/.exec(argument)
  57. if (match?.groups?.path === undefined) return argument
  58. // A bare path stays bare and a prefixed spec keeps its prefix: pnpm's
  59. // link-vs-copy semantics differ between `file:` and a plain directory
  60. // path, and the anchor must not change which one the user asked for.
  61. const prefix = match.groups.prefix ?? ''
  62. return `${prefix}${resolve(cwd, match.groups.path)}`
  63. }
  64. /**
  65. * Run one `dsh plugin` invocation: init if needed, forward to pnpm, reconcile.
  66. * @param profile - the profile name.
  67. * @param args - pnpm arguments with relative path specs anchored to the invoking directory.
  68. * @returns the pnpm exit code.
  69. */
  70. export function runPlugin(profile: string, args: readonly string[]): number {
  71. const dir = resolveProfileDir(profile)
  72. if (!existsSync(join(dir, 'package.json'))) {
  73. const template = PROFILE_TEMPLATES[profile]
  74. initProfile(
  75. dir,
  76. template?.bundles ?? DEFAULT_PROFILE_BUNDLES,
  77. template?.patchReload,
  78. )
  79. process.stderr.write(`${NAME}: initialized profile ${profile} at ${dir}\n`)
  80. }
  81. const before = readProfileManifest(NAME, dir)
  82. // Windows resolves pnpm through its .cmd shim, which spawn() refuses
  83. // without a shell since the CVE-2024-27980 hardening.
  84. const result = spawnSync('pnpm', args.map(argument => anchorPathSpec(argument, process.cwd())), {
  85. cwd: dir,
  86. stdio: 'inherit',
  87. shell: process.platform === 'win32',
  88. })
  89. if (result.error !== undefined) {
  90. const code = (result.error as NodeJS.ErrnoException).code
  91. if (code === 'ENOENT') {
  92. process.stderr.write(`${NAME}: pnpm not found on PATH — install pnpm to manage profile plugins\n`)
  93. return 127
  94. }
  95. throw result.error
  96. }
  97. const exitCode = result.status ?? 1
  98. if (exitCode === 0) {
  99. reconcilePlugins(before, dir)
  100. } else {
  101. // pnpm's own diagnostics name pnpm-workspace.yaml without saying WHICH
  102. // one; the profile owns it, and the commonest failure here is pnpm ≥10
  103. // blocking a git dependency's prepare (build) script until allowlisted.
  104. process.stderr.write(`${NAME}: pnpm failed in profile directory ${dir}\n`)
  105. if (args.some(argument => /^git\+|^github:|\.git(?:#|$)/.test(argument))) {
  106. process.stderr.write(
  107. `${NAME}: git-hosted plugins build on install via their prepare script, which pnpm blocks until allowed — `
  108. + `add the exact key pnpm printed above under allowBuilds in ${join(dir, 'pnpm-workspace.yaml')}, then re-run\n`,
  109. )
  110. }
  111. }
  112. return exitCode
  113. }