@primitivedotdev/cli 0.24.0 → 0.25.1

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.
@@ -313,6 +313,20 @@ export function extractErrorCode(payload) {
313
313
  const ERROR_CODE_HINTS = {
314
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
315
  };
316
+ // Network-layer hints keyed by Node's `cause.code` on a fetch failure.
317
+ // Separate from ERROR_CODE_HINTS because these aren't API-server error
318
+ // codes — they're the values Node sets on the underlying system call
319
+ // that failed before the request ever hit a server. The fix is almost
320
+ // always proxy / DNS / firewall on the caller's side, and the bare
321
+ // envelope (which just says `ENETUNREACH`) tells the user nothing they
322
+ // can act on. AGX walkthroughs in restrictive container environments
323
+ // hit this enough that the hint earns the extra lookup.
324
+ const NETWORK_ERROR_HINTS = {
325
+ 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.",
326
+ 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.",
327
+ 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.",
328
+ 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.",
329
+ };
316
330
  // Write a server / SDK error to stderr in the canonical envelope
317
331
  // shape, plus an actionable hint when the code is one we know how
318
332
  // to advise on. Replaces the bare
@@ -321,9 +335,15 @@ const ERROR_CODE_HINTS = {
321
335
  export function writeErrorWithHints(payload) {
322
336
  process.stderr.write(`${formatErrorPayload(payload)}\n`);
323
337
  const code = extractErrorCode(payload);
324
- if (code && code in ERROR_CODE_HINTS) {
338
+ if (!code)
339
+ return;
340
+ if (code in ERROR_CODE_HINTS) {
325
341
  const hint = ERROR_CODE_HINTS[code];
326
342
  process.stderr.write(`${hint}\n`);
343
+ return;
344
+ }
345
+ if (code in NETWORK_ERROR_HINTS) {
346
+ process.stderr.write(`${NETWORK_ERROR_HINTS[code]}\n`);
327
347
  }
328
348
  }
329
349
  export function removeStaleSavedCredentialOnUnauthorized(params) {
@@ -0,0 +1,338 @@
1
+ import { existsSync, readFileSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { Command, Flags } from "@oclif/core";
4
+ import { getAccount, listDomains, PrimitiveApiClient, } from "@primitivedotdev/sdk/api";
5
+ import { resolveCliAuth } from "../auth.js";
6
+ // `primitive doctor` is a one-command health check the AGX walkthrough
7
+ // kept asking for. Before this command, a user with a misconfigured
8
+ // environment had to triangulate across whoami, list-domains, and raw
9
+ // network probes to figure out which piece was off. The checklist
10
+ // below covers the four things that fail in practice: stale Node, a
11
+ // proxy env we don't pick up, a missing/wrong API key, and an org
12
+ // with no verified domain.
13
+ //
14
+ // Designed as an interactive checklist on stderr (so a piped invocation
15
+ // keeps the structured JSON on stdout). Each check prints its label,
16
+ // runs, and reports OK/WARN/FAIL with a one-line hint when not OK. The
17
+ // command exits 1 if any FAIL check fires; WARN doesn't fail the run.
18
+ const MIN_NODE_MAJOR = 22;
19
+ function renderRow({ label, outcome }) {
20
+ const tag = outcome.status === "ok"
21
+ ? "[OK] "
22
+ : outcome.status === "warn"
23
+ ? "[WARN]"
24
+ : "[FAIL]";
25
+ return `${tag} ${label}: ${outcome.message}`;
26
+ }
27
+ function checkNode() {
28
+ const version = process.version; // e.g. "v22.10.2"
29
+ const majorStr = version.replace(/^v/, "").split(".")[0];
30
+ const major = majorStr ? Number(majorStr) : Number.NaN;
31
+ if (!Number.isFinite(major)) {
32
+ return {
33
+ status: "warn",
34
+ message: `unrecognized version string ${version}`,
35
+ hint: "Ensure node --version reports a semver-shaped value.",
36
+ };
37
+ }
38
+ if (major < MIN_NODE_MAJOR) {
39
+ return {
40
+ status: "fail",
41
+ message: `${version} is below the minimum supported major (${MIN_NODE_MAJOR})`,
42
+ hint: `Install Node.js ${MIN_NODE_MAJOR} or newer. The CLI relies on Web Fetch APIs that are stable from ${MIN_NODE_MAJOR} on.`,
43
+ };
44
+ }
45
+ return { status: "ok", message: version };
46
+ }
47
+ function checkProxy() {
48
+ // Surface the four env vars Node's fetch consults when
49
+ // NODE_USE_ENV_PROXY=1 is set. Don't claim they're broken if absent;
50
+ // many environments don't need them. Surface NODE_USE_ENV_PROXY
51
+ // itself because Node 22+ ignores HTTP_PROXY etc. without it: a
52
+ // surprisingly common gotcha that turns the CLI into ENETUNREACH
53
+ // from inside containers and corporate networks.
54
+ const vars = [
55
+ "NODE_USE_ENV_PROXY",
56
+ "HTTPS_PROXY",
57
+ "HTTP_PROXY",
58
+ "NO_PROXY",
59
+ ];
60
+ const present = vars
61
+ .map((name) => {
62
+ const value = process.env[name];
63
+ return value && value.length > 0 ? `${name}=${value}` : null;
64
+ })
65
+ .filter((entry) => entry !== null);
66
+ if (present.length === 0) {
67
+ return { status: "ok", message: "no proxy env vars set" };
68
+ }
69
+ // Identify which specific proxy host var(s) are set so the warning
70
+ // names what the shell actually has, not a hardcoded string. Order
71
+ // is reporting-only; if both are set, both surface in the message.
72
+ const proxyHostVars = ["HTTPS_PROXY", "HTTP_PROXY"].filter((name) => (process.env[name] ?? "").length > 0);
73
+ const proxyEnabled = process.env.NODE_USE_ENV_PROXY === "1";
74
+ if (proxyHostVars.length > 0 && !proxyEnabled) {
75
+ return {
76
+ status: "warn",
77
+ message: `${present.join(", ")} (${proxyHostVars.join(" / ")} set, NODE_USE_ENV_PROXY not)`,
78
+ hint: "Node 22+ ignores HTTP(S)_PROXY by default. Re-run with NODE_USE_ENV_PROXY=1 if API calls fail with ENETUNREACH or ECONNREFUSED.",
79
+ };
80
+ }
81
+ return { status: "ok", message: present.join(", ") };
82
+ }
83
+ function checkApiKey(opts) {
84
+ if (opts.apiKey?.startsWith("prim_")) {
85
+ return { status: "ok", message: "provided via flag/env (prim_ prefix)" };
86
+ }
87
+ if (opts.apiKey) {
88
+ return {
89
+ status: "warn",
90
+ message: "provided but does not start with prim_",
91
+ hint: "Verify the key is a Primitive API key, not a value from another service.",
92
+ };
93
+ }
94
+ const credsPath = join(opts.configDir, "credentials.json");
95
+ if (existsSync(credsPath)) {
96
+ let parsed = null;
97
+ let parseError = null;
98
+ try {
99
+ parsed = JSON.parse(readFileSync(credsPath, "utf8"));
100
+ }
101
+ catch (error) {
102
+ parseError = error instanceof Error ? error.message : String(error);
103
+ }
104
+ if (parsed?.api_key) {
105
+ return { status: "ok", message: `loaded from ${credsPath}` };
106
+ }
107
+ if (parsed) {
108
+ // File parsed but had no usable api_key. Different cause than a
109
+ // malformed file; surface the distinction so the user knows
110
+ // whether to re-run login or to inspect the file by hand.
111
+ return {
112
+ status: "fail",
113
+ message: `${credsPath} exists but contains no api_key`,
114
+ hint: "Run `primitive logout` to clear it, then `primitive login` to recreate.",
115
+ };
116
+ }
117
+ return {
118
+ status: "fail",
119
+ message: `${credsPath} exists but is unreadable or malformed${parseError ? ` (${parseError})` : ""}`,
120
+ hint: "Run `primitive logout` to clear it, then `primitive login` to recreate.",
121
+ };
122
+ }
123
+ return {
124
+ status: "fail",
125
+ message: "no API key found",
126
+ hint: "Run `primitive login`, pass --api-key explicitly, or export PRIMITIVE_API_KEY=prim_...",
127
+ };
128
+ }
129
+ async function checkAccount(opts) {
130
+ try {
131
+ const client = new PrimitiveApiClient({
132
+ apiKey: opts.apiKey,
133
+ apiBaseUrl1: opts.apiBaseUrl1,
134
+ apiBaseUrl2: opts.apiBaseUrl2,
135
+ });
136
+ const result = await getAccount({
137
+ client: client.client,
138
+ responseStyle: "fields",
139
+ });
140
+ // Capture once to avoid TS over-narrowing across the truthy +
141
+ // typeof checks; result.error can resolve to never on the third
142
+ // access when the generated union types collapse.
143
+ const apiError = result.error;
144
+ if (apiError) {
145
+ const errorBody = typeof apiError === "object" && apiError !== null
146
+ ? JSON.stringify(apiError).slice(0, 300)
147
+ : String(apiError).slice(0, 300);
148
+ return {
149
+ outcome: {
150
+ status: "fail",
151
+ message: `API rejected the key (${errorBody})`,
152
+ hint: "Run `primitive whoami` for the full error envelope. If the key was rotated, regenerate it in the dashboard.",
153
+ },
154
+ account: null,
155
+ };
156
+ }
157
+ const envelope = result.data;
158
+ const account = envelope?.data ?? null;
159
+ if (!account) {
160
+ return {
161
+ outcome: {
162
+ status: "fail",
163
+ message: "/account returned an empty body",
164
+ },
165
+ account: null,
166
+ };
167
+ }
168
+ return {
169
+ outcome: {
170
+ status: "ok",
171
+ message: `${account.email} (plan: ${account.plan}, id: ${account.id})`,
172
+ },
173
+ account,
174
+ };
175
+ }
176
+ catch (error) {
177
+ const code = error instanceof Error &&
178
+ error.cause &&
179
+ typeof error.cause === "object" &&
180
+ typeof error.cause.code === "string"
181
+ ? error.cause.code
182
+ : undefined;
183
+ const message = error instanceof Error ? error.message : String(error);
184
+ const hint = code === "ENETUNREACH" ||
185
+ code === "ECONNREFUSED" ||
186
+ code === "ETIMEDOUT" ||
187
+ code === "EAI_AGAIN"
188
+ ? "Network unreachable. If you're behind a proxy, re-run with NODE_USE_ENV_PROXY=1 and HTTPS_PROXY set. If you're in a container, check that egress to *.primitive.dev is allowed."
189
+ : 'Inspect the error above. `curl https://www.primitive.dev/api/v1/account -H "Authorization: Bearer $PRIMITIVE_API_KEY"` is the fastest way to bisect CLI vs network.';
190
+ return {
191
+ outcome: {
192
+ status: "fail",
193
+ message: code ? `${message} (${code})` : message,
194
+ hint,
195
+ },
196
+ account: null,
197
+ };
198
+ }
199
+ }
200
+ async function checkDomains(opts) {
201
+ try {
202
+ const client = new PrimitiveApiClient({
203
+ apiKey: opts.apiKey,
204
+ apiBaseUrl1: opts.apiBaseUrl1,
205
+ apiBaseUrl2: opts.apiBaseUrl2,
206
+ });
207
+ const result = await listDomains({
208
+ client: client.client,
209
+ responseStyle: "fields",
210
+ });
211
+ if (result.error) {
212
+ return {
213
+ status: "warn",
214
+ message: "could not list domains",
215
+ hint: "Run `primitive domains:list-domains` for the full error envelope.",
216
+ };
217
+ }
218
+ const envelope = result.data;
219
+ const rows = envelope?.data ?? [];
220
+ const active = rows.filter((row) => row.is_active === true);
221
+ if (active.length === 0 && rows.length === 0) {
222
+ return {
223
+ status: "warn",
224
+ message: "no domains on this account yet",
225
+ hint: "A managed `*.primitive.email` subdomain is auto-issued on signup. If this is empty, complete onboarding or check the dashboard.",
226
+ };
227
+ }
228
+ if (active.length === 0) {
229
+ return {
230
+ status: "warn",
231
+ message: `${rows.length} domain(s), none active`,
232
+ hint: "Run `primitive domains:verify-domain --id <id>` for any domain you intend to send / receive on.",
233
+ };
234
+ }
235
+ return {
236
+ status: "ok",
237
+ message: `${active.length} active domain(s): ${active.map((row) => row.domain).join(", ")}`,
238
+ };
239
+ }
240
+ catch (error) {
241
+ return {
242
+ status: "warn",
243
+ message: `listDomains threw: ${error instanceof Error ? error.message : String(error)}`,
244
+ };
245
+ }
246
+ }
247
+ class DoctorCommand extends Command {
248
+ static description = `Run a one-shot environment health check: Node version, proxy env, API key resolution, /account reachability, and verified-domain status. Fails fast on anything that would block other commands and prints actionable hints for each warning or failure.`;
249
+ static summary = "Check the local environment and live API for common problems";
250
+ static examples = [
251
+ "<%= config.bin %> doctor",
252
+ "<%= config.bin %> doctor --api-key prim_...",
253
+ ];
254
+ static flags = {
255
+ "api-key": Flags.string({
256
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
257
+ env: "PRIMITIVE_API_KEY",
258
+ }),
259
+ "api-base-url-1": Flags.string({
260
+ description: "Override the primary API base URL. Internal testing only; not documented to customers.",
261
+ env: "PRIMITIVE_API_BASE_URL_1",
262
+ hidden: true,
263
+ }),
264
+ "api-base-url-2": Flags.string({
265
+ description: "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
266
+ env: "PRIMITIVE_API_BASE_URL_2",
267
+ hidden: true,
268
+ }),
269
+ };
270
+ async run() {
271
+ const { flags } = await this.parse(DoctorCommand);
272
+ const rows = [];
273
+ rows.push({ label: "Node version", outcome: checkNode() });
274
+ rows.push({ label: "Proxy env", outcome: checkProxy() });
275
+ const apiKeyCheck = checkApiKey({
276
+ apiKey: flags["api-key"],
277
+ configDir: this.config.configDir,
278
+ });
279
+ rows.push({ label: "API key", outcome: apiKeyCheck });
280
+ // Only run the live checks if we have a key to authenticate with.
281
+ // Reporting the network-failure case without a key would just
282
+ // confuse the user; the missing-key row above already covers it.
283
+ if (apiKeyCheck.status !== "fail") {
284
+ const auth = resolveCliAuth({
285
+ apiKey: flags["api-key"],
286
+ apiBaseUrl1: flags["api-base-url-1"],
287
+ apiBaseUrl2: flags["api-base-url-2"],
288
+ configDir: this.config.configDir,
289
+ });
290
+ // resolveCliAuth's apiKey is typed as string | undefined; we
291
+ // narrowed the failure case via apiKeyCheck above, so the
292
+ // undefined branch shouldn't fire in practice. Skip the live
293
+ // checks defensively rather than passing "" to the API.
294
+ if (auth.apiKey !== undefined) {
295
+ const accountCheck = await checkAccount({
296
+ apiKey: auth.apiKey,
297
+ apiBaseUrl1: auth.apiBaseUrl1,
298
+ apiBaseUrl2: auth.apiBaseUrl2,
299
+ });
300
+ rows.push({ label: "API auth", outcome: accountCheck.outcome });
301
+ if (accountCheck.outcome.status === "ok") {
302
+ const domainsOutcome = await checkDomains({
303
+ apiKey: auth.apiKey,
304
+ apiBaseUrl1: auth.apiBaseUrl1,
305
+ apiBaseUrl2: auth.apiBaseUrl2,
306
+ });
307
+ rows.push({ label: "Domains", outcome: domainsOutcome });
308
+ }
309
+ }
310
+ }
311
+ for (const row of rows) {
312
+ process.stderr.write(`${renderRow(row)}\n`);
313
+ if ("hint" in row.outcome && row.outcome.hint) {
314
+ process.stderr.write(` hint: ${row.outcome.hint}\n`);
315
+ }
316
+ }
317
+ // Structured stdout for piping. Keep stderr human-readable;
318
+ // stdout JSON is what `primitive doctor | jq` consumers parse.
319
+ const summary = {
320
+ ok: rows.every((row) => row.outcome.status === "ok"),
321
+ checks: rows.map(({ label, outcome }) => ({
322
+ label,
323
+ status: outcome.status,
324
+ message: outcome.message,
325
+ ...("hint" in outcome && outcome.hint ? { hint: outcome.hint } : {}),
326
+ })),
327
+ };
328
+ this.log(JSON.stringify(summary, null, 2));
329
+ if (rows.some((row) => row.outcome.status === "fail")) {
330
+ process.exitCode = 1;
331
+ }
332
+ }
333
+ }
334
+ export default DoctorCommand;
335
+ // Exported for unit testing. The pure helpers (formatters and the
336
+ // proxy / node-version checks) get isolated coverage so the oclif
337
+ // run() lifecycle doesn't have to be stood up for every case.
338
+ export { checkApiKey, checkNode, checkProxy, renderRow };
@@ -18,7 +18,18 @@ import { Args, Command, Errors, Flags } from "@oclif/core";
18
18
  // the CLI's own @primitivedotdev/sdk dep range in cli-node/package.json
19
19
  // so scaffolded projects use the same SDK version the CLI was built
20
20
  // and tested against.
21
- const SDK_VERSION_RANGE = "^0.23.0";
21
+ const SDK_VERSION_RANGE = "^0.25.0";
22
+ // The CLI version range that ships in the scaffolded devDependencies.
23
+ // Pinned separately from SDK_VERSION_RANGE because @primitivedotdev/cli
24
+ // and @primitivedotdev/sdk are independent packages on independent
25
+ // release cadences. Coupling them silently breaks `npm install` in
26
+ // every scaffolded project the day we bump one without publishing the
27
+ // other. Must include this CLI's own version: a `primitive
28
+ // functions:init` run from CLI v1.2.3 should scaffold a project that
29
+ // resolves at least v1.2.3, so the user does not silently downgrade
30
+ // the bin under themselves. The lockstep test in functions-init.test.ts
31
+ // enforces that invariant.
32
+ const CLI_VERSION_RANGE = "^0.25.0";
22
33
  // esbuild version range. Pinned to the latest stable major used
23
34
  // elsewhere in the Primitive codebase for bundling Workers-style
24
35
  // handlers. Caret range so patch fixes flow in automatically.
@@ -40,24 +51,58 @@ export function renderHandler() {
40
51
  return `// env.PRIMITIVE_API_KEY is auto-injected by the Primitive Functions runtime.
41
52
  import { createPrimitiveClient } from "@primitivedotdev/sdk/api";
42
53
 
54
+ interface EmailReceivedEvent {
55
+ event: string;
56
+ email: {
57
+ headers: { from?: string; subject?: string };
58
+ };
59
+ }
60
+
43
61
  export default {
44
62
  async fetch(
45
63
  req: Request,
46
64
  env: { PRIMITIVE_API_KEY: string },
47
65
  ): Promise<Response> {
48
- const event = (await req.json()) as {
49
- email: { headers: { from?: string; subject?: string } };
50
- };
51
- const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
66
+ try {
67
+ const event = (await req.json()) as EmailReceivedEvent;
52
68
 
53
- const reply = await client.send({
54
- from: "you@your-domain.primitive.email",
55
- to: event.email.headers.from ?? "you@your-domain.primitive.email",
56
- subject: \`Re: \${event.email.headers.subject ?? ""}\`,
57
- bodyText: "Got your message.",
58
- });
69
+ // Only "email.received" exists today. Future event types will
70
+ // arrive with a different discriminator; return 2xx so the
71
+ // delivery loop does not burn its retry budget on payloads you
72
+ // intentionally skipped.
73
+ if (event.event !== "email.received") {
74
+ return Response.json({ ok: true, skipped: event.event });
75
+ }
59
76
 
60
- return Response.json({ ok: true, reply });
77
+ const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
78
+
79
+ // Recipient gate
80
+ // https://www.primitive.dev/docs/sending#who-you-can-send-to
81
+ // New accounts can send to *.primitive.email addresses,
82
+ // verified domains, addresses that have authenticated to you,
83
+ // and other org-member signup emails. Sends to arbitrary
84
+ // external addresses return 403 recipient_not_allowed with a
85
+ // structured gates[] array until the recipient has authenticated
86
+ // to you or support has enabled the gate.
87
+ const reply = await client.send({
88
+ from: "you@your-domain.primitive.email",
89
+ to: event.email.headers.from ?? "you@your-domain.primitive.email",
90
+ subject: \`Re: \${event.email.headers.subject ?? ""}\`,
91
+ bodyText: "Got your message.",
92
+ });
93
+
94
+ return Response.json({ ok: true, reply });
95
+ } catch (err) {
96
+ // Return 2xx so the webhook delivery loop does not retry a bug
97
+ // it cannot fix. The function-invocation row still records the
98
+ // error body for debugging. Flip to a 5xx status if you want
99
+ // transient failures retried (e.g. a flaky external API you call).
100
+ console.error("handler error:", err);
101
+ return Response.json(
102
+ { ok: false, error: err instanceof Error ? err.message : String(err) },
103
+ { status: 200 },
104
+ );
105
+ }
61
106
  },
62
107
  };
63
108
  `;
@@ -77,6 +122,15 @@ export function renderPackageJson(name) {
77
122
  "@primitivedotdev/sdk": SDK_VERSION_RANGE,
78
123
  },
79
124
  devDependencies: {
125
+ // @primitivedotdev/cli ships the primitive bin. Including it as
126
+ // a devDep here means `node_modules/.bin/primitive` resolves to
127
+ // the real CLI inside the scaffolded project; otherwise the
128
+ // bin falls through to @primitivedotdev/sdk's deprecated CLI
129
+ // alias and every `npm run deploy` invocation prints the
130
+ // "CLI moved" stderr banner. Pinned via CLI_VERSION_RANGE, a
131
+ // dedicated constant so the version is decoupled from the SDK
132
+ // range and bumps are explicit on both ends.
133
+ "@primitivedotdev/cli": CLI_VERSION_RANGE,
80
134
  esbuild: ESBUILD_VERSION_RANGE,
81
135
  typescript: "^5.7.2",
82
136
  },
@@ -1,6 +1,7 @@
1
1
  import { Args, Command, Errors } from "@oclif/core";
2
2
  import { operationManifest, } from "@primitivedotdev/sdk/openapi";
3
3
  import { createOperationCommand } from "./api-command.js";
4
+ import DoctorCommand from "./commands/doctor.js";
4
5
  import EmailsLatestCommand from "./commands/emails-latest.js";
5
6
  import EmailsWaitCommand from "./commands/emails-wait.js";
6
7
  import EmailsWatchCommand from "./commands/emails-watch.js";
@@ -133,6 +134,13 @@ export const COMMANDS = {
133
134
  // wanting this before risking a real call against a possibly-
134
135
  // bad key.
135
136
  whoami: WhoamiCommand,
137
+ // `doctor` is the environment health check. Node version, proxy
138
+ // env, API key resolution, /account reachability, verified-domain
139
+ // status — every check that whoami implicitly assumes is fine.
140
+ // AGX walkthroughs that hit ENETUNREACH from inside containers
141
+ // had no single command to bisect "is the CLI / network / key /
142
+ // server broken"; doctor is that command.
143
+ doctor: DoctorCommand,
136
144
  // `emails:latest` is the inbox-triage shortcut: the most recent N
137
145
  // inbound emails as a compact text table. emails:list-emails stays
138
146
  // available for the full JSON envelope + cursor pagination.
@@ -308,6 +308,52 @@
308
308
  "summary": "Print the authenticated account (credentials smoke test)",
309
309
  "enableJsonFlag": false
310
310
  },
311
+ "doctor": {
312
+ "aliases": [],
313
+ "args": {},
314
+ "description": "Run a one-shot environment health check: Node version, proxy env, API key resolution, /account reachability, and verified-domain status. Fails fast on anything that would block other commands and prints actionable hints for each warning or failure.",
315
+ "examples": [
316
+ "<%= config.bin %> doctor",
317
+ "<%= config.bin %> doctor --api-key prim_..."
318
+ ],
319
+ "flags": {
320
+ "api-key": {
321
+ "description": "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
322
+ "env": "PRIMITIVE_API_KEY",
323
+ "name": "api-key",
324
+ "hasDynamicHelp": false,
325
+ "multiple": false,
326
+ "type": "option"
327
+ },
328
+ "api-base-url-1": {
329
+ "description": "Override the primary API base URL. Internal testing only; not documented to customers.",
330
+ "env": "PRIMITIVE_API_BASE_URL_1",
331
+ "hidden": true,
332
+ "name": "api-base-url-1",
333
+ "hasDynamicHelp": false,
334
+ "multiple": false,
335
+ "type": "option"
336
+ },
337
+ "api-base-url-2": {
338
+ "description": "Override the attachments-supporting send host base URL. Internal testing only; not documented to customers.",
339
+ "env": "PRIMITIVE_API_BASE_URL_2",
340
+ "hidden": true,
341
+ "name": "api-base-url-2",
342
+ "hasDynamicHelp": false,
343
+ "multiple": false,
344
+ "type": "option"
345
+ }
346
+ },
347
+ "hasDynamicHelp": false,
348
+ "hiddenAliases": [],
349
+ "id": "doctor",
350
+ "pluginAlias": "@primitivedotdev/cli",
351
+ "pluginName": "@primitivedotdev/cli",
352
+ "pluginType": "core",
353
+ "strict": true,
354
+ "summary": "Check the local environment and live API for common problems",
355
+ "enableJsonFlag": false
356
+ },
311
357
  "emails:latest": {
312
358
  "aliases": [],
313
359
  "args": {},
@@ -3565,7 +3611,7 @@
3565
3611
  "functions:test-function": {
3566
3612
  "aliases": [],
3567
3613
  "args": {},
3568
- "description": "Sends a real test email from a Primitive-controlled sender to a\nsynthetic local-part on one of the org's verified inbound\ndomains. The function fires through the normal MX delivery\npath, so reply / send-mail calls from inside the handler\nagainst the inbound's `email.id` work the same as in\nproduction. Returns immediately after the send is queued; the\ninvocation appears on the function's invocations list within a\nfew seconds.\n\nRequires that the function is currently `deployed`. Returns 422\nif the function is in `pending` or `failed` state, or if the\norg has no verified inbound domain to receive the test mail.\n",
3614
+ "description": "Sends a real test email from a Primitive-controlled sender to a\nlocal-part on one of the org's verified inbound domains. By\ndefault the recipient is a synthetic\n`__primitive_function_test+<random>@<domain>` address that\nevery handler's catch-all routing receives identically; pass\n`local_part` to override and exercise routing logic that\nbranches on a specific recipient (the common pattern when one\nfunction handles multiple inboxes like `summarize@` and\n`action@`). The function fires through the normal MX delivery\npath, so reply / send-mail calls from inside the handler\nagainst the inbound's `email.id` work the same as in\nproduction. Returns immediately after the send is queued; the\ninvocation appears on the function's invocations list within a\nfew seconds.\n\nRequires that the function is currently `deployed`. Returns 422\nif the function is in `pending` or `failed` state, or if the\norg has no verified inbound domain to receive the test mail.\nReturns 400 if `local_part` is set to a value that does not\nmatch the local-part character set.\n",
3569
3615
  "flags": {
3570
3616
  "api-key": {
3571
3617
  "description": "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
@@ -3606,6 +3652,27 @@
3606
3652
  "hasDynamicHelp": false,
3607
3653
  "multiple": false,
3608
3654
  "type": "option"
3655
+ },
3656
+ "raw-body": {
3657
+ "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.",
3658
+ "name": "raw-body",
3659
+ "hasDynamicHelp": false,
3660
+ "multiple": false,
3661
+ "type": "option"
3662
+ },
3663
+ "body-file": {
3664
+ "description": "Path to a JSON file used as the request body. Same role as --raw-body for callers passing a saved payload.",
3665
+ "name": "body-file",
3666
+ "hasDynamicHelp": false,
3667
+ "multiple": false,
3668
+ "type": "option"
3669
+ },
3670
+ "local-part": {
3671
+ "description": "Override the synthetic local-part. When set, the test email is sent to `<local_part>@<picked-domain>` instead of the default `__primitive_function_test+<random>@<picked-domain>`. Must start with an alphanumeric and contain only letters, digits, dots, plus signs, hyphens, or underscores; 1-64 characters total.",
3672
+ "name": "local-part",
3673
+ "hasDynamicHelp": false,
3674
+ "multiple": false,
3675
+ "type": "option"
3609
3676
  }
3610
3677
  },
3611
3678
  "hasDynamicHelp": false,
@@ -4283,5 +4350,5 @@
4283
4350
  "enableJsonFlag": false
4284
4351
  }
4285
4352
  },
4286
- "version": "0.24.0"
4353
+ "version": "0.25.1"
4287
4354
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitivedotdev/cli",
3
- "version": "0.24.0",
3
+ "version": "0.25.1",
4
4
  "description": "Official Primitive CLI: deploy Primitive Functions, send and inspect mail, manage endpoints, all from the terminal. Wraps the @primitivedotdev/sdk runtime client with one-shot commands.",
5
5
  "type": "module",
6
6
  "sideEffects": false,
@@ -92,7 +92,7 @@
92
92
  "@oclif/core": "^4.10.5",
93
93
  "@oclif/plugin-autocomplete": "^3.2.45",
94
94
  "@oclif/plugin-help": "^6.2.44",
95
- "@primitivedotdev/sdk": "^0.23.0"
95
+ "@primitivedotdev/sdk": "^0.25.0"
96
96
  },
97
97
  "devDependencies": {
98
98
  "@biomejs/biome": "^2.4.10",