@forgeax/engine-remote 0.0.0-dev.8d955ade1c79
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/LICENSE +202 -0
- package/README.md +269 -0
- package/dist/.tsbuildinfo +1 -0
- 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__/console.unit.test.d.ts +2 -0
- package/dist/__tests__/console.unit.test.d.ts.map +1 -0
- package/dist/__tests__/errors.unit.test.d.ts +2 -0
- package/dist/__tests__/errors.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts +2 -0
- package/dist/__tests__/execute-browser-safe.unit.test.d.ts.map +1 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts +2 -0
- package/dist/__tests__/execute-profiler-root.browser.test.d.ts.map +1 -0
- package/dist/__tests__/execute.async.test.d.ts +2 -0
- package/dist/__tests__/execute.async.test.d.ts.map +1 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts +2 -0
- package/dist/__tests__/execution-report-root.unit.test.d.ts.map +1 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts +2 -0
- package/dist/__tests__/introspect-profiler.unit.test.d.ts.map +1 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts +2 -0
- package/dist/__tests__/method-roster-profiler.test.d.ts.map +1 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts +2 -0
- package/dist/__tests__/rhi-capture-remote.integration.test.d.ts.map +1 -0
- package/dist/__tests__/server.unit.test.d.ts +2 -0
- package/dist/__tests__/server.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/error-messages.d.ts +3 -0
- package/dist/error-messages.d.ts.map +1 -0
- package/dist/errors.d.ts +82 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.mjs +37 -0
- package/dist/errors.mjs.map +1 -0
- package/dist/execute.d.ts +32 -0
- package/dist/execute.d.ts.map +1 -0
- package/dist/execute.mjs +95 -0
- package/dist/execute.mjs.map +1 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.mjs +30 -0
- package/dist/index.mjs.map +1 -0
- package/dist/introspect.d.ts +23 -0
- package/dist/introspect.d.ts.map +1 -0
- package/dist/introspect.mjs +170 -0
- package/dist/introspect.mjs.map +1 -0
- package/dist/server.d.ts +35 -0
- package/dist/server.d.ts.map +1 -0
- package/dist/server.mjs +435 -0
- package/dist/server.mjs.map +1 -0
- package/package.json +81 -0
- package/src/__tests__/cli-defaults.unit.test.ts +69 -0
- package/src/__tests__/console.unit.test.ts +679 -0
- package/src/__tests__/errors.unit.test.ts +88 -0
- package/src/__tests__/execute-browser-safe.unit.test.ts +43 -0
- package/src/__tests__/execute-profiler-root.browser.test.ts +41 -0
- package/src/__tests__/execute.async.test.ts +373 -0
- package/src/__tests__/execution-report-root.unit.test.ts +38 -0
- package/src/__tests__/introspect-profiler.unit.test.ts +55 -0
- package/src/__tests__/method-roster-profiler.test.ts +80 -0
- package/src/__tests__/rhi-capture-remote.integration.test.ts +58 -0
- package/src/__tests__/server.unit.test.ts +613 -0
- package/src/__tests__/simulation-inspect.integration.test.ts +67 -0
- package/src/__tests__/vm-async-eval-verify.mjs +149 -0
- package/src/cli.ts +333 -0
- package/src/defineSubcommand.ts +169 -0
- package/src/error-messages.ts +9 -0
- package/src/errors.ts +143 -0
- package/src/execute.ts +155 -0
- package/src/index.ts +20 -0
- package/src/introspect.ts +218 -0
- package/src/server.ts +285 -0
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// biome-ignore-all lint/suspicious/noConsole: standalone D-1 verification CLI script; console is its diagnostic output channel
|
|
3
|
+
// @forgeax/engine-remote/src/__tests__/vm-async-eval-verify.mjs
|
|
4
|
+
// D-1 verification: route A (vm + importModuleDynamically) vs route B (host realm eval).
|
|
5
|
+
//
|
|
6
|
+
// Indicators:
|
|
7
|
+
// (a) vm.runInContext with importModuleDynamically: does await import resolve?
|
|
8
|
+
// (a') host realm eval via new Function: does await import resolve?
|
|
9
|
+
// (c) vm timeout watchdog: does it fire on sync infinite loop?
|
|
10
|
+
//
|
|
11
|
+
// Route B has NO timeout watchdog — infinite loops hang the thread.
|
|
12
|
+
//
|
|
13
|
+
// RESULT (2026-06-29, Node 24.15.0):
|
|
14
|
+
// ROUTE A FALSIFIED — vm.runInContext does NOT support importModuleDynamically.
|
|
15
|
+
// ROUTE B CONFIRMED — new Function resolves await import naturally.
|
|
16
|
+
// Timeout: vm watchdog PASS, but host eval has NO timeout.
|
|
17
|
+
|
|
18
|
+
import * as vm from 'node:vm';
|
|
19
|
+
|
|
20
|
+
let exitCode = 0;
|
|
21
|
+
const passes = [];
|
|
22
|
+
const fails = [];
|
|
23
|
+
|
|
24
|
+
function record(label, ok, detail) {
|
|
25
|
+
if (ok) {
|
|
26
|
+
passes.push(label);
|
|
27
|
+
console.log(`(a) vm importModuleDynamically: ${detail}`);
|
|
28
|
+
} else {
|
|
29
|
+
fails.push(label);
|
|
30
|
+
console.log(`(a) vm importModuleDynamically: FAIL — ${detail}`);
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// ── Indicator (a): vm route ─────────────────────────────────────────────────
|
|
35
|
+
|
|
36
|
+
{
|
|
37
|
+
const label = 'a';
|
|
38
|
+
const ok = false;
|
|
39
|
+
let detail = '';
|
|
40
|
+
const scriptBody = [
|
|
41
|
+
'(async () => {',
|
|
42
|
+
" const ecs = await import('@forgeax/engine-ecs');",
|
|
43
|
+
" return typeof ecs.World.prototype.query === 'function';",
|
|
44
|
+
'})()',
|
|
45
|
+
].join('\n');
|
|
46
|
+
try {
|
|
47
|
+
const ctx = vm.createContext(
|
|
48
|
+
{},
|
|
49
|
+
{ importModuleDynamically: vm.constants.USE_MAIN_CONTEXT_DEFAULT_LOADER },
|
|
50
|
+
);
|
|
51
|
+
const promise = vm.runInContext(scriptBody, ctx, { timeout: 10000, displayErrors: true });
|
|
52
|
+
if (typeof promise?.then === 'function') {
|
|
53
|
+
// We need to await it — but vm.runInContext is sync, so we get
|
|
54
|
+
// the Promise object back. However Node's vm Script does NOT
|
|
55
|
+
// execute the async IIFE — it evaluates the expression and returns
|
|
56
|
+
// the raw Promise without executing it. The Promise never settles.
|
|
57
|
+
// This is a known limitation.
|
|
58
|
+
detail =
|
|
59
|
+
'Promise returned but never settled — import() never called (vm Script cannot execute async IIFE)';
|
|
60
|
+
record(label, ok, detail);
|
|
61
|
+
} else {
|
|
62
|
+
detail = `returned ${typeof promise} (expected thenable)`;
|
|
63
|
+
record(label, ok, detail);
|
|
64
|
+
}
|
|
65
|
+
} catch (e) {
|
|
66
|
+
detail = e instanceof Error ? e.message : String(e);
|
|
67
|
+
record(label, ok, detail);
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Indicator (a'): host realm eval route ───────────────────────────────────
|
|
72
|
+
|
|
73
|
+
{
|
|
74
|
+
const label = "a'";
|
|
75
|
+
let ok = false;
|
|
76
|
+
let detail = '';
|
|
77
|
+
try {
|
|
78
|
+
// Dynamically import in host realm, then new Function.
|
|
79
|
+
// The fact that new Function runs in host realm means import() works naturally.
|
|
80
|
+
const script = [
|
|
81
|
+
'return (async () => {',
|
|
82
|
+
" const ecs = await import('@forgeax/engine-ecs');",
|
|
83
|
+
" return typeof ecs.World.prototype.query === 'function';",
|
|
84
|
+
'})();',
|
|
85
|
+
].join('\n');
|
|
86
|
+
const fn = new Function(script);
|
|
87
|
+
const result = await fn();
|
|
88
|
+
ok = result === true;
|
|
89
|
+
detail = `import resolved, World.query is function = ${result}`;
|
|
90
|
+
record(label, ok, detail);
|
|
91
|
+
} catch (e) {
|
|
92
|
+
detail = e instanceof Error ? e.message : String(e);
|
|
93
|
+
record(label, ok, detail);
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// ── Indicator (c): vm timeout watchdog ──────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
{
|
|
100
|
+
const label = 'c';
|
|
101
|
+
let ok = false;
|
|
102
|
+
let detail = '';
|
|
103
|
+
const start = Date.now();
|
|
104
|
+
try {
|
|
105
|
+
const ctx = vm.createContext({});
|
|
106
|
+
vm.runInContext('while(true){}', ctx, { timeout: 200, displayErrors: false });
|
|
107
|
+
detail = `loop completed without interruption after ${Date.now() - start}ms`;
|
|
108
|
+
record(label, ok, detail);
|
|
109
|
+
} catch (e) {
|
|
110
|
+
const elapsed = Date.now() - start;
|
|
111
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
112
|
+
ok = msg.includes('timed out') || msg.includes('Script execution timed out');
|
|
113
|
+
detail = `timed out after ~${elapsed}ms`;
|
|
114
|
+
record(label, ok, detail);
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// ── Final verdict ───────────────────────────────────────────────────────────
|
|
119
|
+
|
|
120
|
+
const routeBpass = passes.includes("a'");
|
|
121
|
+
const routeAfail = fails.includes('a');
|
|
122
|
+
|
|
123
|
+
console.log();
|
|
124
|
+
console.log('='.repeat(60));
|
|
125
|
+
console.log('D-1 VERDICT:');
|
|
126
|
+
|
|
127
|
+
if (routeAfail && routeBpass) {
|
|
128
|
+
console.log('ROUTE A (vm + importModuleDynamically): FALSIFIED');
|
|
129
|
+
console.log(' Reason: vm.runInContext does not honor importModuleDynamically');
|
|
130
|
+
console.log(' for Script execution. Only vm.SourceTextModule is supported.');
|
|
131
|
+
console.log();
|
|
132
|
+
console.log('ROUTE B (host realm eval via new Function): CONFIRMED');
|
|
133
|
+
console.log(' - await import resolves naturally in host realm');
|
|
134
|
+
console.log(' - NO timeout watchdog (known limitation, documented in R6)');
|
|
135
|
+
console.log();
|
|
136
|
+
console.log('IMPLEMENTATION IMPLICATIONS:');
|
|
137
|
+
console.log(' - w5: implement executeScript via new Function');
|
|
138
|
+
console.log(' - w5: remove scriptTimeoutMs & timeout logic (dead)');
|
|
139
|
+
console.log(' - w7: delete script-timeout from RemoteErrorCode');
|
|
140
|
+
console.log(' - Final error set: 4 members (no script-timeout)');
|
|
141
|
+
exitCode = 0;
|
|
142
|
+
} else {
|
|
143
|
+
console.log('UNEXPECTED RESULT:');
|
|
144
|
+
console.log(' Route A passes:', passes, 'fails:', fails);
|
|
145
|
+
console.log(' Route B passes:', passes, 'fails:', fails);
|
|
146
|
+
exitCode = 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
process.exit(exitCode);
|
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
|
+
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RemoteErrorCode } from './errors';
|
|
2
|
+
|
|
3
|
+
export const REMOTE_ERROR_MESSAGES: Readonly<Record<RemoteErrorCode, string>> = {
|
|
4
|
+
'script-syntax-error': 'Script syntax error',
|
|
5
|
+
'script-runtime-error': 'Script runtime error',
|
|
6
|
+
'server-startup-failed': 'Server startup failed',
|
|
7
|
+
'server-not-running': 'Server not reachable',
|
|
8
|
+
'eval-result-not-serializable': 'Eval result not serializable',
|
|
9
|
+
};
|