@primitivedotdev/sdk 0.18.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,9 +308,26 @@ 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
  }
305
332
  // Format milliseconds as a short human-readable wall-clock duration.
306
333
  // Sub-second uses 2 decimal places (e.g. `0.18s`); seconds use 2
@@ -395,7 +422,7 @@ function bodyFieldFlag(field) {
395
422
  function buildFlags(operation) {
396
423
  const flags = {
397
424
  "api-key": Flags.string({
398
- description: "Primitive API key (defaults to PRIMITIVE_API_KEY)",
425
+ description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
399
426
  env: "PRIMITIVE_API_KEY",
400
427
  }),
401
428
  "base-url": Flags.string({
@@ -503,13 +530,19 @@ export function createOperationCommand(operation) {
503
530
  const { flags } = await this.parse(OperationCommand);
504
531
  const parsedFlags = flags;
505
532
  await runWithTiming(parsedFlags.time === true, async () => {
506
- const apiClient = new PrimitiveApiClient({
533
+ const baseUrlOverridden = typeof parsedFlags["base-url"] === "string";
534
+ const auth = resolveCliAuth({
507
535
  apiKey: typeof parsedFlags["api-key"] === "string"
508
536
  ? parsedFlags["api-key"]
509
537
  : undefined,
510
538
  baseUrl: typeof parsedFlags["base-url"] === "string"
511
539
  ? parsedFlags["base-url"]
512
540
  : undefined,
541
+ configDir: this.config.configDir,
542
+ });
543
+ const apiClient = new PrimitiveApiClient({
544
+ apiKey: auth.apiKey,
545
+ baseUrl: auth.baseUrl,
513
546
  });
514
547
  // Two body sources, merged: explicit JSON via --body /
515
548
  // --body-file (the base) plus per-field flags (the
@@ -564,7 +597,14 @@ export function createOperationCommand(operation) {
564
597
  responseStyle: "fields",
565
598
  });
566
599
  if (result.error) {
567
- writeErrorWithHints(extractErrorPayload(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
+ });
568
608
  process.exitCode = 1;
569
609
  return;
570
610
  }
@@ -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, runWithTiming, TIME_FLAG_DESCRIPTION, 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({
@@ -120,9 +121,15 @@ class EmailsLatestCommand extends Command {
120
121
  async run() {
121
122
  const { flags } = await this.parse(EmailsLatestCommand);
122
123
  await runWithTiming(flags.time, async () => {
123
- const apiClient = new PrimitiveApiClient({
124
+ const baseUrlOverridden = flags["base-url"] !== undefined;
125
+ const auth = resolveCliAuth({
124
126
  apiKey: flags["api-key"],
125
127
  baseUrl: flags["base-url"],
128
+ configDir: this.config.configDir,
129
+ });
130
+ const apiClient = new PrimitiveApiClient({
131
+ apiKey: auth.apiKey,
132
+ baseUrl: auth.baseUrl,
126
133
  });
127
134
  const result = await listEmails({
128
135
  client: apiClient.client,
@@ -130,7 +137,14 @@ class EmailsLatestCommand extends Command {
130
137
  responseStyle: "fields",
131
138
  });
132
139
  if (result.error) {
133
- writeErrorWithHints(extractErrorPayload(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
+ });
134
148
  process.exitCode = 1;
135
149
  return;
136
150
  }
@@ -0,0 +1,233 @@
1
+ import { spawn } from "node:child_process";
2
+ import { hostname } from "node:os";
3
+ import { Command, Errors, Flags } from "@oclif/core";
4
+ import { getAccount, pollCliLogin, startCliLogin, } from "../../api/generated/sdk.gen.js";
5
+ import { PrimitiveApiClient } from "../../api/index.js";
6
+ import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
7
+ import { acquireCliCredentialsLock, credentialsPath, loadCliCredentials, normalizeBaseUrl, saveCliCredentials, } from "../auth.js";
8
+ const MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS = 60;
9
+ function cliError(message) {
10
+ return new Errors.CLIError(message, { exit: 1 });
11
+ }
12
+ function sleep(ms) {
13
+ return new Promise((resolve) => setTimeout(resolve, ms));
14
+ }
15
+ function openBrowser(url) {
16
+ const command = process.platform === "darwin"
17
+ ? "open"
18
+ : process.platform === "win32"
19
+ ? "cmd"
20
+ : "xdg-open";
21
+ const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
22
+ const child = spawn(command, args, { detached: true, stdio: "ignore" });
23
+ child.on("error", () => undefined);
24
+ child.unref();
25
+ }
26
+ function unwrapData(value) {
27
+ const envelope = value;
28
+ return envelope?.data ?? null;
29
+ }
30
+ function retryAfterSeconds(result) {
31
+ const response = result.response;
32
+ const raw = response?.headers.get("retry-after");
33
+ if (!raw)
34
+ return null;
35
+ const parsed = Number.parseInt(raw, 10);
36
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
37
+ }
38
+ export async function checkExistingLogin(params) {
39
+ const baseUrlOverridden = params.baseUrl !== undefined;
40
+ const probeBaseUrl = baseUrlOverridden
41
+ ? normalizeBaseUrl(params.baseUrl)
42
+ : params.credentials.base_url;
43
+ const apiClient = new PrimitiveApiClient({
44
+ apiKey: params.credentials.api_key,
45
+ baseUrl: probeBaseUrl,
46
+ });
47
+ const result = await (params.checkAccount ??
48
+ ((client) => getAccount({
49
+ client: client.client,
50
+ responseStyle: "fields",
51
+ })))(apiClient);
52
+ if (!result.error)
53
+ return { status: "valid" };
54
+ const payload = extractErrorPayload(result.error);
55
+ const auth = {
56
+ apiKey: params.credentials.api_key,
57
+ baseUrl: probeBaseUrl,
58
+ credentials: params.credentials,
59
+ source: "stored",
60
+ };
61
+ const removed = removeStaleSavedCredentialOnUnauthorized({
62
+ auth,
63
+ baseUrlOverridden,
64
+ configDir: params.configDir,
65
+ payload,
66
+ });
67
+ if (removed)
68
+ return { status: "removed_stale" };
69
+ const code = extractErrorCode(payload);
70
+ return {
71
+ status: "blocked",
72
+ payload,
73
+ message: code === API_ERROR_CODES.unauthorized
74
+ ? "Saved Primitive CLI credentials were rejected. Run `primitive logout` to remove them before logging in again."
75
+ : "A saved Primitive CLI login exists, but the CLI could not verify whether it is still valid. Run `primitive logout` before logging in again.",
76
+ };
77
+ }
78
+ class LoginCommand extends Command {
79
+ static description = "Log in by opening Primitive in your browser and saving an org-scoped CLI API key locally.";
80
+ static summary = "Log in with browser approval";
81
+ static examples = [
82
+ "<%= config.bin %> login",
83
+ "<%= config.bin %> login --device-name work-laptop",
84
+ "<%= config.bin %> login --force",
85
+ ];
86
+ static flags = {
87
+ "base-url": Flags.string({
88
+ description: "API base URL (defaults to PRIMITIVE_API_URL or production)",
89
+ env: "PRIMITIVE_API_URL",
90
+ }),
91
+ "device-name": Flags.string({
92
+ description: "Device name shown in the browser approval screen",
93
+ }),
94
+ "no-browser": Flags.boolean({
95
+ description: "Do not attempt to open the browser automatically",
96
+ }),
97
+ force: Flags.boolean({
98
+ char: "f",
99
+ description: "Replace saved credentials without first verifying the existing login",
100
+ }),
101
+ };
102
+ async run() {
103
+ const { flags } = await this.parse(LoginCommand);
104
+ let releaseCredentialsLock;
105
+ try {
106
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
107
+ }
108
+ catch (error) {
109
+ const detail = error instanceof Error ? error.message : String(error);
110
+ throw cliError(detail);
111
+ }
112
+ try {
113
+ await this.runWithCredentialLock(flags);
114
+ }
115
+ finally {
116
+ releaseCredentialsLock();
117
+ }
118
+ }
119
+ async runWithCredentialLock(flags) {
120
+ const baseUrl = normalizeBaseUrl(flags["base-url"]);
121
+ let existing;
122
+ try {
123
+ existing = loadCliCredentials(this.config.configDir);
124
+ }
125
+ catch (error) {
126
+ if (!flags.force)
127
+ throw error;
128
+ const detail = error instanceof Error ? error.message : String(error);
129
+ process.stderr.write(`Replacing unreadable Primitive CLI credentials because --force was set: ${detail}\n`);
130
+ existing = null;
131
+ }
132
+ if (existing && flags.force) {
133
+ process.stderr.write("Replacing saved Primitive CLI credentials after browser approval because --force was set.\n");
134
+ }
135
+ else if (existing) {
136
+ const existingStatus = await checkExistingLogin({
137
+ baseUrl: flags["base-url"],
138
+ configDir: this.config.configDir,
139
+ credentials: existing,
140
+ });
141
+ if (existingStatus.status === "removed_stale") {
142
+ process.stderr.write("Continuing with a new Primitive CLI login...\n");
143
+ }
144
+ else if (existingStatus.status === "blocked") {
145
+ writeErrorWithHints(existingStatus.payload);
146
+ throw cliError(existingStatus.message);
147
+ }
148
+ else {
149
+ const org = existing.org_name ? ` for ${existing.org_name}` : "";
150
+ throw cliError(`Already logged in${org}. Run \`primitive logout\` before logging in again.`);
151
+ }
152
+ }
153
+ const apiClient = new PrimitiveApiClient({ baseUrl });
154
+ const deviceName = flags["device-name"] ?? hostname();
155
+ const started = await startCliLogin({
156
+ body: {
157
+ device_name: deviceName,
158
+ },
159
+ client: apiClient.client,
160
+ responseStyle: "fields",
161
+ });
162
+ if (started.error) {
163
+ writeErrorWithHints(extractErrorPayload(started.error));
164
+ throw cliError("Could not start Primitive CLI login.");
165
+ }
166
+ const start = unwrapData(started.data);
167
+ if (!start) {
168
+ throw cliError("Primitive API returned an empty CLI login response.");
169
+ }
170
+ process.stderr.write(`Your login code is: ${start.user_code}\n`);
171
+ if (!flags["no-browser"]) {
172
+ openBrowser(start.verification_uri_complete);
173
+ process.stderr.write("Opening Primitive in your browser...\n");
174
+ }
175
+ process.stderr.write(`If the browser did not open, visit: ${start.verification_uri_complete}\n`);
176
+ process.stderr.write("Waiting for browser approval...\n");
177
+ const deadline = Date.now() + start.expires_in * 1000;
178
+ let interval = Math.min(Math.max(1, start.interval), MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
179
+ let nextPollDelay = 1;
180
+ while (Date.now() < deadline) {
181
+ await sleep(nextPollDelay * 1000);
182
+ nextPollDelay = interval;
183
+ const polled = await pollCliLogin({
184
+ body: { device_code: start.device_code },
185
+ client: apiClient.client,
186
+ responseStyle: "fields",
187
+ });
188
+ if (polled.data) {
189
+ const login = unwrapData(polled.data);
190
+ if (!login) {
191
+ throw cliError("Primitive API returned an empty CLI poll response.");
192
+ }
193
+ saveCliCredentials(this.config.configDir, {
194
+ api_key: login.api_key,
195
+ base_url: baseUrl,
196
+ created_at: new Date().toISOString(),
197
+ key_id: login.key_id,
198
+ key_prefix: login.key_prefix,
199
+ org_id: login.org_id,
200
+ org_name: login.org_name,
201
+ });
202
+ const org = login.org_name ? ` (${login.org_name})` : "";
203
+ process.stderr.write(`Logged in to org ${login.org_id}${org}.\n`);
204
+ process.stderr.write(`Saved credentials to ${credentialsPath(this.config.configDir)}.\n`);
205
+ return;
206
+ }
207
+ const payload = extractErrorPayload(polled.error);
208
+ const code = extractErrorCode(payload);
209
+ if (code === API_ERROR_CODES.authorizationPending) {
210
+ nextPollDelay = interval;
211
+ continue;
212
+ }
213
+ if (code === API_ERROR_CODES.slowDown) {
214
+ interval = Math.min(retryAfterSeconds(polled) ?? interval + 5, MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
215
+ nextPollDelay = interval;
216
+ continue;
217
+ }
218
+ if (code === API_ERROR_CODES.accessDenied) {
219
+ throw cliError("Primitive CLI login was denied in the browser.");
220
+ }
221
+ if (code === API_ERROR_CODES.expiredToken) {
222
+ throw cliError("Primitive CLI login expired. Run `primitive login` again.");
223
+ }
224
+ if (code === API_ERROR_CODES.invalidDeviceCode) {
225
+ throw cliError("Primitive CLI login device code is invalid. Run `primitive login` again.");
226
+ }
227
+ writeErrorWithHints(payload);
228
+ throw cliError("Primitive CLI login failed while polling for approval.");
229
+ }
230
+ throw cliError("Primitive CLI login expired. Run `primitive login` again.");
231
+ }
232
+ }
233
+ export default LoginCommand;
@@ -0,0 +1,87 @@
1
+ import { Command, Errors, Flags } from "@oclif/core";
2
+ import { cliLogout } from "../../api/generated/sdk.gen.js";
3
+ import { PrimitiveApiClient } from "../../api/index.js";
4
+ import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, writeErrorWithHints, } from "../api-command.js";
5
+ import { acquireCliCredentialsLock, deleteCliCredentials, loadCliCredentials, normalizeBaseUrl, } from "../auth.js";
6
+ function cliError(message) {
7
+ return new Errors.CLIError(message, { exit: 1 });
8
+ }
9
+ function unwrapData(value) {
10
+ const envelope = value;
11
+ return envelope?.data ?? null;
12
+ }
13
+ class LogoutCommand extends Command {
14
+ static description = "Log out by revoking the saved Primitive CLI API key and deleting local credentials.";
15
+ static summary = "Log out and revoke the saved CLI key";
16
+ static examples = ["<%= config.bin %> logout"];
17
+ static flags = {
18
+ "base-url": Flags.string({
19
+ description: "Override the API base URL used for key revocation",
20
+ env: "PRIMITIVE_API_URL",
21
+ }),
22
+ };
23
+ async run() {
24
+ const { flags } = await this.parse(LogoutCommand);
25
+ let releaseCredentialsLock;
26
+ try {
27
+ releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
28
+ }
29
+ catch (error) {
30
+ const detail = error instanceof Error ? error.message : String(error);
31
+ throw cliError(detail);
32
+ }
33
+ try {
34
+ await this.runWithCredentialLock(flags);
35
+ }
36
+ finally {
37
+ releaseCredentialsLock();
38
+ }
39
+ }
40
+ async runWithCredentialLock(flags) {
41
+ let credentials;
42
+ try {
43
+ credentials = loadCliCredentials(this.config.configDir);
44
+ }
45
+ catch (error) {
46
+ deleteCliCredentials(this.config.configDir);
47
+ const detail = error instanceof Error ? error.message : String(error);
48
+ process.stderr.write(`Removed unreadable Primitive CLI credentials. Backing API key was not revoked: ${detail}\n`);
49
+ process.exitCode = 1;
50
+ return;
51
+ }
52
+ if (!credentials) {
53
+ throw cliError("Not logged in. Run `primitive login` to create saved CLI credentials.");
54
+ }
55
+ const baseUrl = flags["base-url"]
56
+ ? normalizeBaseUrl(flags["base-url"])
57
+ : credentials.base_url;
58
+ const apiClient = new PrimitiveApiClient({
59
+ apiKey: credentials.api_key,
60
+ baseUrl,
61
+ });
62
+ const result = await cliLogout({
63
+ body: { key_id: credentials.key_id },
64
+ client: apiClient.client,
65
+ responseStyle: "fields",
66
+ });
67
+ if (result.error) {
68
+ const payload = extractErrorPayload(result.error);
69
+ const code = extractErrorCode(payload);
70
+ if (code === API_ERROR_CODES.unauthorized ||
71
+ code === API_ERROR_CODES.notFound) {
72
+ deleteCliCredentials(this.config.configDir);
73
+ writeErrorWithHints(payload);
74
+ process.stderr.write("Removed saved Primitive CLI credentials because the backing API key is already unavailable.\n");
75
+ process.exitCode = 1;
76
+ return;
77
+ }
78
+ writeErrorWithHints(payload);
79
+ throw cliError("Could not revoke the saved Primitive CLI API key.");
80
+ }
81
+ const logout = unwrapData(result.data);
82
+ deleteCliCredentials(this.config.configDir);
83
+ const keyId = logout?.key_id ?? credentials.key_id;
84
+ process.stderr.write(`Logged out and revoked API key ${keyId}.\n`);
85
+ }
86
+ }
87
+ export default LogoutCommand;