@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
|
|
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.
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
};
|
|
51
|
-
const client = createPrimitiveClient({ apiKey: env.PRIMITIVE_API_KEY });
|
|
66
|
+
try {
|
|
67
|
+
const event = (await req.json()) as EmailReceivedEvent;
|
|
52
68
|
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
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
|
},
|
package/dist/oclif/index.js
CHANGED
|
@@ -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.
|
package/oclif.manifest.json
CHANGED
|
@@ -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\
|
|
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.
|
|
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.
|
|
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.
|
|
95
|
+
"@primitivedotdev/sdk": "^0.25.0"
|
|
96
96
|
},
|
|
97
97
|
"devDependencies": {
|
|
98
98
|
"@biomejs/biome": "^2.4.10",
|