diff --git a/packages/ui/acp-agent/tests/built-bin.e2e.ts b/packages/ui/acp-agent/tests/built-bin.e2e.ts index c6130130b9..b31082fa23 100644 --- a/packages/ui/acp-agent/tests/built-bin.e2e.ts +++ b/packages/ui/acp-agent/tests/built-bin.e2e.ts @@ -147,11 +147,11 @@ describe.skipIf(!existsSync(acpBin))('dsh-acp-agent BUILT bin (node lib/bin.js, }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A nonexistent directory prevents even the include plugin import. Loader logs the failure and - // leaves no fiber; boot's settled-entry guard must convert that state into non-zero exit. + // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config + // directory cannot break its import; the include plugin's own read must fail loud instead. const { code, stderr } = await runBinExpectingExit('/nonexistent/dir/cordis.yml') expect(code).not.toBe(0) - expect(stderr).toContain('failed to load') + expect(stderr).toContain('config file not found') }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => { diff --git a/packages/ui/app-boot/README.md b/packages/ui/app-boot/README.md index 73b126c33e..c3deaa4f48 100644 --- a/packages/ui/app-boot/README.md +++ b/packages/ui/app-boot/README.md @@ -8,7 +8,7 @@ Shared boot glue for the app bins ([`dsh-stdio-agent`](../stdio-agent/README.md) | `loadEnv(binName, dir?, warn?)` | Load the gitignored `.env` (Node `process.loadEnvFile`); absent file is fine, an unloadable one warns a single labelled line (default: stderr) | | `installFailLoud(binName, proc?)` | Turn a post-`boot()` unhandled Loader rejection into one labelled stderr line + `exit(1)`; returns the uninstaller (for tests) | | `assertEntriesLoaded(ctx, binName)` | Throw when a settled tree holds an enabled entry with no fiber (a plugin module that failed to import) | -| `boot(binName, absoluteConfigPath)` | Mount the Loader, include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | +| `boot(binName, absoluteConfigPath)` | Mount the Loader, mount the statically imported include plugin as the `cordis:include` builtin (so the config may live outside `node_modules` reach), include the config by absolute `file://` URL, await the whole tree, assert entries loaded, return the root context | Two failure classes the guards handle: `loader.await()` swallows init rejections (`Promise.allSettled`) — Node still exits non-zero on the resulting unhandled rejection, and `installFailLoud` replaces the noisy dump with one labelled line and a guaranteed `exit(1)`; a failed plugin IMPORT is only logged by the Loader (the process would otherwise exit 0 on a usable config typo), leaving a fiber-less entry that `assertEntriesLoaded` turns into a `boot()` rejection. diff --git a/packages/ui/app-boot/src/index.ts b/packages/ui/app-boot/src/index.ts index e270ead589..3521769816 100644 --- a/packages/ui/app-boot/src/index.ts +++ b/packages/ui/app-boot/src/index.ts @@ -9,6 +9,7 @@ import { pathToFileURL } from 'node:url' import { basename, dirname, resolve } from 'node:path' import { Context } from 'cordis' import Loader from '@cordisjs/plugin-loader' +import Include from '@cordisjs/plugin-include' /** * Resolve the config to boot. Replay swaps a `cordis.yml` basename for @@ -95,11 +96,16 @@ export function assertEntriesLoaded(ctx: Context, binName: string): void { /** * Boot the Loader against `absoluteConfigPath` and return only after the whole - * tree settles. The include uses an absolute file URL while `baseUrl` stays at - * the config directory for its relative imports. A missing fiber rejects here; - * a later init rejection is handled by {@link installFailLoud}. Built bins need - * `--expose-internals` or the Loader's native fallback for bare plugin - * specifiers; relative specifiers do not. + * tree settles. Entry names load through the Loader's internal module loader + * against `baseUrl` (the config directory), which may live outside + * `node_modules` reach and, unbuilt, cannot load vendored source; the + * bootstrap include is therefore statically imported and mounted as the + * `cordis:include` builtin, loading through the ambient module pipeline + * (vite/tsx/plain ESM) while the included tree's own specifiers stay + * config-relative. A missing fiber rejects here; a later init rejection is + * handled by {@link installFailLoud}. Built bins need `--expose-internals` or + * the Loader's native fallback for bare plugin specifiers; relative specifiers + * do not. * @param binName - the diagnostic prefix for load-failure errors. * @param absoluteConfigPath - the config to include; must already be absolute * (see {@link resolveConfigPath}). @@ -109,8 +115,9 @@ export async function boot(binName: string, absoluteConfigPath: string): Promise const ctx = new Context() ctx.baseUrl = pathToFileURL(dirname(absoluteConfigPath)).href + '/' await ctx.plugin(Loader) + ctx.loader.builtins.include = Include await ctx.loader.create({ - name: '@cordisjs/plugin-include', + name: 'cordis:include', config: { path: pathToFileURL(absoluteConfigPath).href }, }) await ctx.loader.await() diff --git a/packages/ui/stdio-agent/tests/built-bin.e2e.ts b/packages/ui/stdio-agent/tests/built-bin.e2e.ts index ddd7ca454e..5aa42bbcda 100644 --- a/packages/ui/stdio-agent/tests/built-bin.e2e.ts +++ b/packages/ui/stdio-agent/tests/built-bin.e2e.ts @@ -146,12 +146,12 @@ describe.skipIf(!existsSync(stdioBin))('dsh-stdio-agent BUILT bin (node lib/bin. }, 30_000) it('fails LOUD (non-zero exit + stderr) on a config whose directory does not exist', async () => { - // A nonexistent directory prevents even the include plugin import. Loader leaves no fiber, and - // boot's settled-entry guard must turn that state into a clear non-zero failure. + // boot() pre-resolves the bootstrap include to an absolute URL, so a nonexistent config + // directory cannot break its import; the include plugin's own read must fail loud instead. consumer = await makeConsumer('unused') const { code, stderr } = await runBuiltBin(consumer, '/nonexistent/dir/cordis.yml', '') expect(code).not.toBe(0) - expect(stderr).toContain('failed to load') + expect(stderr).toContain('config file not found') }, 30_000) it('fails LOUD (non-zero exit + stderr) on a missing config file in a real directory', async () => {