@primitivedotdev/cli 0.26.1 → 0.26.3

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.
@@ -1,799 +0,0 @@
1
- import { readFileSync, writeFileSync } from "node:fs";
2
- import { Command, Errors, Flags } from "@oclif/core";
3
- import { operations, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
4
- import { deleteCliCredentials, resolveCliAuth, } from "./auth.js";
5
- import { maybeWriteFunctionEndpointRedirect, } from "./endpoints-test-redirect.js";
6
- export const API_ERROR_CODES = {
7
- accessDenied: "access_denied",
8
- authorizationPending: "authorization_pending",
9
- expiredToken: "expired_token",
10
- invalidDeviceCode: "invalid_device_code",
11
- notFound: "not_found",
12
- slowDown: "slow_down",
13
- unauthorized: "unauthorized",
14
- };
15
- function flagName(parameterName) {
16
- return parameterName.replace(/_/g, "-");
17
- }
18
- function flagDescription(parameter) {
19
- return parameter.description ?? parameter.name;
20
- }
21
- function extractBodyFields(schema) {
22
- if (!schema || typeof schema !== "object")
23
- return [];
24
- const properties = schema.properties;
25
- if (!properties || typeof properties !== "object")
26
- return [];
27
- const requiredArr = Array.isArray(schema.required)
28
- ? schema.required.filter((k) => typeof k === "string")
29
- : [];
30
- const required = new Set(requiredArr);
31
- const fields = [];
32
- for (const [name, raw] of Object.entries(properties)) {
33
- const propSchema = raw && typeof raw === "object" ? raw : {};
34
- const t = propSchema.type;
35
- let displayType = "any";
36
- let kind = "complex";
37
- if (typeof t === "string") {
38
- displayType = t;
39
- if (t === "string")
40
- kind = "string";
41
- else if (t === "integer" || t === "number")
42
- kind = "integer";
43
- else if (t === "boolean")
44
- kind = "boolean";
45
- else if (t === "array") {
46
- const items = propSchema.items;
47
- if (items && typeof items === "object") {
48
- const itemType = items.type;
49
- if (typeof itemType === "string") {
50
- displayType = `array<${itemType}>`;
51
- }
52
- }
53
- kind = "complex";
54
- }
55
- else {
56
- kind = "complex";
57
- }
58
- }
59
- else if (Array.isArray(t)) {
60
- // Nullable shorthand the codegen normalizes to e.g.
61
- // ["string","null"]. If exactly one non-null member, surface
62
- // it as that scalar with a trailing `?`.
63
- const nonNull = t.filter((s) => s !== "null");
64
- if (nonNull.length === 1) {
65
- const single = nonNull[0];
66
- displayType = `${single}?`;
67
- if (single === "string")
68
- kind = "string";
69
- else if (single === "integer" || single === "number")
70
- kind = "integer";
71
- else if (single === "boolean")
72
- kind = "boolean";
73
- else
74
- kind = "complex";
75
- }
76
- else {
77
- displayType = nonNull.join("|");
78
- kind = "complex";
79
- }
80
- }
81
- // Pull the first paragraph of the schema description for use
82
- // as the CLI flag's --help string. We split on a blank line
83
- // (paragraph break) and then collapse any soft line wraps
84
- // inside that paragraph to spaces. This avoids the previous
85
- // bug where `split("\n")[0]` truncated wrapped prose like
86
- // "Optional override for ... Defaults to\nthe inbound's..."
87
- // to "Optional override for ... Defaults to" - a sentence
88
- // ending with "to" with nothing after it, which read as
89
- // ellipsis truncation in --help. The remaining paragraphs
90
- // are intentionally dropped so multi-paragraph schemas don't
91
- // blow out the per-flag help block.
92
- const description = typeof propSchema.description === "string"
93
- ? propSchema.description
94
- .split(/\n\s*\n/)[0]
95
- .replace(/\s*\n\s*/g, " ")
96
- .trim()
97
- : "";
98
- const enumRaw = propSchema.enum;
99
- const enumValues = kind === "string" && Array.isArray(enumRaw)
100
- ? enumRaw.filter((e) => typeof e === "string")
101
- : undefined;
102
- fields.push({
103
- name,
104
- description,
105
- required: required.has(name),
106
- displayType,
107
- kind,
108
- ...(enumValues && enumValues.length > 0 ? { enumValues } : {}),
109
- });
110
- }
111
- return fields.sort((a, b) => {
112
- if (a.required !== b.required)
113
- return a.required ? -1 : 1;
114
- return a.name.localeCompare(b.name);
115
- });
116
- }
117
- /**
118
- * Render a "Body fields" summary for the per-command help.
119
- *
120
- * Most scalar fields are exposed as individual `--flag` flags,
121
- * which oclif auto-renders in the FLAGS section above. To avoid
122
- * duplicating that, the summary here only documents fields that
123
- * MUST go through `--raw-body` (complex types: arrays, objects,
124
- * mixed-non-nullable). When an operation has only scalars, the
125
- * summary is omitted entirely and oclif's FLAGS section is the
126
- * full story.
127
- *
128
- * For operations with mixed scalar and complex fields, we also
129
- * include a short header pointing the agent at the flag form so
130
- * the natural reading is "use the flags above; --raw-body for
131
- * the leftovers below."
132
- */
133
- function renderRequestSchemaSummary(schema) {
134
- const fields = extractBodyFields(schema);
135
- if (fields.length === 0)
136
- return null;
137
- const complex = fields.filter((f) => f.kind === "complex");
138
- if (complex.length === 0)
139
- return null;
140
- const nameWidth = Math.min(24, Math.max(...complex.map((f) => f.name.length)));
141
- const descMax = 78;
142
- const lines = [
143
- "Body fields requiring --raw-body JSON (these are not exposed as flags):",
144
- ];
145
- for (const f of complex) {
146
- const marker = f.required ? " *" : " ";
147
- const padName = f.name.padEnd(nameWidth);
148
- const trimmedDesc = f.description.length > descMax
149
- ? `${f.description.slice(0, descMax - 3)}...`
150
- : f.description;
151
- const desc = trimmedDesc ? ` ${trimmedDesc}` : "";
152
- lines.push(`${marker} ${padName} ${f.displayType}${desc}`);
153
- }
154
- lines.push("(* = required. Scalar body fields are exposed as individual --flag-name flags; see FLAGS above.)");
155
- return lines.join("\n");
156
- }
157
- export function flagForParameter(parameter) {
158
- const common = {
159
- description: flagDescription(parameter),
160
- required: parameter.required,
161
- };
162
- if (parameter.type === "boolean") {
163
- return Flags.boolean(common);
164
- }
165
- if (parameter.type === "integer") {
166
- return Flags.integer(common);
167
- }
168
- if (parameter.enum && parameter.enum.length > 0) {
169
- return Flags.string({ ...common, options: parameter.enum });
170
- }
171
- return Flags.string(common);
172
- }
173
- function coerceParameterValue(parameter, value) {
174
- if (value === undefined) {
175
- return undefined;
176
- }
177
- if (parameter.type === "number") {
178
- if (typeof value === "number") {
179
- return value;
180
- }
181
- const parsed = Number(value);
182
- if (Number.isNaN(parsed)) {
183
- throw new Errors.CLIError(`Invalid number for --${parameter.name}: ${value}`);
184
- }
185
- return parsed;
186
- }
187
- if (typeof value === "boolean" ||
188
- typeof value === "number" ||
189
- typeof value === "string") {
190
- return value;
191
- }
192
- throw new Errors.CLIError(`Unsupported flag value for --${parameter.name}`);
193
- }
194
- function cliError(message) {
195
- return new Errors.CLIError(message, { exit: 1 });
196
- }
197
- function parseJson(source, flagLabel) {
198
- try {
199
- return JSON.parse(source);
200
- }
201
- catch (error) {
202
- const detail = error instanceof Error ? error.message : String(error);
203
- throw cliError(`${flagLabel} is not valid JSON: ${detail}`);
204
- }
205
- }
206
- export function readJsonBody(flags) {
207
- const bodyFile = flags["body-file"];
208
- const rawBody = flags["raw-body"];
209
- if (bodyFile && rawBody) {
210
- throw cliError("Use either --raw-body or --body-file, not both");
211
- }
212
- if (typeof bodyFile === "string") {
213
- let contents;
214
- try {
215
- contents = readFileSync(bodyFile, "utf8");
216
- }
217
- catch (error) {
218
- const detail = error instanceof Error ? error.message : String(error);
219
- throw cliError(`Could not read --body-file ${bodyFile}: ${detail}`);
220
- }
221
- return parseJson(contents, `--body-file ${bodyFile}`);
222
- }
223
- if (typeof rawBody === "string") {
224
- return parseJson(rawBody, "--raw-body");
225
- }
226
- return undefined;
227
- }
228
- // Read a UTF-8 text file off disk, mapping any failure to a CLIError
229
- // tagged with the originating flag so the user sees which path failed
230
- // to open. Used by hand-rolled commands that take a file-input flag
231
- // (e.g. functions:deploy --file).
232
- export function readTextFileFlag(path, flagLabel) {
233
- try {
234
- return readFileSync(path, "utf8");
235
- }
236
- catch (error) {
237
- const detail = error instanceof Error ? error.message : String(error);
238
- throw cliError(`Could not read ${flagLabel} ${path}: ${detail}`);
239
- }
240
- }
241
- export function extractErrorPayload(raw) {
242
- if (raw &&
243
- typeof raw === "object" &&
244
- !(raw instanceof Error) &&
245
- "error" in raw) {
246
- const inner = raw.error;
247
- if (inner !== null && inner !== undefined) {
248
- return inner;
249
- }
250
- }
251
- return raw;
252
- }
253
- function extractCauseDetails(cause) {
254
- const details = {};
255
- let code;
256
- if (!cause || typeof cause !== "object") {
257
- return { details };
258
- }
259
- for (const [key, value] of Object.entries(cause)) {
260
- if (typeof value === "string" || typeof value === "number") {
261
- details[key] = value;
262
- if (key === "code" && typeof value === "string") {
263
- code = value;
264
- }
265
- }
266
- }
267
- return { code, details };
268
- }
269
- export function formatErrorPayload(payload) {
270
- if (payload instanceof Error) {
271
- const { code, details } = extractCauseDetails(payload.cause);
272
- const body = {
273
- code: code ?? "client_error",
274
- message: payload.message || payload.name || String(payload),
275
- };
276
- if (Object.keys(details).length > 0) {
277
- body.cause = details;
278
- }
279
- return JSON.stringify(body, null, 2);
280
- }
281
- return JSON.stringify(payload, null, 2);
282
- }
283
- // Pull the top-level error code out of either a server response
284
- // payload (`{ error: { code: '...' } }` or `{ code: '...' }`) or a
285
- // thrown Error whose `cause.code` carries the value. Used to drive
286
- // `--api-key` and similar hints in writeErrorWithHints below.
287
- // Also exported so individual commands (send, whoami) can branch
288
- // on auth failures and avoid surfacing misleading "fix this flag"
289
- // guidance when the real problem is the API key.
290
- export function extractErrorCode(payload) {
291
- if (payload instanceof Error) {
292
- const { code } = extractCauseDetails(payload.cause);
293
- return code;
294
- }
295
- if (payload && typeof payload === "object") {
296
- const inner = payload.error;
297
- if (inner && typeof inner === "object" && typeof inner.code === "string") {
298
- return inner.code;
299
- }
300
- const direct = payload.code;
301
- if (typeof direct === "string")
302
- return direct;
303
- }
304
- return undefined;
305
- }
306
- // Common-case actionable hints keyed by error code. The full
307
- // JSON envelope still goes to stderr unchanged for any caller
308
- // that wants to parse it; the hint is an extra trailing line so
309
- // a human reading the output sees "what to actually do next."
310
- // The AGX walkthrough flagged that an `unauthorized` envelope
311
- // alone left the agent without context for the env var or the
312
- // `--api-key` flag; this closes that gap without having to
313
- // special-case every command.
314
- const ERROR_CODE_HINTS = {
315
- [API_ERROR_CODES.unauthorized]: "Hint: run `primitive login`, pass --api-key explicitly, or set PRIMITIVE_API_KEY in your environment. `primitive whoami` is the fastest way to verify a key is live.",
316
- };
317
- // Network-layer hints keyed by Node's `cause.code` on a fetch failure.
318
- // Separate from ERROR_CODE_HINTS because these aren't API-server error
319
- // codes — they're the values Node sets on the underlying system call
320
- // that failed before the request ever hit a server. The fix is almost
321
- // always proxy / DNS / firewall on the caller's side, and the bare
322
- // envelope (which just says `ENETUNREACH`) tells the user nothing they
323
- // can act on. AGX walkthroughs in restrictive container environments
324
- // hit this enough that the hint earns the extra lookup.
325
- const NETWORK_ERROR_HINTS = {
326
- ENETUNREACH: "Hint: the network is unreachable. If you're behind a proxy and set HTTP(S)_PROXY, re-run with NODE_USE_ENV_PROXY=1 (Node 22+ ignores those env vars by default). `primitive doctor` reports the local environment in one shot.",
327
- ECONNREFUSED: "Hint: the server refused the connection. Check that your firewall allows egress to *.primitive.dev, that your PRIMITIVE_API_BASE_URL_* overrides (if any) point at a reachable host, and re-run with NODE_USE_ENV_PROXY=1 if you're behind a proxy. `primitive doctor` reports the local environment in one shot.",
328
- ETIMEDOUT: "Hint: the connection timed out. Check egress rules and proxy configuration; if you're behind a proxy, re-run with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set. `primitive doctor` reports the local environment in one shot.",
329
- EAI_AGAIN: "Hint: DNS lookup failed. Check /etc/resolv.conf inside containers, and try `curl -v https://www.primitive.dev/api/v1/account` to confirm the host resolves. `primitive doctor` reports the local environment in one shot.",
330
- };
331
- // Write a server / SDK error to stderr in the canonical envelope
332
- // shape, plus an actionable hint when the code is one we know how
333
- // to advise on. Replaces the bare
334
- // `process.stderr.write(${formatErrorPayload(p)}\n)` dance every
335
- // command was doing.
336
- export function writeErrorWithHints(payload) {
337
- process.stderr.write(`${formatErrorPayload(payload)}\n`);
338
- const code = extractErrorCode(payload);
339
- if (!code)
340
- return;
341
- if (code in ERROR_CODE_HINTS) {
342
- const hint = ERROR_CODE_HINTS[code];
343
- process.stderr.write(`${hint}\n`);
344
- return;
345
- }
346
- if (code in NETWORK_ERROR_HINTS) {
347
- process.stderr.write(`${NETWORK_ERROR_HINTS[code]}\n`);
348
- }
349
- }
350
- export function removeStaleSavedCredentialOnUnauthorized(params) {
351
- if (extractErrorCode(params.payload) !== API_ERROR_CODES.unauthorized ||
352
- params.auth.source !== "stored") {
353
- return false;
354
- }
355
- const baseUrlDiffersFromSaved = params.baseUrlOverridden &&
356
- params.auth.credentials !== null &&
357
- params.auth.apiBaseUrl1 !== params.auth.credentials.api_base_url_1;
358
- if (baseUrlDiffersFromSaved) {
359
- // Override env vars (PRIMITIVE_API_BASE_URL_1 / _2) are intentionally
360
- // not advertised in --help; this hint is the only customer-visible
361
- // mention. They're for internal staging/local testing.
362
- process.stderr.write("Saved Primitive CLI credentials were rejected by the overridden API base URL. The local credential was not removed; unset PRIMITIVE_API_BASE_URL_1, or run `primitive logout` to remove the stored credential.\n");
363
- return false;
364
- }
365
- deleteCliCredentials(params.configDir);
366
- process.stderr.write("Removed saved Primitive CLI credentials because the backing API key is no longer valid. Run `primitive login` to create a new one.\n");
367
- return true;
368
- }
369
- // Format milliseconds as a short human-readable wall-clock duration.
370
- // Sub-second uses 2 decimal places (e.g. `0.18s`); seconds use 2
371
- // decimals up to 60s (`12.34s`); minute-plus uses `Mm SS.SSs`.
372
- // Display-only; the underlying ms value is what the caller computed.
373
- export function formatElapsed(ms) {
374
- const seconds = ms / 1000;
375
- if (seconds < 60)
376
- return `${seconds.toFixed(2)}s`;
377
- const minutes = Math.floor(seconds / 60);
378
- const rem = seconds - minutes * 60;
379
- return `${minutes}m ${rem.toFixed(2)}s`;
380
- }
381
- // Run `fn` and, when `enabled` is true, write a one-line wall-clock
382
- // timing report to stderr after it completes. Stderr keeps the row
383
- // data on stdout grep/jq-friendly. The timer captures the full
384
- // duration of the function (HTTPS round trip, server-side gate +
385
- // agent + delivery, polling, etc.), not just the API call's
386
- // server-side processing.
387
- //
388
- // Used by every `--time` callsite across the CLI: generated
389
- // operation commands and hand-coded shortcuts (send, whoami,
390
- // emails:latest, describe). Pulled out as a helper so timing is
391
- // uniform across commands and a single render-format change
392
- // propagates everywhere.
393
- export async function runWithTiming(enabled, fn) {
394
- if (!enabled)
395
- return fn();
396
- const start = Date.now();
397
- try {
398
- return await fn();
399
- }
400
- finally {
401
- process.stderr.write(`[time: ${formatElapsed(Date.now() - start)}]\n`);
402
- }
403
- }
404
- // Shared `--time` flag definition every CLI command spreads into its
405
- // own static flags. Lives here so the flag's description and short
406
- // name stay consistent across the hand-coded and generated commands.
407
- export const TIME_FLAG_DESCRIPTION = "Print the wall-clock duration of this command to stderr after it completes (e.g. `[time: 1.34s]`). Useful for measuring `--wait` send latency, comparing CLI overhead, or capturing timing in scripts.";
408
- // Shared description text for the api-base-url override flags. Keeps
409
- // the wording identical across every command that includes them. The
410
- // flags themselves are hidden from --help (internal staging/local-only).
411
- export const API_BASE_URL_1_FLAG_DESCRIPTION = "Override the primary API base URL. Internal testing only; not documented to customers.";
412
- export const API_BASE_URL_2_FLAG_DESCRIPTION = "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.";
413
- // Helper: was either api-base-url override set by the caller? Used by
414
- // removeStaleSavedCredentialOnUnauthorized to decide whether to
415
- // preserve the saved credential when a 401 comes back.
416
- export function baseUrlOverriddenFromFlags(flags) {
417
- return (typeof flags["api-base-url-1"] === "string" ||
418
- typeof flags["api-base-url-2"] === "string");
419
- }
420
- // Helper: resolve auth from a parsed-flags bag. Mirrors what every CLI
421
- // command does inline so the api-base-url-1 / api-base-url-2 mapping
422
- // stays in one place as we add more migration knobs.
423
- export function resolveCliAuthFromFlags(flags, configDir) {
424
- return resolveCliAuth({
425
- apiKey: typeof flags["api-key"] === "string"
426
- ? flags["api-key"]
427
- : undefined,
428
- apiBaseUrl1: typeof flags["api-base-url-1"] === "string"
429
- ? flags["api-base-url-1"]
430
- : undefined,
431
- apiBaseUrl2: typeof flags["api-base-url-2"] === "string"
432
- ? flags["api-base-url-2"]
433
- : undefined,
434
- configDir,
435
- });
436
- }
437
- // Operations that route to the attachments-supporting host
438
- // (apiBaseUrl2) instead of the primary API host. Internal to the CLI:
439
- // as more operations migrate to host 2 over time, add their generated
440
- // sdkName here. Today it is just /send-mail.
441
- const HOST_2_OPERATIONS = new Set(["sendEmail"]);
442
- // Reserved flag names the body-field expander must never overwrite.
443
- // `--raw-body` and `--body-file` are the JSON escape hatches.
444
- // `--api-key`, `--api-base-url-1`, `--api-base-url-2`, `--output` are
445
- // infra. Path and query params get added before body fields and take
446
- // precedence.
447
- //
448
- // Note: `--body` is intentionally NOT reserved here. The naive
449
- // agent expectation (per AGX walkthrough) is that --body means
450
- // "the message body content," which collides with the JSON
451
- // escape-hatch meaning we used pre-0.12. The escape hatch is now
452
- // `--raw-body`; --body is free to be claimed by per-field flag
453
- // expansion as the kebab-cased version of a `body` schema field
454
- // (e.g. on a future `body: { ... }` schema). For send-mail today,
455
- // the body-text field is `body_text` -> `--body-text`, and there
456
- // is no top-level `body` field, so --body remains unclaimed at
457
- // the generated-command level. The agent shortcut `primitive
458
- // send` defines its own --body for the message text.
459
- const RESERVED_FLAG_NAMES = new Set([
460
- "api-key",
461
- "api-base-url-1",
462
- "api-base-url-2",
463
- "raw-body",
464
- "body-file",
465
- "output",
466
- ]);
467
- function bodyFieldFlag(field) {
468
- // Pass the full first-line description through. oclif's --help
469
- // renderer wraps long values across multiple lines on its own,
470
- // so a fixed character cap here just produces ellipsis-truncated
471
- // sentences ("body_html is required. Th...") that mislead the
472
- // reader. extractBodyFields already normalizes by taking only
473
- // the first paragraph of the schema description, so multi-
474
- // paragraph fields don't blow out the help.
475
- //
476
- // Field-flag UX choice: do NOT mark scalar body fields as
477
- // required at the oclif level even when the JSON Schema marks
478
- // them required. Reason: a caller can satisfy the requirement
479
- // either via the individual flag OR via --raw-body / --body-file.
480
- // Marking the flag required would force the individual-flag
481
- // form. The runtime body merger validates the final assembled
482
- // body against the same server-side schema either way.
483
- const common = {
484
- description: field.description || field.name,
485
- };
486
- if (field.kind === "boolean")
487
- return Flags.boolean(common);
488
- if (field.kind === "integer")
489
- return Flags.integer(common);
490
- if (field.enumValues) {
491
- return Flags.string({ ...common, options: field.enumValues });
492
- }
493
- return Flags.string(common);
494
- }
495
- function buildFlags(operation) {
496
- const flags = {
497
- "api-key": Flags.string({
498
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
499
- env: "PRIMITIVE_API_KEY",
500
- }),
501
- // Two override knobs for the dual-host setup. Hidden because they
502
- // are for internal staging/local testing only. Production users
503
- // should not override; the defaults route correctly. Env vars
504
- // PRIMITIVE_API_BASE_URL_1 and PRIMITIVE_API_BASE_URL_2 carry the
505
- // same semantics. Both are intentionally absent from --help output.
506
- "api-base-url-1": Flags.string({
507
- description: "Override the primary API base URL. Internal testing only; not documented to customers.",
508
- env: "PRIMITIVE_API_BASE_URL_1",
509
- hidden: true,
510
- }),
511
- "api-base-url-2": Flags.string({
512
- description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
513
- env: "PRIMITIVE_API_BASE_URL_2",
514
- hidden: true,
515
- }),
516
- time: Flags.boolean({
517
- description: TIME_FLAG_DESCRIPTION,
518
- }),
519
- };
520
- for (const parameter of [...operation.pathParams, ...operation.queryParams]) {
521
- flags[flagName(parameter.name)] = flagForParameter(parameter);
522
- }
523
- const bodyFieldFlagToProperty = new Map();
524
- if (operation.hasJsonBody) {
525
- flags["raw-body"] = Flags.string({
526
- description: "Full request body as raw JSON. Escape hatch for nested or complex fields (e.g. arrays); prefer per-field flags (e.g. --to, --from, --body-text) when available.",
527
- });
528
- flags["body-file"] = Flags.string({
529
- description: "Path to a JSON file used as the request body. Same role as --raw-body for callers passing a saved payload.",
530
- });
531
- // Expand top-level scalar body fields into individual flags so
532
- // `primitive sending:send-email --to alice@x --from support@x
533
- // --body-text "hi"` works without constructing JSON. Driven by
534
- // the requestSchema embedded on the manifest. Skip flags that
535
- // collide with reserved names or with path/query params already
536
- // added above; those collisions fall back to --body.
537
- //
538
- // Collisions are tracked in the returned map so the run()
539
- // handler doesn't misread a path/query param's value as a
540
- // body-field override. (A naive "look up parsedFlags[name]"
541
- // pass would happily pick up the path param's value and
542
- // silently write it into the body.)
543
- const bodyFields = extractBodyFields(operation.requestSchema);
544
- for (const field of bodyFields) {
545
- if (field.kind === "complex")
546
- continue;
547
- const name = flagName(field.name);
548
- if (RESERVED_FLAG_NAMES.has(name))
549
- continue;
550
- if (flags[name] !== undefined)
551
- continue;
552
- flags[name] = bodyFieldFlag(field);
553
- bodyFieldFlagToProperty.set(name, field.name);
554
- }
555
- }
556
- if (operation.binaryResponse) {
557
- flags.output = Flags.string({
558
- description: "Write binary response bytes to a file",
559
- });
560
- }
561
- return { flags, bodyFieldFlagToProperty };
562
- }
563
- // Pull body field values out of the parsed CLI flags. Returns
564
- // only fields the user actually supplied (omits undefined). Used
565
- // to override / extend the JSON --body when both forms are
566
- // present (per-field flags take precedence on key conflicts).
567
- //
568
- // The `bodyFieldFlagToProperty` allowlist comes from buildFlags and
569
- // records ONLY the flags actually registered as body-field flags.
570
- // Without it, this function would naively read parsedFlags by
571
- // kebab-cased field name and pick up values from a colliding path
572
- // or query param flag, silently writing them into the body under
573
- // the body-field key. The allowlist keeps the merge honest: only
574
- // flags this CLI generator owns end up in the body.
575
- function collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty) {
576
- const result = {};
577
- for (const [flag, property] of bodyFieldFlagToProperty) {
578
- const value = parsedFlags[flag];
579
- if (value === undefined)
580
- continue;
581
- result[property] = value;
582
- }
583
- return result;
584
- }
585
- function collectValues(parameters, flags) {
586
- const values = {};
587
- for (const parameter of parameters) {
588
- const value = coerceParameterValue(parameter, flags[flagName(parameter.name)]);
589
- if (value !== undefined) {
590
- values[parameter.name] = value;
591
- }
592
- }
593
- return values;
594
- }
595
- // Discoverability hints for generated commands that have a
596
- // hand-rolled ergonomic shortcut. Keyed by the manifest's
597
- // `sdkName` (camelCase, matches the generated SDK function). The
598
- // hint is appended to the operation's --help description so an
599
- // agent reading `<command> --help` finds the shortcut without
600
- // having to enumerate the full command list. AGX walkthrough:
601
- // an agent reached for `functions:update-function` to redeploy
602
- // (which forces a JSON-stringified `code` body) when
603
- // `functions:redeploy --file <bundle>` was the intended path.
604
- //
605
- // Add an entry here whenever a hand-rolled shortcut shadows a
606
- // generated operation; the COMMANDS map in `index.ts` is the
607
- // authoritative list of shortcuts.
608
- export const OPERATION_HINTS = {
609
- createFunction: "Tip: prefer `primitive functions:deploy --name <name> --file <bundle>` for file-input ergonomics. This raw command exists for callers passing JSON.",
610
- updateFunction: "Tip: prefer `primitive functions:redeploy --id <id> --file <bundle>` for file-input ergonomics. This raw command exists for callers passing JSON.",
611
- createFunctionSecret: "Tip: prefer `primitive functions:set-secret --id <id> --key <KEY> --value <value> [--redeploy]` for secret writes that also push the binding live. This raw command exists for callers passing JSON.",
612
- setFunctionSecret: "Tip: prefer `primitive functions:set-secret --id <id> --key <KEY> --value <value> [--redeploy]` for secret writes that also push the binding live. This raw command exists for callers passing JSON.",
613
- };
614
- export function createOperationCommand(operation) {
615
- const { flags, bodyFieldFlagToProperty } = buildFlags(operation);
616
- // Append a "Body fields" summary to the description so agents
617
- // running `<command> --help` learn the JSON shape immediately.
618
- // Without this, `--help` only said "JSON request body" and agents
619
- // had to probe the server with malformed payloads to discover
620
- // required fields. (CLI agent walkthrough surfaced this.)
621
- const baseDescription = operation.description ?? `${operation.method} ${operation.path}`;
622
- const schemaSummary = operation.hasJsonBody
623
- ? renderRequestSchemaSummary(operation.requestSchema)
624
- : null;
625
- const hint = OPERATION_HINTS[operation.sdkName];
626
- const descriptionWithSchema = schemaSummary
627
- ? `${baseDescription}\n\n${schemaSummary}`
628
- : baseDescription;
629
- const fullDescription = hint
630
- ? `${descriptionWithSchema}\n\n${hint}`
631
- : descriptionWithSchema;
632
- class OperationCommand extends Command {
633
- static description = fullDescription;
634
- static flags = flags;
635
- static summary = operation.summary ?? `${operation.method} ${operation.path}`;
636
- async run() {
637
- const { flags } = await this.parse(OperationCommand);
638
- const parsedFlags = flags;
639
- await runWithTiming(parsedFlags.time === true, async () => {
640
- const baseUrlOverridden = typeof parsedFlags["api-base-url-1"] === "string" ||
641
- typeof parsedFlags["api-base-url-2"] === "string";
642
- const auth = resolveCliAuth({
643
- apiKey: typeof parsedFlags["api-key"] === "string"
644
- ? parsedFlags["api-key"]
645
- : undefined,
646
- apiBaseUrl1: typeof parsedFlags["api-base-url-1"] === "string"
647
- ? parsedFlags["api-base-url-1"]
648
- : undefined,
649
- apiBaseUrl2: typeof parsedFlags["api-base-url-2"] === "string"
650
- ? parsedFlags["api-base-url-2"]
651
- : undefined,
652
- configDir: this.config.configDir,
653
- });
654
- const apiClient = new PrimitiveApiClient({
655
- apiKey: auth.apiKey,
656
- apiBaseUrl1: auth.apiBaseUrl1,
657
- apiBaseUrl2: auth.apiBaseUrl2,
658
- });
659
- // Two body sources, merged: explicit JSON via --body /
660
- // --body-file (the base) plus per-field flags (the
661
- // overrides). Per-field flag values take precedence on key
662
- // conflicts so a caller can pass a base payload via --body
663
- // and override one field on the command line.
664
- let body;
665
- if (operation.hasJsonBody) {
666
- const explicit = readJsonBody(parsedFlags);
667
- const overrides = collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty);
668
- if (Object.keys(overrides).length > 0) {
669
- if (explicit === undefined) {
670
- body = overrides;
671
- }
672
- else if (explicit !== null &&
673
- typeof explicit === "object" &&
674
- !Array.isArray(explicit)) {
675
- body = { ...explicit, ...overrides };
676
- }
677
- else {
678
- // Caller passed --raw-body as null, an array, or a
679
- // primitive AND also passed per-field flags. We can't
680
- // merge per-field overrides into a non-object body
681
- // shape, and silently dropping either source would
682
- // leave the caller's actual intent unclear. Refuse
683
- // loudly so the next attempt is unambiguous.
684
- const explicitKind = explicit === null
685
- ? "null"
686
- : Array.isArray(explicit)
687
- ? "array"
688
- : typeof explicit;
689
- const overrideFlags = Object.keys(overrides)
690
- .map((p) => `--${flagName(p)}`)
691
- .join(", ");
692
- throw new Errors.CLIError(`--raw-body must be a JSON object when also passing per-field flags (got ${explicitKind}); supplied per-field flags: ${overrideFlags}. Either drop --raw-body and rely on the per-field flags, or move every field into the JSON --raw-body and drop the flags.`);
693
- }
694
- }
695
- else {
696
- body = explicit;
697
- }
698
- }
699
- if (operation.bodyRequired && body === undefined) {
700
- throw new Errors.CLIError(`Operation ${operation.operationId} requires a body. Pass each field as a --flag (see --help) or supply JSON via --raw-body / --body-file.`);
701
- }
702
- const operationFn = operations[operation.sdkName];
703
- // Operations in HOST_2_OPERATIONS route to the attachments-
704
- // supporting send host (apiBaseUrl2). Today that's only
705
- // sendEmail; the list grows as we migrate more endpoints.
706
- const targetClient = HOST_2_OPERATIONS.has(operation.sdkName)
707
- ? apiClient._sendClient
708
- : apiClient.client;
709
- const result = await operationFn({
710
- body,
711
- client: targetClient,
712
- parseAs: operation.binaryResponse ? "blob" : "auto",
713
- path: collectValues(operation.pathParams, parsedFlags),
714
- query: collectValues(operation.queryParams, parsedFlags),
715
- responseStyle: "fields",
716
- });
717
- if (result.error) {
718
- const errorPayload = extractErrorPayload(result.error);
719
- writeErrorWithHints(errorPayload);
720
- removeStaleSavedCredentialOnUnauthorized({
721
- auth,
722
- baseUrlOverridden,
723
- configDir: this.config.configDir,
724
- payload: errorPayload,
725
- });
726
- // Function-endpoint redirect. POST /endpoints/{id}/test on a
727
- // function-kind endpoint returns `not_found` even though the
728
- // same id IS visible in `endpoints:list-endpoints`. The hook
729
- // looks the id up via listEndpoints; if it matches a
730
- // function-kind row, it prints a redirect to
731
- // `functions:test-function` (with the function id) so the
732
- // caller does not have to translate the id themselves.
733
- // No-op for any other operation, any other error code, or
734
- // when the lookup misses or fails.
735
- const listClient = apiClient.client;
736
- const listEndpointsFn = () => operations.listEndpoints({
737
- client: listClient,
738
- responseStyle: "fields",
739
- });
740
- await maybeWriteFunctionEndpointRedirect({
741
- sdkName: operation.sdkName,
742
- errorCode: extractErrorCode(errorPayload),
743
- endpointId: typeof parsedFlags.id === "string" ? parsedFlags.id : undefined,
744
- listEndpoints: listEndpointsFn,
745
- writeStderr: (chunk) => {
746
- process.stderr.write(chunk);
747
- },
748
- });
749
- process.exitCode = 1;
750
- return;
751
- }
752
- if (operation.binaryResponse) {
753
- const blob = result.data;
754
- const bytes = Buffer.from(await blob.arrayBuffer());
755
- const output = parsedFlags.output;
756
- if (typeof output === "string") {
757
- writeFileSync(output, bytes);
758
- return;
759
- }
760
- process.stdout.write(bytes);
761
- return;
762
- }
763
- const envelope = result.data;
764
- const cursor = envelope?.meta?.cursor;
765
- if (cursor) {
766
- process.stderr.write(`next cursor: ${cursor}\n`);
767
- }
768
- // Empty-result hint. When a list-style operation returns
769
- // an empty array, emit an operation-specific note to
770
- // stderr so a naive caller can distinguish "nothing here"
771
- // from "something isn't set up." Stdout still gets the
772
- // raw `[]` so machine-readable output is unchanged. The
773
- // AGX walkthrough flagged this: `list-deliveries` returning
774
- // `[]` left the agent unsure whether they had an empty
775
- // delivery log or no endpoints configured at all.
776
- if (Array.isArray(envelope?.data) && envelope.data.length === 0) {
777
- const hint = EMPTY_RESULT_HINTS[operation.sdkName];
778
- if (hint)
779
- process.stderr.write(`${hint}\n`);
780
- }
781
- this.log(JSON.stringify(envelope?.data ?? null, null, 2));
782
- });
783
- }
784
- }
785
- return OperationCommand;
786
- }
787
- // Empty-state hints for list-style operations whose empty result
788
- // would otherwise leave the caller wondering "is this empty
789
- // because there's nothing to list, or because something earlier
790
- // in the setup chain isn't done?" Keys are the manifest's
791
- // `sdkName` for the operation. Operations without an entry fall
792
- // back to no hint (silent empty array, same as before).
793
- const EMPTY_RESULT_HINTS = {
794
- listDeliveries: "(no results) No webhook deliveries logged yet. If you have an endpoint configured but expected to see test fires here: test deliveries from `primitive endpoints:test-endpoint` are NOT logged in this list, they're synchronous and visible only in the test-endpoint command's response. Real deliveries are logged when an inbound `email.received` event fans out to your endpoints. If you have no endpoints, run `primitive endpoints:list-endpoints` to check.",
795
- listEndpoints: "(no results) No webhook endpoints configured. Add one with `primitive endpoints:create-endpoint --url <your-url>`.",
796
- listEmails: "(no results) No inbound emails received yet on this account. Send one to a verified domain to populate this list. For a compact view, prefer `primitive emails:latest`.",
797
- listDomains: "(no results) No domains on this account. Add one with `primitive domains:add-domain --domain <yourdomain.example>`.",
798
- listFilters: "(no results) No filter rules configured.",
799
- };