plugin.ts 4.5 KB

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