@feasibleone/blong-gogo 1.31.3 → 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/CHANGELOG.md +13 -0
- package/package.json +5 -3
- package/src/AdapterBase.ts +3 -0
- package/src/Gateway.ts +20 -0
- package/src/Mcp.ts +227 -0
- package/src/Registry.ts +39 -0
- package/src/Watch.ts +57 -7
- package/src/cli.ts +209 -0
- package/src/cliHelp.ts +2 -1
- package/src/exit.intent.test.ts +98 -0
- package/src/load.ts +157 -94
- package/src/loadBrowser.ts +3 -0
- package/src/loadServer.ts +2 -1
- package/src/runServer.ts +7 -2
- package/src/template-files.ts +10 -49
- package/template/browser/test/test/test$Object.flow.ts +14 -4
- package/template/server/test/test/test$Object.ts +16 -5
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
|
+
});
|
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
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
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
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
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
|
-
|
|
449
|
-
|
|
450
|
-
|
|
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
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
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
|
-
|
|
482
|
+
/* cSpell:enable */
|
|
481
483
|
},
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
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
|
-
|
|
492
|
-
|
|
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
|
}
|
package/src/loadBrowser.ts
CHANGED
|
@@ -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),
|