| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593 |
- /** Current-profile plugin and bundle management over shared dsh plugin operations. */
- import { randomUUID } from 'node:crypto'
- import { existsSync, readFileSync } from 'node:fs'
- import { readFile, rm } from 'node:fs/promises'
- import { join } from 'node:path'
- import { withFileLock, writeFileAtomic } from '@deepseek-ai/dsh-atomic-write'
- import { Context } from '@deepseek-ai/cordis'
- import type { EntryOptions } from '@deepseek-ai/cordis-plugin-loader'
- import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
- import z from '@deepseek-ai/schemastery'
- import { TypertRemoteService, Remote } from '@deepseek-ai/dsh-typert-protocol'
- import { pluginEntryId, readPluginInventory } from '@deepseek-ai/dsh-host-plugin-inventory'
- import {
- readProfileManifest, resolveBundleDir, loadOverlayPatches, composeEntries, reconcileProfilePatches, readProfilePatches, OPTIONAL_BUNDLES,
- } from '@deepseek-ai/dsh-app-boot'
- import type {} from '@deepseek-ai/dsh-hmr'
- import type { ProfileContext, ProfileManifest } from '@deepseek-ai/dsh-app-boot'
- import { bundleManifest, runProfilePnpm, saveManifest, viewProfilePackage } from './operations.ts'
- import { classifyInstallFailure } from './install-failure.ts'
- import { InvalidInstallSpecError, parseInstallSpec } from './install-spec.ts'
- import { writePluginEnabled } from './patch.ts'
- import { ManagementFailure } from './failure.ts'
- import { approveBuilds, readPendingBuilds } from './build-approval.ts'
- import type {
- BundleInfo, BundleRowInfo, ChangeResult, InstallBundleOptions, ManagementError, PackageResult, PluginChange, PluginEntryId, PluginInfo,
- PluginInspectProblem, PluginInstallCancellation, PluginInstallProgress, PluginInstallRequestId, PluginSpecInspection,
- } from './types.ts'
- export type * from './types.ts'
- export { classifyInstallFailure, type InstallFailureFacts } from './install-failure.ts'
- export { InvalidInstallSpecError, parseInstallSpec, type ParsedInstallSpec } from './install-spec.ts'
- /** The pnpm executable and the limits for package diagnostics and registry lookups. */
- export interface Config {
- /** The pnpm executable name or path; resolved through `PATH` like the `dsh plugin` command. */
- pnpmCommand?: string
- /** Maximum retained pnpm diagnostic bytes per operation. */
- outputBytes?: number
- /** Maximum time to wait for another process's profile package operation. */
- lockWaitMs?: number
- /** Bound on one registry lookup an inspection runs, in milliseconds. */
- inspectTimeoutMs?: number
- }
- const protectedModules = new Set([
- '@deepseek-ai/dsh-plugin-manager', '@deepseek-ai/cordis-plugin-loader',
- '@deepseek-ai/cordis-plugin-include', '@deepseek-ai/dsh-api-gateway',
- '@deepseek-ai/dsh-host-webserver', '@deepseek-ai/dsh-client-modules',
- '@deepseek-ai/dsh-client-ui-settings-plugin-inventory', '@deepseek-ai/dsh-client-ui-plugin-manager',
- '@deepseek-ai/dsh-host-plugin-inventory', '@deepseek-ai/dsh-typert-registry',
- '@deepseek-ai/dsh-api-remotes',
- '@deepseek-ai/cordis-plugin-timer', '@deepseek-ai/dsh-client-connection',
- '@deepseek-ai/dsh-host-frontend-static', '@deepseek-ai/dsh-tools',
- '@deepseek-ai/dsh-hmr',
- ])
- /** The profile files an installation writes and a failed or cancelled one restores. */
- const RESTORED_FILES = ['package.json', 'pnpm-lock.yaml'] as const
- /** pnpm's colour escapes, which a JSON answer may be wrapped in. */
- const ANSI_SEQUENCE = /\x1b\[[0-9;]*m/g
- /** Flatten only the groups addressable by the profile's patch composer. */
- function flatten(rows: EntryOptions[]): EntryOptions[] {
- return rows.flatMap(row => [row, ...(row.group && Array.isArray(row.config) ? flatten(row.config as EntryOptions[]) : [])])
- }
- /** Preserve the exact observed diagnostic, including non-Error failures. */
- function messageOf(error: unknown): string { return error instanceof Error ? error.message : String(error) }
- /** An expected refusal keeps its code; anything else becomes an operation error carrying its exact diagnostic. */
- function managementError(error: unknown): ManagementError {
- return error instanceof ManagementFailure ? { code: error.code } : { code: 'operation-error', diagnostic: messageOf(error) }
- }
- /** The caller stopped an installation; its files are restored before this is thrown. */
- class InstallCancelledError extends Error {
- constructor() {
- super('Installation cancelled')
- this.name = 'InstallCancelledError'
- }
- }
- /** One installation the manager owns until its call settles. */
- interface InstallControl {
- readonly abort: AbortController
- /** `applying` once pnpm has exited and the bundle is being selected and loaded, which cannot be stopped. */
- phase: 'installing' | 'applying'
- /** Settlement of the install call, whichever way it ended. */
- settled: Promise<void>
- }
- /** A manifest field that is a string, when the manifest carries one. */
- function stringField(manifest: object, field: string): string | undefined {
- const value = (manifest as Record<string, unknown>)[field]
- return typeof value === 'string' ? value : undefined
- }
- /** The fields of the dsh installation's own manifest the manager reads. */
- interface InstallationManifest {
- dependencies?: Record<string, string>
- }
- /** What a package manifest says about the package: identity, one-liner, and whether it is a bundle. */
- function inspectionOf(kind: 'registry' | 'path', manifest: object): Extract<PluginSpecInspection, { status: 'accepted' }> {
- const dsh = (manifest as { dsh?: unknown }).dsh
- const declared = typeof dsh === 'object' && dsh !== null ? dsh as { bundle?: unknown } : undefined
- const bundle = declared !== undefined && typeof declared.bundle === 'object' && declared.bundle !== null
- const name = stringField(manifest, 'name')
- const version = stringField(manifest, 'version')
- const description = stringField(manifest, 'description')
- return {
- status: 'accepted', kind, bundle,
- ...name === undefined ? {} : { name },
- ...version === undefined ? {} : { version },
- ...description === undefined || description === '' ? {} : { description },
- }
- }
- function refused(problem: PluginInspectProblem, reason: string): PluginSpecInspection {
- return { status: 'refused', problem, reason }
- }
- declare module '@deepseek-ai/cordis' {
- interface Context {
- /** Persistent management of the current profile's composition and packages. */
- pluginManager: PluginManager
- }
- }
- /** Manage profile files and apply their declared reload lifecycle. */
- export class PluginManager extends TypertRemoteService {
- static inject = ['loader', 'profileContext']
- static Config: z<Config> = z.object({
- pnpmCommand: z.string().default('pnpm'),
- outputBytes: z.number().step(1).min(1).default(16384),
- lockWaitMs: z.number().step(1).min(0).default(120000),
- inspectTimeoutMs: z.number().step(1).min(1000).default(20000),
- })
- private readonly ownerEntryId: string | undefined
- private readonly packageOperations = new Set<Promise<unknown>>()
- private readonly profile: ProfileContext
- private readonly outputBytes: number
- private readonly lockWaitMs: number
- private readonly inspectTimeoutMs: number
- private readonly pnpmCommand: string
- private readonly ownerContext: Context
- private readonly abort = new AbortController()
- /** Installations by request id, from their call until it settles. */
- private readonly installs = new Map<PluginInstallRequestId, InstallControl>()
- constructor(ctx: Context, config: Config) {
- super(ctx, 'pluginManager')
- this.ownerEntryId = ctx.fiber.entry?.id
- this.ownerContext = ctx
- this.profile = ctx.profileContext
- this.outputBytes = (config as Required<Config>).outputBytes
- this.lockWaitMs = (config as Required<Config>).lockWaitMs
- this.inspectTimeoutMs = (config as Required<Config>).inspectTimeoutMs
- this.pnpmCommand = (config as Required<Config>).pnpmCommand
- ctx.effect(() => async () => {
- this.abort.abort()
- await Promise.allSettled([...this.packageOperations])
- }, 'plugin-manager: package cancellation')
- }
- /** Read current plugins, including why a row cannot be changed through the profile patch.
- * @returns Current runtime entries with persistent patch targets.
- */
- @Remote
- async listPlugins(): Promise<PluginInfo[]> {
- const rows = flatten(composeEntries([readProfilePatches('dsh', this.profile)]))
- const snapshot = await readPluginInventory(this.ctx)
- return snapshot.entries.map((entry) => {
- const actual = [...this.ctx.loader.entries()].find(row => row.id === entry.entryId)
- const candidates = rows.filter(row => row.id === actual?.options.id)
- const candidate = candidates[0]
- if (protectedModules.has(entry.moduleName) || entry.entryId === this.ownerEntryId) {
- return { ...entry, readOnlyReason: 'management-required' as const }
- }
- if (candidate === undefined || candidates.length > 1 || candidate.name !== entry.moduleName
- || actual?.parent.tree.ctx.fiber.entry?.id !== 'include') {
- return { ...entry, readOnlyReason: 'unaddressable' as const }
- }
- return { ...entry, patchId: candidate.id }
- })
- }
- /** Read the profile's installed bundles, the bundles this dsh installation supplies, and the selected names that are not bundles.
- * A dependency without a bundle patch is listed, as a `not-bundle` problem, only while it is selected.
- * @returns Package versions, one-liners, rows, activation selections, whether the installation offers the
- * bundle, and removal availability.
- */
- @Remote
- listBundles(): Promise<BundleInfo[]> {
- const manifest = readProfileManifest('dsh', this.profile.dir)
- const selected = manifest.dsh?.profile?.bundles ?? []
- const dependencies = Object.keys(manifest.dependencies ?? {})
- const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
- const names = [...new Set([...selected, ...dependencies, ...Object.keys(installation.dependencies ?? {})])]
- const bundles: BundleInfo[] = []
- for (const name of names) {
- const installed = dependencies.includes(name)
- const optional = OPTIONAL_BUNDLES.includes(name)
- const removable = installed && !Object.hasOwn(installation.dependencies ?? {}, name)
- const enabled = selected.includes(name)
- try {
- const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
- if (info === undefined) {
- if (enabled) bundles.push({ name, enabled, installed, optional, removable, error: { code: 'not-bundle' }, rows: [], overrides: [] })
- continue
- }
- const readOnlyReason = this.protectsManager(name) ? 'management-required' as const : undefined
- bundles.push({ name, ...(info.version === undefined ? {} : { version: info.version }),
- ...(info.description === undefined || info.description === '' ? {} : { description: info.description }),
- enabled, installed, optional, removable: removable && readOnlyReason === undefined,
- ...(readOnlyReason === undefined ? {} : { readOnlyReason }),
- ...this.declaredRows(name, info) })
- } catch (error) {
- if (enabled || installed) {
- bundles.push({ name, enabled, installed, optional, removable, error: managementError(error), rows: [], overrides: [] })
- }
- }
- }
- return Promise.resolve(bundles)
- }
- /** Read what a spec names before installing it.
- * @param spec One package spec: a registry name, an absolute path, a git address, or a tarball.
- * @param signal Ends a registry lookup early.
- * @returns The package the spec names, or why it is refused.
- */
- @Remote
- async inspect(spec: string, signal?: AbortSignal): Promise<PluginSpecInspection> {
- let parsed
- try {
- parsed = parseInstallSpec(spec)
- } catch (error) {
- /* v8 ignore next 2 -- parseInstallSpec throws nothing but its own refusal */
- if (!(error instanceof InvalidInstallSpecError)) throw error
- return refused('invalid-spec', error.reason)
- }
- const manifest = readProfileManifest('dsh', this.profile.dir)
- const installation = JSON.parse(readFileSync(this.profile.installAnchor, 'utf8')) as InstallationManifest
- const known = new Set([
- ...manifest.dsh?.profile?.bundles ?? [], ...Object.keys(manifest.dependencies ?? {}), ...Object.keys(installation.dependencies ?? {}),
- ])
- switch (parsed.kind) {
- case 'git': return { status: 'accepted', kind: 'git', bundle: null }
- case 'tarball':
- if (parsed.path !== undefined && !existsSync(parsed.path)) return refused('not-a-package', 'the tarball does not exist')
- return { status: 'accepted', kind: 'tarball', bundle: null }
- case 'path': {
- if (!existsSync(parsed.path)) return refused('not-a-package', 'the path does not exist')
- let read: object
- try {
- read = JSON.parse(await readFile(join(parsed.path, 'package.json'), 'utf8')) as object
- } catch (error) {
- return refused('not-a-package', `no readable package.json at the path: ${messageOf(error)}`)
- }
- const inspection = inspectionOf('path', read)
- if (inspection.name === undefined) return refused('not-a-package', 'the package.json names no package')
- if (known.has(inspection.name)) return refused('already-installed', `${inspection.name} is already installed`)
- if (!inspection.bundle) return refused('not-a-bundle', `${inspection.name} declares no dsh.bundle`)
- return inspection
- }
- case 'registry': {
- if (known.has(parsed.name)) return refused('already-installed', `${parsed.name} is already installed`)
- const view = await viewProfilePackage(this.profile.dir, spec.trim(), {
- command: this.pnpmCommand, timeoutMs: this.inspectTimeoutMs, ...signal === undefined ? {} : { signal },
- })
- const log = `${view.stderr}${view.cause === undefined ? '' : `${messageOf(view.cause)}\n`}`.trim()
- if (view.exitCode !== 0 || view.cause !== undefined || view.timedOut) {
- const kind = classifyInstallFailure({ log, timedOut: view.timedOut, ...view.cause === undefined ? {} : { cause: view.cause } })
- const reason = log || view.stdout.trim() || `pnpm view exited with ${String(view.exitCode)}`
- if (kind === 'not-found' || kind === 'no-matching-version') return refused('not-found', reason)
- if (kind === 'network') return refused('network', reason)
- return refused('unknown', view.timedOut ? `pnpm view timed out after ${String(this.inspectTimeoutMs)}ms` : reason)
- }
- let answer: unknown
- try {
- answer = JSON.parse(view.stdout.replace(ANSI_SEQUENCE, '').trim() || 'null')
- } catch (error) {
- return refused('unknown', `unreadable pnpm view output: ${messageOf(error)}`)
- }
- // A range answers one object per matching version, oldest first.
- const latest: unknown = Array.isArray(answer) ? answer.at(-1) : answer
- if (typeof latest !== 'object' || latest === null) return refused('unknown', 'pnpm view answered no package')
- const inspection = inspectionOf('registry', latest)
- const named = inspection.name === undefined ? { ...inspection, name: parsed.name } : inspection
- if (!named.bundle) return refused('not-a-bundle', `${named.name} declares no dsh.bundle`)
- return named
- }
- }
- }
- /** Persist a plugin entry's desired enablement and apply it on live profiles.
- * @param id Loader entry identity returned by listPlugins.
- * @param enabled Whether the plugin should run.
- * @returns Saved and runtime outcomes, including higher-priority overrides.
- */
- @Remote
- setPluginEnabled(id: PluginEntryId, enabled: boolean): Promise<ChangeResult> {
- return this.change(result => this.configure(async () => {
- const row = (await this.listPlugins()).find(item => item.entryId === id)
- if (row === undefined) throw new ManagementFailure('unknown-plugin')
- if (row.readOnlyReason !== undefined) throw new ManagementFailure(row.readOnlyReason)
- await writePluginEnabled(this.profile.patchPath, row.patchId, row.moduleName, enabled)
- result.warnings = await this.reload(enabled ? [row.patchId] : [])
- const current = (await this.listPlugins()).find(item => item.entryId === id)
- return current?.enabled !== enabled && this.ownerContext.get('hmr') !== undefined ? 'overridden' : undefined
- }), { stage: 'enable', target: id, enabled }, 'plugin')
- }
- /** Select or remove a bundle layer while retaining installed dependencies.
- * @param name Bundle package name.
- * @param enabled Whether the bundle contributes its patch layer.
- * @returns Persisted and runtime outcomes.
- */
- @Remote
- setBundleEnabled(name: string, enabled: boolean): Promise<ChangeResult> {
- return this.change(result => this.configure(async () => {
- await this.selectBundle(name, enabled)
- result.warnings = await this.reload(enabled ? this.bundleRows(name).map(row => row.id) : [])
- }), { stage: 'enable', target: name, enabled }, 'bundle')
- }
- /**
- * Install a package using the same pnpm implementation as dsh plugin. A run
- * that fails, is cancelled, or adds a package without a bundle patch restores
- * `package.json` and `pnpm-lock.yaml` as they were; downloaded files can stay.
- * @param spec One package spec, including local paths relative to the invocation directory.
- * @param options Whether to activate the installed bundle (defaults to true), the request id a cancellation names, and
- * the pending build scripts to allow for this profile before pnpm runs.
- * @returns Package-manager diagnostics and observed activation outcome.
- */
- @Remote
- installBundle(spec: string, options?: InstallBundleOptions): Promise<ChangeResult> {
- const requestId = options?.requestId
- const control: InstallControl = { abort: new AbortController(), phase: 'installing', settled: Promise.resolve() }
- const stopped = (): boolean => control.abort.signal.aborted
- if (requestId !== undefined) this.installs.set(requestId, control)
- const announce = (phase: PluginInstallProgress['phase']): void => {
- if (requestId !== undefined) this.ownerContext.emit('plugin-manager/install-state', { requestId, phase })
- }
- const result = this.change(async (result) => {
- if (spec.trim() === '' || spec.startsWith('-')) throw new ManagementFailure('invalid-spec')
- if (stopped()) throw new InstallCancelledError()
- if (options?.approvedBuilds !== undefined) {
- await approveBuilds(this.profile.dir, options.approvedBuilds)
- result.approvedBuilds = options.approvedBuilds
- }
- const files = await this.readRestoredFiles()
- const before = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
- announce('installing')
- let name: string
- try {
- result.packageResult = await this.runPnpm(['add', spec], control.abort.signal, requestId)
- if (stopped()) throw new InstallCancelledError()
- if (result.packageResult.exitCode !== 0) {
- // pnpm-workspace.yaml is not restored, so the names pnpm left undecided there can be offered for approval.
- try { result.pendingBuilds = await readPendingBuilds(this.profile.dir) }
- catch (error) {
- this.ownerContext.logger.warn('Could not read pending build approvals after pnpm failed', error)
- }
- throw new Error(result.packageResult.output)
- }
- const after = readProfileManifest('dsh', this.profile.dir).dependencies ?? {}
- const installed = Object.keys(after).filter(name => before[name] !== after[name])
- // Registry retries can retain the saved range after a partial installation.
- if (installed.length === 0) installed.push(...Object.keys(after).filter(name => spec === name || spec.startsWith(`${name}@`)))
- const target = installed[0]
- if (installed.length !== 1 || target === undefined) throw new ManagementFailure('ambiguous-install')
- name = target
- const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
- const manifest = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
- if (manifest?.dsh?.bundle?.patch === undefined) throw new ManagementFailure('not-bundle')
- loadOverlayPatches('dsh', join(dir, manifest.dsh.bundle.patch))
- } catch (error) {
- // pnpm has exited by now, so the files it rewrote go back as they were.
- await this.restoreFiles(files)
- throw error
- }
- control.phase = 'applying'
- announce('applying')
- result.bundle = name
- result.target = name
- result.stage = 'enable'
- return this.configure(async () => {
- if (options?.enabled !== false) await this.selectBundle(name, true)
- if (Object.hasOwn(before, name)) return 'restart-required'
- if (options?.enabled !== false) result.warnings = await this.reload()
- })
- }, { stage: 'install', target: spec, enabled: options?.enabled !== false }, 'install')
- /* v8 ignore next -- change() folds every failure into its result; only a lock or disposal error rejects */
- control.settled = result.then(() => undefined, () => undefined)
- return result.finally(() => { if (requestId !== undefined) this.installs.delete(requestId) })
- }
- /** Stop an installation this manager owns and wait until its files are back.
- * @param requestId The id the installation was started with.
- * @returns `cancelled` once pnpm exited and the files are restored, `too-late` once the bundle is being
- * applied, `not-running` for any other id.
- */
- @Remote
- async cancelInstall(requestId: PluginInstallRequestId): Promise<PluginInstallCancellation> {
- const control = this.installs.get(requestId)
- if (control === undefined) return { status: 'not-running' }
- if (control.phase === 'applying') return { status: 'too-late' }
- this.ownerContext.emit('plugin-manager/install-state', { requestId, phase: 'cancelling' })
- control.abort.abort()
- await control.settled
- return { status: 'cancelled' }
- }
- /** Unload and remove a profile-owned bundle dependency through dsh plugin's pnpm path.
- * @param name Installed dependency name.
- * @returns Removal diagnostics and the remaining profile state.
- */
- @Remote
- removeBundle(name: string): Promise<ChangeResult> {
- return this.change(async (result) => {
- await this.configure(async () => {
- const bundle = (await this.listBundles()).find(item => item.name === name)
- if (bundle === undefined || !bundle.removable) throw new ManagementFailure('not-removable')
- if (this.ownerContext.get('hmr') === undefined && (this.profile.startedBundles.includes(name)
- || this.bundleRows(name).some(row => [...this.ctx.loader.entries()]
- .some(entry => entry.options.id === row.id && entry.fiber !== undefined)))) {
- throw new ManagementFailure('stop-profile')
- }
- const contributions = bundle.error === undefined ? this.bundleRows(name) : []
- if (bundle.enabled) {
- await this.selectBundle(name, false)
- result.warnings = await this.reload()
- }
- if ([...this.ctx.loader.entries()].some(entry => entry.fiber?.uid != null
- && contributions.some(row => row.id === entry.options.id && row.name === entry.options.name))) {
- throw new ManagementFailure('bundle-in-use')
- }
- })
- result.packageResult = await this.runPnpm(['remove', name])
- if (result.packageResult.exitCode !== 0) throw new Error(result.packageResult.output)
- }, { stage: 'remove', target: name }, 'remove')
- }
- /** The rows a bundle's patch inserts and the existing rows it changes; an unreadable patch throws. */
- private declaredRows(name: string, info: ProfileManifest): Pick<BundleInfo, 'rows' | 'overrides'> {
- const patch = info.dsh?.bundle?.patch
- /* v8 ignore next -- bundleManifest answers only manifests that declare a patch */
- if (patch === undefined) return { rows: [], overrides: [] }
- const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
- const patches: PatchOptions[] = loadOverlayPatches('dsh', join(dir, patch))
- // One entry per row id: the Loader keeps a single entry for an id, whichever layer declared it last.
- const live = new Map<string, PluginEntryId>()
- for (const entry of this.ctx.loader.entries()) {
- /* v8 ignore next -- the Loader gives every entry an id before it is listed */
- if (typeof entry.options.id === 'string') live.set(entry.options.id, pluginEntryId(entry.id))
- }
- const rows: BundleRowInfo[] = []
- for (const row of flatten(composeEntries([patches.filter(item => item.insert !== undefined)]))) {
- if (typeof row.id !== 'string' || typeof row.name !== 'string') continue
- const entryId = live.get(row.id)
- rows.push({ rowId: row.id, moduleName: row.name, ...entryId === undefined ? {} : { entryId } })
- }
- const declared = new Set(rows.map(row => row.rowId))
- const overrides = [...new Set(patches.flatMap(item =>
- item.insert === undefined && typeof item.id === 'string' && !declared.has(item.id) ? [item.id] : []))]
- return { rows, overrides }
- }
- /** Run one pnpm command in the profile, streaming its output as install-log chunks. */
- private async runPnpm(
- args: readonly string[], signal?: AbortSignal, requestId?: PluginInstallRequestId,
- ): Promise<PackageResult> {
- const jobId = randomUUID()
- const argv = ['pnpm', ...args]
- const cwd = this.profile.dir
- const identity = requestId === undefined ? {} : { requestId }
- const task = runProfilePnpm({ ...this.profile, profile: this.profile.name }, args, {
- execution: 'service', command: this.pnpmCommand,
- signal: signal === undefined ? this.abort.signal : AbortSignal.any([this.abort.signal, signal]),
- outputBytes: this.outputBytes, activateNewBundles: false,
- onOutput: (text, stream) => {
- this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream, text })
- },
- })
- this.packageOperations.add(task)
- try {
- const result = await task
- this.ownerContext.emit('plugin-manager/install-log', {
- ...identity, jobId, argv, cwd, stream: 'stdout', text: '', exitCode: signal?.aborted === true ? null : result.exitCode,
- })
- return result.exitCode === 0 ? result : { ...result, kind: classifyInstallFailure({ log: result.output }) }
- } catch (error) {
- this.ownerContext.emit('plugin-manager/install-log', { ...identity, jobId, argv, cwd, stream: 'stderr', text: messageOf(error), exitCode: null })
- throw error
- } finally {
- this.packageOperations.delete(task)
- }
- }
- /** The profile files an installation may rewrite, as they are now; absent files read as undefined. */
- private async readRestoredFiles(): Promise<Map<string, string | undefined>> {
- const files = new Map<string, string | undefined>()
- for (const name of RESTORED_FILES) {
- const path = join(this.profile.dir, name)
- files.set(path, existsSync(path) ? await readFile(path, 'utf8') : undefined)
- }
- return files
- }
- /** Put the profile files back; pnpm has exited by the time this runs. */
- private async restoreFiles(files: Map<string, string | undefined>): Promise<void> {
- for (const [path, content] of files) {
- if (content === undefined) await rm(path, { force: true })
- else await writeFileAtomic(path, content, { mode: 0o600 })
- }
- }
- private async selectBundle(name: string, enabled: boolean): Promise<void> {
- const manifest = readProfileManifest('dsh', this.profile.dir)
- const previous = manifest.dsh?.profile?.bundles ?? []
- if ((enabled || !previous.includes(name)) && bundleManifest(name, this.profile.dir, this.profile.installAnchor) === undefined) {
- throw new ManagementFailure('not-bundle')
- }
- if (!enabled && previous.includes(name)) {
- if (this.protectsManager(name)) throw new ManagementFailure('management-required')
- }
- const bundles = enabled ? [...previous, ...previous.includes(name) ? [] : [name]] : previous.filter(item => item !== name)
- if (JSON.stringify(previous) === JSON.stringify(bundles)) return
- manifest.dsh = { ...manifest.dsh, profile: { ...manifest.dsh?.profile, bundles } }
- await saveManifest(this.profile.dir, manifest)
- }
- private bundleRows(name: string): EntryOptions[] {
- const info = bundleManifest(name, this.profile.dir, this.profile.installAnchor)
- if (info?.dsh?.bundle === undefined) return []
- const dir = resolveBundleDir('dsh', name, this.profile.installAnchor, this.profile.dir)
- return flatten(composeEntries([loadOverlayPatches('dsh', join(dir, info.dsh.bundle.patch))]))
- }
- private protectsManager(name: string): boolean {
- return this.bundleRows(name).some(row => protectedModules.has(row.name) || `include:${row.id}` === this.ownerEntryId)
- }
- private configure<T>(operation: () => Promise<T>): Promise<T> {
- const hmr = this.ownerContext.get('hmr')
- const apply = () => { this.abort.signal.throwIfAborted(); return operation() }
- return hmr === undefined ? apply() : hmr.runExclusive(apply)
- }
- private async reload(requiredIds: readonly string[] = []): Promise<string[]> {
- if (this.ownerContext.get('hmr') === undefined) return []
- return reconcileProfilePatches(this.ownerContext.root, readProfilePatches('dsh', this.profile), 'dsh', requiredIds)
- }
- private async change(
- operation: (result: ChangeResult) => Promise<ChangeResult['application'] | void>,
- request: Pick<ChangeResult, 'stage' | 'target' | 'enabled'>,
- reason: PluginChange['reason'],
- ): Promise<ChangeResult> {
- return withFileLock(join(this.profile.dir, 'package.json'), async () => {
- this.abort.signal.throwIfAborted()
- const before = this.diskState()
- const result: ChangeResult = { ...request, changed: false,
- application: this.ownerContext.get('hmr') !== undefined ? 'applied' : 'restart-required' }
- try {
- result.application = await operation(result) ?? result.application
- } catch (error) {
- if (error instanceof InstallCancelledError) {
- result.application = 'cancelled'
- } else {
- result.application = 'failed'
- result.error = managementError(error)
- }
- }
- result.changed = before !== this.diskState()
- this.ownerContext.emit('plugin-manager/changed', { reason })
- return result
- }, { waitMs: this.lockWaitMs })
- }
- private diskState(): string {
- return ['package.json', 'cordis.patch.yml', 'pnpm-workspace.yaml'].map((file) => {
- try { return readFileSync(join(this.profile.dir, file), 'utf8') }
- catch (error) {
- if ((error as NodeJS.ErrnoException).code === 'ENOENT') return ''
- throw error
- }
- }).join('\0')
- }
- }
- export default PluginManager
|