@primitivedotdev/cli 0.26.1 → 0.26.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,223 +0,0 @@
1
- import { randomUUID } from "node:crypto";
2
- import { chmodSync, mkdirSync, readFileSync, renameSync, rmSync, statSync, writeFileSync, } from "node:fs";
3
- import { join } from "node:path";
4
- import { DEFAULT_API_BASE_URL_1, DEFAULT_API_BASE_URL_2, } from "@primitivedotdev/sdk/api";
5
- const CREDENTIALS_FILE = "credentials.json";
6
- const CREDENTIALS_LOCK_DIR = "credentials.lock";
7
- const CREDENTIALS_LOCK_STALE_MS = 30 * 60 * 1000;
8
- const MALFORMED_CREDENTIALS_HINT = "Run `primitive logout` and then `primitive login`.";
9
- function isRecord(value) {
10
- return value !== null && typeof value === "object" && !Array.isArray(value);
11
- }
12
- function requireString(value, key) {
13
- const raw = value[key];
14
- if (typeof raw !== "string" || raw.trim().length === 0) {
15
- throw new Error(`Stored Primitive CLI credentials are malformed: ${key} must be a non-empty string. ${MALFORMED_CREDENTIALS_HINT}`);
16
- }
17
- return raw;
18
- }
19
- /**
20
- * Sentinel returned by parseCredentials when the on-disk credentials
21
- * were written by a pre-dual-host CLI version (i.e. they have
22
- * `base_url` instead of `api_base_url_1`). The caller treats this as
23
- * "no saved credentials" after auto-cleaning the stale file. Defined
24
- * as a class-tagged error so loadCliCredentials can distinguish it
25
- * from a genuine malformed-credentials error.
26
- */
27
- class StaleCredentialFormatError extends Error {
28
- constructor() {
29
- super("stale_credential_format");
30
- this.name = "StaleCredentialFormatError";
31
- }
32
- }
33
- function parseCredentials(raw) {
34
- if (!isRecord(raw)) {
35
- throw new Error(`Stored Primitive CLI credentials are malformed: expected a JSON object. ${MALFORMED_CREDENTIALS_HINT}`);
36
- }
37
- // Stored credentials from an older CLI version used the field name
38
- // `base_url`; the dual-host rename moved this to `api_base_url_1`.
39
- // Detect the old shape specifically so loadCliCredentials can wipe
40
- // the stale file and emit a clear "you've been logged out" notice
41
- // instead of every command hard-failing with a generic "malformed"
42
- // error that doesn't surface the actual fix (re-login).
43
- if (typeof raw.api_base_url_1 !== "string" &&
44
- typeof raw.base_url === "string") {
45
- throw new StaleCredentialFormatError();
46
- }
47
- const orgName = raw.org_name;
48
- if (orgName !== null && typeof orgName !== "string") {
49
- throw new Error(`Stored Primitive CLI credentials are malformed: org_name must be a string or null. ${MALFORMED_CREDENTIALS_HINT}`);
50
- }
51
- return {
52
- api_key: requireString(raw, "api_key"),
53
- key_id: requireString(raw, "key_id"),
54
- key_prefix: requireString(raw, "key_prefix"),
55
- org_id: requireString(raw, "org_id"),
56
- org_name: orgName,
57
- api_base_url_1: requireString(raw, "api_base_url_1"),
58
- created_at: requireString(raw, "created_at"),
59
- };
60
- }
61
- export function credentialsPath(configDir) {
62
- return join(configDir, CREDENTIALS_FILE);
63
- }
64
- function normalize(url, fallback) {
65
- const trimmed = url?.trim();
66
- if (!trimmed)
67
- return fallback;
68
- return trimmed.replace(/\/+$/, "");
69
- }
70
- export function normalizeApiBaseUrl1(url) {
71
- return normalize(url, DEFAULT_API_BASE_URL_1);
72
- }
73
- export function normalizeApiBaseUrl2(url) {
74
- return normalize(url, DEFAULT_API_BASE_URL_2);
75
- }
76
- export function loadCliCredentials(configDir) {
77
- const path = credentialsPath(configDir);
78
- let contents;
79
- try {
80
- contents = readFileSync(path, "utf8");
81
- }
82
- catch (error) {
83
- if (error &&
84
- typeof error === "object" &&
85
- error.code === "ENOENT") {
86
- return null;
87
- }
88
- const detail = error instanceof Error ? error.message : String(error);
89
- throw new Error(`Could not read Primitive CLI credentials: ${detail}`);
90
- }
91
- try {
92
- return parseCredentials(JSON.parse(contents));
93
- }
94
- catch (error) {
95
- if (error instanceof StaleCredentialFormatError) {
96
- // Saved credentials were written by a pre-dual-host CLI version.
97
- // The format is incompatible (base_url vs api_base_url_1) and
98
- // cannot be recovered. Clear the file so the caller sees "no
99
- // saved credentials" and emit a one-shot notice telling the
100
- // user they need to log back in. Idempotent: once the file is
101
- // gone, this branch never fires again.
102
- try {
103
- rmSync(path, { force: true });
104
- }
105
- catch {
106
- // Best-effort cleanup; if the unlink fails (permissions,
107
- // racing process), the next CLI invocation will hit this
108
- // path again and try once more.
109
- }
110
- process.stderr.write("You've been logged out: your saved Primitive CLI credentials were created by an older CLI version and are no longer compatible. Run `primitive login` to re-authenticate.\n");
111
- return null;
112
- }
113
- if (error instanceof SyntaxError) {
114
- throw new Error("Stored Primitive CLI credentials are not valid JSON. Run `primitive logout` and then `primitive login`.");
115
- }
116
- throw error;
117
- }
118
- }
119
- export function saveCliCredentials(configDir, credentials) {
120
- mkdirSync(configDir, { mode: 0o700, recursive: true });
121
- const path = credentialsPath(configDir);
122
- const tempPath = join(configDir, `${CREDENTIALS_FILE}.${process.pid}.${randomUUID()}.tmp`);
123
- try {
124
- writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, {
125
- mode: 0o600,
126
- });
127
- chmodSync(tempPath, 0o600);
128
- renameSync(tempPath, path);
129
- chmodSync(path, 0o600);
130
- }
131
- catch (error) {
132
- rmSync(tempPath, { force: true });
133
- throw error;
134
- }
135
- }
136
- export function deleteCliCredentials(configDir) {
137
- rmSync(credentialsPath(configDir), { force: true });
138
- }
139
- function errorCode(error) {
140
- return error && typeof error === "object"
141
- ? error.code
142
- : undefined;
143
- }
144
- function removeStaleCliCredentialsLock(lockPath, staleMs, now) {
145
- try {
146
- const stats = statSync(lockPath);
147
- if (now() - stats.mtimeMs < staleMs)
148
- return false;
149
- }
150
- catch (error) {
151
- if (errorCode(error) === "ENOENT")
152
- return true;
153
- throw error;
154
- }
155
- rmSync(lockPath, { force: true, recursive: true });
156
- return true;
157
- }
158
- export function acquireCliCredentialsLock(configDir, options = {}) {
159
- mkdirSync(configDir, { mode: 0o700, recursive: true });
160
- const lockPath = join(configDir, CREDENTIALS_LOCK_DIR);
161
- const now = options.now ?? Date.now;
162
- const staleMs = options.staleMs ?? CREDENTIALS_LOCK_STALE_MS;
163
- let acquired = false;
164
- for (let attempt = 0; attempt < 2; attempt += 1) {
165
- try {
166
- mkdirSync(lockPath, { mode: 0o700 });
167
- acquired = true;
168
- break;
169
- }
170
- catch (error) {
171
- if (errorCode(error) !== "EEXIST")
172
- throw error;
173
- if (removeStaleCliCredentialsLock(lockPath, staleMs, now))
174
- continue;
175
- throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
176
- }
177
- }
178
- if (!acquired) {
179
- throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
180
- }
181
- let released = false;
182
- return () => {
183
- if (released)
184
- return;
185
- released = true;
186
- rmSync(lockPath, { force: true, recursive: true });
187
- };
188
- }
189
- export function resolveCliAuth(params) {
190
- const apiKey = params.apiKey?.trim();
191
- // Host 2 (api_base_url_2) is never stored; either set by env/flag or
192
- // falls back to the production default. The login flow only deals
193
- // with host 1.
194
- const apiBaseUrl2 = normalizeApiBaseUrl2(params.apiBaseUrl2);
195
- if (apiKey) {
196
- return {
197
- apiKey,
198
- apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
199
- apiBaseUrl2,
200
- credentials: null,
201
- source: "flag-or-env",
202
- };
203
- }
204
- const credentials = loadCliCredentials(params.configDir);
205
- if (credentials) {
206
- return {
207
- apiKey: credentials.api_key,
208
- apiBaseUrl1: params.apiBaseUrl1
209
- ? normalizeApiBaseUrl1(params.apiBaseUrl1)
210
- : credentials.api_base_url_1,
211
- apiBaseUrl2,
212
- credentials,
213
- source: "stored",
214
- };
215
- }
216
- return {
217
- apiKey: undefined,
218
- apiBaseUrl1: normalizeApiBaseUrl1(params.apiBaseUrl1),
219
- apiBaseUrl2,
220
- credentials: null,
221
- source: "none",
222
- };
223
- }
@@ -1,338 +0,0 @@
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 };