@primitivedotdev/cli 0.24.0

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