1
/**2
* Shared profile boot for every `dsh` surface: resolve the profile, stack its3
* patch layers (bundle layers in `dsh.profile.bundles` order, the profile's4
* own `cordis.patch.yml`, `--patch` overlays, the telemetry switch), mount the5
* tree over the profile's empty root config, and wire fail-loud plus bounded shutdown.6
*7
* App flags are not the launcher's business: the invocation's inner arguments8
* are provided to the tree through `ctx.cmdlineArgs`, where any injected app9
* plugin may read the same immutable snapshot.10
* @module @deepseek-ai/dsh/profile-boot11
*/13
import { existsSync, mkdirSync, rmSync, writeFileSync } from 'node:fs'14
import { dirname, join, resolve } from 'node:path'15
import { fileURLToPath } from 'node:url'16
import { FiberState, type Context } from '@deepseek-ai/cordis'17
import type { PatchOptions } from '@deepseek-ai/cordis-plugin-include'18
import {19
boot,20
readProfilePatches,21
createRuntimeResolution,22
initProfile,23
installFailLoud,24
loadOverlayPatches,25
loadProfile,26
reportSkippedBundles,27
PluginPackages,28
PROFILE_PATCH_FILENAME,29
PROFILE_TEMPLATES,30
resolveProfileDir,31
type ProfileContext,32
type Profile,33
type RuntimeResolution,34
} from '@deepseek-ai/dsh-app-boot'35
import { resolveDshHome } from '@deepseek-ai/dsh-home-paths'36
import { installProxyFromEnvironment } from '@deepseek-ai/dsh-http-proxy'37
import { DSH_LAUNCH_ENVIRONMENT_KEY, type LaunchEnvironmentSnapshot } from '@deepseek-ai/dsh-launch-environment'38
import { provideCmdline, type AppReady } from '@deepseek-ai/dsh-cmdline'39
import { createProcessShutdown, type ProcessShutdown } from './process-shutdown.ts'41
const NAME = 'dsh'43
/** Launcher-owned readiness signal committed only after boot and host setup succeed. */44
function createAppReady(): { service: AppReady; commit(): void } {45
let ready = false46
const listeners = new Set<() => void>()47
return {48
service: {49
onReady(listener) {50
if (ready) {51
listener()52
return () => {}53
}54
listeners.add(listener)55
return () => { listeners.delete(listener) }56
},57
},58
commit() {59
if (ready) return60
ready = true61
for (const listener of [...listeners]) listener()62
listeners.clear()63
},64
}65
}67
/**68
* The home-level user patch layer (`$DSH_HOME/cordis.patch.yml`), applied69
* over every profile's own layer. Resolved per call, not at module load:70
* `$DSH_HOME` may be set by the test or launcher after import.71
* @returns the absolute patch-file path.72
*/73
export function homePatchPath(): string {74
return join(resolveDshHome(), PROFILE_PATCH_FILENAME)75
}77
/** Absolute path of this dsh installation's package.json (both anchors: src/ and lib/ sit one level under apps/cli). */78
export const INSTALL_ANCHOR = fileURLToPath(new URL('../package.json', import.meta.url))80
/** The empty root entry list every profile tree patches over. */81
const PROFILE_ROOT_CONFIG = `# dsh profile root — an empty entry list. The tree is composed as patches:82
# each bundle in package.json's dsh.profile.bundles, then cordis.patch.yml, then any83
# --patch overlays. Edit cordis.patch.yml, not this file.84
[]85
`87
/** Root config filename inside a profile directory. */88
export const PROFILE_ROOT_FILENAME = 'cordis.yml'90
/**91
* Initialize a missing profile from one shipped template. This copies only92
* the template's bundle list; local state from the93
* same-named shipped profile is not read, and no inheritance metadata is94
* persisted. Shipped profile names are reserved, and the target directory is95
* claimed exclusively so existing or concurrent state is never reused.96
* @param name - the new profile name.97
* @param fromDefaultProfile - shipped profile template to copy.98
* @param home - Harness home containing the profile directory.99
* @throws when the template is unknown, the target name is shipped, or the target directory exists.100
*/101
export function initializeProfileFromDefault(102
name: string,103
fromDefaultProfile: string,104
home: string = resolveDshHome(),105
): void {106
const dir = resolveProfileDir(name, home)107
const template = Object.hasOwn(PROFILE_TEMPLATES, fromDefaultProfile)108
? PROFILE_TEMPLATES[fromDefaultProfile]109
: undefined110
if (template === undefined) {111
const expected = Object.keys(PROFILE_TEMPLATES).sort().map(value => JSON.stringify(value)).join(', ')112
throw new Error(113
`${NAME}: unknown default profile ${JSON.stringify(fromDefaultProfile)}; expected one of ${expected}`,114
)115
}116
if (Object.hasOwn(PROFILE_TEMPLATES, name)) {117
throw new Error(118
`${NAME}: profile ${JSON.stringify(name)} is shipped and cannot be a custom profile target; `119
+ 'omit --from-default-profile to use it',120
)121
}122
mkdirSync(dirname(dir), { recursive: true })123
try {124
mkdirSync(dir)125
} catch (error) {126
if ((error as NodeJS.ErrnoException).code !== 'EEXIST') throw error127
const manifestPath = join(dir, 'package.json')128
if (existsSync(manifestPath)) {129
throw new Error(130
`${NAME}: profile ${JSON.stringify(name)} already exists at ${manifestPath}; `131
+ 'omit --from-default-profile to use it',132
)133
}134
throw new Error(135
`${NAME}: profile directory ${dir} already exists; choose an unused profile name`,136
)137
}138
try {139
initProfile(dir, template.bundles)140
} catch (error) {141
try {142
rmSync(dir, { recursive: true, force: true })143
} catch (cleanupError) {144
throw new AggregateError(145
[error, cleanupError],146
`${NAME}: profile initialization failed and ${dir} could not be removed`,147
)148
}149
throw error150
}151
}152
/**153
* Load a resolved profile for `name` and (re)write the empty root config. The154
* root is always rewritten: the whole composition is patch layers, and the155
* vendored Loader's tree write-back (a plugin self-disposing persists the156
* current tree) can bake composed rows into this file — which would duplicate157
* every bundle insert on the next boot. The file exists on disk only because158
* the Loader needs a real include root to anchor `baseUrl` at the profile159
* directory (the config dump anchors on the same file, so both compose over160
* the identical base).161
* @param name - the profile name.162
* @param userLayer - `false` skips parsing `cordis.patch.yml` (the default dump).163
* @param fromDefaultProfile - shipped template used once to initialize a missing profile.164
* @returns the loaded profile.165
* @throws when explicit initialization names an unknown template or an existing profile.166
*/167
export function prepareProfile(name: string, userLayer = true, fromDefaultProfile?: string): Profile {168
if (fromDefaultProfile !== undefined) initializeProfileFromDefault(name, fromDefaultProfile)169
const profile = loadProfile(NAME, name, INSTALL_ANCHOR, undefined, { userLayer })170
reportSkippedBundles(NAME, profile)171
writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)172
return profile173
}175
/** One profile's patch layers, in application order. */176
interface ComposedProfile {177
profile: Profile178
/** Immutable runtime resolution computed before any plugin imports. */179
resolution: RuntimeResolution180
/** Command-line overlay contents, frozen for this invocation. */181
overlays: PatchOptions[]182
}184
/**185
* Load `name` and compose its effective patch stack: bundle layers in186
* `dsh.profile.bundles` order (a base-backed profile gets the base bundle's187
* platform-gated shell rows), the profile's user layer, the home-level user188
* layer (`$DSH_HOME/cordis.patch.yml` — machine-local preferences that apply189
* to every profile, so it outranks the per-profile layer), `--patch` overlays,190
* then the telemetry switch.191
* @param name - the profile name.192
* @param patchFiles - `--patch` overlay paths, in argv order.193
* @param fromDefaultProfile - shipped template for a missing named profile.194
* @param resolvedProfile - application-owned profile and installation.195
* @returns the profile and its patch layers.196
*/197
async function composeProfile(198
name: string,199
patchFiles: readonly string[],200
fromDefaultProfile?: string,201
resolvedProfile?: ResolvedProfileRuntime,202
): Promise<ComposedProfile> {203
const profile = resolvedProfile?.profile ?? prepareProfile(name, true, fromDefaultProfile)204
if (resolvedProfile !== undefined) writeFileSync(join(profile.dir, PROFILE_ROOT_FILENAME), PROFILE_ROOT_CONFIG)205
const resolutionOptions = { installAnchor: resolvedProfile?.installAnchor ?? INSTALL_ANCHOR, profile }206
const resolution = await createRuntimeResolution(resolutionOptions)207
const overlays = patchFiles.flatMap(file => loadOverlayPatches(NAME, resolve(file)))208
return { profile, resolution, overlays }209
}211
/** An application-owned profile and its independent installation fallback. */212
export interface ResolvedProfileRuntime {213
/** Profile already loaded from the application's own directory. */214
profile: Profile215
/** Absolute package.json path of the application's dsh installation. */216
installAnchor: string217
}219
/** Options for {@link runProfile}. */220
export interface RunProfileOptions {221
/** This run's frozen environment snapshot, provided before any entry mounts. */222
environment: LaunchEnvironmentSnapshot223
/** The profile name to boot. */224
profile: string225
/** Loaded application profile; bypasses named profile initialization when supplied. */226
resolvedProfile?: ResolvedProfileRuntime | undefined227
/** Shipped template used once to initialize a missing profile. */228
fromDefaultProfile?: string | undefined229
/** `--patch` overlay paths, in argv order. */230
patchFiles: readonly string[]231
/** The invocation's inner arguments, handed to the tree through `ctx.cmdlineArgs`. */232
args: readonly string[]233
/** Application-owned package runtime, scoped to plugin package operations. */234
packageManager?: ProfileContext['packageManager']235
}237
/**238
* Boot one profile invocation end to end and leave process lifetime to the239
* mounted plugins (or to a one-shot runner the composition mounts).240
* @param options - environment snapshot, profile name, overlays, and the booted app's own arguments.241
* @returns the settled root context and the shutdown controller.242
* @throws after disposing startup resources; cleanup failures retain the original error.243
*/244
export async function runProfile(options: RunProfileOptions): Promise<{ ctx: Context; shutdown: ProcessShutdown }> {245
// Before the first plugin mounts and before anything can issue a request: Node's fetch ignores the246
// proxy environment on its own, so every profile would otherwise connect directly. Resolving from247
// the launcher's snapshot — not `process.env` — is what lets a proxy declared in a `.env` layer248
// work, which the NODE_USE_ENV_PROXY flag cannot do because Node samples the environment at start.249
const disposeProxy = await installProxyFromEnvironment(250
options.environment,251
(message) => { process.stderr.write(`${NAME}: ${message}\n`) },252
)254
const app: { current?: Context } = {}255
let disposal: Promise<void> | undefined256
const dispose = (): Promise<void> => disposal ??= (async () => {257
const failures: unknown[] = []258
for (const release of [() => app.current?.fiber.dispose(), disposeProxy]) {259
try { await release() } catch (error) { failures.push(error) }260
}261
if (failures.length === 1) throw failures[0]262
if (failures.length > 1) throw new AggregateError(failures, 'dsh: profile cleanup failed')263
})()264
try {265
const composed = await composeProfile(266
options.profile, options.patchFiles, options.fromDefaultProfile, options.resolvedProfile,267
)268
const appReady = createAppReady()269
const shutdown = createProcessShutdown(dispose)270
const signalShutdown = new AbortController()271
const interrupt = (code: number): void => {272
signalShutdown.abort()273
shutdown.interrupt(code)274
}275
// Signals own teardown throughout the startup window, not only after boot()276
// settles: an inserted provider can publish before sibling rows finish mounting.277
// SIGTERM is a supervisor's ordinary stop request and exits 0 on every278
// surface — the launcher does not know whether the app considered its work279
// complete; SIGINT is a user interrupt and reports 130.280
process.on('SIGTERM', () => { interrupt(0) })281
process.on('SIGINT', () => { interrupt(130) })282
installFailLoud(NAME, process, async () => {283
await app.current?.fiber.dispose()284
})286
const rootConfig = join(composed.profile.dir, PROFILE_ROOT_FILENAME)287
const profileContext: ProfileContext = {288
name: options.profile,289
...(options.packageManager === undefined ? {} : { packageManager: options.packageManager }),290
dir: composed.profile.dir, patchPath: composed.profile.patchPath,291
installAnchor: options.resolvedProfile?.installAnchor ?? INSTALL_ANCHOR,292
startedBundles: composed.profile.layers.map(layer => layer.packageName),293
cwd: process.cwd(), home: resolveDshHome(),294
overlays: composed.overlays, telemetryDisabledEnv: process.env.DSH_TELEMETRY_DISABLED,295
}296
const ctx = await boot(NAME, rootConfig, readProfilePatches(NAME, profileContext, composed.profile), async (hostCtx) => {297
app.current = hostCtx298
hostCtx.provide('profileContext', profileContext)299
// Before any config-tree entry mounts, so plugins resolve all launch-time300
// environment values from the same immutable launch snapshot.301
hostCtx.provide(DSH_LAUNCH_ENVIRONMENT_KEY, options.environment)302
await hostCtx.plugin(PluginPackages, {303
resolution: composed.resolution,304
})305
// The command line and bounded exit request are launcher facts available306
// to every app plugin that injects the argument snapshot.307
provideCmdline(hostCtx, {308
args: options.args,309
exit: code => void shutdown.shutdown(code),310
ready: appReady.service,311
})312
})313
app.current = ctx314
if (!signalShutdown.signal.aborted315
&& ctx.fiber.state === FiberState.ACTIVE316
&& ctx.get('loader') !== undefined) {317
appReady.commit()318
}319
return { ctx, shutdown }320
} catch (error) {321
try { await dispose() } catch (cleanupError) {322
throw new AggregateError([error, cleanupError], 'dsh: profile startup and cleanup failed')323
}324
throw error325
}326
}