Reject case variants of shipped profile names, detect a concurrent winner by the target's state instead of errno, warn instead of failing when staging cleanup fails after a successful replacement, and cover the restore paths with deterministic tests. Update the profile Agent Note, reference recovery path, and upgrade-guide migration steps.
354 lines
16 KiB
TypeScript
354 lines
16 KiB
TypeScript
/**
|
|
* Shared profile boot for every `dsh` surface: resolve the profile, stack its
|
|
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's
|
|
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the
|
|
* tree over the profile's empty root config, and wire fail-loud plus bounded shutdown.
|
|
*
|
|
* App flags are not the launcher's business: the invocation's inner arguments
|
|
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app
|
|
* plugin may read the same immutable snapshot.
|
|
* @module @deepseek-ai/dsh/profile-boot
|
|
*/
|
|
|
|
import { existsSync, mkdirSync, mkdtempSync, renameSync, rmSync, writeFileSync } from 'node:fs'
|
|
import { dirname, join, resolve } from 'node:path'
|
|
import { fileURLToPath } from 'node:url'
|
|
import { FiberState, type Context } from '@deepseek-ai/cordis'
|
|
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'
|
|
import {
|
|
boot,
|
|
readProfilePatches,
|
|
createRuntimeResolution,
|
|
initProfile,
|
|
installFailLoud,
|
|
loadOverlayPatches,
|
|
loadProfile,
|
|
reportSkippedBundles,
|
|
PluginPackages,
|
|
PROFILE_PATCH_FILENAME,
|
|
PROFILE_TEMPLATES,
|
|
resolveProfileDir,
|
|
type ProfileContext,
|
|
type Profile,
|
|
type RuntimeResolution,
|
|
} from '@deepseek-ai/dsh-app-boot'
|
|
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'
|
|
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'
|
|
import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'
|
|
import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'
|
|
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'
|
|
|
|
const NAME = 'dsh'
|
|
|
|
/** Launcher-owned readiness signal committed only after boot and host setup succeed. */
|
|
function createAppReady(): { service: AppReady; commit(): void } {
|
|
let ready = false
|
|
const listeners = new Set<() => void>()
|
|
return {
|
|
service: {
|
|
onReady(listener) {
|
|
if (ready) {
|
|
listener()
|
|
return () => {}
|
|
}
|
|
listeners.add(listener)
|
|
return () => { listeners.delete(listener) }
|
|
},
|
|
},
|
|
commit() {
|
|
if (ready) return
|
|
ready = true
|
|
for (const listener of [...listeners]) listener()
|
|
listeners.clear()
|
|
},
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied
|
|
* over every profile's own layer. Resolved per call, not at module load:
|
|
* `$DSH_HOME` may be set by the test or launcher after import.
|
|
* @returns the absolute patch-file path.
|
|
*/
|
|
export function homePatchPath(): string {
|
|
return join(resolveDshHome(), PROFILE_PATCH_FILENAME)
|
|
}
|
|
|
|
/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */
|
|
export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))
|
|
|
|
/** The empty root entry list every profile tree patches over. */
|
|
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:
|
|
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any
|
|
# --patch overlays. Edit cordis.patch.yml, not this file.
|
|
[]
|
|
`
|
|
|
|
/** Root config filename inside a profile directory. */
|
|
export const PROFILE_ROOT_FILENAME = 'cordis.yml'
|
|
|
|
/**
|
|
* Replace the named custom profile with a fresh copy of one shipped template.
|
|
* This copies only the template's bundle list; local state from the
|
|
* same-named shipped profile is not read, and no inheritance metadata is
|
|
* persisted. The new profile is assembled in a `.<name>.staging-*` directory
|
|
* under the profiles directory and moved into place; only then is any previous
|
|
* target directory, including its dependencies and user patch, deleted. A
|
|
* failed move restores the previous directory; when restoring also fails, it
|
|
* stays in the staging directory. Shipped profile names, compared without
|
|
* case, are reserved. Concurrent replacements leave one complete copy; each
|
|
* one either succeeds or reports the concurrent recreation.
|
|
* @param name - the profile name to create or replace.
|
|
* @param fromDefaultProfile - shipped profile template to copy.
|
|
* @param home - Harness home containing the profile directory.
|
|
* @throws when the template is unknown, the target name is shipped, another process recreates the target
|
|
* between the two renames, or a file-system operation fails; a restore failure is an `AggregateError`
|
|
* that names the staging path holding the previous profile.
|
|
*/
|
|
export function initializeProfileFromDefault(
|
|
name: string,
|
|
fromDefaultProfile: string,
|
|
home: string = resolveDshHome(),
|
|
): void {
|
|
const dir = resolveProfileDir(name, home)
|
|
const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)
|
|
? PROFILE_TEMPLATES[fromDefaultProfile]
|
|
: undefined
|
|
if (template === undefined) {
|
|
const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')
|
|
throw new Error(
|
|
`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,
|
|
)
|
|
}
|
|
// Case-insensitive file systems resolve a case variant such as `Web` to the shipped directory.
|
|
const shipped = Object.keys(PROFILE_TEMPLATES).find(shippedName => shippedName.toLowerCase() === name.toLowerCase())
|
|
if (shipped !== undefined) {
|
|
throw new Error(
|
|
`${NAME}: profile ${JSON.stringify(name)} names shipped profile ${JSON.stringify(shipped)} and cannot be a custom profile target; `
|
|
+ 'omit --from-default-profile to use it',
|
|
)
|
|
}
|
|
mkdirSync(dirname(dir), { recursive: true })
|
|
// The staging directory sits beside the target so both renames stay on one filesystem.
|
|
const staging = mkdtempSync(join(dirname(dir), `.${name}.staging-`))
|
|
const previous = join(staging, 'previous')
|
|
try {
|
|
const fresh = join(staging, 'new', name)
|
|
initProfile(fresh, template.bundles)
|
|
let moved = false
|
|
try {
|
|
renameSync(dir, previous)
|
|
moved = true
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') throw error
|
|
}
|
|
try {
|
|
renameSync(fresh, dir)
|
|
} catch (error) {
|
|
// Windows reports EPERM instead of EEXIST or ENOTEMPTY, so the target's state identifies a concurrent winner.
|
|
if (existsSync(dir)) {
|
|
// The concurrent replacement supersedes the previous profile.
|
|
rmSync(previous, { recursive: true, force: true })
|
|
throw new Error(`${NAME}: profile ${JSON.stringify(name)} was recreated concurrently at ${dir}`, { cause: error })
|
|
}
|
|
if (moved) {
|
|
try {
|
|
renameSync(previous, dir)
|
|
} catch (restoreError) {
|
|
throw new AggregateError(
|
|
[error, restoreError],
|
|
`${NAME}: profile replacement failed and ${dir} could not be restored from ${previous}`,
|
|
)
|
|
}
|
|
}
|
|
throw error
|
|
}
|
|
} catch (error) {
|
|
// A previous profile that could not be restored stays in staging at the reported path.
|
|
if (!existsSync(previous)) rmSync(staging, { recursive: true, force: true })
|
|
throw error
|
|
}
|
|
try {
|
|
rmSync(staging, { recursive: true, force: true })
|
|
} catch (error) {
|
|
// The replacement is complete; leftover files only occupy space.
|
|
process.stderr.write(`${NAME}: replaced profile ${JSON.stringify(name)} but could not remove ${staging}: ${String(error)}\n`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Load a resolved profile for `name` and (re)write the empty root config. The
|
|
* root is always rewritten: the whole composition is patch layers, and the
|
|
* vendored Loader's tree write-back (a plugin self-disposing persists the
|
|
* current tree) can bake composed rows into this file — which would duplicate
|
|
* every bundle insert on the next boot. The file exists on disk only because
|
|
* the Loader needs a real include root to anchor `baseUrl` at the profile
|
|
* directory (the config dump anchors on the same file, so both compose over
|
|
* the identical base).
|
|
* @param name - the profile name.
|
|
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).
|
|
* @param fromDefaultProfile - shipped template that replaces the profile before loading it.
|
|
* @returns the loaded profile.
|
|
* @throws when explicit initialization names an unknown template or a shipped target.
|
|
*/
|
|
export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {
|
|
if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)
|
|
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })
|
|
reportSkippedBundles(NAME, profile)
|
|
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
|
return profile
|
|
}
|
|
|
|
/** One profile's patch layers, in application order. */
|
|
interface ComposedProfile {
|
|
profile: Profile
|
|
/** Immutable runtime resolution computed before any plugin imports. */
|
|
resolution: RuntimeResolution
|
|
/** Command-line overlay contents, frozen for this invocation. */
|
|
overlays: PatchOptions[]
|
|
}
|
|
|
|
/**
|
|
* Load `name` and compose its effective patch stack: bundle layers in
|
|
* `dsh.profile.bundles` order (a base-backed profile gets the base bundle's
|
|
* platform-gated shell rows), the profile's user layer, the home-level user
|
|
* layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply
|
|
* to every profile, so it outranks the per-profile layer), `--patch` overlays,
|
|
* then the telemetry switch.
|
|
* @param name - the profile name.
|
|
* @param patchFiles - `--patch` overlay paths, in argv order.
|
|
* @param fromDefaultProfile - shipped template that replaces the named profile.
|
|
* @param resolvedProfile - application-owned profile and installation.
|
|
* @returns the profile and its patch layers.
|
|
*/
|
|
async function composeProfile(
|
|
name: string,
|
|
patchFiles: readonly string[],
|
|
fromDefaultProfile?: string,
|
|
resolvedProfile?: ResolvedProfileRuntime,
|
|
): Promise<ComposedProfile> {
|
|
const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)
|
|
if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)
|
|
const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }
|
|
const resolution = await createRuntimeResolution(resolutionOptions)
|
|
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))
|
|
return { profile, resolution, overlays }
|
|
}
|
|
|
|
/** An application-owned profile and its independent installation fallback. */
|
|
export interface ResolvedProfileRuntime {
|
|
/** Profile already loaded from the application's own directory. */
|
|
profile: Profile
|
|
/** Absolute package.json path of the application's dsh installation. */
|
|
installAnchor: string
|
|
}
|
|
|
|
/** Options for {@link runProfile}. */
|
|
export interface RunProfileOptions {
|
|
/** This run's frozen environment snapshot, provided before any entry mounts. */
|
|
environment: LaunchEnvironmentSnapshot
|
|
/** The profile name to boot. */
|
|
profile: string
|
|
/** Loaded application profile; bypasses named profile initialization when supplied. */
|
|
resolvedProfile?: ResolvedProfileRuntime | undefined
|
|
/** Shipped template that replaces the named profile before boot. */
|
|
fromDefaultProfile?: string | undefined
|
|
/** `--patch` overlay paths, in argv order. */
|
|
patchFiles: readonly string[]
|
|
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */
|
|
args: readonly string[]
|
|
/** Application-owned package runtime, scoped to plugin package operations. */
|
|
packageManager?: ProfileContext['packageManager']
|
|
}
|
|
|
|
/**
|
|
* Boot one profile invocation end to end and leave process lifetime to the
|
|
* mounted plugins (or to a one-shot runner the composition mounts).
|
|
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.
|
|
* @returns the settled root context and the shutdown controller.
|
|
* @throws after disposing startup resources; cleanup failures retain the original error.
|
|
*/
|
|
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {
|
|
// Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the
|
|
// proxy environment on its own, so every profile would otherwise connect directly. Resolving from
|
|
// the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer
|
|
// work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.
|
|
const disposeProxy = await installProxyFromEnvironment(
|
|
options.environment,
|
|
(message) => { process.stderr.write(`${NAME}: ${message}\n`) },
|
|
)
|
|
|
|
const app: { current?: Context } = {}
|
|
let disposal: Promise<void> | undefined
|
|
const dispose = (): Promise<void> => disposal ??= (async () => {
|
|
const failures: unknown[] = []
|
|
for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {
|
|
try { await release() } catch (error) { failures.push(error) }
|
|
}
|
|
if (failures.length === 1) throw failures[0]
|
|
if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')
|
|
})()
|
|
try {
|
|
const composed = await composeProfile(
|
|
options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,
|
|
)
|
|
const appReady = createAppReady()
|
|
const shutdown = createProcessShutdown(dispose)
|
|
const signalShutdown = new AbortController()
|
|
const interrupt = (code: number): void => {
|
|
signalShutdown.abort()
|
|
shutdown.interrupt(code)
|
|
}
|
|
// Signals own teardown throughout the startup window, not only after boot()
|
|
// settles: an inserted provider can publish before sibling rows finish mounting.
|
|
// SIGTERM is a supervisor's ordinary stop request and exits 0 on every
|
|
// surface — the launcher does not know whether the app considered its work
|
|
// complete; SIGINT is a user interrupt and reports 130.
|
|
process.on('SIGTERM', () => { interrupt(0) })
|
|
process.on('SIGINT', () => { interrupt(130) })
|
|
installFailLoud(NAME, process, async () => {
|
|
await app.current?.fiber.dispose()
|
|
})
|
|
|
|
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)
|
|
const profileContext: ProfileContext = {
|
|
name: options.profile,
|
|
...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),
|
|
dir: composed.profile.dir, patchPath: composed.profile.patchPath,
|
|
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,
|
|
startedBundles: composed.profile.layers.map(layer => layer.packageName),
|
|
cwd: process.cwd(), home: resolveDshHome(),
|
|
overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,
|
|
}
|
|
const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {
|
|
app.current = hostCtx
|
|
hostCtx.provide('profileContext', profileContext)
|
|
// Before any config-tree entry mounts, so plugins resolve all launch-time
|
|
// environment values from the same immutable launch snapshot.
|
|
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)
|
|
await hostCtx.plugin(PluginPackages, {
|
|
resolution: composed.resolution,
|
|
})
|
|
// The command line and bounded exit request are launcher facts available
|
|
// to every app plugin that injects the argument snapshot.
|
|
provideCmdline(hostCtx, {
|
|
args: options.args,
|
|
exit: code => void shutdown.shutdown(code),
|
|
ready: appReady.service,
|
|
})
|
|
})
|
|
app.current = ctx
|
|
if (!signalShutdown.signal.aborted
|
|
&& ctx.fiber.state === FiberState.ACTIVE
|
|
&& ctx.get('loader') !== undefined) {
|
|
appReady.commit()
|
|
}
|
|
return { ctx, shutdown }
|
|
} catch (error) {
|
|
try { await dispose() } catch (cleanupError) {
|
|
throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')
|
|
}
|
|
throw error
|
|
}
|
|
}
|