@jini-ai/cli 0.4.1 → 0.6.10
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/dist/command-registry.js +20 -20
- package/dist/daemon-url.js +25 -23
- package/dist/errors.js +26 -11
- package/dist/flags.js +32 -30
- package/dist/http.js +174 -119
- package/dist/introspection.js +2 -2
- package/dist/main.js +61 -47
- package/dist/prompt.js +12 -10
- package/dist/run-command.js +31 -32
- package/package.json +7 -7
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,18 @@
|
|
|
1
1
|
# @jini-ai/cli
|
|
2
2
|
|
|
3
|
+
## 0.6.10
|
|
4
|
+
|
|
5
|
+
### Patch Changes
|
|
6
|
+
|
|
7
|
+
- Updated dependencies [22f0619]
|
|
8
|
+
- Updated dependencies [82ed2d3]
|
|
9
|
+
- Updated dependencies [5a3de86]
|
|
10
|
+
- @jini-ai/core@0.6.10
|
|
11
|
+
|
|
12
|
+
## 0.4.2 — 2026-10-22
|
|
13
|
+
|
|
14
|
+
- 2026-10-22: Complexity refactors, coverage.
|
|
15
|
+
|
|
3
16
|
## 0.4.1
|
|
4
17
|
|
|
5
18
|
### Patch Changes
|
package/dist/command-registry.js
CHANGED
|
@@ -28,7 +28,7 @@ export class CommandRegistry {
|
|
|
28
28
|
if (this.commands.has(name) && opts.override !== true) {
|
|
29
29
|
throw new Error(`command "${name}" is already registered; pass { override: true } to replace it intentionally`);
|
|
30
30
|
}
|
|
31
|
-
this.commands.set(name, {
|
|
31
|
+
this.commands.set(name, { handler, ...(opts.usage !== undefined ? { usage: opts.usage } : {}) });
|
|
32
32
|
return this;
|
|
33
33
|
}
|
|
34
34
|
has({ name }) {
|
|
@@ -56,25 +56,7 @@ export class CommandRegistry {
|
|
|
56
56
|
* next token as a value — so the scan skips over it instead.
|
|
57
57
|
*/
|
|
58
58
|
async dispatch({ argv }, options = {}) {
|
|
59
|
-
const
|
|
60
|
-
let index = -1;
|
|
61
|
-
for (let i = 0; i < argv.length; i++) {
|
|
62
|
-
// Loop is bounded by argv.length, so this index is always in range —
|
|
63
|
-
// noUncheckedIndexedAccess types it as possibly-undefined regardless.
|
|
64
|
-
const token = argv[i];
|
|
65
|
-
if (!token.startsWith('-')) {
|
|
66
|
-
index = i;
|
|
67
|
-
break;
|
|
68
|
-
}
|
|
69
|
-
if (token.startsWith('--')) {
|
|
70
|
-
const eq = token.indexOf('=');
|
|
71
|
-
if (eq < 0) {
|
|
72
|
-
const key = token.slice(2);
|
|
73
|
-
if (valueFlags.has(key))
|
|
74
|
-
i++; // skip this flag's value token
|
|
75
|
-
}
|
|
76
|
-
}
|
|
77
|
-
}
|
|
59
|
+
const index = findCommandIndex({ argv }, { valueFlags: options.valueFlags ?? new Set() });
|
|
78
60
|
if (index === -1)
|
|
79
61
|
return { kind: 'empty' };
|
|
80
62
|
const name = argv[index];
|
|
@@ -86,4 +68,22 @@ export class CommandRegistry {
|
|
|
86
68
|
return { kind: 'handled' };
|
|
87
69
|
}
|
|
88
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* Index of the first token in `argv` that does not start with `-`, or `-1` when there is none.
|
|
73
|
+
* A `--name` token (no `=`) whose `name` is in `valueFlags` also skips the token after it, so a
|
|
74
|
+
* value-flag's own value is never taken for the command name (CR-003). Single-dash and
|
|
75
|
+
* `--name=value` tokens never skip.
|
|
76
|
+
*/
|
|
77
|
+
function findCommandIndex({ argv }, { valueFlags }) {
|
|
78
|
+
for (let i = 0; i < argv.length; i++) {
|
|
79
|
+
// Loop is bounded by argv.length, so this index is always in range —
|
|
80
|
+
// noUncheckedIndexedAccess types it as possibly-undefined regardless.
|
|
81
|
+
const token = argv[i];
|
|
82
|
+
if (!token.startsWith('-'))
|
|
83
|
+
return i;
|
|
84
|
+
if (token.startsWith('--') && !token.includes('=') && valueFlags.has(token.slice(2)))
|
|
85
|
+
i++; // skip this flag's value token
|
|
86
|
+
}
|
|
87
|
+
return -1;
|
|
88
|
+
}
|
|
89
89
|
//# sourceMappingURL=command-registry.js.map
|
package/dist/daemon-url.js
CHANGED
|
@@ -10,30 +10,32 @@ import { isLoopbackHostname } from '@jini-ai/platform/net';
|
|
|
10
10
|
*/
|
|
11
11
|
export async function resolveDaemonUrl(_requiredArgs, options = {}) {
|
|
12
12
|
const env = options.env ?? process.env;
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
return report(flagUrl);
|
|
23
|
-
if (options.envVarName !== undefined) {
|
|
24
|
-
const envUrl = env[options.envVarName];
|
|
25
|
-
if (envUrl !== undefined && envUrl.length > 0)
|
|
26
|
-
return report(envUrl);
|
|
27
|
-
}
|
|
28
|
-
if (options.discover !== undefined) {
|
|
29
|
-
const discovered = await options.discover({ env }, { timeoutMs: options.timeoutMs ?? 800 });
|
|
30
|
-
if (discovered !== null && discovered.length > 0)
|
|
31
|
-
return report(discovered);
|
|
13
|
+
// `??` short-circuits, so discover() is only probed when flag and env both came up empty.
|
|
14
|
+
// defaultUrl is taken as-is (even `''`): only `undefined` skips it.
|
|
15
|
+
const url = nonEmpty(options.flagUrl ?? null) ??
|
|
16
|
+
envCandidate({ env }, options) ??
|
|
17
|
+
(await discoverCandidate({ env }, options)) ??
|
|
18
|
+
options.defaultUrl;
|
|
19
|
+
if (url === undefined) {
|
|
20
|
+
throw new Error('no daemon URL resolved: pass flagUrl, set envVarName on a populated env var, ' +
|
|
21
|
+
'supply a discover() probe, or provide defaultUrl.');
|
|
32
22
|
}
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
23
|
+
const warning = daemonUrlPolicyWarning({ url });
|
|
24
|
+
if (warning !== null)
|
|
25
|
+
options.warn?.({ message: warning });
|
|
26
|
+
return url;
|
|
27
|
+
}
|
|
28
|
+
/** `value` when it is a non-empty string, else `undefined` (`null` and `''` both mean "unset"). */
|
|
29
|
+
function nonEmpty(value) {
|
|
30
|
+
return value !== null && value.length > 0 ? value : undefined;
|
|
31
|
+
}
|
|
32
|
+
/** The env step: `env[envVarName]`, skipped entirely when no `envVarName` was given. */
|
|
33
|
+
function envCandidate({ env }, { envVarName }) {
|
|
34
|
+
return envVarName === undefined ? undefined : nonEmpty(env[envVarName] ?? null);
|
|
35
|
+
}
|
|
36
|
+
/** The discovery step: one `discover()` probe with the caller timeout (default 800ms), when a probe was given. */
|
|
37
|
+
async function discoverCandidate({ env }, { discover, timeoutMs }) {
|
|
38
|
+
return discover === undefined ? undefined : nonEmpty(await discover({ env }, { timeoutMs: timeoutMs ?? 800 }));
|
|
37
39
|
}
|
|
38
40
|
// Matches a scheme + userinfo prefix (`scheme://user:pass@`) on a URL string that failed to
|
|
39
41
|
// parse via the `URL` constructor, so userinfo can still be stripped from a malformed value
|
package/dist/errors.js
CHANGED
|
@@ -47,7 +47,7 @@ export function structuredErrorData({ error }) {
|
|
|
47
47
|
if (error === undefined)
|
|
48
48
|
return undefined;
|
|
49
49
|
const data = {};
|
|
50
|
-
if (
|
|
50
|
+
if (typeof error.data === 'object' && error.data !== null) {
|
|
51
51
|
Object.assign(data, sanitizeUnknownDeep({ value: error.data }));
|
|
52
52
|
}
|
|
53
53
|
if (error.details !== undefined)
|
|
@@ -83,22 +83,37 @@ function extractErrorObject(parsed) {
|
|
|
83
83
|
* from the same untrusted response.
|
|
84
84
|
*/
|
|
85
85
|
export async function structuredHttpFailure({ resp }, { fallbackCode = 'daemon-not-running', ...options } = {}) {
|
|
86
|
+
const { raw, parsed } = await readErrorBody({ resp });
|
|
87
|
+
const errorObj = extractErrorObject(parsed);
|
|
88
|
+
const code = typeof errorObj?.code === 'string' ? stripControlSequences({ text: errorObj.code }) : fallbackCode;
|
|
89
|
+
const structuredMessage = typeof errorObj?.message === 'string' ? sanitizeUntrustedText({ text: errorObj.message }) : undefined;
|
|
90
|
+
const message = structuredMessage ?? `HTTP ${resp.status}`;
|
|
91
|
+
const data = envelopeData({ errorObj, rawExcerptSource: structuredMessage === undefined ? raw : '' });
|
|
92
|
+
// A caller-supplied `options.data` is spread last, so it replaces the daemon-derived data.
|
|
93
|
+
return exitWithStructuredError({ code, message }, { ...(data !== undefined ? { data } : {}), ...options });
|
|
94
|
+
}
|
|
95
|
+
/**
|
|
96
|
+
* Read and JSON-parse a failure body. A `text()` rejection, an empty body, and unparsable JSON
|
|
97
|
+
* all yield `parsed: {}`; `raw` keeps whatever text was read (empty when `text()` rejected).
|
|
98
|
+
*/
|
|
99
|
+
async function readErrorBody({ resp }) {
|
|
86
100
|
let raw = '';
|
|
87
|
-
let parsed = {};
|
|
88
101
|
try {
|
|
89
102
|
raw = await resp.text();
|
|
90
|
-
|
|
103
|
+
return { raw, parsed: raw.length > 0 ? JSON.parse(raw) : {} };
|
|
91
104
|
}
|
|
92
105
|
catch {
|
|
93
|
-
parsed
|
|
106
|
+
return { raw, parsed: {} };
|
|
94
107
|
}
|
|
95
|
-
|
|
108
|
+
}
|
|
109
|
+
/**
|
|
110
|
+
* The envelope `data`: {@link structuredErrorData} plus, when `rawExcerptSource` is non-empty
|
|
111
|
+
* (i.e. there was no structured message), a sanitized `rawExcerpt` of the raw body.
|
|
112
|
+
*/
|
|
113
|
+
function envelopeData({ errorObj, rawExcerptSource }) {
|
|
96
114
|
const data = structuredErrorData({ error: errorObj });
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const rawExcerpt = structuredMessage === undefined && raw.length > 0 ? sanitizeUntrustedText({ text: raw }) : undefined;
|
|
101
|
-
const mergedData = rawExcerpt !== undefined ? { ...(data ?? {}), rawExcerpt } : data;
|
|
102
|
-
return exitWithStructuredError({ code, message }, { ...(mergedData !== undefined ? { data: mergedData } : {}), ...options });
|
|
115
|
+
if (rawExcerptSource.length === 0)
|
|
116
|
+
return data;
|
|
117
|
+
return { ...data, rawExcerpt: sanitizeUntrustedText({ text: rawExcerptSource }) };
|
|
103
118
|
}
|
|
104
119
|
//# sourceMappingURL=errors.js.map
|
package/dist/flags.js
CHANGED
|
@@ -29,39 +29,41 @@ export function parseFlags({ argv }, opts = {}) {
|
|
|
29
29
|
const a = argv[i];
|
|
30
30
|
if (!a.startsWith('--'))
|
|
31
31
|
continue;
|
|
32
|
-
const
|
|
33
|
-
const key = eq >= 0 ? a.slice(2, eq) : a.slice(2);
|
|
32
|
+
const { key, inlineValue } = splitFlagToken({ token: a });
|
|
34
33
|
if (knownFlags.size > 0 && !knownFlags.has(key)) {
|
|
35
34
|
throw new Error(`unknown flag: --${key}. Run with --help for the list of accepted flags.`);
|
|
36
35
|
}
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
}
|
|
41
|
-
if (booleanFlags.has(key)) {
|
|
42
|
-
out[key] = true;
|
|
43
|
-
continue;
|
|
44
|
-
}
|
|
45
|
-
if (stringFlags.has(key)) {
|
|
46
|
-
const next = argv[i + 1];
|
|
47
|
-
if (next === undefined) {
|
|
48
|
-
throw new Error(`flag --${key} requires a value`);
|
|
49
|
-
}
|
|
50
|
-
out[key] = next;
|
|
51
|
-
i++;
|
|
52
|
-
continue;
|
|
53
|
-
}
|
|
54
|
-
const next = argv[i + 1];
|
|
55
|
-
if (next !== undefined && !next.startsWith('--')) {
|
|
56
|
-
out[key] = next;
|
|
36
|
+
const { value, consumesNext } = resolveFlagValue({ key, inlineValue, next: argv[i + 1] }, { stringFlags, booleanFlags });
|
|
37
|
+
out[key] = value;
|
|
38
|
+
if (consumesNext)
|
|
57
39
|
i++;
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
out[key] = true;
|
|
61
|
-
}
|
|
62
40
|
}
|
|
63
41
|
return out;
|
|
64
42
|
}
|
|
43
|
+
/** Split a `--key` / `--key=value` token; `inlineValue` is everything after the first `=`, if any. */
|
|
44
|
+
function splitFlagToken({ token }) {
|
|
45
|
+
const eq = token.indexOf('=');
|
|
46
|
+
return eq >= 0 ? { key: token.slice(2, eq), inlineValue: token.slice(eq + 1) } : { key: token.slice(2), inlineValue: undefined };
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* Decide one flag's value, in precedence order: an inline `=value`, then a declared boolean
|
|
50
|
+
* (`true`), then a declared string flag (always takes `next`, which must exist), then the
|
|
51
|
+
* undeclared heuristic (`next` when it does not itself start with `--`, else `true`).
|
|
52
|
+
*/
|
|
53
|
+
function resolveFlagValue({ key, inlineValue, next }, { stringFlags, booleanFlags }) {
|
|
54
|
+
if (inlineValue !== undefined)
|
|
55
|
+
return { value: inlineValue, consumesNext: false };
|
|
56
|
+
if (booleanFlags.has(key))
|
|
57
|
+
return { value: true, consumesNext: false };
|
|
58
|
+
if (stringFlags.has(key)) {
|
|
59
|
+
if (next === undefined)
|
|
60
|
+
throw new Error(`flag --${key} requires a value`);
|
|
61
|
+
return { value: next, consumesNext: true };
|
|
62
|
+
}
|
|
63
|
+
if (next !== undefined && !next.startsWith('--'))
|
|
64
|
+
return { value: next, consumesNext: true };
|
|
65
|
+
return { value: true, consumesNext: false };
|
|
66
|
+
}
|
|
65
67
|
/**
|
|
66
68
|
* Collect every non-flag token from `argv`, skipping the value token that
|
|
67
69
|
* follows a known string flag (so `od run start --project p1 foo` yields
|
|
@@ -69,12 +71,13 @@ export function parseFlags({ argv }, opts = {}) {
|
|
|
69
71
|
*/
|
|
70
72
|
export function positionalArgs({ argv }, opts = {}) {
|
|
71
73
|
const stringFlags = opts.string ?? new Set();
|
|
74
|
+
const stopAtDoubleDash = opts.stopAtDoubleDash === true;
|
|
72
75
|
const out = [];
|
|
73
76
|
for (let i = 0; i < argv.length; i++) {
|
|
74
77
|
// Loop is bounded by argv.length, so this index is always in range —
|
|
75
78
|
// noUncheckedIndexedAccess types it as possibly-undefined regardless.
|
|
76
79
|
const a = argv[i];
|
|
77
|
-
if (
|
|
80
|
+
if (stopAtDoubleDash && a === '--') {
|
|
78
81
|
out.push(...argv.slice(i + 1));
|
|
79
82
|
break;
|
|
80
83
|
}
|
|
@@ -82,9 +85,8 @@ export function positionalArgs({ argv }, opts = {}) {
|
|
|
82
85
|
out.push(a);
|
|
83
86
|
continue;
|
|
84
87
|
}
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
if (eq < 0 && stringFlags.has(key))
|
|
88
|
+
const { key, inlineValue } = splitFlagToken({ token: a });
|
|
89
|
+
if (inlineValue === undefined && stringFlags.has(key))
|
|
88
90
|
i++;
|
|
89
91
|
}
|
|
90
92
|
return out;
|
package/dist/http.js
CHANGED
|
@@ -11,6 +11,37 @@
|
|
|
11
11
|
import { DEFAULT_CLI_EXIT_CODES, exitWithStructuredError, structuredErrorData } from './errors.js';
|
|
12
12
|
import { sanitizeDaemonUrlForDisplay } from './daemon-url.js';
|
|
13
13
|
import { sanitizeUntrustedText } from '@jini-ai/core/text';
|
|
14
|
+
function writeToStderr({ text }) {
|
|
15
|
+
process.stderr.write(text);
|
|
16
|
+
}
|
|
17
|
+
function exitProcess({ code }) {
|
|
18
|
+
return process.exit(code);
|
|
19
|
+
}
|
|
20
|
+
/** `err.message` for an `Error`, `String(err)` for anything else. */
|
|
21
|
+
function errorText(err) {
|
|
22
|
+
return err instanceof Error ? err.message : String(err);
|
|
23
|
+
}
|
|
24
|
+
/** `value[key]` when `value` is an object whose `key` field is a string; `null` otherwise. */
|
|
25
|
+
function stringField(value, key) {
|
|
26
|
+
if (value === null || typeof value !== 'object')
|
|
27
|
+
return null;
|
|
28
|
+
const field = value[key];
|
|
29
|
+
return typeof field === 'string' ? field : null;
|
|
30
|
+
}
|
|
31
|
+
/** `cause.code` (with `cause.message` when present), else `cause.message`, else the error's own text. */
|
|
32
|
+
function describeFetchFailure(err) {
|
|
33
|
+
const cause = err !== null && typeof err === 'object' ? err.cause : null;
|
|
34
|
+
const code = stringField(cause, 'code');
|
|
35
|
+
const causeMessage = stringField(cause, 'message') ?? '';
|
|
36
|
+
// Stringified up front, before the cause-based choice below, exactly as the original did — so a
|
|
37
|
+
// value whose String() throws still throws even when a cause code is present.
|
|
38
|
+
const ownText = errorText(err);
|
|
39
|
+
if (code !== null)
|
|
40
|
+
return { code, detail: causeMessage.length > 0 ? `${code} — ${causeMessage}` : code };
|
|
41
|
+
return { code, detail: causeMessage.length > 0 ? causeMessage : ownText };
|
|
42
|
+
}
|
|
43
|
+
/** Cause codes that indicate an outbound connect denied by a sandbox policy rather than a down daemon. */
|
|
44
|
+
const SANDBOX_DENIAL_CODES = new Set(['EPERM', 'ENETUNREACH']);
|
|
14
45
|
/**
|
|
15
46
|
* Format a `fetch()` rejection (network-unreachable, sandbox-denied) into a
|
|
16
47
|
* human-readable stderr line, with a hint when the underlying cause looks
|
|
@@ -21,21 +52,10 @@ import { sanitizeUntrustedText } from '@jini-ai/core/text';
|
|
|
21
52
|
* printed verbatim.
|
|
22
53
|
*/
|
|
23
54
|
export function surfaceFetchError({ err, daemonUrl }, options = {}) {
|
|
24
|
-
const write = options.write ??
|
|
25
|
-
const
|
|
26
|
-
const code = cause !== null && typeof cause === 'object' && typeof cause.code === 'string'
|
|
27
|
-
? cause.code
|
|
28
|
-
: null;
|
|
29
|
-
const causeMessage = cause !== null && typeof cause === 'object' && typeof cause.message === 'string'
|
|
30
|
-
? cause.message
|
|
31
|
-
: '';
|
|
32
|
-
let detail = err instanceof Error ? err.message : String(err);
|
|
33
|
-
if (code !== null)
|
|
34
|
-
detail = `${code}${causeMessage.length > 0 ? ` — ${causeMessage}` : ''}`;
|
|
35
|
-
else if (causeMessage.length > 0)
|
|
36
|
-
detail = causeMessage;
|
|
55
|
+
const write = options.write ?? writeToStderr;
|
|
56
|
+
const { code, detail } = describeFetchFailure(err);
|
|
37
57
|
write({ text: `failed to reach daemon at ${sanitizeDaemonUrlForDisplay({ url: daemonUrl })}: ${sanitizeUntrustedText({ text: detail })}\n` });
|
|
38
|
-
if (code
|
|
58
|
+
if (code !== null && SANDBOX_DENIAL_CODES.has(code)) {
|
|
39
59
|
write({ text: 'hint: outbound connect was denied, likely by a sandbox policy. ' +
|
|
40
60
|
'If this command ran inside an agent sandbox, check its network policy — ' +
|
|
41
61
|
'the daemon itself is unaffected and can be reached from a regular shell.\n',
|
|
@@ -55,6 +75,57 @@ class ResponseTooLargeError extends Error {
|
|
|
55
75
|
this.name = 'ResponseTooLargeError';
|
|
56
76
|
}
|
|
57
77
|
}
|
|
78
|
+
/** Rejects up front when `resp` declares a numeric `content-length` above `maxBytes`; a missing or non-numeric header is left to the byte-counting read. */
|
|
79
|
+
function assertDeclaredLengthWithin(resp, maxBytes) {
|
|
80
|
+
const contentLength = resp.headers?.get?.('content-length');
|
|
81
|
+
if (contentLength === null || contentLength === undefined)
|
|
82
|
+
return;
|
|
83
|
+
const declared = Number(contentLength);
|
|
84
|
+
if (Number.isFinite(declared) && declared > maxBytes)
|
|
85
|
+
throw new ResponseTooLargeError(maxBytes);
|
|
86
|
+
}
|
|
87
|
+
/** Drains `body` as UTF-8 text, throwing {@link ResponseTooLargeError} as soon as the running byte count passes `maxBytes`. */
|
|
88
|
+
async function readStreamText(body, maxBytes) {
|
|
89
|
+
const reader = body.getReader();
|
|
90
|
+
const decoder = new TextDecoder();
|
|
91
|
+
let text = '';
|
|
92
|
+
let total = 0;
|
|
93
|
+
try {
|
|
94
|
+
for (;;) {
|
|
95
|
+
const { done, value } = await reader.read();
|
|
96
|
+
if (done === true)
|
|
97
|
+
break;
|
|
98
|
+
if (value === undefined)
|
|
99
|
+
continue;
|
|
100
|
+
total += value.byteLength;
|
|
101
|
+
if (total > maxBytes)
|
|
102
|
+
throw new ResponseTooLargeError(maxBytes);
|
|
103
|
+
text += decoder.decode(value, { stream: true });
|
|
104
|
+
}
|
|
105
|
+
return text + decoder.decode();
|
|
106
|
+
}
|
|
107
|
+
finally {
|
|
108
|
+
reader.releaseLock();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
/** `resp.text()`, byte-checked against `maxBytes` before it is parsed. */
|
|
112
|
+
async function readTextWithin(resp, maxBytes) {
|
|
113
|
+
const text = await resp.text();
|
|
114
|
+
if (Buffer.byteLength(text, 'utf8') > maxBytes)
|
|
115
|
+
throw new ResponseTooLargeError(maxBytes);
|
|
116
|
+
return text;
|
|
117
|
+
}
|
|
118
|
+
/** Parses `text` as JSON; an empty or non-JSON body resolves to `{}`. */
|
|
119
|
+
function parseJsonOrEmpty(text) {
|
|
120
|
+
if (text.length === 0)
|
|
121
|
+
return {};
|
|
122
|
+
try {
|
|
123
|
+
return JSON.parse(text);
|
|
124
|
+
}
|
|
125
|
+
catch {
|
|
126
|
+
return {};
|
|
127
|
+
}
|
|
128
|
+
}
|
|
58
129
|
/**
|
|
59
130
|
* Read `resp`'s body as JSON without ever buffering past `maxBytes`. Prefers streaming via
|
|
60
131
|
* `resp.body`'s reader (what a real `fetch()` `Response` provides) so a huge response is
|
|
@@ -64,130 +135,114 @@ class ResponseTooLargeError extends Error {
|
|
|
64
135
|
* valid JSON resolves to `{}`, matching this module's previous behavior.
|
|
65
136
|
*/
|
|
66
137
|
async function readJsonWithLimit(resp, maxBytes) {
|
|
67
|
-
|
|
68
|
-
if (contentLength !== null && contentLength !== undefined) {
|
|
69
|
-
const declared = Number(contentLength);
|
|
70
|
-
if (Number.isFinite(declared) && declared > maxBytes)
|
|
71
|
-
throw new ResponseTooLargeError(maxBytes);
|
|
72
|
-
}
|
|
138
|
+
assertDeclaredLengthWithin(resp, maxBytes);
|
|
73
139
|
const body = resp.body;
|
|
74
|
-
if (body != null && typeof body.getReader === 'function')
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
}
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
140
|
+
if (body != null && typeof body.getReader === 'function')
|
|
141
|
+
return parseJsonOrEmpty(await readStreamText(body, maxBytes));
|
|
142
|
+
if (typeof resp.text === 'function')
|
|
143
|
+
return parseJsonOrEmpty(await readTextWithin(resp, maxBytes));
|
|
144
|
+
return await resp.json().catch(() => ({}));
|
|
145
|
+
}
|
|
146
|
+
function structuredExitOptions(options) {
|
|
147
|
+
return {
|
|
148
|
+
write: options.write ?? writeToStderr,
|
|
149
|
+
exit: options.exit ?? exitProcess,
|
|
150
|
+
...(options.exitCodes !== undefined ? { exitCodes: options.exitCodes } : {}),
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
/** Arms the request deadline: aborts `controller` with a route-naming error after `timeoutMs`. */
|
|
154
|
+
function startDeadline({ route, timeoutMs }, { signal }) {
|
|
155
|
+
const controller = new AbortController();
|
|
156
|
+
const handle = setTimeout(() => {
|
|
157
|
+
controller.abort(new Error(`request to ${route} timed out after ${timeoutMs}ms`));
|
|
158
|
+
}, timeoutMs);
|
|
159
|
+
// A short-lived CLI invocation shouldn't be kept alive purely by this timer.
|
|
160
|
+
handle.unref?.();
|
|
161
|
+
return {
|
|
162
|
+
controller,
|
|
163
|
+
signal: signal !== undefined ? AbortSignal.any([signal, controller.signal]) : controller.signal,
|
|
164
|
+
clear: () => { clearTimeout(handle); },
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
/** POST adds a JSON content-type (a caller header of the same name still wins) and the serialized body; GET sends only the caller headers. */
|
|
168
|
+
function buildFetchInit(init, headers, signal) {
|
|
169
|
+
if (init.method === 'POST') {
|
|
170
|
+
return { method: 'POST', headers: { 'content-type': 'application/json', ...headers }, body: JSON.stringify(init.body), signal };
|
|
102
171
|
}
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
172
|
+
return { method: 'GET', headers: { ...headers }, signal };
|
|
173
|
+
}
|
|
174
|
+
/** A rejected `fetch()` (or a request init that could not be built) exits as `daemon-not-running`, carrying {@link surfaceFetchError}'s lines as diagnostics. */
|
|
175
|
+
function exitForFetchFailure({ err, base }, structured) {
|
|
176
|
+
const diagnostics = [];
|
|
177
|
+
surfaceFetchError({ err, daemonUrl: base }, { write: ({ text }) => { diagnostics.push(text.trimEnd()); } });
|
|
178
|
+
return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${sanitizeDaemonUrlForDisplay({ url: base })}` }, { data: { diagnostics }, ...structured });
|
|
179
|
+
}
|
|
180
|
+
/** A body-read failure: an oversized body aborts the in-flight request and exits `response-too-large`; anything else exits `request-failed`. */
|
|
181
|
+
function exitForReadFailure({ err, base, route, controller }, structured) {
|
|
182
|
+
if (err instanceof ResponseTooLargeError) {
|
|
183
|
+
controller.abort(err);
|
|
184
|
+
return exitWithStructuredError({ code: 'response-too-large', message: `response from ${sanitizeDaemonUrlForDisplay({ url: base })}${route} exceeded the ${err.limitBytes}-byte limit; aborting.` }, structured);
|
|
113
185
|
}
|
|
114
|
-
return
|
|
186
|
+
return exitWithStructuredError({ code: 'request-failed', message: sanitizeUntrustedText({ text: errorText(err) }) }, structured);
|
|
115
187
|
}
|
|
116
188
|
/**
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
* CR-004/SEC-RB-009, daemon-supplied error detail is redacted before it reaches stderr rather than
|
|
122
|
-
* dumped verbatim. Both public wrappers exist only to fix `init.method`/`init.body` at their own
|
|
123
|
-
* call site — nothing method-specific lives here beyond that.
|
|
189
|
+
* Fetches `<base><route>` and reads its JSON body under one deadline. The timeout signal stays
|
|
190
|
+
* live through both the initial fetch *and* the body read — a daemon that returns headers
|
|
191
|
+
* promptly but then drips its body slowly should still be bounded by the same deadline, not
|
|
192
|
+
* just the connection phase.
|
|
124
193
|
*/
|
|
125
|
-
async function
|
|
194
|
+
async function fetchJsonWithinDeadline({ base, route, init }, { options, structured }) {
|
|
126
195
|
const fetchImpl = options.fetchImpl ?? fetch;
|
|
127
|
-
const write = options.write ?? (({ text }) => { process.stderr.write(text); });
|
|
128
|
-
const exit = options.exit ?? (({ code }) => process.exit(code));
|
|
129
|
-
const timeoutMs = options.timeoutMs ?? DEFAULT_TIMEOUT_MS;
|
|
130
196
|
const maxResponseBytes = options.maxResponseBytes ?? DEFAULT_MAX_RESPONSE_BYTES;
|
|
131
|
-
const
|
|
132
|
-
write,
|
|
133
|
-
exit,
|
|
134
|
-
...(options.exitCodes !== undefined ? { exitCodes: options.exitCodes } : {}),
|
|
135
|
-
};
|
|
136
|
-
const timeoutController = new AbortController();
|
|
137
|
-
const timeoutHandle = setTimeout(() => {
|
|
138
|
-
timeoutController.abort(new Error(`request to ${route} timed out after ${timeoutMs}ms`));
|
|
139
|
-
}, timeoutMs);
|
|
140
|
-
// A short-lived CLI invocation shouldn't be kept alive purely by this timer.
|
|
141
|
-
timeoutHandle.unref?.();
|
|
142
|
-
const signal = options.signal !== undefined ? AbortSignal.any([options.signal, timeoutController.signal]) : timeoutController.signal;
|
|
143
|
-
// The timeout signal stays live through both the initial fetch *and* the body read below — a
|
|
144
|
-
// daemon that returns headers promptly but then drips its body slowly should still be bounded
|
|
145
|
-
// by the same deadline, not just the connection phase.
|
|
146
|
-
let resp;
|
|
147
|
-
let data;
|
|
197
|
+
const deadline = startDeadline({ route, timeoutMs: options.timeoutMs ?? DEFAULT_TIMEOUT_MS }, { signal: options.signal });
|
|
148
198
|
try {
|
|
199
|
+
let resp;
|
|
149
200
|
try {
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
...(init.method === 'POST' ? { body: JSON.stringify(init.body) } : {}),
|
|
154
|
-
signal,
|
|
155
|
-
});
|
|
201
|
+
// The init is built inside this try on purpose: a body JSON.stringify cannot serialize is
|
|
202
|
+
// reported through the same daemon-not-running path as a rejected fetch, as it always was.
|
|
203
|
+
resp = await fetchImpl(`${base}${route}`, buildFetchInit(init, options.headers, deadline.signal));
|
|
156
204
|
}
|
|
157
205
|
catch (err) {
|
|
158
|
-
|
|
159
|
-
surfaceFetchError({ err, daemonUrl: base }, { write: ({ text }) => { diagnostics.push(text.trimEnd()); } });
|
|
160
|
-
return exitWithStructuredError({ code: 'daemon-not-running', message: `Cannot reach daemon at ${sanitizeDaemonUrlForDisplay({ url: base })}` }, { data: { diagnostics }, ...structuredOptions });
|
|
206
|
+
return exitForFetchFailure({ err, base }, structured);
|
|
161
207
|
}
|
|
162
208
|
try {
|
|
163
|
-
data
|
|
209
|
+
return { resp, data: await readJsonWithLimit(resp, maxResponseBytes) };
|
|
164
210
|
}
|
|
165
211
|
catch (err) {
|
|
166
|
-
|
|
167
|
-
timeoutController.abort(err);
|
|
168
|
-
return exitWithStructuredError({ code: 'response-too-large', message: `response from ${sanitizeDaemonUrlForDisplay({ url: base })}${route} exceeded the ${err.limitBytes}-byte limit; aborting.` }, structuredOptions);
|
|
169
|
-
}
|
|
170
|
-
const detail = err instanceof Error ? err.message : String(err);
|
|
171
|
-
return exitWithStructuredError({ code: 'request-failed', message: sanitizeUntrustedText({ text: detail }) }, structuredOptions);
|
|
212
|
+
return exitForReadFailure({ err, base, route, controller: deadline.controller }, structured);
|
|
172
213
|
}
|
|
173
214
|
}
|
|
174
215
|
finally {
|
|
175
|
-
|
|
216
|
+
deadline.clear();
|
|
176
217
|
}
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
}
|
|
187
|
-
const rawExcerpt = describeUnrecognizedDaemonPayload(data);
|
|
188
|
-
const fallbackCode = typeof errorObj?.code === 'string' ? sanitizeUntrustedText({ text: errorObj.code }) : 'http-error';
|
|
189
|
-
return exitWithStructuredError({ code: fallbackCode, message: `${init.method} ${route} failed: ${resp.status}${rawExcerpt}` }, structuredOptions);
|
|
218
|
+
}
|
|
219
|
+
/** Maps a non-2xx daemon response to a structured exit: a recognized `error.code` keeps its own exit code and sanitized message/data; anything else exits with a bounded, redacted payload excerpt. */
|
|
220
|
+
function exitForDaemonError({ resp, data, method, route }, structured) {
|
|
221
|
+
const errorObj = data?.error;
|
|
222
|
+
const exitCodes = { ...DEFAULT_CLI_EXIT_CODES, ...structured.exitCodes };
|
|
223
|
+
const rawCode = errorObj?.code;
|
|
224
|
+
if (typeof rawCode === 'string' && rawCode in exitCodes) {
|
|
225
|
+
const rawMessage = typeof errorObj?.message === 'string' ? errorObj.message : `HTTP ${resp.status}`;
|
|
226
|
+
const errorData = structuredErrorData({ error: errorObj });
|
|
227
|
+
return exitWithStructuredError({ code: rawCode, message: sanitizeUntrustedText({ text: rawMessage }) }, { ...(errorData !== undefined ? { data: errorData } : {}), ...structured });
|
|
190
228
|
}
|
|
229
|
+
const fallbackCode = typeof rawCode === 'string' ? sanitizeUntrustedText({ text: rawCode }) : 'http-error';
|
|
230
|
+
return exitWithStructuredError({ code: fallbackCode, message: `${method} ${route} failed: ${resp.status}${describeUnrecognizedDaemonPayload(data)}` }, structured);
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Shared transport core behind {@link postJsonToDaemon} and {@link getJsonFromDaemon}: fetch
|
|
234
|
+
* `<base><route>` with a bounded timeout and response-size cap, and map a network failure or a
|
|
235
|
+
* non-2xx daemon response through the structured error contract (`errors.ts`) instead of
|
|
236
|
+
* returning — this function either resolves with the parsed body or never returns. Per
|
|
237
|
+
* CR-004/SEC-RB-009, daemon-supplied error detail is redacted before it reaches stderr rather than
|
|
238
|
+
* dumped verbatim. Both public wrappers exist only to fix `init.method`/`init.body` at their own
|
|
239
|
+
* call site — nothing method-specific lives here beyond that.
|
|
240
|
+
*/
|
|
241
|
+
async function requestJsonFromDaemon({ base, route, init }, options) {
|
|
242
|
+
const structured = structuredExitOptions(options);
|
|
243
|
+
const { resp, data } = await fetchJsonWithinDeadline({ base, route, init }, { options, structured });
|
|
244
|
+
if (!resp.ok)
|
|
245
|
+
return exitForDaemonError({ resp, data, method: init.method, route }, structured);
|
|
191
246
|
return data;
|
|
192
247
|
}
|
|
193
248
|
/**
|
|
@@ -202,7 +257,7 @@ async function requestJsonFromDaemon(base, route, init, options = {}) {
|
|
|
202
257
|
* it verbatim.
|
|
203
258
|
*/
|
|
204
259
|
export async function postJsonToDaemon({ base, route, body }, options = {}) {
|
|
205
|
-
return requestJsonFromDaemon(base, route, { method: 'POST', body }, options);
|
|
260
|
+
return requestJsonFromDaemon({ base, route, init: { method: 'POST', body } }, options);
|
|
206
261
|
}
|
|
207
262
|
/**
|
|
208
263
|
* `GET` `<base><route>` and return the parsed JSON response. Same transport contract as
|
|
@@ -211,7 +266,7 @@ export async function postJsonToDaemon({ base, route, body }, options = {}) {
|
|
|
211
266
|
* `GET /api/runs/:runId`.
|
|
212
267
|
*/
|
|
213
268
|
export async function getJsonFromDaemon({ base, route }, options = {}) {
|
|
214
|
-
return requestJsonFromDaemon(base, route, { method: 'GET' }, options);
|
|
269
|
+
return requestJsonFromDaemon({ base, route, init: { method: 'GET' } }, options);
|
|
215
270
|
}
|
|
216
271
|
/** A bounded, redacted excerpt of an unrecognized daemon error payload — never the full raw body. */
|
|
217
272
|
function describeUnrecognizedDaemonPayload(data) {
|
package/dist/introspection.js
CHANGED
|
@@ -20,7 +20,7 @@ export function introspectProgram({ program, excludedCommands }) {
|
|
|
20
20
|
return;
|
|
21
21
|
}
|
|
22
22
|
commands.push({
|
|
23
|
-
name: fullPath.join(" "), description: command.description()
|
|
23
|
+
name: fullPath.join(" "), description: command.description(),
|
|
24
24
|
arguments: command.registeredArguments.map((arg) => ({ name: arg.name(), required: arg.required, description: arg.description ?? "" })),
|
|
25
25
|
options: command.options.map((opt) => ({
|
|
26
26
|
flags: opt.flags, attributeName: opt.attributeName(), description: opt.description ?? "",
|
|
@@ -31,7 +31,7 @@ export function introspectProgram({ program, excludedCommands }) {
|
|
|
31
31
|
}
|
|
32
32
|
for (const command of program.commands)
|
|
33
33
|
visit(command, []);
|
|
34
|
-
return { name: program.name(), description: program.description()
|
|
34
|
+
return { name: program.name(), description: program.description(), commands };
|
|
35
35
|
}
|
|
36
36
|
/**
|
|
37
37
|
* Project a CLI manifest into string-valued MCP input schemas; boolean switches are omitted.
|
package/dist/main.js
CHANGED
|
@@ -89,6 +89,9 @@ function defaultWrite({ text }) {
|
|
|
89
89
|
function defaultWriteErr({ text }) {
|
|
90
90
|
process.stderr.write(text);
|
|
91
91
|
}
|
|
92
|
+
function defaultExit({ code }) {
|
|
93
|
+
return process.exit(code);
|
|
94
|
+
}
|
|
92
95
|
/** Name of the environment variable {@link resolveDaemonUrl} checks when `--daemon-url` is absent. */
|
|
93
96
|
const DAEMON_URL_ENV_VAR = 'JINI_DAEMON_URL';
|
|
94
97
|
const ROOT_USAGE = renderUsage({ usage: ['jini <command> [...args]'] }, { description: 'CLI transport for a Jini daemon over HTTP. Commands: run, daemon, version.', options: [
|
|
@@ -114,34 +117,40 @@ const GLOBAL_FLAG_NAMES = ['daemon-url', 'data-dir', 'registry-path'];
|
|
|
114
117
|
* failure.
|
|
115
118
|
*/
|
|
116
119
|
function partitionGlobalArgv(argv) {
|
|
117
|
-
const names = new Set(GLOBAL_FLAG_NAMES);
|
|
118
120
|
const globals = {};
|
|
119
121
|
const rest = [];
|
|
120
122
|
for (let i = 0; i < argv.length; i++) {
|
|
121
123
|
// Loop is bounded by argv.length, so this index is always in range —
|
|
122
124
|
// noUncheckedIndexedAccess types it as possibly-undefined regardless.
|
|
123
125
|
const token = argv[i];
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
const value = argv[i + 1];
|
|
133
|
-
if (value === undefined)
|
|
134
|
-
throw new Error(`flag --${key} requires a value`);
|
|
135
|
-
globals[key] = value;
|
|
136
|
-
i++;
|
|
137
|
-
}
|
|
138
|
-
continue;
|
|
139
|
-
}
|
|
126
|
+
const match = matchGlobalFlag(token);
|
|
127
|
+
if (match === null) {
|
|
128
|
+
rest.push(token);
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
if (match.inlineValue !== undefined) {
|
|
132
|
+
globals[match.key] = match.inlineValue;
|
|
133
|
+
continue;
|
|
140
134
|
}
|
|
141
|
-
|
|
135
|
+
const value = argv[i + 1];
|
|
136
|
+
if (value === undefined)
|
|
137
|
+
throw new Error(`flag --${match.key} requires a value`);
|
|
138
|
+
globals[match.key] = value;
|
|
139
|
+
i++;
|
|
142
140
|
}
|
|
143
141
|
return { globals, rest };
|
|
144
142
|
}
|
|
143
|
+
const GLOBAL_FLAG_SET = new Set(GLOBAL_FLAG_NAMES);
|
|
144
|
+
/** Recognizes `--<global>` / `--<global>=<value>`; `null` for any token that is not one of this binary's own global flags. */
|
|
145
|
+
function matchGlobalFlag(token) {
|
|
146
|
+
if (!token.startsWith('--'))
|
|
147
|
+
return null;
|
|
148
|
+
const eq = token.indexOf('=');
|
|
149
|
+
const key = eq >= 0 ? token.slice(2, eq) : token.slice(2);
|
|
150
|
+
if (!GLOBAL_FLAG_SET.has(key))
|
|
151
|
+
return null;
|
|
152
|
+
return { key: key, inlineValue: eq >= 0 ? token.slice(eq + 1) : undefined };
|
|
153
|
+
}
|
|
145
154
|
/** Builds a `resolveDaemonUrl`-backed `resolveBaseUrl` closure from this binary's own parsed global flags. */
|
|
146
155
|
function buildResolveBaseUrl(globals, env, warn) {
|
|
147
156
|
const { 'daemon-url': daemonUrl, 'data-dir': dataDir, 'registry-path': registryPath } = globals;
|
|
@@ -158,6 +167,35 @@ function buildResolveBaseUrl(globals, env, warn) {
|
|
|
158
167
|
warn,
|
|
159
168
|
});
|
|
160
169
|
}
|
|
170
|
+
function isRootHelpRequest(argv) {
|
|
171
|
+
return argv.length === 0 || argv[0] === '--help' || argv[0] === '-h';
|
|
172
|
+
}
|
|
173
|
+
// `--version`/`-v` as the very first token is a plain alias for `jini version` — see this
|
|
174
|
+
// file's own module doc for why there is no separate "this package's own version" surface.
|
|
175
|
+
function expandVersionAlias(argv) {
|
|
176
|
+
return argv[0] === '--version' || argv[0] === '-v' ? ['version', ...argv.slice(1)] : argv;
|
|
177
|
+
}
|
|
178
|
+
/** Strips the global flags, registers `run`/`daemon`/`version` against a fresh registry and dispatches the remaining argv. */
|
|
179
|
+
async function dispatchArgv({ argv, env }, { ports, fetchImpl }) {
|
|
180
|
+
const { globals, rest } = partitionGlobalArgv(argv);
|
|
181
|
+
const resolveBaseUrl = buildResolveBaseUrl(globals, env, ({ message }) => ports.writeErr({ text: message }));
|
|
182
|
+
const registry = new CommandRegistry({});
|
|
183
|
+
const commandDeps = { ...ports, ...(fetchImpl === undefined ? {} : { fetchImpl }) };
|
|
184
|
+
registerRunCommands({ registry, resolveBaseUrl }, commandDeps);
|
|
185
|
+
registerDaemonCommands({ registry, resolveBaseUrl }, commandDeps);
|
|
186
|
+
registerVersionCommand({ registry, resolveBaseUrl }, commandDeps);
|
|
187
|
+
return registry.dispatch({ argv: rest });
|
|
188
|
+
}
|
|
189
|
+
/** A dispatch that matched no handler: no command at all prints root usage, an unknown one exits `invalid-flag`. */
|
|
190
|
+
function reportUnmatchedDispatch(result, ports) {
|
|
191
|
+
if (result.kind === 'empty') {
|
|
192
|
+
ports.write({ text: `${ROOT_USAGE}\n` });
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (result.kind === 'not-found') {
|
|
196
|
+
exitWithStructuredError({ code: 'invalid-flag', message: `unknown command: "${result.name}". Run "jini --help" for usage.` }, { write: ports.writeErr, exit: ports.exit });
|
|
197
|
+
}
|
|
198
|
+
}
|
|
161
199
|
/**
|
|
162
200
|
* Parse `argv` (already stripped of `node`/script-path, i.e. `process.argv.slice(2)`) and
|
|
163
201
|
* dispatch to the registered `run`/`daemon`/`version` commands. Never throws a raw error to its
|
|
@@ -167,15 +205,11 @@ function buildResolveBaseUrl(globals, env, warn) {
|
|
|
167
205
|
export async function main({ argv }, deps = {}) {
|
|
168
206
|
const write = deps.write ?? defaultWrite;
|
|
169
207
|
const writeErr = deps.writeErr ?? defaultWriteErr;
|
|
170
|
-
const rawExit = deps.exit ??
|
|
171
|
-
|
|
172
|
-
if (argv.length === 0 || argv[0] === '--help' || argv[0] === '-h') {
|
|
208
|
+
const rawExit = deps.exit ?? defaultExit;
|
|
209
|
+
if (isRootHelpRequest(argv)) {
|
|
173
210
|
write({ text: `${ROOT_USAGE}\n` });
|
|
174
211
|
return;
|
|
175
212
|
}
|
|
176
|
-
// `--version`/`-v` as the very first token is a plain alias for `jini version` — see this
|
|
177
|
-
// file's own module doc for why there is no separate "this package's own version" surface.
|
|
178
|
-
const effectiveArgv = argv[0] === '--version' || argv[0] === '-v' ? ['version', ...argv.slice(1)] : argv;
|
|
179
213
|
// Tracks whether `exit` was ever invoked, so the catch block below can tell "a nested command
|
|
180
214
|
// already exited cleanly" (re-throw untouched) apart from "a genuinely raw, unhandled throw"
|
|
181
215
|
// (reformat into a structured error) — see this file's own module doc, "Error boundary."
|
|
@@ -184,24 +218,10 @@ export async function main({ argv }, deps = {}) {
|
|
|
184
218
|
exitCalled = true;
|
|
185
219
|
return rawExit({ code });
|
|
186
220
|
};
|
|
221
|
+
const ports = { write, writeErr, exit };
|
|
187
222
|
let result;
|
|
188
223
|
try {
|
|
189
|
-
|
|
190
|
-
const resolveBaseUrl = buildResolveBaseUrl(globals, env, ({ message }) => writeErr({ text: message }));
|
|
191
|
-
const registry = new CommandRegistry({});
|
|
192
|
-
registerRunCommands({ registry, resolveBaseUrl }, {
|
|
193
|
-
write, writeErr, exit,
|
|
194
|
-
...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
|
|
195
|
-
});
|
|
196
|
-
registerDaemonCommands({ registry, resolveBaseUrl }, {
|
|
197
|
-
write, writeErr, exit,
|
|
198
|
-
...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
|
|
199
|
-
});
|
|
200
|
-
registerVersionCommand({ registry, resolveBaseUrl }, {
|
|
201
|
-
write, writeErr, exit,
|
|
202
|
-
...(deps.fetchImpl === undefined ? {} : { fetchImpl: deps.fetchImpl }),
|
|
203
|
-
});
|
|
204
|
-
result = await registry.dispatch({ argv: rest });
|
|
224
|
+
result = await dispatchArgv({ argv: expandVersionAlias(argv), env: deps.env ?? process.env }, { ports, fetchImpl: deps.fetchImpl });
|
|
205
225
|
}
|
|
206
226
|
catch (error) {
|
|
207
227
|
if (exitCalled)
|
|
@@ -210,13 +230,7 @@ export async function main({ argv }, deps = {}) {
|
|
|
210
230
|
exitWithStructuredError({ code: 'invalid-flag', message }, { write: writeErr, exit });
|
|
211
231
|
return;
|
|
212
232
|
}
|
|
213
|
-
|
|
214
|
-
write({ text: `${ROOT_USAGE}\n` });
|
|
215
|
-
return;
|
|
216
|
-
}
|
|
217
|
-
if (result.kind === 'not-found') {
|
|
218
|
-
exitWithStructuredError({ code: 'invalid-flag', message: `unknown command: "${result.name}". Run "jini --help" for usage.` }, { write: writeErr, exit });
|
|
219
|
-
}
|
|
233
|
+
reportUnmatchedDispatch(result, ports);
|
|
220
234
|
}
|
|
221
235
|
// Only run for real when this module is the actual process entrypoint (`node dist/main.js
|
|
222
236
|
// ...`, or the `jini` bin shim pnpm/npm links to it) — not merely imported, e.g. by this
|
package/dist/prompt.js
CHANGED
|
@@ -54,13 +54,21 @@ export async function readPromptFromFlags({ flags }, options = {}) {
|
|
|
54
54
|
if (typeof promptFile !== 'string' || promptFile.length === 0) {
|
|
55
55
|
return null;
|
|
56
56
|
}
|
|
57
|
+
return await readFlagSource({ source: promptFile, readDefaultStdin: defaultReadStdin }, options);
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Reads a `--*-file` flag's value: `-` means stdin (the injected `readStdin`, else
|
|
61
|
+
* `readDefaultStdin`), anything else is a path (the injected `readFile`, else the bounded
|
|
62
|
+
* default file reader). The byte cap and signal only reach the default readers.
|
|
63
|
+
*/
|
|
64
|
+
async function readFlagSource({ source, readDefaultStdin }, options) {
|
|
57
65
|
const readLimits = { maxBytes: options.maxBytes ?? DEFAULT_MAX_READ_BYTES, ...(options.signal !== undefined ? { signal: options.signal } : {}) };
|
|
58
|
-
if (
|
|
59
|
-
const readStdin = options.readStdin ?? (() =>
|
|
66
|
+
if (source === '-') {
|
|
67
|
+
const readStdin = options.readStdin ?? (() => readDefaultStdin(readLimits));
|
|
60
68
|
return await readStdin();
|
|
61
69
|
}
|
|
62
70
|
const readFile = options.readFile ?? (({ path }) => defaultReadFile(path, readLimits));
|
|
63
|
-
return await readFile({ path:
|
|
71
|
+
return await readFile({ path: source });
|
|
64
72
|
}
|
|
65
73
|
async function defaultReadFile(path, limits) {
|
|
66
74
|
const { createReadStream } = await import('node:fs');
|
|
@@ -163,13 +171,7 @@ export async function readBodyFromFlags({ flags }, options = {}) {
|
|
|
163
171
|
const bodyFile = flags['body-file'];
|
|
164
172
|
if (typeof bodyFile !== 'string')
|
|
165
173
|
return undefined;
|
|
166
|
-
|
|
167
|
-
if (bodyFile === '-') {
|
|
168
|
-
const readStdin = options.readStdin ?? (() => defaultReadBodyStdin(readLimits));
|
|
169
|
-
return await readStdin();
|
|
170
|
-
}
|
|
171
|
-
const readFile = options.readFile ?? (({ path }) => defaultReadFile(path, readLimits));
|
|
172
|
-
return await readFile({ path: bodyFile });
|
|
174
|
+
return await readFlagSource({ source: bodyFile, readDefaultStdin: defaultReadBodyStdin }, options);
|
|
173
175
|
}
|
|
174
176
|
async function defaultReadBodyStdin(limits) {
|
|
175
177
|
limits.signal?.throwIfAborted();
|
package/dist/run-command.js
CHANGED
|
@@ -67,16 +67,18 @@ function invalidFlag(deps, message) {
|
|
|
67
67
|
function printJsonResult(deps, value) {
|
|
68
68
|
(deps.write ?? defaultWrite)({ text: `${JSON.stringify(value)}\n` });
|
|
69
69
|
}
|
|
70
|
-
|
|
71
|
-
|
|
70
|
+
/** Prints `usage` and reports `true` when `args` asks for help (`--help`/`-h` anywhere), so the command returns before touching the daemon. */
|
|
71
|
+
function printUsageIfHelp({ args, usage }, deps) {
|
|
72
|
+
if (!args.includes('--help') && !args.includes('-h'))
|
|
73
|
+
return false;
|
|
74
|
+
(deps.write ?? defaultWrite)({ text: `${usage}\n` });
|
|
75
|
+
return true;
|
|
72
76
|
}
|
|
73
77
|
/** `run start --context-ref <ref> [--agent-id <id>] [--idempotency-key <key>]` */
|
|
74
78
|
export async function runStartCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
|
|
75
79
|
const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
|
|
76
|
-
if (
|
|
77
|
-
(deps.write ?? defaultWrite)({ text: `${RUN_START_USAGE}\n` });
|
|
80
|
+
if (printUsageIfHelp({ args, usage: RUN_START_USAGE }, deps))
|
|
78
81
|
return;
|
|
79
|
-
}
|
|
80
82
|
const flags = parseFlags({ argv: args }, { string: new Set(['context-ref', 'agent-id', 'idempotency-key']) });
|
|
81
83
|
const contextRef = flags['context-ref'];
|
|
82
84
|
if (typeof contextRef !== 'string' || contextRef.length === 0) {
|
|
@@ -87,48 +89,42 @@ export async function runStartCommand({ args, resolveBaseUrl: resolveBaseUrlPort
|
|
|
87
89
|
body.agentId = flags['agent-id'];
|
|
88
90
|
if (typeof flags['idempotency-key'] === 'string')
|
|
89
91
|
body.idempotencyKey = flags['idempotency-key'];
|
|
90
|
-
const baseUrl = await resolveBaseUrl(
|
|
92
|
+
const baseUrl = await deps.resolveBaseUrl();
|
|
91
93
|
const result = await postJsonToDaemon({ base: baseUrl, route: '/api/runs', body }, transportOptions(deps));
|
|
92
94
|
printJsonResult(deps, result);
|
|
93
95
|
}
|
|
94
96
|
/** `run list [--context-ref <ref>]` */
|
|
95
97
|
export async function runListCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
|
|
96
98
|
const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
|
|
97
|
-
if (
|
|
98
|
-
(deps.write ?? defaultWrite)({ text: `${RUN_LIST_USAGE}\n` });
|
|
99
|
+
if (printUsageIfHelp({ args, usage: RUN_LIST_USAGE }, deps))
|
|
99
100
|
return;
|
|
100
|
-
}
|
|
101
101
|
const flags = parseFlags({ argv: args }, { string: new Set(['context-ref']) });
|
|
102
102
|
const contextRef = flags['context-ref'];
|
|
103
103
|
const route = typeof contextRef === 'string' && contextRef.length > 0
|
|
104
104
|
? `/api/runs?contextRef=${encodeURIComponent(contextRef)}`
|
|
105
105
|
: '/api/runs';
|
|
106
|
-
const baseUrl = await resolveBaseUrl(
|
|
106
|
+
const baseUrl = await deps.resolveBaseUrl();
|
|
107
107
|
const result = await getJsonFromDaemon({ base: baseUrl, route }, transportOptions(deps));
|
|
108
108
|
printJsonResult(deps, result);
|
|
109
109
|
}
|
|
110
110
|
/** `run get <runId>` */
|
|
111
111
|
export async function runGetCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
|
|
112
112
|
const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
|
|
113
|
-
if (
|
|
114
|
-
(deps.write ?? defaultWrite)({ text: `${RUN_GET_USAGE}\n` });
|
|
113
|
+
if (printUsageIfHelp({ args, usage: RUN_GET_USAGE }, deps))
|
|
115
114
|
return;
|
|
116
|
-
}
|
|
117
115
|
const runId = positionalArgs({ argv: args })[0];
|
|
118
116
|
if (typeof runId !== 'string' || runId.length === 0) {
|
|
119
117
|
missingInput(deps, 'runId is required: run get <runId>');
|
|
120
118
|
}
|
|
121
|
-
const baseUrl = await resolveBaseUrl(
|
|
119
|
+
const baseUrl = await deps.resolveBaseUrl();
|
|
122
120
|
const result = await getJsonFromDaemon({ base: baseUrl, route: `/api/runs/${encodeURIComponent(runId)}` }, transportOptions(deps));
|
|
123
121
|
printJsonResult(deps, result);
|
|
124
122
|
}
|
|
125
123
|
/** `run cancel <runId> [--reason <text>]` */
|
|
126
124
|
export async function runCancelCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
|
|
127
125
|
const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
|
|
128
|
-
if (
|
|
129
|
-
(deps.write ?? defaultWrite)({ text: `${RUN_CANCEL_USAGE}\n` });
|
|
126
|
+
if (printUsageIfHelp({ args, usage: RUN_CANCEL_USAGE }, deps))
|
|
130
127
|
return;
|
|
131
|
-
}
|
|
132
128
|
const flags = parseFlags({ argv: args }, { string: new Set(['reason']) });
|
|
133
129
|
const runId = positionalArgs({ argv: args }, { string: new Set(['reason']) })[0];
|
|
134
130
|
if (typeof runId !== 'string' || runId.length === 0) {
|
|
@@ -137,7 +133,7 @@ export async function runCancelCommand({ args, resolveBaseUrl: resolveBaseUrlPor
|
|
|
137
133
|
const body = {};
|
|
138
134
|
if (typeof flags.reason === 'string')
|
|
139
135
|
body.reason = flags.reason;
|
|
140
|
-
const baseUrl = await resolveBaseUrl(
|
|
136
|
+
const baseUrl = await deps.resolveBaseUrl();
|
|
141
137
|
const result = await postJsonToDaemon({ base: baseUrl, route: `/api/runs/${encodeURIComponent(runId)}/cancel`, body }, transportOptions(deps));
|
|
142
138
|
printJsonResult(deps, result);
|
|
143
139
|
}
|
|
@@ -189,23 +185,15 @@ function isTerminalEventFrame(frame) {
|
|
|
189
185
|
* error detail.
|
|
190
186
|
*/
|
|
191
187
|
export async function watchRunEvents({ baseUrl, runId, args }, deps = {}) {
|
|
192
|
-
const
|
|
193
|
-
const afterCursor = flags['after-cursor'];
|
|
188
|
+
const headers = resumeHeaders(args);
|
|
194
189
|
const fetchImpl = deps.fetchImpl ?? fetch;
|
|
195
190
|
const write = deps.write ?? defaultWrite;
|
|
196
|
-
const headers = {};
|
|
197
|
-
if (typeof afterCursor === 'string' && afterCursor.length > 0)
|
|
198
|
-
headers['last-event-id'] = afterCursor;
|
|
199
191
|
const resp = await fetchImpl(`${baseUrl}/api/runs/${encodeURIComponent(runId)}/events`, { headers });
|
|
200
192
|
if (!resp.ok || resp.body === null) {
|
|
201
193
|
return structuredHttpFailure({ resp }, { fallbackCode: 'daemon-not-running', ...errorOptions(deps) });
|
|
202
194
|
}
|
|
203
195
|
try {
|
|
204
|
-
|
|
205
|
-
write({ text: `${stripControlSequences({ text: frame.data })}\n` });
|
|
206
|
-
if (isTerminalEventFrame(frame))
|
|
207
|
-
return;
|
|
208
|
-
}
|
|
196
|
+
await writeEventsUntilEnd({ body: resp.body, write });
|
|
209
197
|
}
|
|
210
198
|
catch (error) {
|
|
211
199
|
// A broken/hostile stream (e.g. the unbounded-buffer guard above) must exit through the same
|
|
@@ -214,18 +202,29 @@ export async function watchRunEvents({ baseUrl, runId, args }, deps = {}) {
|
|
|
214
202
|
return exitWithStructuredError({ code: 'daemon-not-running', message: sanitizeUntrustedText({ text: message }) }, errorOptions(deps));
|
|
215
203
|
}
|
|
216
204
|
}
|
|
205
|
+
/** `last-event-id` from a non-empty `--after-cursor`, so a reconnect resumes after that cursor; no header otherwise. */
|
|
206
|
+
function resumeHeaders(args) {
|
|
207
|
+
const afterCursor = parseFlags({ argv: args }, { string: new Set(['after-cursor']) })['after-cursor'];
|
|
208
|
+
return typeof afterCursor === 'string' && afterCursor.length > 0 ? { 'last-event-id': afterCursor } : {};
|
|
209
|
+
}
|
|
210
|
+
/** Writes one control-sequence-stripped NDJSON line per SSE frame, stopping after the terminal `'end'` event or when the stream closes. */
|
|
211
|
+
async function writeEventsUntilEnd({ body, write }) {
|
|
212
|
+
for await (const frame of readSseFrames(body)) {
|
|
213
|
+
write({ text: `${stripControlSequences({ text: frame.data })}\n` });
|
|
214
|
+
if (isTerminalEventFrame(frame))
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
217
218
|
/** `run watch <runId> [--after-cursor <cursor>]` */
|
|
218
219
|
export async function runWatchCommand({ args, resolveBaseUrl: resolveBaseUrlPort }, optionalArgs = {}) {
|
|
219
220
|
const deps = { resolveBaseUrl: resolveBaseUrlPort, ...optionalArgs };
|
|
220
|
-
if (
|
|
221
|
-
(deps.write ?? defaultWrite)({ text: `${RUN_WATCH_USAGE}\n` });
|
|
221
|
+
if (printUsageIfHelp({ args, usage: RUN_WATCH_USAGE }, deps))
|
|
222
222
|
return;
|
|
223
|
-
}
|
|
224
223
|
const runId = positionalArgs({ argv: args }, { string: new Set(['after-cursor']) })[0];
|
|
225
224
|
if (typeof runId !== 'string' || runId.length === 0) {
|
|
226
225
|
missingInput(deps, 'runId is required: run watch <runId>');
|
|
227
226
|
}
|
|
228
|
-
const baseUrl = await resolveBaseUrl(
|
|
227
|
+
const baseUrl = await deps.resolveBaseUrl();
|
|
229
228
|
await watchRunEvents({ baseUrl, runId, args }, deps);
|
|
230
229
|
}
|
|
231
230
|
/** Registers `run` (dispatching `start`/`list`/`cancel`/`watch` on the first remaining token) against `registry`. */
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@jini-ai/cli",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.6.10",
|
|
4
4
|
"description": "CLI transport shell for driving a Jini daemon over HTTP: flag parsing, daemon-URL resolution, local daemon discovery, and a pluggable command registry.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"repository": {
|
|
@@ -54,16 +54,16 @@
|
|
|
54
54
|
},
|
|
55
55
|
"devDependencies": {
|
|
56
56
|
"commander": "^15.0.0",
|
|
57
|
-
"@jini-ai/core": "0.
|
|
58
|
-
"@jini-ai/sidecar": "0.
|
|
59
|
-
"@jini-ai/platform": "0.
|
|
57
|
+
"@jini-ai/core": "0.6.10",
|
|
58
|
+
"@jini-ai/sidecar": "0.6.10",
|
|
59
|
+
"@jini-ai/platform": "0.6.10"
|
|
60
60
|
},
|
|
61
61
|
"dependencies": {
|
|
62
|
-
"@jini-ai/core": "^0.
|
|
62
|
+
"@jini-ai/core": "^0.6.10"
|
|
63
63
|
},
|
|
64
64
|
"peerDependencies": {
|
|
65
|
-
"@jini-ai/platform": "^0.
|
|
66
|
-
"@jini-ai/sidecar": "^0.
|
|
65
|
+
"@jini-ai/platform": "^0.6.10",
|
|
66
|
+
"@jini-ai/sidecar": "^0.6.10"
|
|
67
67
|
},
|
|
68
68
|
"peerDependenciesMeta": {
|
|
69
69
|
"@jini-ai/platform": {
|