@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.
- package/README.md +165 -65
- package/dist/api/generated/index.js +1 -1
- package/dist/api/generated/sdk.gen.js +49 -1
- package/dist/api/index.d.ts +2 -2
- package/dist/api/index.js +39 -7
- package/dist/{api-DrAZhxS-.js → api-C5VR_Opg.js} +81 -7
- package/dist/contract/index.d.ts +2 -2
- package/dist/contract/index.js +1 -1
- package/dist/{index-CbEivn3S.d.ts → index-CDlwyxdp.d.ts} +7 -7
- package/dist/{index-CHWqMBs6.d.ts → index-oRkCqj6u.d.ts} +195 -13
- package/dist/index.d.ts +2 -2
- package/dist/index.js +2 -2
- package/dist/oclif/api-command.js +176 -92
- package/dist/oclif/auth.js +168 -0
- package/dist/oclif/commands/emails-latest.js +54 -35
- package/dist/oclif/commands/login.js +233 -0
- package/dist/oclif/commands/logout.js +87 -0
- package/dist/oclif/commands/send.js +61 -34
- package/dist/oclif/commands/whoami.js +51 -32
- package/dist/oclif/fish-completion.js +1 -1
- package/dist/oclif/index.js +6 -0
- package/dist/openapi/openapi.generated.js +385 -2
- package/dist/openapi/operations.generated.js +178 -1
- package/dist/webhook/index.d.ts +1 -1
- package/dist/webhook/index.js +1 -1
- package/dist/{webhook-zkN4wUTs.js → webhook-rUjGV6Zu.js} +4 -4
- package/oclif.manifest.json +507 -38
- package/package.json +5 -2
|
@@ -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
|
|
302
|
-
|
|
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
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
:
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
:
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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
|
-
|
|
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
|
-
|
|
508
|
-
body
|
|
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
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
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
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
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
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
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;
|