Files
deepseek-harness/packages/ui/app-boot/src/index.ts
T
2026-07-12 03:36:43 +08:00

119 lines
4.7 KiB
TypeScript

/**
* Shared boot glue for the app bins (`dsh-stdio-agent`, `dsh-acp-agent`): load the gitignored
* `.env`, install the fail-loud Loader guards, resolve the config path (snapshot-aware), and
* drive the cordis Loader against a leaf `cordis.yml` until the whole tree has settled.
* @module @deepseek-ai/dsh-app-boot
*/
import { pathToFileURL } from 'node:url'
import { basename, dirname, resolve } from 'node:path'
import { Context } from 'cordis'
import Loader from '@cordisjs/plugin-loader'
/**
* Resolve the config to boot, honoring snapshot replay.
*
* @param configPath - the requested config path (absolute, or relative to `cwd`).
* @param snapshotMode - the bin's `$DSH_SNAPSHOT` value; only `'replay'` swaps the
* basename.
* @param cwd - the base a relative `configPath` resolves against.
* @returns the absolute path of the config to boot.
*/
export function resolveConfigPath(
configPath: string, snapshotMode: string | undefined, cwd: string = process.cwd(),
): string {
const absolute = resolve(cwd, configPath)
if (snapshotMode !== 'replay') return absolute
const dir = dirname(absolute)
const replayName = basename(absolute).replace(/cordis\.ya?ml$/, 'cordis.snapshot.yml')
return resolve(dir, replayName)
}
/**
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in
* `dir` (Node native `process.loadEnvFile`). An absent file is fine — the
* environment may already carry the variables; the leaf `cordis.yml` reads
* them via the `!!js` tag. A present-but-unreadable `.env` is a real
* misconfiguration: surface it via `warn` (one line, default stderr) rather
* than silently running with the wrong environment.
* @param binName - the diagnostic prefix on the warn line.
* @param dir - the directory whose `.env` to load.
* @param warn - sink for the one-line misconfiguration diagnostic.
*/
export function loadEnv(
binName: string, dir: string = process.cwd(),
warn: (line: string) => void = line => void process.stderr.write(line),
): void {
try {
process.loadEnvFile(resolve(dir, '.env'))
} catch (error) {
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
warn(`${binName}: failed to load .env: ${String(error)}\n`)
}
// ENOENT (no .env) is fine — rely on the ambient environment.
}
}
/**
* The slice of `process` {@link installFailLoud} needs — injectable so tests
* exercise the handler without registering on (or exiting) the real process.
*/
export interface FailLoudProcess {
on(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
off(event: 'unhandledRejection', handler: (err: unknown) => void): unknown
stderr: { write(chunk: string): unknown }
exit(code: number): void
}
/**
* Make a load failure fail loud with a clear message on stderr.
*
* @param binName - the diagnostic prefix on the fatal-failure line.
* @param proc - the process slice to register on; tests inject a fake.
* @returns the uninstaller that removes the rejection handler.
*/
export function installFailLoud(binName: string, proc: FailLoudProcess = process): () => void {
const handler = (err: unknown): void => {
proc.stderr.write(`${binName}: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
proc.exit(1)
}
proc.on('unhandledRejection', handler)
return () => void proc.off('unhandledRejection', handler)
}
/**
* After the tree settles, assert every loader entry actually started.
*
* @param ctx - the settled context whose loader entries to audit.
* @param binName - the diagnostic prefix on the thrown error.
*/
export function assertEntriesLoaded(ctx: Context, binName: string): void {
const failed = [...ctx.loader.entries()].filter(entry => entry.fiber === undefined && !entry.disabled)
if (failed.length > 0) {
const names = failed.map(entry => entry.options.name).join(', ')
throw new Error(`${binName}: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
}
}
/**
* Boot the Loader against `absoluteConfigPath` and return the root context once the whole tree
* has settled.
*
* @param binName - the diagnostic prefix for load-failure errors.
* @param absoluteConfigPath - the config to include; must already be absolute
* (see {@link resolveConfigPath}).
* @returns the root context once every entry has started.
*/
export async function boot(binName: string, absoluteConfigPath: string): Promise<Context> {
const ctx = new Context()
ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/'
await ctx.plugin(Loader)
await ctx.loader.create({
name: '@cordisjs/plugin-include',
config: { path: pathToFileURL(absoluteConfigPath).href },
})
await ctx.loader.await()
assertEntriesLoaded(ctx, binName)
return ctx
}