@happyvertical/smrt-app-cli 0.43.7 → 0.43.9

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/dist/index.js CHANGED
@@ -1,1033 +1,3 @@
1
- import { a as loadCliConfig, c as requestJsonResult, d as createMcpStdioBridge, i as getStoredToken, l as saveAuth, n as clearStoredToken, o as redactTransportValue, p as runMcpStdioBridge, r as getServerUrl, s as requestJson, t as AppCliRequestError, u as saveCliConfig } from "./config-B6arU8x7.js";
2
- import { SMRT_MCP_RESULT_METADATA_KEY, validateDiscoveryConformanceArtifact } from "@happyvertical/smrt-users/app-contract";
3
- import { spawn, spawnSync } from "node:child_process";
4
- //#region src/discovery.ts
5
- /**
6
- * Fetches the resource list from `GET /api/_resources` and re-exports the
7
- * wire types so consumers can build typed extensions on top of the discovery
8
- * output.
9
- *
10
- * Types are imported type-only from `@happyvertical/smrt-users/sveltekit`
11
- * — the single source of truth for the wire contract. The CLI does not
12
- * depend on smrt-users at runtime (the peer dep is `optional` in the
13
- * package.json) so importing types is free of bundle cost.
14
- *
15
- * Importing type-only means a new field added to `CommandDefinition` /
16
- * `CliResource` on the handler side automatically propagates to the CLI's
17
- * typecheck — no manual mirror-update required. (#1311 review D-1.)
18
- *
19
- * @packageDocumentation
20
- */
21
- /**
22
- * Fetch the discovery payload.
23
- *
24
- * Translates a 401 into a friendlier error so the CLI can prompt the
25
- * user to log in rather than dumping a raw HTTP error.
26
- */
27
- async function fetchResourceList(context, options = {}) {
28
- try {
29
- const response = await requestJson(context, options.path ?? "/api/_resources", { method: "GET" }, {
30
- fetch: options.fetch,
31
- requireAuth: options.requireAuth,
32
- loadedConfig: options.loadedConfig
33
- });
34
- if (!response.artifact) return response;
35
- const artifact = validateDiscoveryConformanceArtifact(response.artifact);
36
- return {
37
- ...artifact.discovery,
38
- artifact
39
- };
40
- } catch (error) {
41
- if (error instanceof Error && /401|unauthor/i.test(error.message)) throw new Error(`Not authenticated to ${context.envPrefix.toLowerCase()}. Run \`${context.envPrefix.toLowerCase()} auth login\` first.`);
42
- throw error;
43
- }
44
- }
45
- /**
46
- * Find a resource by slug in the discovery payload. Returns `undefined`
47
- * if the slug isn't present.
48
- */
49
- function findResourceBySlug(response, slug) {
50
- return response.resources.find((r) => r.slug === slug);
51
- }
52
- /**
53
- * Find a command on a resource by its CLI-facing name. Returns
54
- * `undefined` if not found.
55
- */
56
- function findCommand(resource, commandName) {
57
- return resource.commands.find((c) => c.commandName === commandName);
58
- }
59
- //#endregion
60
- //#region src/invoke.ts
61
- /**
62
- * Build the URL the CLI should hit for this command, plus the fetch init
63
- * (headers, body) it should pass. Returns enough so callers can stream
64
- * the response — they decide how to render it (see `output.ts`).
65
- */
66
- async function invokeCommand(options) {
67
- const { context, resource, command, parsed, fetch: fetchImpl } = options;
68
- const url = await buildUrl(context, resource, command, parsed, options.id);
69
- const headers = new Headers();
70
- const token = await getStoredToken(context);
71
- if (token) headers.set("authorization", `Bearer ${token}`);
72
- let body;
73
- if (command.httpMethod !== "GET" && command.httpMethod !== "DELETE") {
74
- if (Object.keys(parsed.body).length > 0 || parsed.fromPositional) {
75
- headers.set("content-type", "application/json");
76
- body = JSON.stringify(parsed.body);
77
- }
78
- }
79
- return (fetchImpl ?? fetch)(url, {
80
- method: command.httpMethod,
81
- headers,
82
- body
83
- });
84
- }
85
- /**
86
- * Build only the URL — exposed for tests.
87
- */
88
- async function buildUrl(context, resource, command, parsed, id) {
89
- const serverUrl = await getServerUrl(context);
90
- const segments = ["api"];
91
- for (const piece of splitPath(resource.apiPath)) segments.push(encodeURIComponent(piece));
92
- if (command.scope === "item") {
93
- if (!id) throw new Error(`Command \`${resource.slug} ${command.commandName}\` requires an id positional argument.`);
94
- segments.push(encodeURIComponent(id));
95
- }
96
- for (const seg of command.pathSegments) for (const piece of splitPath(seg)) segments.push(encodeURIComponent(piece));
97
- const base = `${serverUrl}/${segments.join("/")}`;
98
- if (command.httpMethod === "GET" && Object.keys(parsed.query).length > 0) {
99
- const params = new URLSearchParams();
100
- for (const [k, v] of Object.entries(parsed.query)) if (Array.isArray(v)) for (const x of v) params.append(k, x);
101
- else params.set(k, v);
102
- return `${base}?${params.toString()}`;
103
- }
104
- return base;
105
- }
106
- /**
107
- * Split a URL path string into clean segments, stripping leading/trailing
108
- * slashes and any empty pieces. Defends against `apiPath` overrides like
109
- * `/v1/items/` or `pathSegments` like `users/` that would otherwise
110
- * produce double slashes or trailing nothings in the final URL.
111
- */
112
- function splitPath(s) {
113
- if (typeof s !== "string") return [];
114
- return s.split("/").filter((p) => p.length > 0);
115
- }
116
- //#endregion
117
- //#region src/mcp-oauth.ts
118
- function createMcpRegistrationRequest(options) {
119
- if (!options.clientName.trim()) throw new Error("MCP client_name must not be empty.");
120
- if (options.redirectUris.length === 0) throw new Error("MCP client metadata requires at least one redirect URI.");
121
- for (const redirectUri of options.redirectUris) new URL(redirectUri);
122
- return {
123
- application_type: options.applicationType,
124
- client_name: options.clientName,
125
- grant_types: ["authorization_code"],
126
- redirect_uris: [...options.redirectUris],
127
- response_types: ["code"],
128
- token_endpoint_auth_method: "none"
129
- };
130
- }
131
- /** Build and validate the document hosted at an HTTPS URL used as client_id. */
132
- function createMcpClientIdMetadataDocument(options) {
133
- if (!options.clientId) throw new Error("MCP client_id metadata URL is required for CIMD.");
134
- const clientId = new URL(options.clientId);
135
- if (clientId.protocol !== "https:" || clientId.pathname === "/") throw new Error("MCP client_id metadata URL must use HTTPS and include a path.");
136
- if (clientId.search || clientId.hash) throw new Error("MCP client_id metadata URL must not include query or fragment.");
137
- return {
138
- ...createMcpRegistrationRequest(options),
139
- client_id: options.clientId
140
- };
141
- }
142
- /**
143
- * Select registration in MCP priority order: Client ID Metadata Documents
144
- * first, then RFC 7591 DCR as a compatibility fallback.
145
- */
146
- function resolveMcpClientRegistration(authorizationServer, options) {
147
- if (authorizationServer.client_id_metadata_document_supported === true) {
148
- const metadataDocument = createMcpClientIdMetadataDocument(options);
149
- return {
150
- clientId: metadataDocument.client_id,
151
- kind: "client_id_metadata_document",
152
- metadataDocument
153
- };
154
- }
155
- if (authorizationServer.registration_endpoint) return {
156
- endpoint: authorizationServer.registration_endpoint,
157
- kind: "dynamic_client_registration",
158
- request: createMcpRegistrationRequest(options)
159
- };
160
- throw new Error("Authorization server supports neither Client ID Metadata Documents nor dynamic client registration.");
161
- }
162
- /**
163
- * Complete client registration against discovered authorization-server
164
- * metadata. A Client ID Metadata Document needs no registration request: the
165
- * authorization server retrieves it from the HTTPS client_id. The legacy DCR
166
- * fallback is executed as an RFC 7591 JSON POST.
167
- */
168
- async function registerMcpClient(authorizationServer, options, fetchImpl = fetch) {
169
- const registration = resolveMcpClientRegistration(authorizationServer, options);
170
- if (registration.kind === "client_id_metadata_document") return {
171
- clientId: registration.clientId,
172
- kind: registration.kind,
173
- metadataDocument: registration.metadataDocument
174
- };
175
- const response = await fetchImpl(registration.endpoint, {
176
- body: JSON.stringify(registration.request),
177
- headers: { "content-type": "application/json" },
178
- method: "POST"
179
- });
180
- if (!response.ok) throw new Error(`Dynamic client registration failed: HTTP ${response.status}`);
181
- const body = await response.json();
182
- if (typeof body !== "object" || body === null || typeof body.client_id !== "string" || !body.client_id) throw new Error("Dynamic client registration response omitted client_id.");
183
- return {
184
- clientId: body.client_id,
185
- kind: registration.kind,
186
- registrationResponse: body
187
- };
188
- }
189
- //#endregion
190
- //#region src/output.ts
191
- var JSON_BUFFER_LIMIT = 10 * 1024 * 1024;
192
- /**
193
- * Render the response. Returns the desired exit code.
194
- */
195
- async function renderResponse(response, options = {}) {
196
- try {
197
- return await renderResponseUnchecked(response, options);
198
- } catch (err) {
199
- if (err instanceof BrokenPipeError) return { exitCode: 0 };
200
- throw err;
201
- }
202
- }
203
- async function renderResponseUnchecked(response, options = {}) {
204
- const stdout = options.stdout ?? process.stdout;
205
- const stderr = options.stderr ?? process.stderr;
206
- const isTty = options.stdoutIsTty ?? Boolean(stdout.isTTY);
207
- const ct = (response.headers.get("content-type") ?? "").toLowerCase();
208
- const cl = Number(response.headers.get("content-length") ?? "");
209
- const isJson = ct.startsWith("application/json") || /\+json(\s|;|$)/.test(ct);
210
- const isText = ct.startsWith("text/");
211
- if (response.status === 204) return { exitCode: 0 };
212
- if (response.status >= 400) {
213
- if (isJson) {
214
- const result = await readUntilLimitOrStream(response, JSON_BUFFER_LIMIT, stderr);
215
- if (result.overflowed) stderr.write("\n[smrt-app-cli] error response exceeded 10MB cap; streamed raw\n");
216
- else if (result.text) {
217
- const pretty = safePrettyJson(result.text) ?? result.text;
218
- stderr.write(`${pretty}\n`);
219
- }
220
- return { exitCode: response.status >= 500 ? 2 : 1 };
221
- }
222
- if (isText) {
223
- await pipeBody(response, stderr);
224
- return { exitCode: response.status >= 500 ? 2 : 1 };
225
- }
226
- stderr.write(`error: ${response.status} ${response.statusText || "HTTP error"}\n`);
227
- return { exitCode: response.status >= 500 ? 2 : 1 };
228
- }
229
- if (isJson) {
230
- if (cl && cl > JSON_BUFFER_LIMIT) {
231
- stderr.write(`[smrt-app-cli] response too large to pretty-print (${cl} bytes); streaming raw JSON\n`);
232
- await pipeBody(response, stdout);
233
- return { exitCode: 0 };
234
- }
235
- const result = await readUntilLimitOrStream(response, JSON_BUFFER_LIMIT, stdout);
236
- if (result.overflowed) {
237
- stderr.write("[smrt-app-cli] response exceeded 10MB cap; streamed raw JSON\n");
238
- return { exitCode: 0 };
239
- }
240
- if (!result.text) return { exitCode: 0 };
241
- const pretty = safePrettyJson(result.text) ?? result.text;
242
- stdout.write(`${pretty}\n`);
243
- return { exitCode: 0 };
244
- }
245
- if (isText) {
246
- await pipeBody(response, stdout);
247
- return { exitCode: 0 };
248
- }
249
- if (isTty) {
250
- const size = cl ? ` (${cl} bytes)` : "";
251
- stderr.write(`[smrt-app-cli] binary response${size}; redirect to a file to capture: <cli> ... > out.bin\n`);
252
- return { exitCode: 1 };
253
- }
254
- await pipeBody(response, stdout);
255
- return { exitCode: 0 };
256
- }
257
- /**
258
- * Read the response body into memory up to `limit` bytes. On overflow,
259
- * flush what's been buffered to `out` (default: stdout) and pipe the
260
- * remaining body in chunks, so callers never silently truncate. Returns
261
- * `{ text, overflowed }` so callers can branch on the outcome.
262
- */
263
- async function readUntilLimitOrStream(response, limit, out) {
264
- if (!response.body) return {
265
- text: "",
266
- overflowed: false
267
- };
268
- const writeStream = out ?? process.stdout;
269
- const reader = response.body.getReader();
270
- const chunks = [];
271
- let size = 0;
272
- while (true) {
273
- const { done, value } = await reader.read();
274
- if (done) break;
275
- if (!value) continue;
276
- size += value.byteLength;
277
- if (size > limit) {
278
- for (const c of chunks) await writeChunk(writeStream, c);
279
- await writeChunk(writeStream, value);
280
- while (true) {
281
- const next = await reader.read();
282
- if (next.done) break;
283
- if (next.value) await writeChunk(writeStream, next.value);
284
- }
285
- return {
286
- text: "",
287
- overflowed: true
288
- };
289
- }
290
- chunks.push(value);
291
- }
292
- return {
293
- text: new TextDecoder().decode(Buffer.concat(chunks.map((c) => Buffer.from(c)))),
294
- overflowed: false
295
- };
296
- }
297
- /**
298
- * Sentinel thrown when a write to the output stream fails because the
299
- * downstream consumer closed (EPIPE) or the stream errored. Callers
300
- * catch this and exit cleanly — broken pipes are normal under shell
301
- * pipelines (`<cli> list | head -1`), not errors to escalate.
302
- */
303
- var BrokenPipeError = class extends Error {
304
- constructor(cause) {
305
- super("Output stream closed");
306
- this.name = "BrokenPipeError";
307
- if (cause !== void 0) this.cause = cause;
308
- }
309
- };
310
- /**
311
- * Write a chunk to the output stream and wait for `drain` if needed.
312
- *
313
- * Races the `drain` promise against an `error` event so that broken-pipe
314
- * errors (EPIPE, ECONNRESET on a stdout consumer that died) reject the
315
- * pending promise rather than leaving the CLI hung forever waiting for a
316
- * `drain` that will never come. (#1311 review #1.)
317
- */
318
- async function writeChunk(out, chunk) {
319
- const stream = out;
320
- let ok;
321
- try {
322
- ok = stream.write(Buffer.from(chunk));
323
- } catch (err) {
324
- throw new BrokenPipeError(err);
325
- }
326
- if (ok) return;
327
- await new Promise((resolve, reject) => {
328
- const onDrain = () => {
329
- stream.off("error", onError);
330
- resolve();
331
- };
332
- const onError = (err) => {
333
- stream.off("drain", onDrain);
334
- reject(new BrokenPipeError(err));
335
- };
336
- stream.once("drain", onDrain);
337
- stream.once("error", onError);
338
- });
339
- }
340
- async function pipeBody(response, out) {
341
- if (!response.body) return;
342
- const reader = response.body.getReader();
343
- try {
344
- while (true) {
345
- const { done, value } = await reader.read();
346
- if (done) break;
347
- if (!value) continue;
348
- await writeChunk(out, value);
349
- }
350
- } finally {
351
- try {
352
- reader.releaseLock();
353
- } catch {}
354
- }
355
- }
356
- function safePrettyJson(text) {
357
- if (!text) return void 0;
358
- try {
359
- return JSON.stringify(JSON.parse(text), null, 2);
360
- } catch {
361
- return;
362
- }
363
- }
364
- //#endregion
365
- //#region src/parser.ts
366
- /**
367
- * Classify a JSONSchema. The CLI uses this to decide between rich flag
368
- * parsing and the positional JSON escape hatch.
369
- */
370
- function classifySchema(schema) {
371
- if (!schema || Object.keys(schema).length === 0) return { kind: "missing" };
372
- if (schema.type !== "object") return {
373
- kind: "unsupported",
374
- reason: "schema root is not an object"
375
- };
376
- if (schema.oneOf || schema.anyOf || schema.allOf || schema.$ref) return {
377
- kind: "unsupported",
378
- reason: "oneOf/anyOf/allOf/$ref"
379
- };
380
- const props = schema.properties ?? {};
381
- for (const [name, prop] of Object.entries(props)) {
382
- const status = classifyProperty(prop);
383
- if (status.kind === "unsupported") return {
384
- kind: "unsupported",
385
- reason: `${name}: ${status.reason}`
386
- };
387
- }
388
- return { kind: "ok" };
389
- }
390
- function classifyProperty(prop) {
391
- const t = normaliseType(prop);
392
- if (!t) return {
393
- kind: "unsupported",
394
- reason: "no type"
395
- };
396
- if (prop.oneOf || prop.anyOf || prop.allOf || prop.$ref) return {
397
- kind: "unsupported",
398
- reason: "oneOf/anyOf/allOf/$ref"
399
- };
400
- if (t.primary === "object") return {
401
- kind: "unsupported",
402
- reason: "nested object"
403
- };
404
- if (t.primary === "array") {
405
- const itemsType = prop.items?.type;
406
- if (itemsType !== "string" && itemsType !== "integer" && itemsType !== "number") return {
407
- kind: "unsupported",
408
- reason: "array of non-primitives"
409
- };
410
- }
411
- return { kind: "ok" };
412
- }
413
- function normaliseType(prop) {
414
- const raw = prop.type;
415
- if (typeof raw === "string") return {
416
- primary: raw,
417
- nullable: Boolean(prop.nullable)
418
- };
419
- if (Array.isArray(raw)) {
420
- const nullable = raw.includes("null");
421
- const nonNull = raw.find((t) => t !== "null");
422
- if (!nonNull) return void 0;
423
- return {
424
- primary: nonNull,
425
- nullable
426
- };
427
- }
428
- }
429
- function buildFlagParser(schema, options = {}) {
430
- const status = options.positionalOnly ? {
431
- kind: "unsupported",
432
- reason: "forced positional"
433
- } : classifySchema(schema);
434
- if (status.kind !== "ok") return {
435
- status,
436
- parse: makePositionalParser()
437
- };
438
- return {
439
- status,
440
- parse: makeRichParser(schema)
441
- };
442
- }
443
- function makePositionalParser() {
444
- return (argv, httpMethod) => {
445
- const positional = argv.find((a) => !a.startsWith("-"));
446
- if (!positional) return {
447
- body: {},
448
- query: {},
449
- fromPositional: true
450
- };
451
- let parsed;
452
- try {
453
- parsed = JSON.parse(positional);
454
- } catch (error) {
455
- throw new Error(`Could not parse positional JSON argument: ${error.message}`);
456
- }
457
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Positional JSON argument must be an object.");
458
- if (httpMethod === "GET") return {
459
- body: {},
460
- query: objectToQuery(parsed),
461
- fromPositional: true
462
- };
463
- return {
464
- body: parsed,
465
- query: {},
466
- fromPositional: true
467
- };
468
- };
469
- }
470
- function makeRichParser(schema) {
471
- const props = schema.properties ?? {};
472
- const required = new Set(schema.required ?? [] ?? []);
473
- const additionalProperties = schema.additionalProperties !== false;
474
- return (argv, httpMethod) => {
475
- const out = {};
476
- let positionalJson;
477
- for (let i = 0; i < argv.length; i++) {
478
- const arg = argv[i];
479
- if (!arg.startsWith("-")) {
480
- if (positionalJson) throw new Error(`Unexpected extra positional argument: ${arg}`);
481
- let parsed;
482
- try {
483
- parsed = JSON.parse(arg);
484
- } catch {
485
- throw new Error(`Unknown positional argument (not valid JSON): ${arg}`);
486
- }
487
- if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("Positional JSON argument must be an object.");
488
- positionalJson = parsed;
489
- continue;
490
- }
491
- if (arg === "--") break;
492
- if (arg.startsWith("--no-")) {
493
- const flagName = arg.slice(5);
494
- const prop = props[flagName];
495
- if (!prop) {
496
- if (additionalProperties) {
497
- out[flagName] = false;
498
- continue;
499
- }
500
- throw new Error(`Unknown flag: ${arg}`);
501
- }
502
- if (normaliseType(prop)?.primary !== "boolean") throw new Error(`--no-${flagName} requires a boolean flag.`);
503
- out[flagName] = false;
504
- continue;
505
- }
506
- let key;
507
- let value;
508
- const eqIdx = arg.indexOf("=");
509
- if (eqIdx >= 0) {
510
- key = arg.slice(2, eqIdx);
511
- value = arg.slice(eqIdx + 1);
512
- } else {
513
- key = arg.slice(2);
514
- const prop = props[key];
515
- if ((prop ? normaliseType(prop) : void 0)?.primary === "boolean") {
516
- const next = argv[i + 1];
517
- if (next === "true" || next === "false") {
518
- out[key] = next === "true";
519
- i += 1;
520
- continue;
521
- }
522
- out[key] = true;
523
- continue;
524
- }
525
- const next = argv[i + 1];
526
- if (next === void 0 || next.startsWith("-")) throw new Error(`Flag --${key} requires a value.`);
527
- value = next;
528
- i += 1;
529
- }
530
- const prop = props[key];
531
- if (!prop) {
532
- if (!additionalProperties) throw new Error(`Unknown flag: --${key}`);
533
- appendValue(out, key, value);
534
- continue;
535
- }
536
- const t = normaliseType(prop);
537
- if (!t) throw new Error(`Unknown flag: --${key}`);
538
- if (t.nullable && value === "null") {
539
- out[key] = null;
540
- continue;
541
- }
542
- switch (t.primary) {
543
- case "string": {
544
- const allowed = prop.enum;
545
- if (allowed && !allowed.includes(value)) throw new Error(`--${key}: expected one of ${allowed.join(", ")}; got ${JSON.stringify(value)}.`);
546
- out[key] = value;
547
- break;
548
- }
549
- case "integer": {
550
- const n = Number(value);
551
- if (!Number.isInteger(n)) throw new Error(`--${key}: expected integer; got ${JSON.stringify(value)}.`);
552
- out[key] = n;
553
- break;
554
- }
555
- case "number": {
556
- const n = Number(value);
557
- if (Number.isNaN(n)) throw new Error(`--${key}: expected number; got ${JSON.stringify(value)}.`);
558
- out[key] = n;
559
- break;
560
- }
561
- case "boolean":
562
- if (value === "true" || value === "false") out[key] = value === "true";
563
- else throw new Error(`--${key}: boolean accepts true/false; got ${JSON.stringify(value)}.`);
564
- break;
565
- case "array": {
566
- const itemsType = prop.items?.type;
567
- const parts = value.includes(",") ? value.split(",") : [value];
568
- const arr = Array.isArray(out[key]) ? out[key] : [];
569
- for (const part of parts) arr.push(coerce(part, itemsType));
570
- out[key] = arr;
571
- break;
572
- }
573
- default: throw new Error(`--${key}: unsupported schema type ${t.primary}.`);
574
- }
575
- }
576
- if (positionalJson) {
577
- for (const [k, v] of Object.entries(positionalJson)) if (out[k] === void 0) out[k] = v;
578
- }
579
- for (const [name, prop] of Object.entries(props)) {
580
- if (out[name] !== void 0) continue;
581
- if (prop.default !== void 0) out[name] = prop.default;
582
- }
583
- for (const name of required) if (out[name] === void 0) throw new Error(`Missing required flag: --${name}`);
584
- if (httpMethod === "GET") return {
585
- body: {},
586
- query: objectToQuery(out),
587
- fromPositional: false
588
- };
589
- return {
590
- body: out,
591
- query: {},
592
- fromPositional: false
593
- };
594
- };
595
- }
596
- function appendValue(out, key, value) {
597
- if (out[key] === void 0) out[key] = value;
598
- else if (Array.isArray(out[key])) out[key].push(value);
599
- else out[key] = [out[key], value];
600
- }
601
- function coerce(value, type) {
602
- if (type === "integer") {
603
- const n = Number(value);
604
- if (!Number.isInteger(n)) throw new Error(`expected integer; got ${value}`);
605
- return n;
606
- }
607
- if (type === "number") {
608
- const n = Number(value);
609
- if (Number.isNaN(n)) throw new Error(`expected number; got ${value}`);
610
- return n;
611
- }
612
- return value;
613
- }
614
- function objectToQuery(obj) {
615
- const q = {};
616
- for (const [k, v] of Object.entries(obj)) {
617
- if (v === void 0 || v === null) continue;
618
- if (Array.isArray(v)) q[k] = v.map((x) => String(x));
619
- else q[k] = String(v);
620
- }
621
- return q;
622
- }
623
- //#endregion
624
- //#region src/commands/auth.ts
625
- /**
626
- * `<name> auth login | status | logout` — terminal device-code flow against
627
- * `/api/cli/auth/start` and `/api/cli/auth/token` (shipped by smrt-users).
628
- *
629
- * @packageDocumentation
630
- */
631
- async function runAuthLogin(options, args) {
632
- const stdout = options.stdout ?? process.stdout;
633
- const sleep = options.sleepMs ?? ((ms) => new Promise((resolve) => setTimeout(resolve, ms)));
634
- const { noOpen, serverUrl } = parseAuthLoginArgs(args, options.noOpenDefault);
635
- const targetServer = (serverUrl ?? await getServerUrl(options.context)).replace(/\/+$/u, "");
636
- const start = await requestJson(options.context, "/api/cli/auth/start", { method: "POST" }, {
637
- auth: false,
638
- serverUrl: targetServer
639
- });
640
- stdout.write(`Open ${start.verificationUrl}\n`);
641
- stdout.write(`Code: ${start.userCode}\n`);
642
- if (!noOpen) openVerificationUrl(start.verificationUrl);
643
- const expiresAt = new Date(start.expiresAt).getTime();
644
- let interval = start.interval ?? 2;
645
- const stderr = options.stderr ?? process.stderr;
646
- while (Date.now() < expiresAt) {
647
- await sleep(interval * 1e3);
648
- try {
649
- const token = await requestJson(options.context, "/api/cli/auth/token", {
650
- body: JSON.stringify({ deviceCode: start.deviceCode }),
651
- method: "POST"
652
- }, {
653
- auth: false,
654
- serverUrl: targetServer
655
- });
656
- if (token.status === "approved" && token.accessToken) {
657
- await saveAuth(options.context, targetServer, token.accessToken, start.issuer ?? targetServer);
658
- stdout.write(`Authenticated to ${targetServer}\n`);
659
- return;
660
- }
661
- if (token.status === "expired") break;
662
- interval = token.interval ?? interval;
663
- } catch (error) {
664
- if (error instanceof Error) {
665
- const status = error.status;
666
- if (status === 410 || error.message.includes("HTTP 410")) break;
667
- if (isTransientPollError(error, status)) {
668
- stderr.write(`[smrt-app-cli] auth poll: ${error.message} (retrying in ${interval}s)\n`);
669
- continue;
670
- }
671
- }
672
- throw error;
673
- }
674
- }
675
- throw new Error("Terminal login request expired.");
676
- }
677
- /**
678
- * Decide whether an error from the polling endpoint is transient (worth
679
- * retrying until `expiresAt`) or terminal (re-thrown). The server's own
680
- * `pending`/`expired` states are JSON responses, not thrown errors — so
681
- * anything reaching the catch here is either an HTTP-level non-2xx or
682
- * a fetch-level failure.
683
- *
684
- * Prefers the structured `.status` property attached by `requestJson`
685
- * (since 5xx errors with server-supplied `error` fields don't have the
686
- * status in the message string), falling back to message-pattern
687
- * matching for fetch-level failures (`TypeError: fetch failed`,
688
- * `ECONNRESET`, etc.).
689
- */
690
- function isTransientPollError(error, status) {
691
- if (status !== void 0) {
692
- if (status >= 500 && status < 600) return true;
693
- if (status >= 400 && status < 500) return false;
694
- }
695
- const msg = error.message;
696
- if (/^HTTP 4(?!10)\d\d/.test(msg)) return false;
697
- if (/^HTTP 5\d\d/.test(msg)) return true;
698
- if (/fetch failed/i.test(msg)) return true;
699
- if (/ECONNRESET|ETIMEDOUT|ENOTFOUND|EAI_AGAIN/i.test(msg)) return true;
700
- return false;
701
- }
702
- async function runAuthStatus(options) {
703
- const stdout = options.stdout ?? process.stdout;
704
- const config = await loadCliConfig(options.context);
705
- const serverUrl = await getServerUrl(options.context, config);
706
- if (!await getStoredToken(options.context, config)) {
707
- stdout.write(`${JSON.stringify({
708
- authenticated: false,
709
- serverUrl
710
- }, null, 2)}\n`);
711
- return;
712
- }
713
- try {
714
- const session = await requestJson(options.context, "/api/cli/auth/session", { method: "GET" });
715
- stdout.write(`${JSON.stringify({
716
- ...session,
717
- serverUrl
718
- }, null, 2)}\n`);
719
- } catch (error) {
720
- stdout.write(`${JSON.stringify({
721
- authenticated: false,
722
- serverUrl,
723
- error: error instanceof Error ? error.message : String(error)
724
- }, null, 2)}\n`);
725
- }
726
- }
727
- async function runAuthLogout(options) {
728
- const stdout = options.stdout ?? process.stdout;
729
- try {
730
- await requestJson(options.context, "/api/cli/auth/session", { method: "DELETE" });
731
- } catch {}
732
- await clearStoredToken(options.context);
733
- stdout.write(`${JSON.stringify({ authenticated: false }, null, 2)}\n`);
734
- }
735
- function parseAuthLoginArgs(args, noOpenDefault = false) {
736
- const result = { noOpen: noOpenDefault };
737
- for (let i = 0; i < args.length; i++) {
738
- const arg = args[i];
739
- if (arg === "--no-open") result.noOpen = true;
740
- else if (arg === "--server") {
741
- result.serverUrl = args[i + 1];
742
- i += 1;
743
- } else if (arg?.startsWith("--server=")) result.serverUrl = arg.slice(9);
744
- else throw new Error(`Unknown auth login option: ${arg}`);
745
- }
746
- return result;
747
- }
748
- function commandExists(cmd) {
749
- if (process.platform === "win32") return true;
750
- return spawnSync("sh", ["-lc", `command -v ${cmd}`], { stdio: "ignore" }).status === 0;
751
- }
752
- function openVerificationUrl(url) {
753
- const opener = (process.platform === "darwin" ? [{
754
- args: [url],
755
- command: "open"
756
- }] : process.platform === "win32" ? [{
757
- args: [
758
- "/c",
759
- "start",
760
- "",
761
- url
762
- ],
763
- command: "cmd.exe"
764
- }] : [{
765
- args: [url],
766
- command: "xdg-open"
767
- }, {
768
- args: ["open", url],
769
- command: "gio"
770
- }]).find((c) => commandExists(c.command));
771
- if (!opener) return;
772
- const child = spawn(opener.command, opener.args, {
773
- detached: true,
774
- stdio: "ignore",
775
- windowsHide: true
776
- });
777
- child.on("error", () => void 0);
778
- child.unref();
779
- }
780
- //#endregion
781
- //#region src/commands/mcp.ts
782
- async function runMcpCommand(options, args) {
783
- const stdout = options.stdout ?? process.stdout;
784
- const stderr = options.stderr ?? process.stderr;
785
- const sub = args[0];
786
- if (sub === "tools") {
787
- const result = await requestJsonResult(options.context, "/api/mcp/tools", { method: "GET" }, { fetch: options.fetch });
788
- stdout.write(`${JSON.stringify(result, null, 2)}\n`);
789
- if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
790
- return result.ok;
791
- }
792
- if (sub === "call") {
793
- const name = args[1];
794
- const payload = args[2] ?? "{}";
795
- if (!name) throw new Error("Usage: mcp call <tool> [<json>]");
796
- let parsed;
797
- try {
798
- parsed = JSON.parse(payload);
799
- } catch (error) {
800
- throw new Error(`Could not parse mcp call payload: ${error.message}`);
801
- }
802
- const result = await requestJsonResult(options.context, "/api/mcp/call", {
803
- body: JSON.stringify({
804
- arguments: parsed,
805
- name
806
- }),
807
- method: "POST"
808
- }, { fetch: options.fetch });
809
- stdout.write(`${JSON.stringify(result, null, 2)}\n`);
810
- if (!result.ok) stderr.write(`${result.error.message ?? result.error.code}\n`);
811
- return result.ok;
812
- }
813
- throw new Error("Usage: mcp tools | mcp call <tool> [<json>]");
814
- }
815
- //#endregion
816
- //#region src/commands/resources.ts
817
- async function runResourcesCommand(options, args) {
818
- const stdout = options.stdout ?? process.stdout;
819
- const stderr = options.stderr ?? process.stderr;
820
- const json = args.includes("--json");
821
- const debug = args.includes("--debug");
822
- const response = options.injectResponse ?? await fetchResourceList(options.context, { fetch: options.fetch });
823
- if (json) {
824
- stdout.write(`${JSON.stringify(response, null, 2)}\n`);
825
- return;
826
- }
827
- if (response.resources.length === 0) {
828
- if (!response.user.authenticated) stdout.write("(no resources — not authenticated)\n");
829
- else stdout.write("(no resources discovered)\n");
830
- return;
831
- }
832
- for (const resource of response.resources) {
833
- stdout.write(`${resource.slug} (${resource.className})\n`);
834
- for (const command of resource.commands) stdout.write(` - ${formatCommandLine(resource, command)}\n`);
835
- }
836
- if (response.warnings.length > 0) if (debug) {
837
- stderr.write(`\nwarnings:\n`);
838
- for (const w of response.warnings) stderr.write(` - ${w}\n`);
839
- } else stderr.write(`\n${response.warnings.length} command${response.warnings.length === 1 ? "" : "s"} unavailable (use --debug for details)\n`);
840
- }
841
- function formatCommandLine(_resource, command) {
842
- const idHint = command.scope === "item" ? " <id>" : "";
843
- const description = command.description ? ` — ${command.description}` : "";
844
- return `${command.commandName}${idHint}${description}`;
845
- }
846
- //#endregion
847
- //#region src/index.ts
848
- /**
849
- * `@happyvertical/smrt-app-cli` — reusable CLI factory for SMRT apps.
850
- *
851
- * @example
852
- * ```ts
853
- * #!/usr/bin/env node
854
- * import { createAppCli } from '@happyvertical/smrt-app-cli';
855
- *
856
- * const cli = createAppCli({
857
- * name: 'willgriffin',
858
- * defaultServerUrl: 'https://willgriffin.dev',
859
- * });
860
- * await cli.run(process.argv.slice(2));
861
- * ```
862
- *
863
- * @packageDocumentation
864
- */
865
- function createAppCli(options) {
866
- const context = {
867
- envPrefix: options.envPrefix ?? options.name.toUpperCase(),
868
- appSlug: options.configDir ?? options.name.toLowerCase(),
869
- defaultServerUrl: options.defaultServerUrl
870
- };
871
- const extraByName = /* @__PURE__ */ new Map();
872
- for (const cmd of options.extraCommands ?? []) extraByName.set(cmd.name, cmd);
873
- return {
874
- run: (argv) => runCli(context, options, extraByName, argv),
875
- startMcpBridge: async (serverInfo) => {
876
- const { runMcpStdioBridge } = await import("./bridge-DqndDLee.js");
877
- await runMcpStdioBridge({
878
- ...context,
879
- serverInfo: {
880
- name: serverInfo?.name ?? `${options.name}-mcp`,
881
- version: serverInfo?.version ?? "0.0.0"
882
- }
883
- });
884
- }
885
- };
886
- }
887
- var BUILT_IN_COMMANDS = /* @__PURE__ */ new Set([
888
- "auth",
889
- "resources",
890
- "mcp"
891
- ]);
892
- async function runCli(context, options, extras, argv) {
893
- const stdout = process.stdout;
894
- const stderr = process.stderr;
895
- try {
896
- await dispatchCli(context, options, extras, argv, stdout, stderr);
897
- } catch (error) {
898
- const message = error instanceof Error ? error.message : String(error);
899
- stderr.write(`${message}\n`);
900
- process.exitCode = 1;
901
- }
902
- }
903
- async function dispatchCli(context, options, extras, argv, stdout, stderr) {
904
- if (argv.length === 0 || argv[0] === "help" || argv[0] === "--help") {
905
- printUsage(options, extras, stdout);
906
- return;
907
- }
908
- const [command, ...rest] = argv;
909
- const extra = extras.get(command);
910
- if (extra) {
911
- if (BUILT_IN_COMMANDS.has(command)) stderr.write(`[smrt-app-cli] extra command \`${command}\` shadows a built-in.\n`);
912
- const ctx = await buildAppContext(context, extra.needsResources ?? false);
913
- await extra.run(rest, ctx);
914
- return;
915
- }
916
- if (command === "auth") {
917
- const sub = rest[0];
918
- const opts = {
919
- context,
920
- stdout,
921
- stderr
922
- };
923
- if (sub === "login") return runAuthLogin(opts, rest.slice(1));
924
- if (sub === "status") return runAuthStatus(opts);
925
- if (sub === "logout") return runAuthLogout(opts);
926
- throw new Error("Usage: auth login [--server <url>] [--no-open] | status | logout");
927
- }
928
- if (command === "mcp") {
929
- if (!await runMcpCommand({
930
- context,
931
- stdout,
932
- stderr
933
- }, rest)) process.exitCode = 1;
934
- return;
935
- }
936
- if (command === "resources") return runResourcesCommand({
937
- context,
938
- stdout,
939
- stderr
940
- }, rest);
941
- await runResourceCommand(context, command, rest, stdout, stderr);
942
- }
943
- async function runResourceCommand(context, slug, rest, stdout, stderr) {
944
- const response = await fetchResourceList(context);
945
- const resource = findResourceBySlug(response, slug);
946
- if (!resource) {
947
- const suggestions = response.resources.map((r) => r.slug).filter((s) => similar(s, slug)).slice(0, 3);
948
- const hint = suggestions.length ? ` Did you mean: ${suggestions.join(", ")}?` : "";
949
- throw new Error(`Unknown resource: ${slug}.${hint}`);
950
- }
951
- const commandName = rest[0];
952
- if (!commandName) throw new Error(`Usage: ${slug} <command> [id] [...]. Available: ${resource.commands.map((c) => c.commandName).join(", ")}`);
953
- const command = findCommand(resource, commandName);
954
- if (!command) throw new Error(`Unknown command \`${commandName}\` on resource \`${slug}\`. Available: ${resource.commands.map((c) => c.commandName).join(", ")}`);
955
- let positional = rest.slice(1);
956
- let id;
957
- if (command.scope === "item") {
958
- id = positional[0];
959
- if (!id) throw new Error(`Command \`${slug} ${commandName}\` requires an id positional argument.`);
960
- if (findCommand(resource, id)) throw new Error(`\`${id}\` is a command on \`${slug}\`, not an id. Did you mean: \`${slug} ${id}${findCommand(resource, id)?.scope === "item" ? " <id>" : ""}\`? \`${slug} ${commandName}\` is an item-scope command and needs an id as the next argument.`);
961
- positional = positional.slice(1);
962
- }
963
- const parser = buildFlagParser(command.parameters);
964
- if (parser.status.kind === "unsupported") stderr.write(`[smrt-app-cli] complex schema for \`${slug} ${commandName}\`; pass JSON payload directly\n`);
965
- else if (parser.status.kind === "missing") stderr.write(`[smrt-app-cli] schema unavailable for \`${slug} ${commandName}\`; pass JSON payload directly: $ <cli> ${slug} ${commandName}${id ? ` ${id}` : ""} '<json>'\n`);
966
- const { exitCode } = await renderResponse(await invokeCommand({
967
- context,
968
- resource,
969
- command,
970
- parsed: parser.parse(positional, command.httpMethod),
971
- id
972
- }), {
973
- stdout,
974
- stderr
975
- });
976
- if (exitCode !== 0) process.exitCode = exitCode;
977
- }
978
- async function buildAppContext(context, eagerResources) {
979
- const config = await loadCliConfig(context);
980
- const serverUrl = await getServerUrl(context, config);
981
- const token = await getStoredToken(context, config);
982
- let resourcesPromise = null;
983
- const getResources = () => {
984
- if (!resourcesPromise) resourcesPromise = fetchResourceList(context, { loadedConfig: config });
985
- return resourcesPromise;
986
- };
987
- if (eagerResources) getResources();
988
- return {
989
- config,
990
- serverUrl,
991
- token,
992
- getResources,
993
- requestJson: (path, init, opts) => requestJson(context, path, init, {
994
- loadedConfig: config,
995
- ...opts
996
- }),
997
- request: async (path, init) => {
998
- const headers = new Headers(init?.headers);
999
- if (token) headers.set("authorization", `Bearer ${token}`);
1000
- return fetch(`${serverUrl}${path}`, {
1001
- ...init,
1002
- headers
1003
- });
1004
- },
1005
- stdout: process.stdout,
1006
- stderr: process.stderr
1007
- };
1008
- }
1009
- function printUsage(options, extras, out) {
1010
- const name = options.name;
1011
- out.write(`Usage:\n`);
1012
- out.write(` ${name} auth login [--server <url>] [--no-open]\n`);
1013
- out.write(` ${name} auth status\n`);
1014
- out.write(` ${name} auth logout\n`);
1015
- out.write(` ${name} resources [--json] [--debug]\n`);
1016
- out.write(` ${name} <resource> <command> [id] [--flags...] [json-payload]\n`);
1017
- out.write(` ${name} mcp tools\n`);
1018
- out.write(` ${name} mcp call <tool> [<json>]\n`);
1019
- for (const cmd of extras.values()) out.write(` ${name} ${cmd.name} — ${cmd.description}\n`);
1020
- }
1021
- /** Naive levenshtein-style similarity. */
1022
- function similar(a, b) {
1023
- if (a === b) return true;
1024
- if (Math.abs(a.length - b.length) > 2) return false;
1025
- let diff = 0;
1026
- for (let i = 0; i < Math.max(a.length, b.length); i++) {
1027
- if (a[i] !== b[i]) diff += 1;
1028
- if (diff > 2) return false;
1029
- }
1030
- return true;
1031
- }
1032
- //#endregion
1033
- export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runMcpStdioBridge, saveAuth, saveCliConfig };
1
+ import { a as getStoredToken, c as requestJson, d as saveCliConfig, f as createMcpStdioBridge, i as getServerUrl, l as requestJsonResult, m as runMcpStdioBridge, n as assertSecureServerUrl, o as loadCliConfig, r as clearStoredToken, s as redactTransportValue, t as AppCliRequestError, u as saveAuth } from "./config-CuTFiGxX.js";
2
+ import { a as buildFlagParser, c as createMcpClientIdMetadataDocument, d as buildUrl, f as invokeCommand, h as findResourceBySlug, i as runAppCliExecutable, l as registerMcpClient, m as findCommand, n as createAppCli, o as classifySchema, p as fetchResourceList, r as parseAppCliExecutableConfig, s as renderResponse, t as SMRT_MCP_RESULT_METADATA_KEY, u as resolveMcpClientRegistration } from "./src-DCo67mp8.js";
3
+ export { AppCliRequestError, SMRT_MCP_RESULT_METADATA_KEY, assertSecureServerUrl, buildFlagParser, buildUrl, classifySchema, clearStoredToken, createAppCli, createMcpClientIdMetadataDocument, createMcpStdioBridge, fetchResourceList, findCommand, findResourceBySlug, getServerUrl, getStoredToken, invokeCommand, loadCliConfig, parseAppCliExecutableConfig, redactTransportValue, registerMcpClient, renderResponse, requestJson, requestJsonResult, resolveMcpClientRegistration, runAppCliExecutable, runMcpStdioBridge, saveAuth, saveCliConfig };