Files
deepseek-harness/packages/boot/plugin-manager/src/index.ts
T

595 lines
30 KiB
TypeScript

/** 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(), {
...this.profile.packageManager ?? { 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', ...this.profile.packageManager ?? { 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