@forgeax/engine-remote 0.1.27 → 0.1.29

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/cli.ts DELETED
@@ -1,333 +0,0 @@
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
- }
@@ -1,169 +0,0 @@
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
- }