@feasibleone/blong-gogo 1.31.2 → 1.32.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts ADDED
@@ -0,0 +1,209 @@
1
+ import minimist from 'minimist';
2
+ import {pathToFileURL} from 'node:url';
3
+
4
+ import load from './loadServer.ts';
5
+
6
+ /**
7
+ * Shared plumbing for a realm CLI — the shell around the `cli` intent.
8
+ *
9
+ * A realm CLI is the same realm in another mode. `runCli` loads the suite with
10
+ * the `cli` intent (no gateway, no RPC server, no watcher; every dispatch is
11
+ * resolved in-process), resolves the method the arguments name against the
12
+ * **live** registry, calls it, prints the result and stops. Going through the
13
+ * runtime rather than calling the handlers directly is what makes the CLI and the
14
+ * JSON-RPC surface agree — including the registry-backed answers an offline
15
+ * process could only approximate.
16
+ *
17
+ * What it removes is a handful of things that are subtle, identical in every CLI
18
+ * and wrong in interesting ways when re-derived: which channel the result goes
19
+ * on, that `handles()` alone cannot tell whether a method exists, the
20
+ * `load → start → dispatch → stop` order, and the exit-code conventions. Each of
21
+ * those has been gotten wrong at least once.
22
+ *
23
+ * Typical use, as the whole content of a `bin/*.ts`:
24
+ *
25
+ * ```ts
26
+ * import {isCliEntry, runCli} from '@feasibleone/blong-gogo/cli.ts';
27
+ * import cliSuite from '../cli.ts';
28
+ *
29
+ * const options = {
30
+ * suite: cliSuite,
31
+ * name: 'my-realm',
32
+ * usage: USAGE,
33
+ * command: ({positionals}) =>
34
+ * positionals[0] ? {method: `myRealm.${positionals[0]}.find`} : undefined,
35
+ * };
36
+ *
37
+ * if (isCliEntry(import.meta.url)) await runCli(options);
38
+ * ```
39
+ */
40
+
41
+ export interface Argv {
42
+ [key: string]: unknown;
43
+ _: (string | number)[];
44
+ }
45
+
46
+ export interface ParsedArgs {
47
+ /** The minimist view: named flags plus the `_` positionals. */
48
+ argv: Argv;
49
+ /** `_` coerced to strings, so `source get PATH` reads naturally. */
50
+ positionals: string[];
51
+ }
52
+
53
+ export interface CliCommand {
54
+ /** Fully qualified method, e.g. `kukum.handler.add`. */
55
+ method: string;
56
+ /**
57
+ * Handler params, passed through unchanged. Deliberately `object` rather
58
+ * than `Record<string, unknown>`: an interface of optional params is not
59
+ * assignable to an index signature, so every realm would have to cast.
60
+ */
61
+ params?: object;
62
+ }
63
+
64
+ export interface CliOptions {
65
+ /** The module the `cli` intent loads — normally the realm's `cli.ts`. */
66
+ suite: Parameters<typeof load>[0];
67
+ /** Suite and registry name; also the prefix of every error message. */
68
+ name: string;
69
+ /** Printed for `--help` and before a usage error. */
70
+ usage: string;
71
+ /**
72
+ * Flags to parse as strings (and so to accept repeatedly); every other flag
73
+ * is boolean or numeric.
74
+ */
75
+ stringFlags?: string[];
76
+ /** Intents passed to `load`; defaults to `['cli']`. */
77
+ intents?: string[];
78
+ /** Derive the method and its params from the arguments. */
79
+ command(args: ParsedArgs): CliCommand | undefined;
80
+ /** Rendering for `--output=text`; `undefined` falls back to JSON. */
81
+ format?(method: string, result: unknown): string | undefined;
82
+ /** Non-zero exit code for a call that completed (e.g. diagnostics errors). */
83
+ exitCodeFor?(method: string, result: unknown): number | undefined;
84
+ /** Appended to the unknown-method error, e.g. where to look next. */
85
+ hint?: string;
86
+ }
87
+
88
+ /** Parse a `process.argv.slice(2)`-style argument list. */
89
+ export function parseArgv(args: string[], stringFlags: string[] = []): ParsedArgs {
90
+ const argv = minimist(args, {string: stringFlags}) as Argv;
91
+ return {argv, positionals: argv._.map(String)};
92
+ }
93
+
94
+ /** True when `--help`, `-h` or a leading `help` was asked for. */
95
+ export function wantsHelp({argv, positionals}: ParsedArgs): boolean {
96
+ return argv.help === true || argv.h === true || positionals[0] === 'help';
97
+ }
98
+
99
+ /** The subset of the port API dispatch needs (`AdapterBase`). */
100
+ export type DispatchPort = {
101
+ handles: (method: string) => boolean;
102
+ findHandler: (method: string) => unknown;
103
+ };
104
+
105
+ export type DispatchTarget = {
106
+ port: DispatchPort;
107
+ fn: (params: unknown, meta: unknown) => Promise<unknown>;
108
+ };
109
+
110
+ /**
111
+ * Locate the port that owns a method.
112
+ *
113
+ * A method only exists if its layer loaded, and it is the port's own handler
114
+ * table that answers — the same entry `Remote._findMethod` resolves to when
115
+ * `canSkipSocket` is set. `handles()` alone is not enough: it matches the
116
+ * namespace **prefix**, so it reports true for a method that does not exist.
117
+ * `findHandler` returning a function is the real test, which is what lets an
118
+ * unknown method be reported as such instead of falling through to an HTTP call
119
+ * against a process that binds no port.
120
+ */
121
+ export function resolveMethod(
122
+ registry: {ports: Map<string, unknown>; getPort: (id: string) => unknown},
123
+ method: string,
124
+ ): DispatchTarget | undefined {
125
+ for (const id of registry.ports.keys()) {
126
+ const port = registry.getPort(id) as DispatchPort | undefined;
127
+ if (typeof port?.findHandler !== 'function') continue;
128
+ if (!port.handles(method)) continue;
129
+ const fn = port.findHandler(method);
130
+ if (typeof fn === 'function') {
131
+ return {port, fn: fn as DispatchTarget['fn']};
132
+ }
133
+ }
134
+ return undefined;
135
+ }
136
+
137
+ /**
138
+ * True when this module is the process entry point, so a `bin/*.ts` can be
139
+ * imported by a test without executing the command.
140
+ */
141
+ export function isCliEntry(importMetaUrl: string): boolean {
142
+ const entry = process.argv[1];
143
+ return Boolean(entry) && importMetaUrl === pathToFileURL(entry).href;
144
+ }
145
+
146
+ /**
147
+ * Run one command and exit with a conventional status.
148
+ *
149
+ * Sets `process.exitCode` rather than calling `process.exit()`, so a caller's
150
+ * own cleanup still runs; `1` means "the command did not do what was asked" for
151
+ * a usage error, an unknown method, a thrown error or a result the caller's
152
+ * `exitCodeFor` rejects.
153
+ */
154
+ export async function runCli(options: CliOptions): Promise<void> {
155
+ const {suite, name, usage, intents = ['cli']} = options;
156
+ const args = parseArgv(process.argv.slice(2), options.stringFlags);
157
+
158
+ if (wantsHelp(args)) {
159
+ process.stdout.write(usage);
160
+ return;
161
+ }
162
+ const command = options.command(args);
163
+ if (!command) {
164
+ process.stderr.write(usage);
165
+ process.exitCode = 1;
166
+ return;
167
+ }
168
+
169
+ // Keep stdout for the RESULT only. The loader logs to stdout, and
170
+ // `--output=json` has to stay parseable, so anything written while the realm
171
+ // loads and stops is forwarded to stderr — the loader's messages stay
172
+ // visible, just not on the result channel. `console.log` writes through the
173
+ // replacement too, which is why the result goes through the captured
174
+ // reference instead.
175
+ const json =
176
+ args.argv.output === 'json' || (args.argv.output === undefined && !process.stdout.isTTY);
177
+ const writeResult = process.stdout.write.bind(process.stdout);
178
+ process.stdout.write = ((chunk: string | Uint8Array) =>
179
+ process.stderr.write(chunk)) as typeof process.stdout.write;
180
+
181
+ try {
182
+ const registry = await load(suite, name, name, intents);
183
+ try {
184
+ // The load step that records handler folders and files still runs
185
+ // under `cli` (it is what feeds `Registry.describe()`); only the
186
+ // watcher and the listeners are off.
187
+ await registry.start({});
188
+ const resolved = resolveMethod(registry, command.method);
189
+ if (!resolved) {
190
+ process.stderr.write(
191
+ `${name}: unknown method '${command.method}'.${options.hint ? ` ${options.hint}` : ''}\n`,
192
+ );
193
+ process.exitCode = 1;
194
+ return;
195
+ }
196
+ const result = await resolved.fn.call(resolved.port, command.params ?? {}, {
197
+ method: command.method,
198
+ });
199
+ const text = options.format?.(command.method, result);
200
+ writeResult(`${!json && text ? text : JSON.stringify(result, null, 2)}\n`);
201
+ process.exitCode = options.exitCodeFor?.(command.method, result) ?? 0;
202
+ } finally {
203
+ await registry.stop();
204
+ }
205
+ } catch (error) {
206
+ process.stderr.write(`${name}: ${(error as Error).message}\n`);
207
+ process.exitCode = 1;
208
+ }
209
+ }
package/src/cliHelp.ts CHANGED
@@ -20,9 +20,10 @@ Usage:
20
20
 
21
21
  Well-known intents:
22
22
  dev Development — verbose logs, hot reload
23
- integration Integration testing — watch + test reruns
23
+ integration Integration testing — watch + test reruns (exits when CI is set)
24
24
  microservice Run a realm as a standalone microservice
25
25
  prod Production / UAT
26
+ cli Run a command in-process and exit — no HTTP server, no watcher
26
27
  db Database creation / seeding (short-lived)
27
28
  debug Enable /api/sys/* introspection + stack traces
28
29
 
@@ -0,0 +1,98 @@
1
+ import {server} from '@feasibleone/blong';
2
+ import {test} from 'tap';
3
+
4
+ import load from './loadServer.ts';
5
+
6
+ /**
7
+ * The process-lifetime decision is declared per intent in `load.ts` and resolved
8
+ * onto the registry, so a runner can ask the registry instead of pattern-matching
9
+ * intent names (which is what `runServer` used to do with
10
+ * `process.env.CI && !intents.includes('playwright')`).
11
+ *
12
+ * Only `load` is exercised, never `start`, so nothing binds a port and no
13
+ * watcher is created — the assertions here are about the flag, and the
14
+ * "no listener" property is checked by actually running `blong cli`.
15
+ */
16
+
17
+ /**
18
+ * `pkg` is supplied so the loader skips its `createRequire(mod.url)('./package.json')`
19
+ * lookup, which needs a real on-disk package next to the entry point.
20
+ */
21
+ const suite = () =>
22
+ server(
23
+ () =>
24
+ ({
25
+ url: import.meta.url,
26
+ pkg: {name: 'exit-intent-test', version: '0.0.0'},
27
+ children: [],
28
+ config: {default: {}},
29
+ }) as never,
30
+ );
31
+
32
+ /**
33
+ * `parentConfig` is passed as an object rather than a name on purpose: a string
34
+ * routes through ConfigRuntime's external config-file lookup, which is irrelevant
35
+ * here and would make the test depend on the working directory.
36
+ */
37
+ async function exitFor(intents: string[]): Promise<boolean | undefined> {
38
+ const registry = await load(
39
+ suite() as unknown as Parameters<typeof load>[0],
40
+ 'exit-intent-test',
41
+ {},
42
+ intents,
43
+ );
44
+ return registry.exit;
45
+ }
46
+
47
+ test('cli and db are short-lived', async t => {
48
+ t.equal(await exitFor(['cli']), true, 'a CLI does its work and exits');
49
+ t.equal(await exitFor(['db']), true, 'schema creation / seeding exits');
50
+ });
51
+
52
+ test('playwright is long-lived regardless of other intents', async t => {
53
+ t.equal(await exitFor(['playwright']), false, 'the webServer owns the process lifetime');
54
+ // The precedence that matters: `integration` is short-lived in CI, and adding
55
+ // `playwright` must override it — this is exactly what the old
56
+ // `!intents.includes('playwright')` clause did.
57
+ const previous = process.env.CI;
58
+ process.env.CI = '1';
59
+ try {
60
+ t.equal(await exitFor(['integration']), true, 'integration exits in CI');
61
+ t.equal(
62
+ await exitFor(['integration', 'playwright']),
63
+ false,
64
+ 'a later intent overrides an earlier one',
65
+ );
66
+ // Declares nothing, so it resolves to false — the registry always
67
+ // carries a boolean once loaded, and `undefined` only appears on
68
+ // implementations that do not set it at all.
69
+ t.equal(await exitFor(['dev']), false, 'dev declares no lifetime');
70
+ } finally {
71
+ if (previous === undefined) delete process.env.CI;
72
+ else process.env.CI = previous;
73
+ }
74
+ });
75
+
76
+ test('integration keeps its CI-only lifetime', async t => {
77
+ const previous = process.env.CI;
78
+ delete process.env.CI;
79
+ try {
80
+ t.equal(await exitFor(['integration']), false, 'outside CI the process keeps running');
81
+ } finally {
82
+ if (previous !== undefined) process.env.CI = previous;
83
+ }
84
+ });
85
+
86
+ test('the cli intent turns off everything that would outlive a command', async t => {
87
+ // Asserted through the flag it implies plus the fact that loading still
88
+ // succeeds: the individual keys are read by the infra-item filter, not by
89
+ // anything the registry exposes.
90
+ const registry = await load(
91
+ suite() as unknown as Parameters<typeof load>[0],
92
+ 'exit-intent-test',
93
+ {},
94
+ ['cli'],
95
+ );
96
+ t.equal(registry.exit, true, 'short-lived');
97
+ t.ok(Array.isArray(registry.describe?.().realms), 'the realm still loaded');
98
+ });
@@ -17,7 +17,7 @@ import {test} from 'tap';
17
17
 
18
18
  const repoRoot = resolve(dirname(fileURLToPath(import.meta.url)), '..', '..', '..');
19
19
  const blongBin = resolve(repoRoot, 'core', 'blong-gogo', 'bin', 'blong.ts');
20
- const helloDir = resolve(repoRoot, 'core', 'blong-hello');
20
+ const helloDir = resolve(repoRoot, 'demo', 'blong-hello');
21
21
 
22
22
  test(
23
23
  'blong CLI exits 0 with the shutdown marker after timeout sends SIGTERM',
package/src/load.ts CHANGED
@@ -19,6 +19,7 @@ import {
19
19
  } from '@feasibleone/blong/types';
20
20
 
21
21
  import {withProgress} from '@feasibleone/blong-lib';
22
+ import {WELL_KNOWN_LAYERS} from '@feasibleone/blong-lib/layers';
22
23
  import {Type, type TSchema} from 'typebox';
23
24
  import merge from 'ut-function.merge';
24
25
  import {methodParts} from './lib.ts';
@@ -30,6 +31,25 @@ const extension = '.ts';
30
31
 
31
32
  const LAYER_FILE = 'layer' as const;
32
33
 
34
+ /**
35
+ * Whether the process runs on CI.
36
+ *
37
+ * `load.ts` is shared by both platforms, and `process` does not exist in the
38
+ * browser bundle — a bare `process.env.CI` throws a `ReferenceError` while the
39
+ * browser realm loads (every browser test then fails with "process is not
40
+ * defined"). The optional chain keeps the server behaviour and degrades to
41
+ * `false` in the browser, which is correct: the server platform's `exit` drives
42
+ * the shutdown of both.
43
+ *
44
+ * A function rather than a module constant so the value is read when the config
45
+ * is built, not when the module is first imported.
46
+ */
47
+ function isCI(): boolean {
48
+ return Boolean(
49
+ (globalThis as {process?: {env?: Record<string, string | undefined>}}).process?.env?.['CI'],
50
+ );
51
+ }
52
+
33
53
  /**
34
54
  * An infrastructure item declaration with explicit dependencies.
35
55
  * Items are topologically sorted before instantiation so that each item's
@@ -72,30 +92,6 @@ function topoSort(items: InfraItem[]): InfraItem[] {
72
92
  return sorted;
73
93
  }
74
94
 
75
- /** Well-known layer folder names and their default activation per kind */
76
- const WELL_KNOWN_LAYERS: Record<string, {server?: object; browser?: object}> = {
77
- api: {server: {default: true}, browser: {default: true}},
78
- init: {server: {default: true}, browser: {default: true}},
79
- meta: {server: {default: true}, browser: {default: true}},
80
- error: {server: {integration: true}},
81
- sim: {server: {integration: true}},
82
- adapter: {server: {integration: true}},
83
- orchestrator: {server: {integration: true}},
84
- gateway: {server: {integration: true}},
85
- backend: {browser: {integration: true}},
86
- component: {browser: {integration: true}},
87
- action: {browser: {integration: true}},
88
- actions: {browser: {integration: true}},
89
- test: {browser: {integration: true}},
90
- 'server/api': {server: {integration: true}},
91
- 'server/init': {server: {default: true}},
92
- 'server/test': {server: {integration: true}},
93
- 'browser/api': {browser: {integration: true}},
94
- 'browser/init': {browser: {default: true}},
95
- 'browser/test': {browser: {integration: true}},
96
- 'browser/orchestrator': {browser: {integration: true}},
97
- };
98
-
99
95
  /**
100
96
  * Discover layer folders in a realm directory that are not already listed as children.
101
97
  * Returns a map of folderName -> activation config.
@@ -400,6 +396,12 @@ export default async function loadRealm<T extends TSchema>(
400
396
  if (!('pkg' in mod) && platformApi.platform === 'server')
401
397
  mod.pkg = platformApi.createRequire?.(mod.url)('./package.json');
402
398
  const loadedConfigs = [];
399
+ /**
400
+ * The framework's own intent blocks (`default` + the active intents),
401
+ * resolved once per root load. Kept so their explicit `false` values can be
402
+ * re-asserted after every source has merged.
403
+ */
404
+ let frameworkConfigs: (boolean | object)[] = [];
403
405
  let items:
404
406
  | IModuleConfig['children']
405
407
  | {
@@ -413,85 +415,124 @@ export default async function loadRealm<T extends TSchema>(
413
415
  platform: platformApi,
414
416
  manifest,
415
417
  } as unknown as typeof api;
416
- loadedConfigs.push(
417
- ...activeConfigs(
418
- {
419
- url: '',
420
- config: {
421
- default: {
422
- watch: {
423
- test: [],
424
- },
425
- log: {},
426
- apiSchema: {},
427
- error: {},
428
- registry: {},
429
- port: {},
430
- codec: {},
431
- adapter: {},
432
- orchestrator: {},
433
- remote: {
434
- canSkipSocket: rootKind === 'browser',
435
- },
436
- local: {},
437
- rpcServer: {},
438
- gateway: {},
439
- restFs: {},
440
- systemDebug: {},
441
- apiGateway: {},
418
+ // Retained so the "an intent's `false` wins" reconciliation can re-assert
419
+ // these after every source has merged (see below).
420
+ frameworkConfigs = activeConfigs(
421
+ {
422
+ url: '',
423
+ config: {
424
+ default: {
425
+ watch: {
426
+ test: [],
442
427
  },
443
- dev: {
444
- resolution: true,
445
- rpcServer: {
446
- port: 0,
428
+ log: {},
429
+ apiSchema: {},
430
+ error: {},
431
+ registry: {},
432
+ port: {},
433
+ codec: {},
434
+ adapter: {},
435
+ orchestrator: {},
436
+ remote: {
437
+ canSkipSocket: rootKind === 'browser',
438
+ },
439
+ local: {},
440
+ rpcServer: {},
441
+ gateway: {},
442
+ restFs: {},
443
+ systemDebug: {},
444
+ mcp: {},
445
+ apiGateway: {},
446
+ },
447
+ dev: {
448
+ resolution: true,
449
+ rpcServer: {
450
+ port: 0,
451
+ },
452
+ log: {
453
+ cacache: {
454
+ cachePath: '~/.blong/log-cache',
447
455
  },
448
- log: {
449
- cacache: {
450
- cachePath: '~/.blong/log-cache',
451
- },
456
+ },
457
+ gateway: {
458
+ port: 0,
459
+ static: {},
460
+ debug: true,
461
+ expectedErrors: true,
462
+ // Static development keys, so sessions survive server hot-reloads
463
+ /* cSpell:disable */
464
+ sign: {
465
+ kty: 'EC',
466
+ crv: 'P-384',
467
+ alg: 'ES384',
468
+ use: 'sig',
469
+ x: 'VlRkjgqRHJSk9WN8CaAqHn34BUMy9pgKQUAAW9MrOqh0yvCmJW7JTr6LUCbm9zfW',
470
+ y: '8eYxbAZrv-HZEc4LSgdEHeSp21zO3D8KrynMcVcNAmZKTf3RMkbkh1B26lePHQNz',
471
+ d: 'aj6BkYmpwkKRbmcO1LO6d__HX5bvkqcRjqadlX7plXlGfj1d42XiSUWa4c9xrxwt',
452
472
  },
453
- gateway: {
454
- port: 0,
455
- static: {},
456
- debug: true,
457
- expectedErrors: true,
458
- // Static development keys, so sessions survive server hot-reloads
459
- /* cSpell:disable */
460
- sign: {
461
- kty: 'EC',
462
- crv: 'P-384',
463
- alg: 'ES384',
464
- use: 'sig',
465
- x: 'VlRkjgqRHJSk9WN8CaAqHn34BUMy9pgKQUAAW9MrOqh0yvCmJW7JTr6LUCbm9zfW',
466
- y: '8eYxbAZrv-HZEc4LSgdEHeSp21zO3D8KrynMcVcNAmZKTf3RMkbkh1B26lePHQNz',
467
- d: 'aj6BkYmpwkKRbmcO1LO6d__HX5bvkqcRjqadlX7plXlGfj1d42XiSUWa4c9xrxwt',
468
- },
469
- encrypt: {
470
- kty: 'EC',
471
- crv: 'P-384',
472
- alg: 'ECDH-ES+A256KW',
473
- use: 'enc',
474
- x: '86IBoWsatO3Vky9CRMxmuYcfYoTY1Yr0D1sJGDgLlREMjbL9cIOHcBQnEaW52QJV',
475
- y: 'fsKOmTuXaIRFXXteh7uU0Z8mncX4VsPhqaz9pMKMm8EktQlF7HBS_fYFdkLwqMMN',
476
- d: 'rBY50TZzjONw_oYzWPqaR3DdoFwO-F9sWcmkOltrJHYnfbnTojNImX2xN1DhhC5-',
477
- },
478
- /* cSpell:enable */
473
+ encrypt: {
474
+ kty: 'EC',
475
+ crv: 'P-384',
476
+ alg: 'ECDH-ES+A256KW',
477
+ use: 'enc',
478
+ x: '86IBoWsatO3Vky9CRMxmuYcfYoTY1Yr0D1sJGDgLlREMjbL9cIOHcBQnEaW52QJV',
479
+ y: 'fsKOmTuXaIRFXXteh7uU0Z8mncX4VsPhqaz9pMKMm8EktQlF7HBS_fYFdkLwqMMN',
480
+ d: 'rBY50TZzjONw_oYzWPqaR3DdoFwO-F9sWcmkOltrJHYnfbnTojNImX2xN1DhhC5-',
479
481
  },
480
- systemDebug: {enabled: true},
482
+ /* cSpell:enable */
481
483
  },
482
- integration: {
483
- remote: {canSkipSocket: true},
484
- gateway: {
485
- debug: true,
486
- expectedErrors: true,
487
- },
484
+ systemDebug: {enabled: true},
485
+ },
486
+ integration: {
487
+ remote: {canSkipSocket: true},
488
+ gateway: {
489
+ debug: true,
490
+ expectedErrors: true,
488
491
  },
492
+ // Long-lived normally, but in CI the test run IS the
493
+ // work, so the process is expected to end after it.
494
+ exit: isCI(),
495
+ },
496
+ // Playwright drives the platform from its own webServer,
497
+ // so it has to outlive the test command. Declared here so
498
+ // the runner can ask the config instead of pattern-matching
499
+ // the intent names.
500
+ playwright: {exit: false},
501
+ // Schema creation / seeding finishes, then exits.
502
+ db: {exit: true},
503
+ /**
504
+ * A CLI process does its work in-process and exits: it
505
+ * serves nothing, watches nothing, and resolves every
506
+ * dispatch locally.
507
+ */
508
+ cli: {
509
+ gateway: false,
510
+ rpcServer: false,
511
+ apiGateway: false,
512
+ restFs: false,
513
+ systemDebug: false,
514
+ mcp: false,
515
+ resolution: false,
516
+ // `{enabled: false}`, NOT `false`: the load step that
517
+ // records handler folders and files still runs (it is
518
+ // what feeds `Registry.describe()`); only chokidar is
519
+ // skipped.
520
+ watch: {enabled: false, logLevel: 'warn'},
521
+ // Dispatch in-process instead of over HTTP.
522
+ remote: {canSkipSocket: true},
523
+ // A command's stdout carries its result and has to stay
524
+ // parseable, so the framework's own logging is quietened
525
+ // here rather than by each CLI remembering to do it.
526
+ log: {level: 'warn'},
527
+ apiSchema: {logLevel: 'warn'},
528
+ exit: true,
489
529
  },
490
530
  },
491
- configNames,
492
- platformApi.configs,
493
- ),
531
+ },
532
+ configNames,
533
+ platformApi.configs,
494
534
  );
535
+ loadedConfigs.push(...frameworkConfigs);
495
536
  items = topoSort([
496
537
  {
497
538
  name: 'log',
@@ -590,6 +631,11 @@ export default async function loadRealm<T extends TSchema>(
590
631
  deps: ['log', 'gateway', 'registry', 'rpcServer'],
591
632
  load: () => import(/* @vite-ignore */ './SystemDebug' + extension),
592
633
  },
634
+ {
635
+ name: 'mcp',
636
+ deps: ['log', 'gateway', 'registry', 'remote', 'apiSchema'],
637
+ load: () => import(/* @vite-ignore */ './Mcp' + extension),
638
+ },
593
639
  {
594
640
  name: 'registry',
595
641
  deps: [
@@ -662,6 +708,18 @@ export default async function loadRealm<T extends TSchema>(
662
708
  Object.assign(manifest, mergedConfig.manifest);
663
709
  }
664
710
 
711
+ // An intent that switches a feature OFF is not overridable by a template's
712
+ // convenience defaults. The standalone-realm wrapper below declares
713
+ // ephemeral `gateway`/`rpcServer` ports in its `default` block; that block is
714
+ // pushed *after* the framework's intent blocks and would otherwise silently
715
+ // re-enable a listener the `cli` intent deliberately disabled.
716
+ for (const config of frameworkConfigs) {
717
+ if (!config || typeof config !== 'object') continue;
718
+ for (const [key, value] of Object.entries(config)) {
719
+ if (value === false) (mergedConfig as Record<string, unknown>)[key] = false;
720
+ }
721
+ }
722
+
665
723
  // Wire ConfigRuntime into Watch so config-file changes trigger in-process
666
724
  // reload via ConfigRuntime.reload() instead of restarting the process.
667
725
  if (configRuntime) {
@@ -963,5 +1021,10 @@ export default async function loadRealm<T extends TSchema>(
963
1021
  }
964
1022
  realm ||= new RealmImpl(mergedConfig, api!, rootKind);
965
1023
  if (!api?.registry) throw new Error('Registry not found in loaded modules');
1024
+ // Resolved from the active intents (see the `exit` keys in the synthetic
1025
+ // config above). The runner uses it to decide whether this process outlives
1026
+ // its work; absent means "keep running". Set here rather than in the
1027
+ // constructor so the value is read after every config source has merged.
1028
+ api.registry.exit = Boolean(mergedConfig.exit);
966
1029
  return api.registry;
967
1030
  }
@@ -94,6 +94,9 @@ export default load.bind(null, {
94
94
  writeFileSync: () => {
95
95
  throw new Error('writeFileSync is not supported in the browser');
96
96
  },
97
+ mkdirSync: () => {
98
+ throw new Error('mkdirSync is not supported in the browser');
99
+ },
97
100
  statSync: (() => undefined) as unknown as import('node:fs').StatSyncFn,
98
101
  timing: timing(hrtime),
99
102
  configs: ['browser'],
package/src/loadServer.ts CHANGED
@@ -1,7 +1,7 @@
1
1
  import type {IPlatformApi} from '@feasibleone/blong';
2
2
  import {watch} from 'chokidar';
3
3
  import type {Dirent} from 'fs';
4
- import {existsSync, readFileSync, statSync, writeFileSync} from 'fs';
4
+ import {existsSync, mkdirSync, readFileSync, statSync, writeFileSync} from 'fs';
5
5
  import {readdir} from 'fs/promises';
6
6
  import minimist from 'minimist';
7
7
  import {createRequire} from 'node:module';
@@ -67,6 +67,7 @@ export default load.bind(null, {
67
67
  relative,
68
68
  readFileSync,
69
69
  writeFileSync,
70
+ mkdirSync,
70
71
  statSync,
71
72
  watch,
72
73
  timing: timing(hrtime),