@forgeax/engine-remote 0.1.3 → 0.1.6
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/README.md +3 -3
- package/dist/.tsbuildinfo +1 -1
- package/dist/__tests__/cli-defaults.unit.test.d.ts +2 -0
- package/dist/__tests__/cli-defaults.unit.test.d.ts.map +1 -0
- package/dist/__tests__/simulation-inspect.integration.test.d.ts +2 -0
- package/dist/__tests__/simulation-inspect.integration.test.d.ts.map +1 -0
- package/dist/cli.d.ts +15 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.mjs +336 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/defineSubcommand.d.ts +40 -0
- package/dist/defineSubcommand.d.ts.map +1 -0
- package/dist/execute.d.ts +1 -0
- package/dist/execute.d.ts.map +1 -1
- package/dist/execute.mjs +2 -0
- package/dist/execute.mjs.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/introspect.d.ts +2 -0
- package/dist/introspect.d.ts.map +1 -1
- package/dist/introspect.mjs +5 -8
- package/dist/introspect.mjs.map +1 -1
- package/dist/server.d.ts +2 -2
- package/dist/server.d.ts.map +1 -1
- package/dist/server.mjs +20 -58
- package/dist/server.mjs.map +1 -1
- package/package.json +6 -3
- package/src/__tests__/cli-defaults.unit.test.ts +69 -0
- package/src/__tests__/console.unit.test.ts +676 -9
- package/src/__tests__/errors.unit.test.ts +3 -17
- package/src/__tests__/execute.async.test.ts +17 -13
- package/src/__tests__/server.unit.test.ts +19 -262
- package/src/__tests__/simulation-inspect.integration.test.ts +67 -0
- package/src/__tests__/vm-async-eval-verify.mjs +1 -1
- package/src/cli.ts +333 -0
- package/src/defineSubcommand.ts +169 -0
- package/src/execute.ts +4 -1
- package/src/index.ts +5 -4
- package/src/introspect.ts +10 -15
- package/src/server.ts +14 -60
- package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts +0 -2
- package/dist/__tests__/asset-runtime-inspection.unit.test.d.ts.map +0 -1
- package/src/__tests__/asset-runtime-inspection.unit.test.ts +0 -16
package/src/cli.ts
ADDED
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// @forgeax/engine-remote/src/cli - forgeax CLI binary entry (feat-20260517 D-3
|
|
3
|
+
// + D-4: inspect-subcommand removed; only built-in `script` / `eval`
|
|
4
|
+
// remain. M2 w8: plugin discovery (discoverPlugins) deleted alongside
|
|
5
|
+
// routing layer removal. defaultConnect SSOT lives in
|
|
6
|
+
// `@forgeax/engine-types/inspector-client` and is re-exported here
|
|
7
|
+
// for the legacy import surface).
|
|
8
|
+
//
|
|
9
|
+
// Two-subcommand built-in dispatch:
|
|
10
|
+
// - forgeax-engine-remote script <file>
|
|
11
|
+
// - forgeax-engine-remote eval <inline-script>
|
|
12
|
+
//
|
|
13
|
+
// WebSocket client (D-3 / w18): the in-cli ~80-line `defaultConnect`
|
|
14
|
+
// implementation was extracted to `@forgeax/engine-types/inspector-client`
|
|
15
|
+
// so the engine-remote base CLI and the engine-ecs `cli-ecs` plugin bin
|
|
16
|
+
// (M3) share one client recipe. The Result-form `eval(script)` /
|
|
17
|
+
// `dispose()` surface replaces the legacy `request(method,params)` /
|
|
18
|
+
// `close()` shape that lived inside cli.ts.
|
|
19
|
+
//
|
|
20
|
+
// Argparse via stdlib `node:util.parseArgs` (no commander / sade / cac
|
|
21
|
+
// dep). Help body is produced by the package-internal `defineSubcommand`
|
|
22
|
+
// DSL (plan-strategy D-4 + D-7).
|
|
23
|
+
|
|
24
|
+
import { readFile, realpath } from 'node:fs/promises';
|
|
25
|
+
import { fileURLToPath } from 'node:url';
|
|
26
|
+
import type { RemoteError as RemoteErrorShape } from '@forgeax/engine-types';
|
|
27
|
+
import {
|
|
28
|
+
type ConnectFn,
|
|
29
|
+
defaultConnect,
|
|
30
|
+
INSPECTOR_DEFAULT_HOST,
|
|
31
|
+
INSPECTOR_DEFAULT_PORT,
|
|
32
|
+
type InspectorClient,
|
|
33
|
+
} from '@forgeax/engine-types/inspector-client';
|
|
34
|
+
import { defineSubcommand, renderHelp, type SubcommandSpec } from './defineSubcommand';
|
|
35
|
+
|
|
36
|
+
export type { ConnectFn, InspectorClient };
|
|
37
|
+
export { defaultConnect };
|
|
38
|
+
|
|
39
|
+
// w8: LEGACY_INSPECT_TARGETS removed alongside plugin discovery deletion.
|
|
40
|
+
// ─── Subcommand spec tree (sade utils.js form) ───────────────────────────────
|
|
41
|
+
|
|
42
|
+
export const FORGEAX_CLI_SPEC: SubcommandSpec = defineSubcommand({
|
|
43
|
+
name: 'forgeax-engine-remote',
|
|
44
|
+
description: 'remote eval CLI - drive a running forgeax engine via JSON-RPC over WS',
|
|
45
|
+
options: [
|
|
46
|
+
{
|
|
47
|
+
flag: '--port <n>',
|
|
48
|
+
description: `Inspector WebSocket port (default ${INSPECTOR_DEFAULT_PORT}; monitor uses 5731)`,
|
|
49
|
+
},
|
|
50
|
+
{ flag: '--host <s>', description: `Host name (default ${INSPECTOR_DEFAULT_HOST})` },
|
|
51
|
+
{ flag: '--help, -h', description: 'Show this help and exit 0' },
|
|
52
|
+
],
|
|
53
|
+
subcommands: [
|
|
54
|
+
defineSubcommand({
|
|
55
|
+
name: 'script',
|
|
56
|
+
description: 'eval a script file against the live world/renderer/assets',
|
|
57
|
+
options: [{ flag: '--help, -h', description: 'Show this help and exit 0' }],
|
|
58
|
+
examples: [
|
|
59
|
+
{
|
|
60
|
+
usage: 'forgeax-engine-remote script ./inspect.mjs',
|
|
61
|
+
description: 'eval a local script file',
|
|
62
|
+
},
|
|
63
|
+
],
|
|
64
|
+
}),
|
|
65
|
+
defineSubcommand({
|
|
66
|
+
name: 'eval',
|
|
67
|
+
description: 'evaluate an inline expression against the world',
|
|
68
|
+
options: [{ flag: '--help, -h', description: 'Show this help and exit 0' }],
|
|
69
|
+
examples: [
|
|
70
|
+
{
|
|
71
|
+
usage: 'forgeax-engine-remote eval "world.inspect().entityCount"',
|
|
72
|
+
description: 'inline read of world.inspect()',
|
|
73
|
+
},
|
|
74
|
+
],
|
|
75
|
+
}),
|
|
76
|
+
],
|
|
77
|
+
extraNotes: [
|
|
78
|
+
'eval is full read/write access to the live world/renderer/assets/debugAdapter; the only security boundary is whether the host started the server.',
|
|
79
|
+
'Plugin discovery via PATH-prefix removed in M2 (routing layer deletion).',
|
|
80
|
+
'See also: packages/remote/README.md (eval API, live roots, security model) + AI User Charter.',
|
|
81
|
+
'Simulation inspection is read-only through eval; restore and replay are not Remote or CLI actions.',
|
|
82
|
+
],
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// w8: Plugin discovery (discoverPlugins) removed alongside routing layer deletion.
|
|
86
|
+
// renderTopLevelHelp no longer takes plugins — only built-in commands displayed.
|
|
87
|
+
|
|
88
|
+
function renderTopLevelHelp(): string {
|
|
89
|
+
const lines: string[] = [];
|
|
90
|
+
lines.push(`${FORGEAX_CLI_SPEC.name} - ${FORGEAX_CLI_SPEC.description}`);
|
|
91
|
+
lines.push('');
|
|
92
|
+
lines.push('Usage:');
|
|
93
|
+
lines.push(` ${FORGEAX_CLI_SPEC.name} <subcommand> [args]`);
|
|
94
|
+
lines.push('');
|
|
95
|
+
|
|
96
|
+
lines.push('Built-in commands:');
|
|
97
|
+
const builtIns = FORGEAX_CLI_SPEC.subcommands ?? [];
|
|
98
|
+
const builtInWidth = builtIns.reduce((m, s) => Math.max(m, s.name.length), 0);
|
|
99
|
+
for (const s of builtIns) {
|
|
100
|
+
const pad = ' '.repeat(builtInWidth - s.name.length + 4);
|
|
101
|
+
lines.push(` ${s.name}${pad}${s.description}`);
|
|
102
|
+
}
|
|
103
|
+
lines.push('');
|
|
104
|
+
|
|
105
|
+
if (FORGEAX_CLI_SPEC.options && FORGEAX_CLI_SPEC.options.length > 0) {
|
|
106
|
+
lines.push('Options:');
|
|
107
|
+
const optWidth = FORGEAX_CLI_SPEC.options.reduce((m, o) => Math.max(m, o.flag.length), 0);
|
|
108
|
+
for (const o of FORGEAX_CLI_SPEC.options) {
|
|
109
|
+
const pad = ' '.repeat(optWidth - o.flag.length + 4);
|
|
110
|
+
lines.push(` ${o.flag}${pad}${o.description}`);
|
|
111
|
+
}
|
|
112
|
+
lines.push('');
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
if (FORGEAX_CLI_SPEC.extraNotes && FORGEAX_CLI_SPEC.extraNotes.length > 0) {
|
|
116
|
+
lines.push('Notes:');
|
|
117
|
+
for (const note of FORGEAX_CLI_SPEC.extraNotes) {
|
|
118
|
+
lines.push(` ${note}`);
|
|
119
|
+
}
|
|
120
|
+
lines.push('');
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
return `${lines.join('\n').trimEnd()}\n`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// w8: renderConsoleStartupFailed (plugin-discovery error rendering) removed.
|
|
127
|
+
// For unknown subcommands, a simple stderr fallback is used inline.
|
|
128
|
+
|
|
129
|
+
// --- Dispatch (test-injectable) ---
|
|
130
|
+
|
|
131
|
+
export interface DispatchOptions {
|
|
132
|
+
readonly argv: readonly string[];
|
|
133
|
+
readonly stdoutWrite: (line: string) => void;
|
|
134
|
+
readonly stderrWrite: (line: string) => void;
|
|
135
|
+
readonly connect: ConnectFn;
|
|
136
|
+
readonly fileReader?: (path: string) => Promise<string>;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
const defaultFileReader = async (path: string): Promise<string> => {
|
|
140
|
+
return await readFile(path, 'utf8');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export async function dispatch(opts: DispatchOptions): Promise<number> {
|
|
144
|
+
const { argv, stdoutWrite, stderrWrite, connect } = opts;
|
|
145
|
+
const fileReader = opts.fileReader ?? defaultFileReader;
|
|
146
|
+
const [, , subcommand, ...rest] = argv;
|
|
147
|
+
|
|
148
|
+
if (subcommand === undefined || subcommand === '--help' || subcommand === '-h') {
|
|
149
|
+
stdoutWrite(renderTopLevelHelp());
|
|
150
|
+
return 0;
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
let port = INSPECTOR_DEFAULT_PORT;
|
|
154
|
+
let host = INSPECTOR_DEFAULT_HOST;
|
|
155
|
+
const filteredRest: string[] = [];
|
|
156
|
+
for (let i = 0; i < rest.length; i++) {
|
|
157
|
+
const arg = rest[i];
|
|
158
|
+
if (arg === '--port') {
|
|
159
|
+
const next = rest[i + 1];
|
|
160
|
+
if (typeof next === 'string') {
|
|
161
|
+
const parsed = Number(next);
|
|
162
|
+
if (!Number.isNaN(parsed) && parsed > 0) {
|
|
163
|
+
port = parsed;
|
|
164
|
+
i++;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
if (arg === '--host') {
|
|
170
|
+
const next = rest[i + 1];
|
|
171
|
+
if (typeof next === 'string') {
|
|
172
|
+
host = next;
|
|
173
|
+
i++;
|
|
174
|
+
continue;
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (typeof arg === 'string') filteredRest.push(arg);
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
switch (subcommand) {
|
|
181
|
+
case 'script':
|
|
182
|
+
return runScript(filteredRest, {
|
|
183
|
+
stdoutWrite,
|
|
184
|
+
stderrWrite,
|
|
185
|
+
connect,
|
|
186
|
+
port,
|
|
187
|
+
host,
|
|
188
|
+
fileReader,
|
|
189
|
+
});
|
|
190
|
+
case 'eval':
|
|
191
|
+
return runEval(filteredRest, { stdoutWrite, stderrWrite, connect, port, host });
|
|
192
|
+
default: {
|
|
193
|
+
// CLI argument error (not a RemoteErrorCode — that closed union is the
|
|
194
|
+
// wire/eval failure vocabulary, not a usage-error channel). Plain
|
|
195
|
+
// usage message mirrors the script/eval missing-arg errors above.
|
|
196
|
+
stderrWrite(
|
|
197
|
+
`forgeax: unknown subcommand '${subcommand}'\n expected: subcommand is one of: script, eval\n hint: run 'forgeax-engine-remote --help' for usage\n detail: '${subcommand}' is not a built-in subcommand (plugin discovery removed in M2)\n`,
|
|
198
|
+
);
|
|
199
|
+
return 1;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
interface RunCtx {
|
|
205
|
+
readonly stdoutWrite: (line: string) => void;
|
|
206
|
+
readonly stderrWrite: (line: string) => void;
|
|
207
|
+
readonly connect: ConnectFn;
|
|
208
|
+
readonly port: number;
|
|
209
|
+
readonly host: string;
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
interface RunScriptCtx extends RunCtx {
|
|
213
|
+
readonly fileReader: (path: string) => Promise<string>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function inspectorErrorToStderr(e: RemoteErrorShape): string {
|
|
217
|
+
return [`forgeax: ${e.code}`, ` expected: ${e.expected}`, ` hint: ${e.hint}`].join('\n');
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
async function runScript(rest: string[], ctx: RunScriptCtx): Promise<number> {
|
|
221
|
+
const [file] = rest;
|
|
222
|
+
if (file === '--help' || file === '-h') {
|
|
223
|
+
ctx.stdoutWrite(renderHelp(FORGEAX_CLI_SPEC, ['script']));
|
|
224
|
+
return 0;
|
|
225
|
+
}
|
|
226
|
+
if (typeof file !== 'string') {
|
|
227
|
+
ctx.stderrWrite(
|
|
228
|
+
[
|
|
229
|
+
'forgeax: script requires a <file> positional argument',
|
|
230
|
+
' expected: forgeax-engine-remote script <path-to-js-file>',
|
|
231
|
+
" hint: e.g. 'forgeax-engine-remote script ./inspect.mjs'",
|
|
232
|
+
].join('\n'),
|
|
233
|
+
);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
let body: string;
|
|
237
|
+
try {
|
|
238
|
+
body = await ctx.fileReader(file);
|
|
239
|
+
} catch (e) {
|
|
240
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
241
|
+
ctx.stderrWrite(
|
|
242
|
+
[
|
|
243
|
+
`forgeax: script file unreadable: ${file}`,
|
|
244
|
+
' expected: file exists and is readable',
|
|
245
|
+
` hint: check path; underlying error: ${message}`,
|
|
246
|
+
].join('\n'),
|
|
247
|
+
);
|
|
248
|
+
return 1;
|
|
249
|
+
}
|
|
250
|
+
return invokeExecute(body, ctx);
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function runEval(rest: string[], ctx: RunCtx): Promise<number> {
|
|
254
|
+
const [script] = rest;
|
|
255
|
+
if (script === '--help' || script === '-h') {
|
|
256
|
+
ctx.stdoutWrite(renderHelp(FORGEAX_CLI_SPEC, ['eval']));
|
|
257
|
+
return 0;
|
|
258
|
+
}
|
|
259
|
+
if (typeof script !== 'string') {
|
|
260
|
+
ctx.stderrWrite(
|
|
261
|
+
[
|
|
262
|
+
'forgeax: eval requires an inline <script> positional argument',
|
|
263
|
+
' expected: forgeax-engine-remote eval "<expression>"',
|
|
264
|
+
' hint: e.g. \'forgeax-engine-remote eval "world.inspect().entityCount"\'',
|
|
265
|
+
].join('\n'),
|
|
266
|
+
);
|
|
267
|
+
return 1;
|
|
268
|
+
}
|
|
269
|
+
return invokeExecute(script, ctx);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
async function invokeExecute(script: string, ctx: RunCtx): Promise<number> {
|
|
273
|
+
const url = `ws://${ctx.host}:${ctx.port}/inspector`;
|
|
274
|
+
const connectResult = await ctx.connect(url);
|
|
275
|
+
if (!connectResult.ok) {
|
|
276
|
+
ctx.stderrWrite(inspectorErrorToStderr(connectResult.error));
|
|
277
|
+
return 1;
|
|
278
|
+
}
|
|
279
|
+
const client = connectResult.value;
|
|
280
|
+
try {
|
|
281
|
+
const result = await client.eval(script);
|
|
282
|
+
ctx.stdoutWrite(typeof result === 'string' ? result : JSON.stringify(result, null, 2));
|
|
283
|
+
return 0;
|
|
284
|
+
} catch (e) {
|
|
285
|
+
if (isRemoteError(e)) {
|
|
286
|
+
ctx.stderrWrite(inspectorErrorToStderr(e));
|
|
287
|
+
return 1;
|
|
288
|
+
}
|
|
289
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
290
|
+
ctx.stderrWrite(
|
|
291
|
+
[
|
|
292
|
+
'forgeax: execute failed',
|
|
293
|
+
' expected: server-side execute() resolves Result.ok',
|
|
294
|
+
` hint: underlying: ${message}`,
|
|
295
|
+
].join('\n'),
|
|
296
|
+
);
|
|
297
|
+
return 1;
|
|
298
|
+
} finally {
|
|
299
|
+
await client.dispose();
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
function isRemoteError(e: unknown): e is RemoteErrorShape {
|
|
304
|
+
return (
|
|
305
|
+
typeof e === 'object' &&
|
|
306
|
+
e !== null &&
|
|
307
|
+
typeof (e as { code?: unknown }).code === 'string' &&
|
|
308
|
+
typeof (e as { expected?: unknown }).expected === 'string' &&
|
|
309
|
+
typeof (e as { hint?: unknown }).hint === 'string'
|
|
310
|
+
);
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Bin entry — only runs when this module is the process entry ────────────
|
|
314
|
+
|
|
315
|
+
const isBinEntry = await (async () => {
|
|
316
|
+
const argv1 = process.argv[1];
|
|
317
|
+
if (typeof argv1 !== 'string') return false;
|
|
318
|
+
const argv1Real = await realpath(argv1).catch(() => argv1);
|
|
319
|
+
const selfReal = await realpath(fileURLToPath(import.meta.url)).catch(() =>
|
|
320
|
+
fileURLToPath(import.meta.url),
|
|
321
|
+
);
|
|
322
|
+
return argv1Real === selfReal;
|
|
323
|
+
})();
|
|
324
|
+
|
|
325
|
+
if (isBinEntry) {
|
|
326
|
+
const exitCode = await dispatch({
|
|
327
|
+
argv: process.argv,
|
|
328
|
+
stdoutWrite: (line: string) => process.stdout.write(`${line}\n`),
|
|
329
|
+
stderrWrite: (line: string) => process.stderr.write(`${line}\n`),
|
|
330
|
+
connect: defaultConnect,
|
|
331
|
+
});
|
|
332
|
+
process.exit(exitCode);
|
|
333
|
+
}
|
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
// @forgeax/engine-remote/src/defineSubcommand - sade utils.js form (~94-line
|
|
2
|
+
// flat-dictionary + section primitive) DSL for the `forgeax-engine-console`
|
|
3
|
+
// CLI help renderer. Plan-strategy D-4 + D-7 lock-in:
|
|
4
|
+
//
|
|
5
|
+
// - Single file, package-internal (NOT in package.json#exports).
|
|
6
|
+
// `cli.ts` is the sole consumer; tsup inlines it into dist/cli.mjs.
|
|
7
|
+
// - 94-line ceiling is a pattern target, not a hard cap; we trade a few
|
|
8
|
+
// extra lines for explicit JSDoc that AI users read at edit time.
|
|
9
|
+
// - Three render layers driven by `path` slicing:
|
|
10
|
+
// path = [] -> top-level help
|
|
11
|
+
// path = ['inspect'] -> subcommand help
|
|
12
|
+
// path = ['inspect', 'entities'] -> sub-target help
|
|
13
|
+
// - List-width pin: `maxLen + GAP=4` padding (R-4 mitigation; snapshot
|
|
14
|
+
// test packages/console/src/__tests__/cli-help.test.ts guards drift).
|
|
15
|
+
//
|
|
16
|
+
// charter: proposition 1 (progressive disclosure — `path` is the navigator,
|
|
17
|
+
// not a hidden config) + proposition 3 (machine-readable spec >>> hand-rolled
|
|
18
|
+
// strings) + proposition 4 (explicit failure — render is total: any unknown
|
|
19
|
+
// path returns the closest valid layer rather than throwing).
|
|
20
|
+
|
|
21
|
+
const GAP = 4;
|
|
22
|
+
|
|
23
|
+
/** Single option entry: `--with <Name>`, `--port <number>` ... */
|
|
24
|
+
export interface OptionSpec {
|
|
25
|
+
readonly flag: string;
|
|
26
|
+
readonly description: string;
|
|
27
|
+
readonly multiple?: boolean;
|
|
28
|
+
readonly defaultValue?: string;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** Single example block: usage line + free description. */
|
|
32
|
+
export interface ExampleSpec {
|
|
33
|
+
readonly usage: string;
|
|
34
|
+
readonly description?: string;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** Subcommand spec — recursive (subcommands map to nested specs). */
|
|
38
|
+
export interface SubcommandSpec {
|
|
39
|
+
readonly name: string;
|
|
40
|
+
readonly description: string;
|
|
41
|
+
readonly options?: ReadonlyArray<OptionSpec>;
|
|
42
|
+
readonly subcommands?: ReadonlyArray<SubcommandSpec>;
|
|
43
|
+
readonly examples?: ReadonlyArray<ExampleSpec>;
|
|
44
|
+
readonly extraNotes?: ReadonlyArray<string>;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Single-input wrapping helper: turns a sade-style descriptor into a
|
|
49
|
+
* SubcommandSpec POD. Today the function is a near-identity (the spec is
|
|
50
|
+
* already structurally a POD), but the wrapper preserves a single intercept
|
|
51
|
+
* point for future validation (charter proposition 4: explicit failure on
|
|
52
|
+
* malformed input — we can throw here rather than silently render garbage).
|
|
53
|
+
*/
|
|
54
|
+
export function defineSubcommand(spec: SubcommandSpec): SubcommandSpec {
|
|
55
|
+
if (typeof spec.name !== 'string' || spec.name.length === 0) {
|
|
56
|
+
throw new Error('defineSubcommand: spec.name must be a non-empty string');
|
|
57
|
+
}
|
|
58
|
+
return spec;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Look up the descendant spec at `path`. Returns the closest matching
|
|
63
|
+
* ancestor when the path is partially unknown so renderHelp degrades to the
|
|
64
|
+
* deepest valid layer rather than throwing (charter proposition 4 explicit
|
|
65
|
+
* failure: a wrong path is recoverable; a thrown render is not).
|
|
66
|
+
*/
|
|
67
|
+
function resolvePath(
|
|
68
|
+
root: SubcommandSpec,
|
|
69
|
+
path: readonly string[],
|
|
70
|
+
): { readonly spec: SubcommandSpec; readonly path: readonly string[] } {
|
|
71
|
+
let current = root;
|
|
72
|
+
const consumed: string[] = [];
|
|
73
|
+
for (const segment of path) {
|
|
74
|
+
const next = current.subcommands?.find((s) => s.name === segment);
|
|
75
|
+
if (next === undefined) break;
|
|
76
|
+
current = next;
|
|
77
|
+
consumed.push(segment);
|
|
78
|
+
}
|
|
79
|
+
return { spec: current, path: consumed };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/**
|
|
83
|
+
* Render a single section with `key` left-padded to `maxLen + GAP` columns.
|
|
84
|
+
* `items` carries `[label, body]` pairs; both halves are flat strings.
|
|
85
|
+
*
|
|
86
|
+
* Skips emission entirely when `items` is empty so the rendered help body
|
|
87
|
+
* has no orphan section headers (UX nit: AI users grep section headers as
|
|
88
|
+
* anchors — emitting a header followed by nothing fools the grep).
|
|
89
|
+
*/
|
|
90
|
+
function section(title: string, items: ReadonlyArray<readonly [string, string]>): string[] {
|
|
91
|
+
if (items.length === 0) return [];
|
|
92
|
+
let maxLen = 0;
|
|
93
|
+
for (const [label] of items) {
|
|
94
|
+
if (label.length > maxLen) maxLen = label.length;
|
|
95
|
+
}
|
|
96
|
+
const lines: string[] = [];
|
|
97
|
+
lines.push(`${title}:`);
|
|
98
|
+
for (const [label, body] of items) {
|
|
99
|
+
const pad = ' '.repeat(maxLen + GAP - label.length);
|
|
100
|
+
lines.push(` ${label}${pad}${body}`);
|
|
101
|
+
}
|
|
102
|
+
lines.push('');
|
|
103
|
+
return lines;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Render the help body for `path` against `root`. Always returns a non-empty
|
|
108
|
+
* string ending in a single newline so callers can pipe to stdout without
|
|
109
|
+
* post-processing.
|
|
110
|
+
*
|
|
111
|
+
* Layer 1 (root): title + Usage + Sub-commands + Options + extraNotes
|
|
112
|
+
* Layer 2 (subcommand): title + Usage + Sub-targets (if any) + Options + Examples + extraNotes
|
|
113
|
+
* Layer 3 (sub-target): title + Usage + Options + Examples + extraNotes
|
|
114
|
+
*/
|
|
115
|
+
export function renderHelp(root: SubcommandSpec, path: readonly string[]): string {
|
|
116
|
+
const { spec, path: consumed } = resolvePath(root, path);
|
|
117
|
+
const fullPath = [root.name, ...consumed].join(' ');
|
|
118
|
+
const out: string[] = [];
|
|
119
|
+
out.push(`${fullPath} - ${spec.description}`);
|
|
120
|
+
out.push('');
|
|
121
|
+
|
|
122
|
+
// Usage line — synthesised from `path` + the leaf's surface.
|
|
123
|
+
const usagePieces: string[] = [fullPath];
|
|
124
|
+
if (spec.subcommands && spec.subcommands.length > 0) {
|
|
125
|
+
usagePieces.push('<subcommand>');
|
|
126
|
+
} else {
|
|
127
|
+
// Leaf nodes use a generic <args> token; concrete shape lives in
|
|
128
|
+
// `examples` so the help body stays declarative rather than guessed.
|
|
129
|
+
usagePieces.push('[options]');
|
|
130
|
+
}
|
|
131
|
+
out.push('Usage:');
|
|
132
|
+
out.push(` ${usagePieces.join(' ')}`);
|
|
133
|
+
out.push('');
|
|
134
|
+
|
|
135
|
+
if (spec.subcommands && spec.subcommands.length > 0) {
|
|
136
|
+
out.push(
|
|
137
|
+
...section(
|
|
138
|
+
consumed.length === 0 ? 'Sub-commands' : 'Sub-targets',
|
|
139
|
+
spec.subcommands.map((s) => [s.name, s.description] as const),
|
|
140
|
+
),
|
|
141
|
+
);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
if (spec.options && spec.options.length > 0) {
|
|
145
|
+
out.push(
|
|
146
|
+
...section(
|
|
147
|
+
'Options',
|
|
148
|
+
spec.options.map((o) => [o.flag, o.description] as const),
|
|
149
|
+
),
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
if (spec.examples && spec.examples.length > 0) {
|
|
154
|
+
const exampleItems: Array<readonly [string, string]> = spec.examples.map(
|
|
155
|
+
(e) => [e.usage, e.description ?? ''] as const,
|
|
156
|
+
);
|
|
157
|
+
out.push(...section('Examples', exampleItems));
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (spec.extraNotes && spec.extraNotes.length > 0) {
|
|
161
|
+
out.push('Notes:');
|
|
162
|
+
for (const note of spec.extraNotes) {
|
|
163
|
+
out.push(` ${note}`);
|
|
164
|
+
}
|
|
165
|
+
out.push('');
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
return `${out.join('\n').trimEnd()}\n`;
|
|
169
|
+
}
|
package/src/execute.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
// function with `world` / `renderer` / `assets` / `rhiCapture` / `simulation` / `_import`
|
|
10
10
|
// in scope. So all of these Just Work, un-wrapped:
|
|
11
11
|
// - a bare expression: `renderer.backend` -> auto-returned
|
|
12
|
-
// - top-level await: `await _import('
|
|
12
|
+
// - top-level await: `await _import('engine-module')`
|
|
13
13
|
// - top-level return: `return world.inspect().entityCount`
|
|
14
14
|
// - multi-statement + return: `const m = await _import(...); return m.x`
|
|
15
15
|
// (the historical `(async () => { ... })()` IIFE form still works too — its
|
|
@@ -45,6 +45,7 @@ export type ExecuteContext = {
|
|
|
45
45
|
readonly renderer: unknown;
|
|
46
46
|
readonly assets: unknown;
|
|
47
47
|
readonly rhiCapture?: unknown;
|
|
48
|
+
readonly simulation?: unknown;
|
|
48
49
|
readonly profiler?: unknown;
|
|
49
50
|
readonly execution?: unknown;
|
|
50
51
|
readonly importModule?: (specifier: string) => Promise<unknown>;
|
|
@@ -70,6 +71,7 @@ function compile(script: string): FunctionConstructor['prototype'] {
|
|
|
70
71
|
'renderer',
|
|
71
72
|
'assets',
|
|
72
73
|
'rhiCapture',
|
|
74
|
+
'simulation',
|
|
73
75
|
'profiler',
|
|
74
76
|
'execution',
|
|
75
77
|
'_import',
|
|
@@ -113,6 +115,7 @@ export async function executeScript(script: string, ctx: ExecuteContext): Promis
|
|
|
113
115
|
ctx.renderer,
|
|
114
116
|
ctx.assets,
|
|
115
117
|
ctx.rhiCapture,
|
|
118
|
+
ctx.simulation,
|
|
116
119
|
ctx.profiler,
|
|
117
120
|
ctx.execution,
|
|
118
121
|
ctx.importModule ?? _import,
|
package/src/index.ts
CHANGED
|
@@ -1,12 +1,13 @@
|
|
|
1
|
-
// @forgeax/engine-remote - inspector P0 server +
|
|
1
|
+
// @forgeax/engine-remote - inspector P0 server + CLI dual-exit package.
|
|
2
2
|
//
|
|
3
3
|
// Single entry facade (charter proposition 1 progressive disclosure). The
|
|
4
|
-
// runtime server lives under the ./server sub-path
|
|
5
|
-
//
|
|
4
|
+
// runtime server lives under the ./server sub-path; the standalone CLI binary
|
|
5
|
+
// ships via `bin.forgeax` -> dist/cli.mjs. AI users import the shared error
|
|
6
|
+
// model from this top entry:
|
|
6
7
|
//
|
|
7
8
|
// import { RemoteError, type RemoteErrorCode } from '@forgeax/engine-remote';
|
|
8
9
|
//
|
|
9
|
-
// The error model SSOT (
|
|
10
|
+
// The error model SSOT (4-member closed RemoteErrorCode union) lives in
|
|
10
11
|
// src/errors.ts and is also exposed via the ./errors sub-path for callers that
|
|
11
12
|
// want type-only imports without pulling the runtime surface (D-P4 bundle
|
|
12
13
|
// isolation + AGENTS.md "Inspector / Console" evolution contract minor
|
package/src/introspect.ts
CHANGED
|
@@ -15,6 +15,8 @@ export interface RemoteRootValues {
|
|
|
15
15
|
readonly rhiCapture?: unknown;
|
|
16
16
|
readonly profiler?: unknown;
|
|
17
17
|
readonly execution?: unknown;
|
|
18
|
+
/** Read-only World-owned simulation summary; no restore/replay operation. */
|
|
19
|
+
readonly simulation?: unknown;
|
|
18
20
|
readonly introspection?: readonly ComponentIntrospectionDescriptor[];
|
|
19
21
|
}
|
|
20
22
|
|
|
@@ -25,11 +27,8 @@ type RootProjection = {
|
|
|
25
27
|
readonly capability?: string;
|
|
26
28
|
readonly phaseCatalog?: unknown;
|
|
27
29
|
readonly operations?: {
|
|
28
|
-
readonly startCapture
|
|
29
|
-
readonly latestCapture
|
|
30
|
-
readonly load?: string;
|
|
31
|
-
readonly snapshot?: string;
|
|
32
|
-
readonly subscribe?: string;
|
|
30
|
+
readonly startCapture: string;
|
|
31
|
+
readonly latestCapture: string;
|
|
33
32
|
};
|
|
34
33
|
};
|
|
35
34
|
|
|
@@ -80,20 +79,16 @@ function projectRoot(name: string, value: unknown): RootProjection {
|
|
|
80
79
|
type: 'ExecutionReportProvider',
|
|
81
80
|
description: 'The host execution report provider for tier, health, performance, and fault.',
|
|
82
81
|
},
|
|
82
|
+
simulation: {
|
|
83
|
+
type: 'SimulationInspection',
|
|
84
|
+
description:
|
|
85
|
+
'A read-only World-owned simulation record, participant, trace, and report summary.',
|
|
86
|
+
},
|
|
83
87
|
};
|
|
84
88
|
const descriptor = descriptions[name] ?? { type: 'unknown', description: 'A live eval root.' };
|
|
85
89
|
return {
|
|
86
90
|
available: true,
|
|
87
91
|
...descriptor,
|
|
88
|
-
...(name === 'assets'
|
|
89
|
-
? {
|
|
90
|
-
operations: {
|
|
91
|
-
load: 'assets.load(guid, expectedKind)',
|
|
92
|
-
snapshot: 'assets.snapshot()',
|
|
93
|
-
subscribe: 'assets.subscribe(listener)',
|
|
94
|
-
},
|
|
95
|
-
}
|
|
96
|
-
: {}),
|
|
97
92
|
...(name === 'profiler'
|
|
98
93
|
? {
|
|
99
94
|
capability: 'cpu-profile-v1',
|
|
@@ -190,7 +185,7 @@ export function buildIntrospectDoc(host: string, port: number, roots: RemoteRoot
|
|
|
190
185
|
title: '@forgeax/engine-remote remote eval',
|
|
191
186
|
version: '0.0.0',
|
|
192
187
|
description:
|
|
193
|
-
'Remote eval server. Methods: eval / introspect. Errors map to JSON-RPC -32001..-
|
|
188
|
+
'Remote eval server. Methods: eval / introspect. Errors map to JSON-RPC -32001..-32006.',
|
|
194
189
|
},
|
|
195
190
|
servers: [{ name: 'in-process', url: `ws://${host}:${port}/inspector` }],
|
|
196
191
|
methods: [
|