@primitivedotdev/sdk 0.17.0 → 0.19.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.
@@ -1,6 +1,16 @@
1
1
  import { readFileSync, writeFileSync } from "node:fs";
2
2
  import { Command, Errors, Flags } from "@oclif/core";
3
3
  import { operations, PrimitiveApiClient } from "../api/index.js";
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
+ };
4
14
  function flagName(parameterName) {
5
15
  return parameterName.replace(/_/g, "-");
6
16
  }
@@ -288,7 +298,7 @@ export function extractErrorCode(payload) {
288
298
  // `--api-key` flag; this closes that gap without having to
289
299
  // special-case every command.
290
300
  const ERROR_CODE_HINTS = {
291
- unauthorized: "Hint: pass --api-key explicitly, or set PRIMITIVE_API_KEY in your environment. `primitive whoami` is the fastest way to verify a key is live.",
301
+ [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.",
292
302
  };
293
303
  // Write a server / SDK error to stderr in the canonical envelope
294
304
  // shape, plus an actionable hint when the code is one we know how
@@ -298,10 +308,66 @@ const ERROR_CODE_HINTS = {
298
308
  export function writeErrorWithHints(payload) {
299
309
  process.stderr.write(`${formatErrorPayload(payload)}\n`);
300
310
  const code = extractErrorCode(payload);
301
- if (code && ERROR_CODE_HINTS[code]) {
302
- process.stderr.write(`${ERROR_CODE_HINTS[code]}\n`);
311
+ if (code && code in ERROR_CODE_HINTS) {
312
+ const hint = ERROR_CODE_HINTS[code];
313
+ process.stderr.write(`${hint}\n`);
314
+ }
315
+ }
316
+ export function removeStaleSavedCredentialOnUnauthorized(params) {
317
+ if (extractErrorCode(params.payload) !== API_ERROR_CODES.unauthorized ||
318
+ params.auth.source !== "stored") {
319
+ return false;
320
+ }
321
+ const baseUrlDiffersFromSaved = params.baseUrlOverridden &&
322
+ params.auth.credentials !== null &&
323
+ params.auth.baseUrl !== params.auth.credentials.base_url;
324
+ if (baseUrlDiffersFromSaved) {
325
+ process.stderr.write("Saved Primitive CLI credentials were rejected by the overridden API base URL. The local credential was not removed; check --base-url / PRIMITIVE_API_URL, or run `primitive logout` to remove it.\n");
326
+ return false;
303
327
  }
328
+ deleteCliCredentials(params.configDir);
329
+ 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");
330
+ return true;
304
331
  }
332
+ // Format milliseconds as a short human-readable wall-clock duration.
333
+ // Sub-second uses 2 decimal places (e.g. `0.18s`); seconds use 2
334
+ // decimals up to 60s (`12.34s`); minute-plus uses `Mm SS.SSs`.
335
+ // Display-only; the underlying ms value is what the caller computed.
336
+ export function formatElapsed(ms) {
337
+ const seconds = ms / 1000;
338
+ if (seconds < 60)
339
+ return `${seconds.toFixed(2)}s`;
340
+ const minutes = Math.floor(seconds / 60);
341
+ const rem = seconds - minutes * 60;
342
+ return `${minutes}m ${rem.toFixed(2)}s`;
343
+ }
344
+ // Run `fn` and, when `enabled` is true, write a one-line wall-clock
345
+ // timing report to stderr after it completes. Stderr keeps the row
346
+ // data on stdout grep/jq-friendly. The timer captures the full
347
+ // duration of the function (HTTPS round trip, server-side gate +
348
+ // agent + delivery, polling, etc.), not just the API call's
349
+ // server-side processing.
350
+ //
351
+ // Used by every `--time` callsite across the CLI: generated
352
+ // operation commands and hand-coded shortcuts (send, whoami,
353
+ // emails:latest, describe). Pulled out as a helper so timing is
354
+ // uniform across commands and a single render-format change
355
+ // propagates everywhere.
356
+ export async function runWithTiming(enabled, fn) {
357
+ if (!enabled)
358
+ return fn();
359
+ const start = Date.now();
360
+ try {
361
+ return await fn();
362
+ }
363
+ finally {
364
+ process.stderr.write(`[time: ${formatElapsed(Date.now() - start)}]\n`);
365
+ }
366
+ }
367
+ // Shared `--time` flag definition every CLI command spreads into its
368
+ // own static flags. Lives here so the flag's description and short
369
+ // name stay consistent across the hand-coded and generated commands.
370
+ 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.";
305
371
  // Reserved flag names the body-field expander must never overwrite.
306
372
  // `--raw-body` and `--body-file` are the JSON escape hatches.
307
373
  // `--api-key`, `--base-url`, `--output` are infra. Path and query
@@ -356,13 +422,16 @@ function bodyFieldFlag(field) {
356
422
  function buildFlags(operation) {
357
423
  const flags = {
358
424
  "api-key": Flags.string({
359
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
425
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
360
426
  env: "PRIMITIVE_API_KEY",
361
427
  }),
362
428
  "base-url": Flags.string({
363
429
  description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
364
430
  env: "PRIMITIVE_API_URL",
365
431
  }),
432
+ time: Flags.boolean({
433
+ description: TIME_FLAG_DESCRIPTION,
434
+ }),
366
435
  };
367
436
  for (const parameter of [...operation.pathParams, ...operation.queryParams]) {
368
437
  flags[flagName(parameter.name)] = flagForParameter(parameter);
@@ -460,101 +529,116 @@ export function createOperationCommand(operation) {
460
529
  async run() {
461
530
  const { flags } = await this.parse(OperationCommand);
462
531
  const parsedFlags = flags;
463
- const apiClient = new PrimitiveApiClient({
464
- apiKey: typeof parsedFlags["api-key"] === "string"
465
- ? parsedFlags["api-key"]
466
- : undefined,
467
- baseUrl: typeof parsedFlags["base-url"] === "string"
468
- ? parsedFlags["base-url"]
469
- : undefined,
470
- });
471
- // Two body sources, merged: explicit JSON via --body /
472
- // --body-file (the base) plus per-field flags (the
473
- // overrides). Per-field flag values take precedence on key
474
- // conflicts so a caller can pass a base payload via --body
475
- // and override one field on the command line.
476
- let body;
477
- if (operation.hasJsonBody) {
478
- const explicit = readJsonBody(parsedFlags);
479
- const overrides = collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty);
480
- if (Object.keys(overrides).length > 0) {
481
- if (explicit === undefined) {
482
- body = overrides;
483
- }
484
- else if (explicit !== null &&
485
- typeof explicit === "object" &&
486
- !Array.isArray(explicit)) {
487
- body = { ...explicit, ...overrides };
532
+ await runWithTiming(parsedFlags.time === true, async () => {
533
+ const baseUrlOverridden = typeof parsedFlags["base-url"] === "string";
534
+ const auth = resolveCliAuth({
535
+ apiKey: typeof parsedFlags["api-key"] === "string"
536
+ ? parsedFlags["api-key"]
537
+ : undefined,
538
+ baseUrl: typeof parsedFlags["base-url"] === "string"
539
+ ? parsedFlags["base-url"]
540
+ : undefined,
541
+ configDir: this.config.configDir,
542
+ });
543
+ const apiClient = new PrimitiveApiClient({
544
+ apiKey: auth.apiKey,
545
+ baseUrl: auth.baseUrl,
546
+ });
547
+ // Two body sources, merged: explicit JSON via --body /
548
+ // --body-file (the base) plus per-field flags (the
549
+ // overrides). Per-field flag values take precedence on key
550
+ // conflicts so a caller can pass a base payload via --body
551
+ // and override one field on the command line.
552
+ let body;
553
+ if (operation.hasJsonBody) {
554
+ const explicit = readJsonBody(parsedFlags);
555
+ const overrides = collectBodyFieldFlags(parsedFlags, bodyFieldFlagToProperty);
556
+ if (Object.keys(overrides).length > 0) {
557
+ if (explicit === undefined) {
558
+ body = overrides;
559
+ }
560
+ else if (explicit !== null &&
561
+ typeof explicit === "object" &&
562
+ !Array.isArray(explicit)) {
563
+ body = { ...explicit, ...overrides };
564
+ }
565
+ else {
566
+ // Caller passed --raw-body as null, an array, or a
567
+ // primitive AND also passed per-field flags. We can't
568
+ // merge per-field overrides into a non-object body
569
+ // shape, and silently dropping either source would
570
+ // leave the caller's actual intent unclear. Refuse
571
+ // loudly so the next attempt is unambiguous.
572
+ const explicitKind = explicit === null
573
+ ? "null"
574
+ : Array.isArray(explicit)
575
+ ? "array"
576
+ : typeof explicit;
577
+ const overrideFlags = Object.keys(overrides)
578
+ .map((p) => `--${flagName(p)}`)
579
+ .join(", ");
580
+ 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.`);
581
+ }
488
582
  }
489
583
  else {
490
- // Caller passed --raw-body as null, an array, or a
491
- // primitive AND also passed per-field flags. We can't
492
- // merge per-field overrides into a non-object body
493
- // shape, and silently dropping either source would
494
- // leave the caller's actual intent unclear. Refuse
495
- // loudly so the next attempt is unambiguous.
496
- const explicitKind = explicit === null
497
- ? "null"
498
- : Array.isArray(explicit)
499
- ? "array"
500
- : typeof explicit;
501
- const overrideFlags = Object.keys(overrides)
502
- .map((p) => `--${flagName(p)}`)
503
- .join(", ");
504
- 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.`);
584
+ body = explicit;
505
585
  }
506
586
  }
507
- else {
508
- body = explicit;
587
+ if (operation.bodyRequired && body === undefined) {
588
+ 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.`);
509
589
  }
510
- }
511
- if (operation.bodyRequired && body === undefined) {
512
- 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.`);
513
- }
514
- const operationFn = operations[operation.sdkName];
515
- const result = await operationFn({
516
- body,
517
- client: apiClient.client,
518
- parseAs: operation.binaryResponse ? "blob" : "auto",
519
- path: collectValues(operation.pathParams, parsedFlags),
520
- query: collectValues(operation.queryParams, parsedFlags),
521
- responseStyle: "fields",
522
- });
523
- if (result.error) {
524
- writeErrorWithHints(extractErrorPayload(result.error));
525
- process.exitCode = 1;
526
- return;
527
- }
528
- if (operation.binaryResponse) {
529
- const blob = result.data;
530
- const bytes = Buffer.from(await blob.arrayBuffer());
531
- const output = parsedFlags.output;
532
- if (typeof output === "string") {
533
- writeFileSync(output, bytes);
590
+ const operationFn = operations[operation.sdkName];
591
+ const result = await operationFn({
592
+ body,
593
+ client: apiClient.client,
594
+ parseAs: operation.binaryResponse ? "blob" : "auto",
595
+ path: collectValues(operation.pathParams, parsedFlags),
596
+ query: collectValues(operation.queryParams, parsedFlags),
597
+ responseStyle: "fields",
598
+ });
599
+ if (result.error) {
600
+ const errorPayload = extractErrorPayload(result.error);
601
+ writeErrorWithHints(errorPayload);
602
+ removeStaleSavedCredentialOnUnauthorized({
603
+ auth,
604
+ baseUrlOverridden,
605
+ configDir: this.config.configDir,
606
+ payload: errorPayload,
607
+ });
608
+ process.exitCode = 1;
534
609
  return;
535
610
  }
536
- process.stdout.write(bytes);
537
- return;
538
- }
539
- const envelope = result.data;
540
- const cursor = envelope?.meta?.cursor;
541
- if (cursor) {
542
- process.stderr.write(`next cursor: ${cursor}\n`);
543
- }
544
- // Empty-result hint. When a list-style operation returns
545
- // an empty array, emit an operation-specific note to
546
- // stderr so a naive caller can distinguish "nothing here"
547
- // from "something isn't set up." Stdout still gets the
548
- // raw `[]` so machine-readable output is unchanged. The
549
- // AGX walkthrough flagged this: `list-deliveries` returning
550
- // `[]` left the agent unsure whether they had an empty
551
- // delivery log or no endpoints configured at all.
552
- if (Array.isArray(envelope?.data) && envelope.data.length === 0) {
553
- const hint = EMPTY_RESULT_HINTS[operation.sdkName];
554
- if (hint)
555
- process.stderr.write(`${hint}\n`);
556
- }
557
- this.log(JSON.stringify(envelope?.data ?? null, null, 2));
611
+ if (operation.binaryResponse) {
612
+ const blob = result.data;
613
+ const bytes = Buffer.from(await blob.arrayBuffer());
614
+ const output = parsedFlags.output;
615
+ if (typeof output === "string") {
616
+ writeFileSync(output, bytes);
617
+ return;
618
+ }
619
+ process.stdout.write(bytes);
620
+ return;
621
+ }
622
+ const envelope = result.data;
623
+ const cursor = envelope?.meta?.cursor;
624
+ if (cursor) {
625
+ process.stderr.write(`next cursor: ${cursor}\n`);
626
+ }
627
+ // Empty-result hint. When a list-style operation returns
628
+ // an empty array, emit an operation-specific note to
629
+ // stderr so a naive caller can distinguish "nothing here"
630
+ // from "something isn't set up." Stdout still gets the
631
+ // raw `[]` so machine-readable output is unchanged. The
632
+ // AGX walkthrough flagged this: `list-deliveries` returning
633
+ // `[]` left the agent unsure whether they had an empty
634
+ // delivery log or no endpoints configured at all.
635
+ if (Array.isArray(envelope?.data) && envelope.data.length === 0) {
636
+ const hint = EMPTY_RESULT_HINTS[operation.sdkName];
637
+ if (hint)
638
+ process.stderr.write(`${hint}\n`);
639
+ }
640
+ this.log(JSON.stringify(envelope?.data ?? null, null, 2));
641
+ });
558
642
  }
559
643
  }
560
644
  return OperationCommand;
@@ -0,0 +1,168 @@
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_BASE_URL } from "../api/index.js";
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
+ function parseCredentials(raw) {
20
+ if (!isRecord(raw)) {
21
+ throw new Error(`Stored Primitive CLI credentials are malformed: expected a JSON object. ${MALFORMED_CREDENTIALS_HINT}`);
22
+ }
23
+ const orgName = raw.org_name;
24
+ if (orgName !== null && typeof orgName !== "string") {
25
+ throw new Error(`Stored Primitive CLI credentials are malformed: org_name must be a string or null. ${MALFORMED_CREDENTIALS_HINT}`);
26
+ }
27
+ return {
28
+ api_key: requireString(raw, "api_key"),
29
+ key_id: requireString(raw, "key_id"),
30
+ key_prefix: requireString(raw, "key_prefix"),
31
+ org_id: requireString(raw, "org_id"),
32
+ org_name: orgName,
33
+ base_url: requireString(raw, "base_url"),
34
+ created_at: requireString(raw, "created_at"),
35
+ };
36
+ }
37
+ export function credentialsPath(configDir) {
38
+ return join(configDir, CREDENTIALS_FILE);
39
+ }
40
+ export function normalizeBaseUrl(baseUrl) {
41
+ const trimmed = baseUrl?.trim();
42
+ if (!trimmed)
43
+ return DEFAULT_BASE_URL;
44
+ return trimmed.replace(/\/+$/, "");
45
+ }
46
+ export function loadCliCredentials(configDir) {
47
+ const path = credentialsPath(configDir);
48
+ let contents;
49
+ try {
50
+ contents = readFileSync(path, "utf8");
51
+ }
52
+ catch (error) {
53
+ if (error &&
54
+ typeof error === "object" &&
55
+ error.code === "ENOENT") {
56
+ return null;
57
+ }
58
+ const detail = error instanceof Error ? error.message : String(error);
59
+ throw new Error(`Could not read Primitive CLI credentials: ${detail}`);
60
+ }
61
+ try {
62
+ return parseCredentials(JSON.parse(contents));
63
+ }
64
+ catch (error) {
65
+ if (error instanceof SyntaxError) {
66
+ throw new Error("Stored Primitive CLI credentials are not valid JSON. Run `primitive logout` and then `primitive login`.");
67
+ }
68
+ throw error;
69
+ }
70
+ }
71
+ export function saveCliCredentials(configDir, credentials) {
72
+ mkdirSync(configDir, { mode: 0o700, recursive: true });
73
+ const path = credentialsPath(configDir);
74
+ const tempPath = join(configDir, `${CREDENTIALS_FILE}.${process.pid}.${randomUUID()}.tmp`);
75
+ try {
76
+ writeFileSync(tempPath, `${JSON.stringify(credentials, null, 2)}\n`, {
77
+ mode: 0o600,
78
+ });
79
+ chmodSync(tempPath, 0o600);
80
+ renameSync(tempPath, path);
81
+ chmodSync(path, 0o600);
82
+ }
83
+ catch (error) {
84
+ rmSync(tempPath, { force: true });
85
+ throw error;
86
+ }
87
+ }
88
+ export function deleteCliCredentials(configDir) {
89
+ rmSync(credentialsPath(configDir), { force: true });
90
+ }
91
+ function errorCode(error) {
92
+ return error && typeof error === "object"
93
+ ? error.code
94
+ : undefined;
95
+ }
96
+ function removeStaleCliCredentialsLock(lockPath, staleMs, now) {
97
+ try {
98
+ const stats = statSync(lockPath);
99
+ if (now() - stats.mtimeMs < staleMs)
100
+ return false;
101
+ }
102
+ catch (error) {
103
+ if (errorCode(error) === "ENOENT")
104
+ return true;
105
+ throw error;
106
+ }
107
+ rmSync(lockPath, { force: true, recursive: true });
108
+ return true;
109
+ }
110
+ export function acquireCliCredentialsLock(configDir, options = {}) {
111
+ mkdirSync(configDir, { mode: 0o700, recursive: true });
112
+ const lockPath = join(configDir, CREDENTIALS_LOCK_DIR);
113
+ const now = options.now ?? Date.now;
114
+ const staleMs = options.staleMs ?? CREDENTIALS_LOCK_STALE_MS;
115
+ let acquired = false;
116
+ for (let attempt = 0; attempt < 2; attempt += 1) {
117
+ try {
118
+ mkdirSync(lockPath, { mode: 0o700 });
119
+ acquired = true;
120
+ break;
121
+ }
122
+ catch (error) {
123
+ if (errorCode(error) !== "EEXIST")
124
+ throw error;
125
+ if (removeStaleCliCredentialsLock(lockPath, staleMs, now))
126
+ continue;
127
+ throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
128
+ }
129
+ }
130
+ if (!acquired) {
131
+ throw new Error("Another Primitive CLI credential operation is already in progress. Wait for it to finish, then retry.");
132
+ }
133
+ let released = false;
134
+ return () => {
135
+ if (released)
136
+ return;
137
+ released = true;
138
+ rmSync(lockPath, { force: true, recursive: true });
139
+ };
140
+ }
141
+ export function resolveCliAuth(params) {
142
+ const apiKey = params.apiKey?.trim();
143
+ if (apiKey) {
144
+ return {
145
+ apiKey,
146
+ baseUrl: normalizeBaseUrl(params.baseUrl),
147
+ credentials: null,
148
+ source: "flag-or-env",
149
+ };
150
+ }
151
+ const credentials = loadCliCredentials(params.configDir);
152
+ if (credentials) {
153
+ return {
154
+ apiKey: credentials.api_key,
155
+ baseUrl: params.baseUrl
156
+ ? normalizeBaseUrl(params.baseUrl)
157
+ : credentials.base_url,
158
+ credentials,
159
+ source: "stored",
160
+ };
161
+ }
162
+ return {
163
+ apiKey: undefined,
164
+ baseUrl: normalizeBaseUrl(params.baseUrl),
165
+ credentials: null,
166
+ source: "none",
167
+ };
168
+ }
@@ -1,7 +1,8 @@
1
1
  import { Command, Flags } from "@oclif/core";
2
2
  import { listEmails } from "../../api/generated/sdk.gen.js";
3
3
  import { PrimitiveApiClient } from "../../api/index.js";
4
- import { extractErrorPayload, writeErrorWithHints } from "../api-command.js";
4
+ import { extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
5
+ import { resolveCliAuth } from "../auth.js";
5
6
  // `primitive emails:latest` is the agent-grade shortcut for "show me
6
7
  // the most recent inbound emails as something I can read at a glance."
7
8
  // `emails:list-emails` returns the full JSON envelope which is great
@@ -94,7 +95,7 @@ class EmailsLatestCommand extends Command {
94
95
  ];
95
96
  static flags = {
96
97
  "api-key": Flags.string({
97
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
98
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
98
99
  env: "PRIMITIVE_API_KEY",
99
100
  }),
100
101
  "base-url": Flags.string({
@@ -113,43 +114,61 @@ class EmailsLatestCommand extends Command {
113
114
  json: Flags.boolean({
114
115
  description: "Print the raw response envelope (with full UUIDs and meta) as JSON on STDOUT instead of the text table. Useful for piping into `jq`, capturing ids for follow-up commands, or scripting.",
115
116
  }),
117
+ time: Flags.boolean({
118
+ description: TIME_FLAG_DESCRIPTION,
119
+ }),
116
120
  };
117
121
  async run() {
118
122
  const { flags } = await this.parse(EmailsLatestCommand);
119
- const apiClient = new PrimitiveApiClient({
120
- apiKey: flags["api-key"],
121
- baseUrl: flags["base-url"],
122
- });
123
- const result = await listEmails({
124
- client: apiClient.client,
125
- query: { limit: flags.limit },
126
- responseStyle: "fields",
123
+ await runWithTiming(flags.time, async () => {
124
+ const baseUrlOverridden = flags["base-url"] !== undefined;
125
+ const auth = resolveCliAuth({
126
+ apiKey: flags["api-key"],
127
+ baseUrl: flags["base-url"],
128
+ configDir: this.config.configDir,
129
+ });
130
+ const apiClient = new PrimitiveApiClient({
131
+ apiKey: auth.apiKey,
132
+ baseUrl: auth.baseUrl,
133
+ });
134
+ const result = await listEmails({
135
+ client: apiClient.client,
136
+ query: { limit: flags.limit },
137
+ responseStyle: "fields",
138
+ });
139
+ if (result.error) {
140
+ const errorPayload = extractErrorPayload(result.error);
141
+ writeErrorWithHints(errorPayload);
142
+ removeStaleSavedCredentialOnUnauthorized({
143
+ auth,
144
+ baseUrlOverridden,
145
+ configDir: this.config.configDir,
146
+ payload: errorPayload,
147
+ });
148
+ process.exitCode = 1;
149
+ return;
150
+ }
151
+ const envelope = result.data;
152
+ if (flags.json) {
153
+ // Raw envelope on stdout. Mirrors the shape `emails:list-emails`
154
+ // emits so callers can swap one for the other when they want
155
+ // table vs json without remembering different command names.
156
+ this.log(JSON.stringify(envelope ?? null, null, 2));
157
+ return;
158
+ }
159
+ const rows = envelope?.data ?? [];
160
+ if (rows.length === 0) {
161
+ process.stderr.write("No inbound emails yet. Send an email to one of your verified domains to populate this list.\n");
162
+ return;
163
+ }
164
+ const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
165
+ // Header on stderr so the table itself stays grep-friendly.
166
+ const header = `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
167
+ process.stderr.write(`${header}\n`);
168
+ for (const row of rows) {
169
+ this.log(formatRow(row, idWidth));
170
+ }
127
171
  });
128
- if (result.error) {
129
- writeErrorWithHints(extractErrorPayload(result.error));
130
- process.exitCode = 1;
131
- return;
132
- }
133
- const envelope = result.data;
134
- if (flags.json) {
135
- // Raw envelope on stdout. Mirrors the shape `emails:list-emails`
136
- // emits so callers can swap one for the other when they want
137
- // table vs json without remembering different command names.
138
- this.log(JSON.stringify(envelope ?? null, null, 2));
139
- return;
140
- }
141
- const rows = envelope?.data ?? [];
142
- if (rows.length === 0) {
143
- process.stderr.write("No inbound emails yet. Send an email to one of your verified domains to populate this list.\n");
144
- return;
145
- }
146
- const idWidth = pickIdWidth(Boolean(process.stdout.isTTY));
147
- // Header on stderr so the table itself stays grep-friendly.
148
- const header = `${"ID".padEnd(idWidth)} ${"RECEIVED (UTC)".padEnd(RECEIVED_DISPLAY_WIDTH)} ${"FROM".padEnd(ADDRESS_DISPLAY_WIDTH)} ${"TO".padEnd(ADDRESS_DISPLAY_WIDTH)} SUBJECT`;
149
- process.stderr.write(`${header}\n`);
150
- for (const row of rows) {
151
- this.log(formatRow(row, idWidth));
152
- }
153
172
  }
154
173
  }
155
174
  export default EmailsLatestCommand;