@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.
- package/README.md +47 -0
- package/bin/run.js +5 -0
- package/dist/oclif/api-command.js +755 -0
- package/dist/oclif/auth.js +223 -0
- package/dist/oclif/commands/emails-latest.js +184 -0
- package/dist/oclif/commands/emails-poll.js +121 -0
- package/dist/oclif/commands/emails-wait.js +171 -0
- package/dist/oclif/commands/emails-watch.js +165 -0
- package/dist/oclif/commands/functions-deploy.js +123 -0
- package/dist/oclif/commands/functions-init.js +262 -0
- package/dist/oclif/commands/functions-redeploy.js +112 -0
- package/dist/oclif/commands/functions-set-secret.js +212 -0
- package/dist/oclif/commands/login.js +236 -0
- package/dist/oclif/commands/logout.js +87 -0
- package/dist/oclif/commands/send.js +221 -0
- package/dist/oclif/commands/whoami.js +94 -0
- package/dist/oclif/fish-completion.js +87 -0
- package/dist/oclif/index.js +167 -0
- package/dist/oclif/lint/raw-send-mail-fetch.js +98 -0
- package/oclif.manifest.json +4287 -0
- package/package.json +108 -0
|
@@ -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
|
+
};
|