@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.
- package/dist/oclif/index.js +12554 -178
- package/dist/oclif/proxy-auto-detect.js +38 -57
- package/package.json +7 -9
- package/dist/oclif/api-command.js +0 -799
- package/dist/oclif/auth.js +0 -223
- package/dist/oclif/commands/doctor.js +0 -338
- package/dist/oclif/commands/emails-latest.js +0 -184
- package/dist/oclif/commands/emails-poll.js +0 -121
- package/dist/oclif/commands/emails-wait.js +0 -171
- package/dist/oclif/commands/emails-watch.js +0 -165
- package/dist/oclif/commands/functions-deploy.js +0 -302
- package/dist/oclif/commands/functions-init.js +0 -376
- package/dist/oclif/commands/functions-redeploy.js +0 -240
- package/dist/oclif/commands/functions-set-secret.js +0 -212
- package/dist/oclif/commands/functions-test-function.js +0 -238
- package/dist/oclif/commands/login.js +0 -236
- package/dist/oclif/commands/logout.js +0 -87
- package/dist/oclif/commands/send.js +0 -221
- package/dist/oclif/commands/whoami.js +0 -94
- package/dist/oclif/endpoints-test-redirect.js +0 -94
- package/dist/oclif/fish-completion.js +0 -87
- package/dist/oclif/lint/raw-send-mail-fetch.js +0 -98
- package/dist/oclif/secret-flags.js +0 -59
- package/oclif.manifest.json +0 -4390
|
@@ -1,238 +0,0 @@
|
|
|
1
|
-
import { Command, Flags } from "@oclif/core";
|
|
2
|
-
import { getEmail, PrimitiveApiClient, testFunction, } from "@primitivedotdev/sdk/api";
|
|
3
|
-
import { API_BASE_URL_1_FLAG_DESCRIPTION, API_BASE_URL_2_FLAG_DESCRIPTION, baseUrlOverriddenFromFlags, extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, runWithTiming, TIME_FLAG_DESCRIPTION, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
-
import { resolveCliAuth } from "../auth.js";
|
|
5
|
-
import { DEFAULT_EMAIL_POLL_INTERVAL_SECONDS, fetchEmailSearchPage, sleep, } from "./emails-poll.js";
|
|
6
|
-
// `primitive functions:test-function` is the agent-grade shortcut for
|
|
7
|
-
// triggering a real round-trip and (optionally) waiting for the
|
|
8
|
-
// function to actually run before exiting. The underlying
|
|
9
|
-
// `POST /functions/{id}/test` operation only kicks off a synthetic
|
|
10
|
-
// inbound through MX and returns the queued send id; AGX walkthroughs
|
|
11
|
-
// flagged the missing wait-and-show-sends step as the single biggest
|
|
12
|
-
// time-sink in the verification loop.
|
|
13
|
-
//
|
|
14
|
-
// Shapes:
|
|
15
|
-
// primitive functions:test-function --id <fn-id>
|
|
16
|
-
// Fire-and-forget. Returns the TestInvocationResult JSON
|
|
17
|
-
// (recipient, poll_since, watch_url). Same behavior as the
|
|
18
|
-
// auto-generated functions:test-function it replaces.
|
|
19
|
-
//
|
|
20
|
-
// primitive functions:test-function --id <fn-id> --wait
|
|
21
|
-
// Blocks until the test inbound has arrived AND the function's
|
|
22
|
-
// webhook has fired (or --timeout elapses). Exits non-zero on
|
|
23
|
-
// timeout or on exhausted retries.
|
|
24
|
-
//
|
|
25
|
-
// primitive functions:test-function --id <fn-id> --wait --show-sends
|
|
26
|
-
// Same as --wait, plus prints the inbound's `replies` array
|
|
27
|
-
// (every outbound the function emitted while processing the
|
|
28
|
-
// test inbound), with each send's id, status, recipient,
|
|
29
|
-
// subject, and queue id.
|
|
30
|
-
//
|
|
31
|
-
// The auto-generated functions:test-function entry is filtered out
|
|
32
|
-
// of the generated-command set in oclif/index.ts so this hand-rolled
|
|
33
|
-
// version owns the id.
|
|
34
|
-
const DEFAULT_WAIT_TIMEOUT_SECONDS = 60;
|
|
35
|
-
// Terminal states from the EmailWebhookStatus enum. `fired` means the
|
|
36
|
-
// function returned 2xx; `exhausted` means all retries are spent and
|
|
37
|
-
// the delivery is permanently failed. `pending` / `in_flight` /
|
|
38
|
-
// `failed` are intermediate (`failed` is a temporary failure that may
|
|
39
|
-
// retry into `fired` or eventually `exhausted`), so we keep polling.
|
|
40
|
-
const TERMINAL_WEBHOOK_STATUSES = new Set(["fired", "exhausted"]);
|
|
41
|
-
class FunctionsTestFunctionCommand extends Command {
|
|
42
|
-
static description = "Send a real test email through MX to trigger this function. With --wait, blocks until the function has processed the inbound; with --show-sends, also prints any outbound sends the function emitted in response.";
|
|
43
|
-
static summary = "Trigger a test invocation; with --wait, watch it land";
|
|
44
|
-
static examples = [
|
|
45
|
-
"<%= config.bin %> functions:test-function --id <fn-id>",
|
|
46
|
-
"<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize",
|
|
47
|
-
"<%= config.bin %> functions:test-function --id <fn-id> --wait --show-sends",
|
|
48
|
-
"<%= config.bin %> functions:test-function --id <fn-id> --local-part summarize --wait --timeout 120",
|
|
49
|
-
];
|
|
50
|
-
static flags = {
|
|
51
|
-
"api-key": Flags.string({
|
|
52
|
-
description: "Primitive API key (defaults to PRIMITIVE_API_KEY or saved `primitive login` credentials)",
|
|
53
|
-
env: "PRIMITIVE_API_KEY",
|
|
54
|
-
}),
|
|
55
|
-
"api-base-url-1": Flags.string({
|
|
56
|
-
description: API_BASE_URL_1_FLAG_DESCRIPTION,
|
|
57
|
-
env: "PRIMITIVE_API_BASE_URL_1",
|
|
58
|
-
hidden: true,
|
|
59
|
-
}),
|
|
60
|
-
"api-base-url-2": Flags.string({
|
|
61
|
-
description: API_BASE_URL_2_FLAG_DESCRIPTION,
|
|
62
|
-
env: "PRIMITIVE_API_BASE_URL_2",
|
|
63
|
-
hidden: true,
|
|
64
|
-
}),
|
|
65
|
-
id: Flags.string({
|
|
66
|
-
description: "Function id (UUID).",
|
|
67
|
-
required: true,
|
|
68
|
-
}),
|
|
69
|
-
"local-part": Flags.string({
|
|
70
|
-
description: "Override the synthetic local-part the test inbound is addressed to. Otherwise the runtime picks `__primitive_function_test+<random>`.",
|
|
71
|
-
}),
|
|
72
|
-
wait: Flags.boolean({
|
|
73
|
-
description: "Block until the function has processed the test inbound (webhook status is `fired` or `exhausted`) or --timeout elapses. Exits non-zero on timeout or on exhausted retries.",
|
|
74
|
-
}),
|
|
75
|
-
"show-sends": Flags.boolean({
|
|
76
|
-
description: "When the wait resolves, also print the outbound emails the function emitted while processing the test inbound (id, status, to, subject). Implies --wait.",
|
|
77
|
-
}),
|
|
78
|
-
timeout: Flags.integer({
|
|
79
|
-
default: DEFAULT_WAIT_TIMEOUT_SECONDS,
|
|
80
|
-
description: "Seconds to wait before exiting non-zero when --wait is set; 0 waits forever.",
|
|
81
|
-
min: 0,
|
|
82
|
-
}),
|
|
83
|
-
"poll-interval": Flags.integer({
|
|
84
|
-
default: DEFAULT_EMAIL_POLL_INTERVAL_SECONDS,
|
|
85
|
-
description: "Seconds between polls while waiting.",
|
|
86
|
-
min: 1,
|
|
87
|
-
}),
|
|
88
|
-
time: Flags.boolean({
|
|
89
|
-
description: TIME_FLAG_DESCRIPTION,
|
|
90
|
-
}),
|
|
91
|
-
};
|
|
92
|
-
async run() {
|
|
93
|
-
const { flags } = await this.parse(FunctionsTestFunctionCommand);
|
|
94
|
-
// --show-sends implies --wait. You can't print what was sent
|
|
95
|
-
// until the function has actually run.
|
|
96
|
-
const shouldWait = flags.wait || flags["show-sends"];
|
|
97
|
-
const shouldShowSends = flags["show-sends"];
|
|
98
|
-
const baseUrlOverridden = baseUrlOverriddenFromFlags(flags);
|
|
99
|
-
const auth = resolveCliAuth({
|
|
100
|
-
apiKey: flags["api-key"],
|
|
101
|
-
apiBaseUrl1: flags["api-base-url-1"],
|
|
102
|
-
apiBaseUrl2: flags["api-base-url-2"],
|
|
103
|
-
configDir: this.config.configDir,
|
|
104
|
-
});
|
|
105
|
-
const apiClient = new PrimitiveApiClient({
|
|
106
|
-
apiKey: auth.apiKey,
|
|
107
|
-
apiBaseUrl1: auth.apiBaseUrl1,
|
|
108
|
-
apiBaseUrl2: auth.apiBaseUrl2,
|
|
109
|
-
});
|
|
110
|
-
await runWithTiming(flags.time, async () => {
|
|
111
|
-
// 1. Trigger the test send.
|
|
112
|
-
const triggerResult = await testFunction({
|
|
113
|
-
client: apiClient.client,
|
|
114
|
-
path: { id: flags.id },
|
|
115
|
-
body: flags["local-part"]
|
|
116
|
-
? { local_part: flags["local-part"] }
|
|
117
|
-
: undefined,
|
|
118
|
-
responseStyle: "fields",
|
|
119
|
-
});
|
|
120
|
-
if (triggerResult.error) {
|
|
121
|
-
const payload = extractErrorPayload(triggerResult.error);
|
|
122
|
-
writeErrorWithHints(payload);
|
|
123
|
-
removeStaleSavedCredentialOnUnauthorized({
|
|
124
|
-
auth,
|
|
125
|
-
baseUrlOverridden,
|
|
126
|
-
configDir: this.config.configDir,
|
|
127
|
-
payload,
|
|
128
|
-
});
|
|
129
|
-
process.exitCode = 1;
|
|
130
|
-
return;
|
|
131
|
-
}
|
|
132
|
-
const invocation = triggerResult.data
|
|
133
|
-
.data;
|
|
134
|
-
if (!shouldWait) {
|
|
135
|
-
// Fire-and-forget path: print the TestInvocationResult JSON
|
|
136
|
-
// unchanged. Same shape the auto-generated command emitted.
|
|
137
|
-
this.log(JSON.stringify(invocation, null, 2));
|
|
138
|
-
return;
|
|
139
|
-
}
|
|
140
|
-
const startedAt = Date.now();
|
|
141
|
-
const timeoutMs = flags.timeout * 1000;
|
|
142
|
-
const pollIntervalMs = flags["poll-interval"] * 1000;
|
|
143
|
-
const isExpired = () => flags.timeout > 0 && Date.now() - startedAt > timeoutMs;
|
|
144
|
-
// 2. Wait for the test inbound to arrive. The synthetic
|
|
145
|
-
// recipient is unique per call (random suffix in the local-part
|
|
146
|
-
// unless --local-part overrides), so `to` + `since` uniquely
|
|
147
|
-
// identifies the test inbound row.
|
|
148
|
-
this.log(`Waiting for test inbound to arrive at ${invocation.to}...`);
|
|
149
|
-
let inboundId;
|
|
150
|
-
while (!isExpired()) {
|
|
151
|
-
const page = await fetchEmailSearchPage({
|
|
152
|
-
apiClient,
|
|
153
|
-
filters: { to: invocation.to },
|
|
154
|
-
pageSize: 25,
|
|
155
|
-
since: invocation.poll_since,
|
|
156
|
-
});
|
|
157
|
-
if (!page.ok) {
|
|
158
|
-
const payload = extractErrorPayload(page.error);
|
|
159
|
-
writeErrorWithHints(payload);
|
|
160
|
-
removeStaleSavedCredentialOnUnauthorized({
|
|
161
|
-
auth,
|
|
162
|
-
baseUrlOverridden,
|
|
163
|
-
configDir: this.config.configDir,
|
|
164
|
-
payload,
|
|
165
|
-
});
|
|
166
|
-
process.exitCode = 1;
|
|
167
|
-
return;
|
|
168
|
-
}
|
|
169
|
-
const found = page.rows[0];
|
|
170
|
-
if (found) {
|
|
171
|
-
inboundId = found.id;
|
|
172
|
-
break;
|
|
173
|
-
}
|
|
174
|
-
await sleep(pollIntervalMs);
|
|
175
|
-
}
|
|
176
|
-
if (!inboundId) {
|
|
177
|
-
this.error(`Timed out after ${flags.timeout}s waiting for test inbound ${invocation.to} to land. Browse ${invocation.watch_url} for the live view.`, { exit: 2 });
|
|
178
|
-
}
|
|
179
|
-
// 3. Wait for the function (webhook) to actually run. We poll
|
|
180
|
-
// the email-detail endpoint because it already carries both the
|
|
181
|
-
// webhook_status terminal state and the `replies` array we'll
|
|
182
|
-
// print under --show-sends. No second endpoint needed.
|
|
183
|
-
this.log(`Inbound landed (${inboundId}). Waiting for function to run...`);
|
|
184
|
-
let detail;
|
|
185
|
-
while (!isExpired()) {
|
|
186
|
-
const result = await getEmail({
|
|
187
|
-
client: apiClient.client,
|
|
188
|
-
path: { id: inboundId },
|
|
189
|
-
responseStyle: "fields",
|
|
190
|
-
});
|
|
191
|
-
if (result.error) {
|
|
192
|
-
const payload = extractErrorPayload(result.error);
|
|
193
|
-
writeErrorWithHints(payload);
|
|
194
|
-
removeStaleSavedCredentialOnUnauthorized({
|
|
195
|
-
auth,
|
|
196
|
-
baseUrlOverridden,
|
|
197
|
-
configDir: this.config.configDir,
|
|
198
|
-
payload,
|
|
199
|
-
});
|
|
200
|
-
process.exitCode = 1;
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const fetched = result.data.data;
|
|
204
|
-
if (fetched.webhook_status &&
|
|
205
|
-
TERMINAL_WEBHOOK_STATUSES.has(fetched.webhook_status)) {
|
|
206
|
-
detail = fetched;
|
|
207
|
-
break;
|
|
208
|
-
}
|
|
209
|
-
await sleep(pollIntervalMs);
|
|
210
|
-
}
|
|
211
|
-
if (!detail) {
|
|
212
|
-
this.error(`Timed out after ${flags.timeout}s waiting for function webhook to fire for inbound ${inboundId}. Browse ${invocation.watch_url} for the live view.`, { exit: 2 });
|
|
213
|
-
}
|
|
214
|
-
// 4. Emit the outcome.
|
|
215
|
-
const elapsedSeconds = Math.round((Date.now() - startedAt) / 1000);
|
|
216
|
-
const outcome = {
|
|
217
|
-
function_id: flags.id,
|
|
218
|
-
inbound_id: inboundId,
|
|
219
|
-
inbound_to: invocation.to,
|
|
220
|
-
webhook_status: detail.webhook_status,
|
|
221
|
-
webhook_attempt_count: detail.webhook_attempt_count,
|
|
222
|
-
webhook_last_status_code: detail.webhook_last_status_code,
|
|
223
|
-
webhook_last_error: detail.webhook_last_error,
|
|
224
|
-
elapsed_seconds: elapsedSeconds,
|
|
225
|
-
};
|
|
226
|
-
if (shouldShowSends) {
|
|
227
|
-
outcome.sent_emails = detail.replies;
|
|
228
|
-
}
|
|
229
|
-
this.log(JSON.stringify(outcome, null, 2));
|
|
230
|
-
// Exit non-zero when the function failed permanently so CI
|
|
231
|
-
// scripts can gate on the exit code.
|
|
232
|
-
if (detail.webhook_status === "exhausted") {
|
|
233
|
-
process.exitCode = 1;
|
|
234
|
-
}
|
|
235
|
-
});
|
|
236
|
-
}
|
|
237
|
-
}
|
|
238
|
-
export default FunctionsTestFunctionCommand;
|
|
@@ -1,236 +0,0 @@
|
|
|
1
|
-
import { spawn } from "node:child_process";
|
|
2
|
-
import { hostname } from "node:os";
|
|
3
|
-
import { Command, Errors, Flags } from "@oclif/core";
|
|
4
|
-
import { getAccount, PrimitiveApiClient, pollCliLogin, startCliLogin, } from "@primitivedotdev/sdk/api";
|
|
5
|
-
import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, removeStaleSavedCredentialOnUnauthorized, writeErrorWithHints, } from "../api-command.js";
|
|
6
|
-
import { acquireCliCredentialsLock, credentialsPath, loadCliCredentials, normalizeApiBaseUrl1, normalizeApiBaseUrl2, saveCliCredentials, } from "../auth.js";
|
|
7
|
-
const MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS = 60;
|
|
8
|
-
function cliError(message) {
|
|
9
|
-
return new Errors.CLIError(message, { exit: 1 });
|
|
10
|
-
}
|
|
11
|
-
function sleep(ms) {
|
|
12
|
-
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
13
|
-
}
|
|
14
|
-
function openBrowser(url) {
|
|
15
|
-
const command = process.platform === "darwin"
|
|
16
|
-
? "open"
|
|
17
|
-
: process.platform === "win32"
|
|
18
|
-
? "cmd"
|
|
19
|
-
: "xdg-open";
|
|
20
|
-
const args = process.platform === "win32" ? ["/c", "start", "", url] : [url];
|
|
21
|
-
const child = spawn(command, args, { detached: true, stdio: "ignore" });
|
|
22
|
-
child.on("error", () => undefined);
|
|
23
|
-
child.unref();
|
|
24
|
-
}
|
|
25
|
-
function unwrapData(value) {
|
|
26
|
-
const envelope = value;
|
|
27
|
-
return envelope?.data ?? null;
|
|
28
|
-
}
|
|
29
|
-
function retryAfterSeconds(result) {
|
|
30
|
-
const response = result.response;
|
|
31
|
-
const raw = response?.headers.get("retry-after");
|
|
32
|
-
if (!raw)
|
|
33
|
-
return null;
|
|
34
|
-
const parsed = Number.parseInt(raw, 10);
|
|
35
|
-
return Number.isFinite(parsed) && parsed > 0 ? parsed : null;
|
|
36
|
-
}
|
|
37
|
-
export async function checkExistingLogin(params) {
|
|
38
|
-
const baseUrlOverridden = params.apiBaseUrl1 !== undefined;
|
|
39
|
-
const probeApiBaseUrl1 = baseUrlOverridden
|
|
40
|
-
? normalizeApiBaseUrl1(params.apiBaseUrl1)
|
|
41
|
-
: params.credentials.api_base_url_1;
|
|
42
|
-
const apiClient = new PrimitiveApiClient({
|
|
43
|
-
apiKey: params.credentials.api_key,
|
|
44
|
-
apiBaseUrl1: probeApiBaseUrl1,
|
|
45
|
-
});
|
|
46
|
-
const result = await (params.checkAccount ??
|
|
47
|
-
((client) => getAccount({
|
|
48
|
-
client: client.client,
|
|
49
|
-
responseStyle: "fields",
|
|
50
|
-
})))(apiClient);
|
|
51
|
-
if (!result.error)
|
|
52
|
-
return { status: "valid" };
|
|
53
|
-
const payload = extractErrorPayload(result.error);
|
|
54
|
-
const auth = {
|
|
55
|
-
apiKey: params.credentials.api_key,
|
|
56
|
-
apiBaseUrl1: probeApiBaseUrl1,
|
|
57
|
-
// Host-2 isn't relevant to checkExistingLogin (login is on host-1
|
|
58
|
-
// only), but the auth shape requires it. Use the default.
|
|
59
|
-
apiBaseUrl2: normalizeApiBaseUrl2(undefined),
|
|
60
|
-
credentials: params.credentials,
|
|
61
|
-
source: "stored",
|
|
62
|
-
};
|
|
63
|
-
const removed = removeStaleSavedCredentialOnUnauthorized({
|
|
64
|
-
auth,
|
|
65
|
-
baseUrlOverridden,
|
|
66
|
-
configDir: params.configDir,
|
|
67
|
-
payload,
|
|
68
|
-
});
|
|
69
|
-
if (removed)
|
|
70
|
-
return { status: "removed_stale" };
|
|
71
|
-
const code = extractErrorCode(payload);
|
|
72
|
-
return {
|
|
73
|
-
status: "blocked",
|
|
74
|
-
payload,
|
|
75
|
-
message: code === API_ERROR_CODES.unauthorized
|
|
76
|
-
? "Saved Primitive CLI credentials were rejected. Run `primitive logout` to remove them before logging in again."
|
|
77
|
-
: "A saved Primitive CLI login exists, but the CLI could not verify whether it is still valid. Run `primitive logout` before logging in again.",
|
|
78
|
-
};
|
|
79
|
-
}
|
|
80
|
-
class LoginCommand extends Command {
|
|
81
|
-
static description = "Log in by opening Primitive in your browser and saving an org-scoped CLI API key locally.";
|
|
82
|
-
static summary = "Log in with browser approval";
|
|
83
|
-
static examples = [
|
|
84
|
-
"<%= config.bin %> login",
|
|
85
|
-
"<%= config.bin %> login --device-name work-laptop",
|
|
86
|
-
"<%= config.bin %> login --force",
|
|
87
|
-
];
|
|
88
|
-
static flags = {
|
|
89
|
-
"api-base-url-1": Flags.string({
|
|
90
|
-
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
91
|
-
env: "PRIMITIVE_API_BASE_URL_1",
|
|
92
|
-
hidden: true,
|
|
93
|
-
}),
|
|
94
|
-
"device-name": Flags.string({
|
|
95
|
-
description: "Device name shown in the browser approval screen",
|
|
96
|
-
}),
|
|
97
|
-
"no-browser": Flags.boolean({
|
|
98
|
-
description: "Do not attempt to open the browser automatically",
|
|
99
|
-
}),
|
|
100
|
-
force: Flags.boolean({
|
|
101
|
-
char: "f",
|
|
102
|
-
description: "Replace saved credentials without first verifying the existing login",
|
|
103
|
-
}),
|
|
104
|
-
};
|
|
105
|
-
async run() {
|
|
106
|
-
const { flags } = await this.parse(LoginCommand);
|
|
107
|
-
let releaseCredentialsLock;
|
|
108
|
-
try {
|
|
109
|
-
releaseCredentialsLock = acquireCliCredentialsLock(this.config.configDir);
|
|
110
|
-
}
|
|
111
|
-
catch (error) {
|
|
112
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
113
|
-
throw cliError(detail);
|
|
114
|
-
}
|
|
115
|
-
try {
|
|
116
|
-
await this.runWithCredentialLock(flags);
|
|
117
|
-
}
|
|
118
|
-
finally {
|
|
119
|
-
releaseCredentialsLock();
|
|
120
|
-
}
|
|
121
|
-
}
|
|
122
|
-
async runWithCredentialLock(flags) {
|
|
123
|
-
const apiBaseUrl1 = normalizeApiBaseUrl1(flags["api-base-url-1"]);
|
|
124
|
-
let existing;
|
|
125
|
-
try {
|
|
126
|
-
existing = loadCliCredentials(this.config.configDir);
|
|
127
|
-
}
|
|
128
|
-
catch (error) {
|
|
129
|
-
if (!flags.force)
|
|
130
|
-
throw error;
|
|
131
|
-
const detail = error instanceof Error ? error.message : String(error);
|
|
132
|
-
process.stderr.write(`Replacing unreadable Primitive CLI credentials because --force was set: ${detail}\n`);
|
|
133
|
-
existing = null;
|
|
134
|
-
}
|
|
135
|
-
if (existing && flags.force) {
|
|
136
|
-
process.stderr.write("Replacing saved Primitive CLI credentials after browser approval because --force was set.\n");
|
|
137
|
-
}
|
|
138
|
-
else if (existing) {
|
|
139
|
-
const existingStatus = await checkExistingLogin({
|
|
140
|
-
apiBaseUrl1: flags["api-base-url-1"],
|
|
141
|
-
configDir: this.config.configDir,
|
|
142
|
-
credentials: existing,
|
|
143
|
-
});
|
|
144
|
-
if (existingStatus.status === "removed_stale") {
|
|
145
|
-
process.stderr.write("Continuing with a new Primitive CLI login...\n");
|
|
146
|
-
}
|
|
147
|
-
else if (existingStatus.status === "blocked") {
|
|
148
|
-
writeErrorWithHints(existingStatus.payload);
|
|
149
|
-
throw cliError(existingStatus.message);
|
|
150
|
-
}
|
|
151
|
-
else {
|
|
152
|
-
const org = existing.org_name ? ` for ${existing.org_name}` : "";
|
|
153
|
-
throw cliError(`Already logged in${org}. Run \`primitive logout\` before logging in again.`);
|
|
154
|
-
}
|
|
155
|
-
}
|
|
156
|
-
const apiClient = new PrimitiveApiClient({ apiBaseUrl1 });
|
|
157
|
-
const deviceName = flags["device-name"] ?? hostname();
|
|
158
|
-
const started = await startCliLogin({
|
|
159
|
-
body: {
|
|
160
|
-
device_name: deviceName,
|
|
161
|
-
},
|
|
162
|
-
client: apiClient.client,
|
|
163
|
-
responseStyle: "fields",
|
|
164
|
-
});
|
|
165
|
-
if (started.error) {
|
|
166
|
-
writeErrorWithHints(extractErrorPayload(started.error));
|
|
167
|
-
throw cliError("Could not start Primitive CLI login.");
|
|
168
|
-
}
|
|
169
|
-
const start = unwrapData(started.data);
|
|
170
|
-
if (!start) {
|
|
171
|
-
throw cliError("Primitive API returned an empty CLI login response.");
|
|
172
|
-
}
|
|
173
|
-
process.stderr.write(`Your login code is: ${start.user_code}\n`);
|
|
174
|
-
if (!flags["no-browser"]) {
|
|
175
|
-
openBrowser(start.verification_uri_complete);
|
|
176
|
-
process.stderr.write("Opening Primitive in your browser...\n");
|
|
177
|
-
}
|
|
178
|
-
process.stderr.write(`If the browser did not open, visit: ${start.verification_uri_complete}\n`);
|
|
179
|
-
process.stderr.write("Waiting for browser approval...\n");
|
|
180
|
-
const deadline = Date.now() + start.expires_in * 1000;
|
|
181
|
-
let interval = Math.min(Math.max(1, start.interval), MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
|
|
182
|
-
let nextPollDelay = 1;
|
|
183
|
-
while (Date.now() < deadline) {
|
|
184
|
-
await sleep(nextPollDelay * 1000);
|
|
185
|
-
nextPollDelay = interval;
|
|
186
|
-
const polled = await pollCliLogin({
|
|
187
|
-
body: { device_code: start.device_code },
|
|
188
|
-
client: apiClient.client,
|
|
189
|
-
responseStyle: "fields",
|
|
190
|
-
});
|
|
191
|
-
if (polled.data) {
|
|
192
|
-
const login = unwrapData(polled.data);
|
|
193
|
-
if (!login) {
|
|
194
|
-
throw cliError("Primitive API returned an empty CLI poll response.");
|
|
195
|
-
}
|
|
196
|
-
saveCliCredentials(this.config.configDir, {
|
|
197
|
-
api_key: login.api_key,
|
|
198
|
-
api_base_url_1: apiBaseUrl1,
|
|
199
|
-
created_at: new Date().toISOString(),
|
|
200
|
-
key_id: login.key_id,
|
|
201
|
-
key_prefix: login.key_prefix,
|
|
202
|
-
org_id: login.org_id,
|
|
203
|
-
org_name: login.org_name,
|
|
204
|
-
});
|
|
205
|
-
const org = login.org_name ? ` (${login.org_name})` : "";
|
|
206
|
-
process.stderr.write(`Logged in to org ${login.org_id}${org}.\n`);
|
|
207
|
-
process.stderr.write(`Saved credentials to ${credentialsPath(this.config.configDir)}.\n`);
|
|
208
|
-
return;
|
|
209
|
-
}
|
|
210
|
-
const payload = extractErrorPayload(polled.error);
|
|
211
|
-
const code = extractErrorCode(payload);
|
|
212
|
-
if (code === API_ERROR_CODES.authorizationPending) {
|
|
213
|
-
nextPollDelay = interval;
|
|
214
|
-
continue;
|
|
215
|
-
}
|
|
216
|
-
if (code === API_ERROR_CODES.slowDown) {
|
|
217
|
-
interval = Math.min(retryAfterSeconds(polled) ?? interval + 5, MAX_CLI_LOGIN_POLL_INTERVAL_SECONDS);
|
|
218
|
-
nextPollDelay = interval;
|
|
219
|
-
continue;
|
|
220
|
-
}
|
|
221
|
-
if (code === API_ERROR_CODES.accessDenied) {
|
|
222
|
-
throw cliError("Primitive CLI login was denied in the browser.");
|
|
223
|
-
}
|
|
224
|
-
if (code === API_ERROR_CODES.expiredToken) {
|
|
225
|
-
throw cliError("Primitive CLI login expired. Run `primitive login` again.");
|
|
226
|
-
}
|
|
227
|
-
if (code === API_ERROR_CODES.invalidDeviceCode) {
|
|
228
|
-
throw cliError("Primitive CLI login device code is invalid. Run `primitive login` again.");
|
|
229
|
-
}
|
|
230
|
-
writeErrorWithHints(payload);
|
|
231
|
-
throw cliError("Primitive CLI login failed while polling for approval.");
|
|
232
|
-
}
|
|
233
|
-
throw cliError("Primitive CLI login expired. Run `primitive login` again.");
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
export default LoginCommand;
|
|
@@ -1,87 +0,0 @@
|
|
|
1
|
-
import { Command, Errors, Flags } from "@oclif/core";
|
|
2
|
-
import { cliLogout, PrimitiveApiClient } from "@primitivedotdev/sdk/api";
|
|
3
|
-
import { API_ERROR_CODES, extractErrorCode, extractErrorPayload, writeErrorWithHints, } from "../api-command.js";
|
|
4
|
-
import { acquireCliCredentialsLock, deleteCliCredentials, loadCliCredentials, normalizeApiBaseUrl1, } from "../auth.js";
|
|
5
|
-
function cliError(message) {
|
|
6
|
-
return new Errors.CLIError(message, { exit: 1 });
|
|
7
|
-
}
|
|
8
|
-
function unwrapData(value) {
|
|
9
|
-
const envelope = value;
|
|
10
|
-
return envelope?.data ?? null;
|
|
11
|
-
}
|
|
12
|
-
class LogoutCommand extends Command {
|
|
13
|
-
static description = "Log out by revoking the saved Primitive CLI API key and deleting local credentials.";
|
|
14
|
-
static summary = "Log out and revoke the saved CLI key";
|
|
15
|
-
static examples = ["<%= config.bin %> logout"];
|
|
16
|
-
static flags = {
|
|
17
|
-
"api-base-url-1": Flags.string({
|
|
18
|
-
description: "Override the primary API base URL. Internal testing only; not documented to customers.",
|
|
19
|
-
env: "PRIMITIVE_API_BASE_URL_1",
|
|
20
|
-
hidden: true,
|
|
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 apiBaseUrl1 = flags["api-base-url-1"]
|
|
56
|
-
? normalizeApiBaseUrl1(flags["api-base-url-1"])
|
|
57
|
-
: credentials.api_base_url_1;
|
|
58
|
-
const apiClient = new PrimitiveApiClient({
|
|
59
|
-
apiKey: credentials.api_key,
|
|
60
|
-
apiBaseUrl1,
|
|
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;
|