index.ts 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593
  1. /** Current-profile plugin and bundle management over shared dsh plugin operations. */
  2. import { randomUUID } from 'node:crypto'
  3. import { existsSync, readFileSync } from 'node:fs'
  4. import { readFile, rm } from 'node:fs/promises'
  5. import { join } from 'node:path'
  6. import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
  7. import { Context } from '@deepseek-ai/cordis'
  8. import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
  9. import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
  10. import z from '@deepseek-ai/schemastery'
  11. import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
  12. import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
  13. import {
  14. readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
  15. } from '@deepseek-ai/dsh-app-boot'
  16. import type {} from '@deepseek-ai/dsh-hmr'
  17. import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
  18. import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts'
  19. import { classifyInstallFailure } from './install-failure.ts'
  20. import { InvalidInstallSpecError, parseInstallSpec } from './install-spec.ts'
  21. import { writePluginEnabled } from './patch.ts'
  22. import { ManagementFailure } from './failure.ts'
  23. import { approveBuilds, readPendingBuilds } from './build-approval.ts'
  24. import type {
  25. BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo,
  26. PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection,
  27. } from './types.ts'
  28. export type * from './types.ts'
  29. export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
  30. export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
  31. /** The pnpm executable and the limits for package diagnostics and registry lookups. */
  32. export interface Config {
  33. /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
  34. pnpmCommand?: string
  35. /** Maximum retained pnpm diagnostic bytes per operation. */
  36. outputBytes?: number
  37. /** Maximum time to wait for another process's profile package operation. */
  38. lockWaitMs?: number
  39. /** Bound on one registry lookup an inspection runs, in milliseconds. */
  40. inspectTimeoutMs?: number
  41. }
  42. const protectedModules = new Set([
  43. '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
  44. '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
  45. '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
  46. '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager',
  47. '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
  48. '@deepseek-ai/dsh-api-remotes',
  49. '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
  50. '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
  51. '@deepseek-ai/dsh-hmr',
  52. ])
  53. /** The profile files an installation writes and a failed or cancelled one restores. */
  54. const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const
  55. /** pnpm's colour escapes, which a JSON answer may be wrapped in. */
  56. const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g
  57. /** Flatten only the groups addressable by the profile's patch composer. */
  58. function flatten(rows: EntryOptions[]): EntryOptions[] {
  59. return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])])
  60. }
  61. /** Preserve the exact observed diagnostic, including non-Error failures. */
  62. function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
  63. /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
  64. function managementError(error: unknown): ManagementError {
  65. return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) }
  66. }
  67. /** The caller stopped an installation; its files are restored before this is thrown. */
  68. class InstallCancelledError extends Error {
  69. constructor() {
  70. super('Installation cancelled')
  71. this.name = 'InstallCancelledError'
  72. }
  73. }
  74. /** One installation the manager owns until its call settles. */
  75. interface InstallControl {
  76. readonly abort: AbortController
  77. /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */
  78. phase: 'installing' | 'applying'
  79. /** Settlement of the install call, whichever way it ended. */
  80. settled: Promise<void>
  81. }
  82. /** A manifest field that is a string, when the manifest carries one. */
  83. function stringField(manifest: object, field: string): string | undefined {
  84. const value = (manifest as Record<string, unknown>)[field]
  85. return typeof value === 'string' ? value : undefined
  86. }
  87. /** The fields of the dsh installation's own manifest the manager reads. */
  88. interface InstallationManifest {
  89. dependencies?: Record<string, string>
  90. }
  91. /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
  92. function inspectionOf(kind: 'registry' | 'path', manifest: object): Extract<PluginSpecInspection, { status: 'accepted' }> {
  93. const dsh = (manifest as { dsh?: unknown }).dsh
  94. const declared = typeof dsh === 'object' && dsh !== null ? dsh as { bundle?: unknown } : undefined
  95. const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null
  96. const name = stringField(manifest, 'name')
  97. const version = stringField(manifest, 'version')
  98. const description = stringField(manifest, 'description')
  99. return {
  100. status: 'accepted', kind, bundle,
  101. ...name === undefined ? {} : { name },
  102. ...version === undefined ? {} : { version },
  103. ...description === undefined || description === '' ? {} : { description },
  104. }
  105. }
  106. function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection {
  107. return { status: 'refused', problem, reason }
  108. }
  109. declare module '@deepseek-ai/cordis' {
  110. interface Context {
  111. /** Persistent management of the current profile's composition and packages. */
  112. pluginManager: PluginManager
  113. }
  114. }
  115. /** Manage profile files and apply their declared reload lifecycle. */
  116. export class PluginManager extends TypertRemoteService {
  117. static inject = ['loader', 'profileContext']
  118. static Config: z<Config> = z.object({
  119. pnpmCommand: z.string().default('pnpm'),
  120. outputBytes: z.number().step(1).min(1).default(16384),
  121. lockWaitMs: z.number().step(1).min(0).default(120000),
  122. inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
  123. })
  124. private readonly ownerEntryId: string | undefined
  125. private readonly packageOperations = new Set<Promise<unknown>>()
  126. private readonly profile: ProfileContext
  127. private readonly outputBytes: number
  128. private readonly lockWaitMs: number
  129. private readonly inspectTimeoutMs: number
  130. private readonly pnpmCommand: string
  131. private readonly ownerContext: Context
  132. private readonly abort = new AbortController()
  133. /** Installations by request id, from their call until it settles. */
  134. private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
  135. constructor(ctx: Context, config: Config) {
  136. super(ctx, 'pluginManager')
  137. this.ownerEntryId = ctx.fiber.entry?.id
  138. this.ownerContext = ctx
  139. this.profile = ctx.profileContext
  140. this.outputBytes = (config as Required<Config>).outputBytes
  141. this.lockWaitMs = (config as Required<Config>).lockWaitMs
  142. this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
  143. this.pnpmCommand = (config as Required<Config>).pnpmCommand
  144. ctx.effect(() => async () => {
  145. this.abort.abort()
  146. await Promise.allSettled([...this.packageOperations])
  147. }, 'plugin-manager: package cancellation')
  148. }
  149. /** Read current plugins, including why a row cannot be changed through the profile patch.
  150. * @returns Current runtime entries with persistent patch targets.
  151. */
  152. @Remote
  153. async listPlugins(): Promise<PluginInfo[]> {
  154. const rows = flatten(composeEntries([readProfilePatches('dsh', this.profile)]))
  155. const snapshot = await readPluginInventory(this.ctx)
  156. return snapshot.entries.map((entry) => {
  157. const actual = [...this.ctx.loader.entries()].find(row => row.id === entry.entryId)
  158. const candidates = rows.filter(row => row.id === actual?.options.id)
  159. const candidate = candidates[0]
  160. if (protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId) {
  161. return { ...entry, readOnlyReason: 'management-required' as const }
  162. }
  163. if (candidate === undefined || candidates.length > 1 || candidate.name !== entry.moduleName
  164. || actual?.parent.tree.ctx.fiber.entry?.id !== 'include') {
  165. return { ...entry, readOnlyReason: 'unaddressable' as const }
  166. }
  167. return { ...entry, patchId: candidate.id }
  168. })
  169. }
  170. /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
  171. * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
  172. * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
  173. * bundle, and removal availability.
  174. */
  175. @Remote
  176. listBundles(): Promise<BundleInfo[]> {
  177. const manifest = readProfileManifest('dsh', this.profile.dir)
  178. const selected = manifest.dsh?.profile?.bundles ?? []
  179. const dependencies = Object.keys(manifest.dependencies ?? {})
  180. const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
  181. const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])]
  182. const bundles: BundleInfo[] = []
  183. for (const name of names) {
  184. const installed = dependencies.includes(name)
  185. const optional = OPTIONAL_BUNDLES.includes(name)
  186. const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
  187. const enabled = selected.includes(name)
  188. try {
  189. const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
  190. if (info === undefined) {
  191. if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] })
  192. continue
  193. }
  194. const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
  195. bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
  196. ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
  197. enabled, installed, optional, removable: removable && readOnlyReason === undefined,
  198. ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
  199. ...this.declaredRows(name, info) })
  200. } catch (error) {
  201. if (enabled || installed) {
  202. bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] })
  203. }
  204. }
  205. }
  206. return Promise.resolve(bundles)
  207. }
  208. /** Read what a spec names before installing it.
  209. * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
  210. * @param signal Ends a registry lookup early.
  211. * @returns The package the spec names, or why it is refused.
  212. */
  213. @Remote
  214. async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
  215. let parsed
  216. try {
  217. parsed = parseInstallSpec(spec)
  218. } catch (error) {
  219. /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */
  220. if (!(error instanceof InvalidInstallSpecError)) throw error
  221. return refused('invalid-spec', error.reason)
  222. }
  223. const manifest = readProfileManifest('dsh', this.profile.dir)
  224. const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
  225. const known = new Set([
  226. ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
  227. ])
  228. switch (parsed.kind) {
  229. case 'git': return { status: 'accepted', kind: 'git', bundle: null }
  230. case 'tarball':
  231. if (parsed.path !== undefined && !existsSync(parsed.path)) return refused('not-a-package', 'the tarball does not exist')
  232. return { status: 'accepted', kind: 'tarball', bundle: null }
  233. case 'path': {
  234. if (!existsSync(parsed.path)) return refused('not-a-package', 'the path does not exist')
  235. let read: object
  236. try {
  237. read = JSON.parse(await readFile(join(parsed.path, 'package.json'), 'utf8')) as object
  238. } catch (error) {
  239. return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`)
  240. }
  241. const inspection = inspectionOf('path', read)
  242. if (inspection.name === undefined) return refused('not-a-package', 'the package.json names no package')
  243. if (known.has(inspection.name)) return refused('already-installed', `${inspection.name} is already installed`)
  244. if (!inspection.bundle) return refused('not-a-bundle', `${inspection.name} declares no dsh.bundle`)
  245. return inspection
  246. }
  247. case 'registry': {
  248. if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`)
  249. const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
  250. command: this.pnpmCommand, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
  251. })
  252. const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim()
  253. if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
  254. const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } })
  255. const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}`
  256. if (kind === 'not-found' || kind === 'no-matching-version') return refused('not-found', reason)
  257. if (kind === 'network') return refused('network', reason)
  258. return refused('unknown', view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason)
  259. }
  260. let answer: unknown
  261. try {
  262. answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, '').trim() || 'null')
  263. } catch (error) {
  264. return refused('unknown', `unreadable pnpm view output: ${messageOf(error)}`)
  265. }
  266. // A range answers one object per matching version, oldest first.
  267. const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer
  268. if (typeof latest !== 'object' || latest === null) return refused('unknown', 'pnpm view answered no package')
  269. const inspection = inspectionOf('registry', latest)
  270. const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
  271. if (!named.bundle) return refused('not-a-bundle', `${named.name} declares no dsh.bundle`)
  272. return named
  273. }
  274. }
  275. }
  276. /** Persist a plugin entry's desired enablement and apply it on live profiles.
  277. * @param id Loader entry identity returned by listPlugins.
  278. * @param enabled Whether the plugin should run.
  279. * @returns Saved and runtime outcomes, including higher-priority overrides.
  280. */
  281. @Remote
  282. setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult> {
  283. return this.change(result => this.configure(async () => {
  284. const row = (await this.listPlugins()).find(item => item.entryId === id)
  285. if (row === undefined) throw new ManagementFailure('unknown-plugin')
  286. if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason)
  287. await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled)
  288. result.warnings = await this.reload(enabled ? [row.patchId] : [])
  289. const current = (await this.listPlugins()).find(item => item.entryId === id)
  290. return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined
  291. }), { stage: 'enable', target: id, enabled }, 'plugin')
  292. }
  293. /** Select or remove a bundle layer while retaining installed dependencies.
  294. * @param name Bundle package name.
  295. * @param enabled Whether the bundle contributes its patch layer.
  296. * @returns Persisted and runtime outcomes.
  297. */
  298. @Remote
  299. setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult> {
  300. return this.change(result => this.configure(async () => {
  301. await this.selectBundle(name, enabled)
  302. result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
  303. }), { stage: 'enable', target: name, enabled }, 'bundle')
  304. }
  305. /**
  306. * Install a package using the same pnpm implementation as dsh plugin. A run
  307. * that fails, is cancelled, or adds a package without a bundle patch restores
  308. * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
  309. * @param spec One package spec, including local paths relative to the invocation directory.
  310. * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
  311. * the pending build scripts to allow for this profile before pnpm runs.
  312. * @returns Package-manager diagnostics and observed activation outcome.
  313. */
  314. @Remote
  315. installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
  316. const requestId = options?.requestId
  317. const control: InstallControl = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() }
  318. const stopped = (): boolean => control.abort.signal.aborted
  319. if (requestId !== undefined) this.installs.set(requestId, control)
  320. const announce = (phase: PluginInstallProgress['phase']): void => {
  321. if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase })
  322. }
  323. const result = this.change(async (result) => {
  324. if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec')
  325. if (stopped()) throw new InstallCancelledError()
  326. if (options?.approvedBuilds !== undefined) {
  327. await approveBuilds(this.profile.dir, options.approvedBuilds)
  328. result.approvedBuilds = options.approvedBuilds
  329. }
  330. const files = await this.readRestoredFiles()
  331. const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
  332. announce('installing')
  333. let name: string
  334. try {
  335. result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId)
  336. if (stopped()) throw new InstallCancelledError()
  337. if (result.packageResult.exitCode !== 0) {
  338. // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
  339. try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
  340. catch (error) {
  341. this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
  342. }
  343. throw new Error(result.packageResult.output)
  344. }
  345. const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
  346. const installed = Object.keys(after).filter(name => before[name] !== after[name])
  347. // Registry retries can retain the saved range after a partial installation.
  348. if (installed.length === 0) installed.push(...Object.keys(after).filter(name => spec === name || spec.startsWith(`${name}@`)))
  349. const target = installed[0]
  350. if (installed.length !== 1 || target === undefined) throw new ManagementFailure('ambiguous-install')
  351. name = target
  352. const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
  353. const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
  354. if (manifest?.dsh?.bundle?.patch === undefined) throw new ManagementFailure('not-bundle')
  355. loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
  356. } catch (error) {
  357. // pnpm has exited by now, so the files it rewrote go back as they were.
  358. await this.restoreFiles(files)
  359. throw error
  360. }
  361. control.phase = 'applying'
  362. announce('applying')
  363. result.bundle = name
  364. result.target = name
  365. result.stage = 'enable'
  366. return this.configure(async () => {
  367. if (options?.enabled !== false) await this.selectBundle(name, true)
  368. if (Object.hasOwn(before, name)) return 'restart-required'
  369. if (options?.enabled !== false) result.warnings = await this.reload()
  370. })
  371. }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install')
  372. /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
  373. control.settled = result.then(() => undefined, () => undefined)
  374. return result.finally(() => { if (requestId !== undefined) this.installs.delete(requestId) })
  375. }
  376. /** Stop an installation this manager owns and wait until its files are back.
  377. * @param requestId The id the installation was started with.
  378. * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
  379. * applied, `not-running` for any other id.
  380. */
  381. @Remote
  382. async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation> {
  383. const control = this.installs.get(requestId)
  384. if (control === undefined) return { status: 'not-running' }
  385. if (control.phase === 'applying') return { status: 'too-late' }
  386. this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' })
  387. control.abort.abort()
  388. await control.settled
  389. return { status: 'cancelled' }
  390. }
  391. /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
  392. * @param name Installed dependency name.
  393. * @returns Removal diagnostics and the remaining profile state.
  394. */
  395. @Remote
  396. removeBundle(name: string): Promise<ChangeResult> {
  397. return this.change(async (result) => {
  398. await this.configure(async () => {
  399. const bundle = (await this.listBundles()).find(item => item.name === name)
  400. if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
  401. if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
  402. || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
  403. .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
  404. throw new ManagementFailure('stop-profile')
  405. }
  406. const contributions = bundle.error === undefined ? this.bundleRows(name) : []
  407. if (bundle.enabled) {
  408. await this.selectBundle(name, false)
  409. result.warnings = await this.reload()
  410. }
  411. if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
  412. && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
  413. throw new ManagementFailure('bundle-in-use')
  414. }
  415. })
  416. result.packageResult = await this.runPnpm(['remove', name])
  417. if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
  418. }, { stage: 'remove', target: name }, 'remove')
  419. }
  420. /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
  421. private declaredRows(name: string, info: ProfileManifest): Pick<BundleInfo, 'rows' | 'overrides'> {
  422. const patch = info.dsh?.bundle?.patch
  423. /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
  424. if (patch === undefined) return { rows: [], overrides: [] }
  425. const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
  426. const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch))
  427. // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
  428. const live = new Map<string, PluginEntryId>()
  429. for (const entry of this.ctx.loader.entries()) {
  430. /* v8 ignore next -- the Loader gives every entry an id before it is listed */
  431. if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id))
  432. }
  433. const rows: BundleRowInfo[] = []
  434. for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
  435. if (typeof row.id !== 'string' || typeof row.name !== 'string') continue
  436. const entryId = live.get(row.id)
  437. rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } })
  438. }
  439. const declared = new Set(rows.map(row => row.rowId))
  440. const overrides = [...new Set(patches.flatMap(item =>
  441. item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))]
  442. return { rows, overrides }
  443. }
  444. /** Run one pnpm command in the profile, streaming its output as install-log chunks. */
  445. private async runPnpm(
  446. args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId,
  447. ): Promise<PackageResult> {
  448. const jobId = randomUUID()
  449. const argv = ['pnpm', ...args]
  450. const cwd = this.profile.dir
  451. const identity = requestId === undefined ? {} : { requestId }
  452. const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
  453. execution: 'service', command: this.pnpmCommand,
  454. signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
  455. outputBytes: this.outputBytes, activateNewBundles: false,
  456. onOutput: (text, stream) => {
  457. this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text })
  458. },
  459. })
  460. this.packageOperations.add(task)
  461. try {
  462. const result = await task
  463. this.ownerContext.emit('plugin-manager/install-log', {
  464. ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode,
  465. })
  466. return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) }
  467. } catch (error) {
  468. this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null })
  469. throw error
  470. } finally {
  471. this.packageOperations.delete(task)
  472. }
  473. }
  474. /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */
  475. private async readRestoredFiles(): Promise<Map<string, string | undefined>> {
  476. const files = new Map<string, string | undefined>()
  477. for (const name of RESTORED_FILES) {
  478. const path = join(this.profile.dir, name)
  479. files.set(path, existsSync(path) ? await readFile(path, 'utf8') : undefined)
  480. }
  481. return files
  482. }
  483. /** Put the profile files back; pnpm has exited by the time this runs. */
  484. private async restoreFiles(files: Map<string, string | undefined>): Promise<void> {
  485. for (const [path, content] of files) {
  486. if (content === undefined) await rm(path, { force: true })
  487. else await writeFileAtomic(path, content, { mode: 0o600 })
  488. }
  489. }
  490. private async selectBundle(name: string, enabled: boolean): Promise<void> {
  491. const manifest = readProfileManifest('dsh', this.profile.dir)
  492. const previous = manifest.dsh?.profile?.bundles ?? []
  493. if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
  494. throw new ManagementFailure('not-bundle')
  495. }
  496. if (!enabled && previous.includes(name)) {
  497. if (this.protectsManager(name)) throw new ManagementFailure('management-required')
  498. }
  499. const bundles = enabled ? [...previous, ...previous.includes(name) ? [] : [name]] : previous.filter(item => item !== name)
  500. if (JSON.stringify(previous) === JSON.stringify(bundles)) return
  501. manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
  502. await saveManifest(this.profile.dir, manifest)
  503. }
  504. private bundleRows(name: string): EntryOptions[] {
  505. const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
  506. if (info?.dsh?.bundle === undefined) return []
  507. const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
  508. return flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
  509. }
  510. private protectsManager(name: string): boolean {
  511. return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
  512. }
  513. private configure<T>(operation: () => Promise<T>): Promise<T> {
  514. const hmr = this.ownerContext.get('hmr')
  515. const apply = () => { this.abort.signal.throwIfAborted(); return operation() }
  516. return hmr === undefined ? apply() : hmr.runExclusive(apply)
  517. }
  518. private async reload(requiredIds: readonly string[] = []): Promise<string[]> {
  519. if (this.ownerContext.get('hmr') === undefined) return []
  520. return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
  521. }
  522. private async change(
  523. operation: (result: ChangeResult) => Promise<ChangeResult['application'] | void>,
  524. request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
  525. reason: PluginChange['reason'],
  526. ): Promise<ChangeResult> {
  527. return withFileLock(join(this.profile.dir, 'package.json'), async () => {
  528. this.abort.signal.throwIfAborted()
  529. const before = this.diskState()
  530. const result: ChangeResult = { ...request, changed: false,
  531. application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' }
  532. try {
  533. result.application = await operation(result) ?? result.application
  534. } catch (error) {
  535. if (error instanceof InstallCancelledError) {
  536. result.application = 'cancelled'
  537. } else {
  538. result.application = 'failed'
  539. result.error = managementError(error)
  540. }
  541. }
  542. result.changed = before !== this.diskState()
  543. this.ownerContext.emit('plugin-manager/changed', { reason })
  544. return result
  545. }, { waitMs: this.lockWaitMs })
  546. }
  547. private diskState(): string {
  548. return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => {
  549. try { return readFileSync(join(this.profile.dir, file), 'utf8') }
  550. catch (error) {
  551. if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
  552. throw error
  553. }
  554. }).join('\0')
  555. }
  556. }
  557. export default PluginManager