mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
assertEntriesLoaded() flagged ANY fiber-less entry as a failed import, but a `disabled: true` entry settles without a fiber by design (Entry.refresh() skips init() when disabled) — a valid "plugin off" config, not a broken import. Both app bins now filter `fiber === undefined && !entry.disabled`. The stdio built-bin smoke gains a disabled-(unresolvable)-entry config that must still boot. The verify-package-paths lib-skip was unconditional and ran before the moved-package check, so a stale group-less `packages/acp-agent/lib/bin.js` (the exact drift this gate catches) was silently ignored just for containing `lib`. Scope the skip: only exempt `lib` when it is the segment after an EXISTING `packages/<group>/<pkg>` root, so a real-but-unbuilt `lib/bin.js` is still exempt while a stale package path flags.
141 lines
6.9 KiB
JavaScript
141 lines
6.9 KiB
JavaScript
#!/usr/bin/env node
|
|
/**
|
|
* The `dsh-stdio-agent` bin: boot a Cordis app from a leaf `cordis.yml` that
|
|
* loads the {@link @deepseek-ai/dsh-stdio-agent} app plugin (plus a backend LLM
|
|
* adapter and a bash executor). Owns the boot glue the three `examples/*` once
|
|
* duplicated in their `start.ts`: load the gitignored repo-root `.env`, then
|
|
* drive the cordis Loader against the config path (default `./cordis.yml`).
|
|
*
|
|
* Usage: `dsh-stdio-agent [path-to-cordis.yml]`. The `demo:echo` / `demo:coding`
|
|
* scripts invoke it with the example's config.
|
|
*
|
|
* @module @deepseek-ai/dsh-stdio-agent/bin
|
|
*/
|
|
|
|
import { pathToFileURL } from 'node:url'
|
|
import { dirname, resolve } from 'node:path'
|
|
import { Context } from 'cordis'
|
|
import Loader from '@cordisjs/plugin-loader'
|
|
|
|
/**
|
|
* Load `DEEPSEEK_API_KEY` / `DEEPSEEK_BASE_URL` from a gitignored `.env` in the
|
|
* CURRENT WORKING DIRECTORY (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/malformed
|
|
* `.env` is a real misconfiguration: surface it on stderr rather than silently
|
|
* running with the wrong environment. The mock-model demo (echo) ships no key
|
|
* and simply has no `.env`.
|
|
*/
|
|
function loadEnv(): void {
|
|
try {
|
|
process.loadEnvFile(resolve(process.cwd(), '.env'))
|
|
} catch (error) {
|
|
if ((error as NodeJS.ErrnoException | null)?.code !== 'ENOENT') {
|
|
process.stderr.write(`dsh-stdio-agent: failed to load .env: ${String(error)}\n`)
|
|
}
|
|
// ENOENT (no .env) is fine — rely on the ambient environment.
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Make a load failure fail loud with a clear message on stderr. Covers the
|
|
* failure path the entry-tree check below cannot: when the include's
|
|
* `[Service.init]` throws (e.g. a config FILE that does not exist in a real
|
|
* directory), the cordis Loader surfaces it as an unhandled promise rejection
|
|
* AFTER `boot()` has resolved — `loader.await()` does NOT rethrow it, because
|
|
* `EntryTree.await()` uses `Promise.allSettled`, which swallows rejections.
|
|
* Node's default handler already exits non-zero on an unhandled rejection, so
|
|
* this does not change the exit code; it replaces Node's noisy stack dump with a
|
|
* single labelled line and guarantees `process.exit(1)`. Install before `boot()`.
|
|
*/
|
|
export function installFailLoud(): void {
|
|
process.on('unhandledRejection', (err: unknown) => {
|
|
process.stderr.write(`dsh-stdio-agent: fatal load failure: ${err instanceof Error ? err.stack ?? err.message : String(err)}\n`)
|
|
process.exit(1)
|
|
})
|
|
}
|
|
|
|
/**
|
|
* After the tree settles, assert every loader entry actually started. This is
|
|
* the load-bearing guard against the SILENT-exit-0 bug: when a plugin module
|
|
* fails to IMPORT (e.g. a config path in a non-existent directory, so the include
|
|
* plugin itself cannot be resolved), the cordis Loader catches the import error
|
|
* and only LOGS it (`entry._init`), leaving the entry with no `fiber` and
|
|
* producing no rejection — so the process would otherwise exit 0 with a usable
|
|
* config typo reported only as a log line. A started entry has a `fiber`; an
|
|
* entry with `fiber === undefined` after the tree settled never loaded. Throw on
|
|
* any such entry so `boot()` rejects (and the top-level `await` fails the process
|
|
* non-zero) instead of returning a half-empty context.
|
|
*
|
|
* A `disabled` entry is the one legitimate fiber-less state: `Entry.refresh()`
|
|
* deliberately skips `init()` for it, so it settles without a fiber by design.
|
|
* That is a valid config (a consumer turning an optional plugin off), not a
|
|
* failed import — exclude it so the guard catches only real load failures.
|
|
*/
|
|
function assertEntriesLoaded(ctx: Context): 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(`dsh-stdio-agent: plugin(s) failed to load: ${names} (see the error(s) logged above)`)
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Boot the Loader against `configPath` (resolved from the CWD). The include is
|
|
* handed the config's ABSOLUTE `file://` URL as its `path`, so resolution never
|
|
* depends on `ctx.baseUrl` (an absolute URL ignores the base) and can never fall
|
|
* back to the cwd. `baseUrl` is still pinned to the config's directory so the
|
|
* config's OWN relative plugin/include paths (e.g. `./src/mock-llm.ts`) resolve
|
|
* against it. Returns the root context once the whole tree has settled.
|
|
*
|
|
* The `await ctx.loader.await()` is load-bearing: `loader.create()` returns once
|
|
* the include ENTRY is registered, but the include then loads its child plugins
|
|
* asynchronously. Without awaiting the tree, `boot()` (and `main()`) would
|
|
* resolve while the app plugins — the stdin reader, the agent loop — are still
|
|
* mounting, and a CLI process with no attached handles yet exits 0 silently.
|
|
* Awaiting the tree keeps the process alive until the app's handles are attached.
|
|
*
|
|
* `loader.await()` does NOT, however, rethrow load errors (`EntryTree.await()`
|
|
* uses `Promise.allSettled`), so failures are surfaced two ways: a plugin that
|
|
* fails to IMPORT leaves an entry with no fiber, caught here by
|
|
* {@link assertEntriesLoaded} (this `boot()` rejects); a plugin whose init
|
|
* THROWS surfaces as an unhandled rejection caught by {@link installFailLoud}
|
|
* (installed by `main()` before this runs). Together they make any load failure
|
|
* exit non-zero with a clear message.
|
|
*
|
|
* Bare plugin specifiers in the config (`@deepseek-ai/dsh-*`, npm packages) are
|
|
* resolved by the cordis Loader's internal module loader, which is only active
|
|
* under `node --expose-internals` (the flag the `demo:echo`/`demo:coding` scripts
|
|
* pass). Without it the Loader falls back to resolving relative to its own module
|
|
* and cannot find the config's plugins, so a consumer running the built bin must
|
|
* pass `--expose-internals` (or install the plugins where node hoists them).
|
|
*/
|
|
export async function boot(configPath: string): Promise<Context> {
|
|
const absolute = resolve(process.cwd(), configPath)
|
|
const ctx = new Context()
|
|
ctx.baseUrl = pathToFileURL(dirname(absolute)).href + '/'
|
|
await ctx.plugin(Loader)
|
|
await ctx.loader.create({
|
|
name: '@cordisjs/plugin-include',
|
|
config: { path: pathToFileURL(absolute).href },
|
|
})
|
|
await ctx.loader.await()
|
|
assertEntriesLoaded(ctx)
|
|
return ctx
|
|
}
|
|
|
|
/**
|
|
* Entry point: install the fail-loud guard, load `.env`, then boot the config
|
|
* named on argv (default `./cordis.yml`). Awaited at the module top level by the
|
|
* published bin (`#!/usr/bin/env node` shebang via the package's `bin` field).
|
|
*/
|
|
export async function main(argv: string[] = process.argv.slice(2)): Promise<void> {
|
|
installFailLoud()
|
|
loadEnv()
|
|
await boot(argv[0] ?? './cordis.yml')
|
|
}
|
|
|
|
/* v8 ignore start -- top-level CLI invocation; the testable core is boot()/main(), driven by the keyless Loader-path smoke */
|
|
await main()
|
|
/* v8 ignore stop */
|