primcli 1.75.0 → 3.0.0-preview.2
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 +124 -63
- package/bin/claude-pending-mail.mjs +1 -1
- package/bin/run.js +14 -0
- package/dist/{api-command-rlS3eTlZ.js → api-command-B37DPKFA.js} +752 -56
- package/dist/{cli-config-B80p0pLk.js → cli-config-Ms_0vNjq.js} +59 -16
- package/dist/{machine-session-Cq_nD5eF.js → machine-session-g-xPd3X0.js} +25 -12
- package/dist/{notify-session-CbY4FYUt.js → notify-session-Bo8WgG3q.js} +334 -4
- package/dist/oclif/auto-signal-worker.js +3 -3
- package/dist/oclif/auto-signals.js +2 -2
- package/dist/oclif/global-org-flag.js +76 -0
- package/dist/oclif/index.js +112344 -49050
- package/dist/oclif/receiver-heal.js +3 -3
- package/dist/oclif/root-signup-hint.js +2 -2
- package/dist/receiver.d.ts +95 -0
- package/dist/receiver.js +569 -0
- package/dist/{repeats-Dj_o6c-X-LyCkKYjp.js → repeats-Dj_o6c-X-BzXj5ICh.js} +4973 -2383
- package/dist/{scoped-chat-B0thCwME.js → scoped-chat-TJlyUTi7.js} +1 -1
- package/dist/{signal-command-DCPsJBdz.js → signal-command-BO93dM2Q.js} +9 -8
- package/man/primitive.1 +43 -0
- package/package.json +53 -8
|
@@ -1,10 +1,602 @@
|
|
|
1
|
-
import { $ as AutomatedFilterUnsupportedError,
|
|
2
|
-
import { C as
|
|
1
|
+
import { $ as AutomatedFilterUnsupportedError, F as assertAwaitingFilterRows, G as WEBHOOK_EVENT_TYPES, I as awaitingRejectedError, Jt as getThread, L as ensureSearchReportsReplyState, Lt as getEmail, P as ReplyStateUnsupportedError, Pn as sdk_gen_exports, U as replyStateSurfaceForOperation, V as isAwaitingRejectedError, W as SENT_EMAIL_EVENT_TYPES, et as assertAutomatedVerdict, it as expectedAutomatedVerdicts, nt as automatedSurfaceForOperation, ot as isAutomatedRejectedError, rt as ensureSearchReportsAutomated, sn as listEndpoints, tt as automatedRejectedError, v as parseRetryAfter } from "./repeats-Dj_o6c-X-BzXj5ICh.js";
|
|
2
|
+
import { C as saveCliCredentials, F as agentProfilesDirectory, H as requireDefaultLoginProfile, N as agentProfileDirectory, R as loadConnectedAgentProfile, S as resolveCliAuth, W as PrimitiveApiClient, _ as deleteCliCredentials, b as loadCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, i as loadCliConfig, j as AGENT_PROFILE_ENV, m as cliAccessTokenExpiresAt, p as acquireCliCredentialsLock, w as saveSignupCredentials, x as normalizeApiBaseUrl, y as detectPrimitiveKeyEnvMisname } from "./cli-config-Ms_0vNjq.js";
|
|
3
|
+
import "./oclif/global-org-flag.js";
|
|
3
4
|
import { d as readMailJson, h as writeMailJson, m as withMailLock, p as syncMailDirectory, u as privateMailPermissions } from "./shared-mail-files-BAQSGxxz.js";
|
|
4
5
|
import { Command, Errors, Flags } from "@oclif/core";
|
|
5
6
|
import { existsSync, lstatSync, readFileSync, readdirSync, renameSync, unlinkSync, writeFileSync } from "node:fs";
|
|
6
7
|
import { createHash } from "node:crypto";
|
|
7
8
|
import { join } from "node:path";
|
|
9
|
+
// src/oclif/user-grant.ts
|
|
10
|
+
/**
|
|
11
|
+
* CLI user logins: a login as the person, not bound to one organization,
|
|
12
|
+
* that switches between the person's organizations without logging in again.
|
|
13
|
+
*
|
|
14
|
+
* The saved login holds the person's identity token (`prim_uat_`, short
|
|
15
|
+
* lived) and refresh token (`prim_urt_`). Neither calls the API: the
|
|
16
|
+
* identity token only lists organizations (`GET /v1/cli/orgs`) and is
|
|
17
|
+
* exchanged (RFC 8693, at `/oauth/token`) for a short-lived access token in
|
|
18
|
+
* one organization. That per-org token is what every other command sends.
|
|
19
|
+
* It cannot be refreshed, so the CLI exchanges again when it expires or
|
|
20
|
+
* when the API answers 401.
|
|
21
|
+
*
|
|
22
|
+
* Saved logins made by older CLIs, and logins against servers without this
|
|
23
|
+
* feature, keep their org-bound OAuth format (`auth_method: "oauth"`) and
|
|
24
|
+
* never reach this module.
|
|
25
|
+
*/
|
|
26
|
+
const TOKEN_EXCHANGE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:token-exchange";
|
|
27
|
+
const ACCESS_TOKEN_TYPE = "urn:ietf:params:oauth:token-type:access_token";
|
|
28
|
+
/** Exchange or refresh this long before a token's stated expiry. */
|
|
29
|
+
const TOKEN_SKEW_MS = 6e4;
|
|
30
|
+
/** Cached tokens for organizations other than the current one. */
|
|
31
|
+
const MAX_CACHED_ORG_TOKENS = 20;
|
|
32
|
+
const USER_LOGIN_EXPIRED_MESSAGE = "Saved Primitive CLI login expired or was revoked. Run `primitive login` to sign in again.";
|
|
33
|
+
const NO_CURRENT_ORG_MESSAGE = "No organization is selected for this login. Run `primitive orgs` to see where you can use the CLI, then `primitive org use <org>`.";
|
|
34
|
+
const REFUSAL_FALLBACK_MESSAGES = {
|
|
35
|
+
not_a_member: "You are not a member of this organization.",
|
|
36
|
+
cli_access_disabled_by_org: "This organization does not allow CLI access.",
|
|
37
|
+
role_insufficient: "Your role in this organization does not allow CLI access. Ask an owner or admin to allow CLI access for members.",
|
|
38
|
+
cli_access_revoked: "Your CLI access to this organization was revoked. Run `primitive login` again to sign in afresh.",
|
|
39
|
+
grant_not_available: "The requested grant is not available for CLI logins on this server."
|
|
40
|
+
};
|
|
41
|
+
/** A refusal from the exchange or the org list, with the server's reason. */
|
|
42
|
+
var CliOrgRefusedError = class extends Errors.CLIError {
|
|
43
|
+
reason;
|
|
44
|
+
orgId;
|
|
45
|
+
constructor(params) {
|
|
46
|
+
super(params.message, {
|
|
47
|
+
code: params.reason,
|
|
48
|
+
exit: 1
|
|
49
|
+
});
|
|
50
|
+
this.reason = params.reason;
|
|
51
|
+
this.orgId = params.orgId;
|
|
52
|
+
}
|
|
53
|
+
};
|
|
54
|
+
function isRecord(value) {
|
|
55
|
+
return value !== null && typeof value === "object" && !Array.isArray(value);
|
|
56
|
+
}
|
|
57
|
+
function apiOrigin(apiBaseUrl) {
|
|
58
|
+
const url = new URL(apiBaseUrl);
|
|
59
|
+
url.search = "";
|
|
60
|
+
url.hash = "";
|
|
61
|
+
return url;
|
|
62
|
+
}
|
|
63
|
+
function oauthEndpoint(apiBaseUrl, path) {
|
|
64
|
+
const url = apiOrigin(apiBaseUrl);
|
|
65
|
+
url.pathname = path;
|
|
66
|
+
return url.toString();
|
|
67
|
+
}
|
|
68
|
+
function cliOrgsEndpoint(apiBaseUrl) {
|
|
69
|
+
const url = apiOrigin(apiBaseUrl);
|
|
70
|
+
url.pathname = `${url.pathname.replace(/\/+$/, "")}/cli/orgs`;
|
|
71
|
+
return url.toString();
|
|
72
|
+
}
|
|
73
|
+
function expiringSoon(expiresAt, now) {
|
|
74
|
+
const at = Date.parse(expiresAt);
|
|
75
|
+
return !Number.isFinite(at) || at <= now() + TOKEN_SKEW_MS;
|
|
76
|
+
}
|
|
77
|
+
function isUuid(value) {
|
|
78
|
+
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(value);
|
|
79
|
+
}
|
|
80
|
+
function isOrgAccessToken(value) {
|
|
81
|
+
return isRecord(value) && typeof value.access_token === "string" && value.access_token.length > 0 && value.token_type === "Bearer" && typeof value.expires_in === "number" && value.expires_in > 0 && typeof value.org_id === "string" && (value.org_name === null || typeof value.org_name === "string") && typeof value.oauth_grant_id === "string";
|
|
82
|
+
}
|
|
83
|
+
function orgTokenFromResponse(token, now = Date.now) {
|
|
84
|
+
return {
|
|
85
|
+
access_token: token.access_token,
|
|
86
|
+
expires_at: cliAccessTokenExpiresAt(token.expires_in, now),
|
|
87
|
+
oauth_grant_id: token.oauth_grant_id,
|
|
88
|
+
org_id: token.org_id,
|
|
89
|
+
org_name: token.org_name ?? null,
|
|
90
|
+
role: typeof token.role === "string" ? token.role : null
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
/**
|
|
94
|
+
* The org-bound view every other command reads: the per-org token in the
|
|
95
|
+
* standard fields, the person's grant kept alongside.
|
|
96
|
+
*/
|
|
97
|
+
function credentialsForOrg(credentials, org) {
|
|
98
|
+
return {
|
|
99
|
+
...credentials,
|
|
100
|
+
access_token: org.access_token,
|
|
101
|
+
expires_at: org.expires_at,
|
|
102
|
+
oauth_grant_id: org.oauth_grant_id,
|
|
103
|
+
org_id: org.org_id,
|
|
104
|
+
org_name: org.org_name
|
|
105
|
+
};
|
|
106
|
+
}
|
|
107
|
+
function requireUserGrant(credentials) {
|
|
108
|
+
if (credentials.auth_method !== "user_grant" || !credentials.user_grant) throw new Errors.CLIError("This saved login is bound to one organization. Run `primitive logout`, then `primitive login`, to sign in as yourself and switch between organizations.", { exit: 1 });
|
|
109
|
+
return credentials.user_grant;
|
|
110
|
+
}
|
|
111
|
+
async function postTokenEndpoint(params) {
|
|
112
|
+
const fetchImpl = params.fetch ?? fetch;
|
|
113
|
+
let response;
|
|
114
|
+
try {
|
|
115
|
+
response = await fetchImpl(oauthEndpoint(params.apiBaseUrl, params.path ?? "/oauth/token"), {
|
|
116
|
+
body: new URLSearchParams(params.body),
|
|
117
|
+
headers: {
|
|
118
|
+
...params.headers ?? {},
|
|
119
|
+
"content-type": "application/x-www-form-urlencoded"
|
|
120
|
+
},
|
|
121
|
+
method: "POST"
|
|
122
|
+
});
|
|
123
|
+
} catch (error) {
|
|
124
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
125
|
+
throw new Errors.CLIError(`Could not reach the Primitive OAuth token endpoint: ${detail}`, { exit: 1 });
|
|
126
|
+
}
|
|
127
|
+
const payload = await response.json().catch(() => null);
|
|
128
|
+
if (response.ok && isRecord(payload)) return {
|
|
129
|
+
ok: true,
|
|
130
|
+
payload
|
|
131
|
+
};
|
|
132
|
+
if (response.ok && params.allowEmptySuccess) return {
|
|
133
|
+
ok: true,
|
|
134
|
+
payload: {}
|
|
135
|
+
};
|
|
136
|
+
const record = isRecord(payload) ? payload : {};
|
|
137
|
+
return {
|
|
138
|
+
ok: false,
|
|
139
|
+
status: response.status,
|
|
140
|
+
error: typeof record.error === "string" ? record.error : null,
|
|
141
|
+
description: typeof record.error_description === "string" ? record.error_description : null,
|
|
142
|
+
reason: typeof record.reason === "string" ? record.reason : null
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Refresh the identity token when it is about to expire. The refresh token
|
|
147
|
+
* rotates on every use, so the new pair is saved before it is used.
|
|
148
|
+
* `invalid_grant` means the login is gone: the saved login is removed.
|
|
149
|
+
*/
|
|
150
|
+
async function freshIdentity(params) {
|
|
151
|
+
const grant = requireUserGrant(params.credentials);
|
|
152
|
+
if (!params.force && !expiringSoon(grant.expires_at, params.now)) return params.credentials;
|
|
153
|
+
const result = await postTokenEndpoint({
|
|
154
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
155
|
+
body: {
|
|
156
|
+
client_id: params.credentials.oauth_client_id,
|
|
157
|
+
grant_type: "refresh_token",
|
|
158
|
+
refresh_token: params.credentials.refresh_token
|
|
159
|
+
},
|
|
160
|
+
fetch: params.fetch,
|
|
161
|
+
headers: params.headers
|
|
162
|
+
});
|
|
163
|
+
if (!result.ok) {
|
|
164
|
+
if (result.error === "invalid_grant") {
|
|
165
|
+
deleteCliCredentials(params.configDir);
|
|
166
|
+
throw new Errors.CLIError(USER_LOGIN_EXPIRED_MESSAGE, { exit: 1 });
|
|
167
|
+
}
|
|
168
|
+
throw new Errors.CLIError(`Could not refresh the saved Primitive CLI login${result.description ? `: ${result.description}` : "."}`, { exit: 1 });
|
|
169
|
+
}
|
|
170
|
+
const payload = result.payload;
|
|
171
|
+
if (typeof payload.access_token !== "string" || typeof payload.refresh_token !== "string" || typeof payload.expires_in !== "number" || payload.expires_in <= 0) throw new Errors.CLIError("Primitive OAuth token endpoint returned an unexpected refresh response.", { exit: 1 });
|
|
172
|
+
const next = {
|
|
173
|
+
...params.credentials,
|
|
174
|
+
refresh_token: payload.refresh_token,
|
|
175
|
+
user_grant: {
|
|
176
|
+
...grant,
|
|
177
|
+
access_token: payload.access_token,
|
|
178
|
+
expires_at: cliAccessTokenExpiresAt(payload.expires_in, params.now)
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
saveCliCredentials(params.configDir, next);
|
|
182
|
+
return next;
|
|
183
|
+
}
|
|
184
|
+
function refusalMessage(reason, description) {
|
|
185
|
+
return description ?? (reason ? REFUSAL_FALLBACK_MESSAGES[reason] : void 0) ?? "Primitive refused access to this organization.";
|
|
186
|
+
}
|
|
187
|
+
/** One exchange of the identity token for an access token in `orgId`. */
|
|
188
|
+
async function exchangeForOrg(params) {
|
|
189
|
+
const body = {
|
|
190
|
+
client_id: params.clientId,
|
|
191
|
+
grant_type: TOKEN_EXCHANGE_GRANT_TYPE,
|
|
192
|
+
org: params.orgId,
|
|
193
|
+
subject_token: params.identityToken,
|
|
194
|
+
subject_token_type: ACCESS_TOKEN_TYPE
|
|
195
|
+
};
|
|
196
|
+
if (params.requestedGrant) body.requested_grant = params.requestedGrant;
|
|
197
|
+
const result = await postTokenEndpoint({
|
|
198
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
199
|
+
body,
|
|
200
|
+
fetch: params.fetch,
|
|
201
|
+
headers: params.headers
|
|
202
|
+
});
|
|
203
|
+
if (!result.ok) return result;
|
|
204
|
+
if (!isOrgAccessToken(result.payload)) throw new Errors.CLIError("Primitive OAuth token endpoint returned an unexpected token exchange response.", { exit: 1 });
|
|
205
|
+
return {
|
|
206
|
+
ok: true,
|
|
207
|
+
token: result.payload
|
|
208
|
+
};
|
|
209
|
+
}
|
|
210
|
+
/**
|
|
211
|
+
* Exchange for `orgId`, refreshing the identity token first when needed and
|
|
212
|
+
* once more if the exchange says it expired. Saves the result: as the
|
|
213
|
+
* current organization when `makeCurrent`, otherwise in the per-org cache.
|
|
214
|
+
* Must run while the credentials lock is held.
|
|
215
|
+
*/
|
|
216
|
+
async function exchangeAndSave(params) {
|
|
217
|
+
let credentials = await freshIdentity(params);
|
|
218
|
+
let attempt = 0;
|
|
219
|
+
for (;;) {
|
|
220
|
+
const grant = requireUserGrant(credentials);
|
|
221
|
+
const result = await exchangeForOrg({
|
|
222
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
223
|
+
clientId: credentials.oauth_client_id,
|
|
224
|
+
fetch: params.fetch,
|
|
225
|
+
headers: params.headers,
|
|
226
|
+
identityToken: grant.access_token,
|
|
227
|
+
orgId: params.orgId,
|
|
228
|
+
requestedGrant: grant.requested_grant ?? null
|
|
229
|
+
});
|
|
230
|
+
if (result.ok) {
|
|
231
|
+
const org = orgTokenFromResponse(result.token, params.now);
|
|
232
|
+
const saved = saveOrgToken(credentials, org, params.makeCurrent);
|
|
233
|
+
saveCliCredentials(params.configDir, saved);
|
|
234
|
+
return {
|
|
235
|
+
credentials: saved,
|
|
236
|
+
org
|
|
237
|
+
};
|
|
238
|
+
}
|
|
239
|
+
if (result.error === "invalid_grant" && attempt === 0) {
|
|
240
|
+
attempt += 1;
|
|
241
|
+
credentials = await freshIdentity({
|
|
242
|
+
...params,
|
|
243
|
+
credentials,
|
|
244
|
+
force: true
|
|
245
|
+
});
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
if (result.error === "invalid_grant") {
|
|
249
|
+
deleteCliCredentials(params.configDir);
|
|
250
|
+
throw new Errors.CLIError(USER_LOGIN_EXPIRED_MESSAGE, { exit: 1 });
|
|
251
|
+
}
|
|
252
|
+
if (result.error === "unsupported_grant_type") throw new Errors.CLIError("This Primitive server does not support switching organizations for CLI logins. Run `primitive logout`, then `primitive login`, to sign in to one organization.", { exit: 1 });
|
|
253
|
+
if (result.reason) throw new CliOrgRefusedError({
|
|
254
|
+
message: refusalMessage(result.reason, result.description),
|
|
255
|
+
orgId: params.orgId,
|
|
256
|
+
reason: result.reason
|
|
257
|
+
});
|
|
258
|
+
throw new Errors.CLIError(`Could not get an access token for organization ${params.orgId}${result.description ? `: ${result.description}` : "."}`, { exit: 1 });
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
function saveOrgToken(credentials, org, makeCurrent) {
|
|
262
|
+
const grant = requireUserGrant(credentials);
|
|
263
|
+
const cache = { ...grant.org_tokens };
|
|
264
|
+
if (makeCurrent) {
|
|
265
|
+
if (credentials.org_id && credentials.access_token) cache[credentials.org_id] = {
|
|
266
|
+
access_token: credentials.access_token,
|
|
267
|
+
expires_at: credentials.expires_at,
|
|
268
|
+
oauth_grant_id: credentials.oauth_grant_id,
|
|
269
|
+
org_id: credentials.org_id,
|
|
270
|
+
org_name: credentials.org_name,
|
|
271
|
+
role: grant.org_role ?? null
|
|
272
|
+
};
|
|
273
|
+
delete cache[org.org_id];
|
|
274
|
+
return {
|
|
275
|
+
...credentialsForOrg(credentials, org),
|
|
276
|
+
user_grant: {
|
|
277
|
+
...grant,
|
|
278
|
+
org_role: org.role,
|
|
279
|
+
org_tokens: pruneOrgTokens(cache)
|
|
280
|
+
}
|
|
281
|
+
};
|
|
282
|
+
}
|
|
283
|
+
if (org.org_id === credentials.org_id) return {
|
|
284
|
+
...credentialsForOrg(credentials, org),
|
|
285
|
+
user_grant: {
|
|
286
|
+
...grant,
|
|
287
|
+
org_role: org.role
|
|
288
|
+
}
|
|
289
|
+
};
|
|
290
|
+
cache[org.org_id] = org;
|
|
291
|
+
return {
|
|
292
|
+
...credentials,
|
|
293
|
+
user_grant: {
|
|
294
|
+
...grant,
|
|
295
|
+
org_tokens: pruneOrgTokens(cache)
|
|
296
|
+
}
|
|
297
|
+
};
|
|
298
|
+
}
|
|
299
|
+
function pruneOrgTokens(cache) {
|
|
300
|
+
const entries = Object.entries(cache).sort(([, left], [, right]) => Date.parse(right.expires_at) - Date.parse(left.expires_at));
|
|
301
|
+
return Object.fromEntries(entries.slice(0, MAX_CACHED_ORG_TOKENS));
|
|
302
|
+
}
|
|
303
|
+
/** The organizations the person can switch to. */
|
|
304
|
+
async function fetchCliOrgs(params) {
|
|
305
|
+
const now = params.now ?? Date.now;
|
|
306
|
+
let credentials = params.credentials;
|
|
307
|
+
requireUserGrant(credentials);
|
|
308
|
+
const grant = credentials.user_grant;
|
|
309
|
+
if (expiringSoon(grant.expires_at, now)) credentials = await withLock(params, () => {
|
|
310
|
+
const current = loadCliCredentials(params.configDir) ?? credentials;
|
|
311
|
+
return freshIdentity({
|
|
312
|
+
...params,
|
|
313
|
+
credentials: current,
|
|
314
|
+
now
|
|
315
|
+
});
|
|
316
|
+
});
|
|
317
|
+
const request = async (token) => (params.fetch ?? fetch)(cliOrgsEndpoint(params.apiBaseUrl), {
|
|
318
|
+
headers: {
|
|
319
|
+
...params.headers ?? {},
|
|
320
|
+
authorization: `Bearer ${token}`
|
|
321
|
+
},
|
|
322
|
+
method: "GET"
|
|
323
|
+
});
|
|
324
|
+
let response = await request(credentials.user_grant.access_token);
|
|
325
|
+
if (response.status === 401) {
|
|
326
|
+
credentials = await withLock(params, () => {
|
|
327
|
+
const current = loadCliCredentials(params.configDir) ?? credentials;
|
|
328
|
+
return freshIdentity({
|
|
329
|
+
...params,
|
|
330
|
+
credentials: current,
|
|
331
|
+
force: true,
|
|
332
|
+
now
|
|
333
|
+
});
|
|
334
|
+
});
|
|
335
|
+
response = await request(credentials.user_grant.access_token);
|
|
336
|
+
}
|
|
337
|
+
const payload = await response.json().catch(() => null);
|
|
338
|
+
if (!response.ok) {
|
|
339
|
+
const error = isRecord(payload) && isRecord(payload.error) ? payload.error : {};
|
|
340
|
+
if (response.status === 404 && error.code === "feature_disabled") throw new Errors.CLIError("This Primitive server does not support switching organizations for CLI logins.", { exit: 1 });
|
|
341
|
+
throw new Errors.CLIError(`Could not list organizations${typeof error.message === "string" ? `: ${error.message}` : ` (HTTP ${response.status}).`}`, { exit: 1 });
|
|
342
|
+
}
|
|
343
|
+
const data = isRecord(payload) ? payload.data : null;
|
|
344
|
+
if (!isRecord(data) || !Array.isArray(data.orgs)) throw new Errors.CLIError("Primitive API returned an unexpected organization list.", { exit: 1 });
|
|
345
|
+
return data;
|
|
346
|
+
}
|
|
347
|
+
/** How long a command waits for another CLI process to finish with the saved login. */
|
|
348
|
+
const LOCK_WAIT_MS = 15e3;
|
|
349
|
+
const LOCK_RETRY_MS = 100;
|
|
350
|
+
function isLockBusy(error) {
|
|
351
|
+
return error instanceof Error && error.message.startsWith("Another Primitive CLI credential operation is already in progress");
|
|
352
|
+
}
|
|
353
|
+
/**
|
|
354
|
+
* Run `fn` holding the credentials lock. Exchanges happen every few minutes
|
|
355
|
+
* and from parallel commands, so a busy lock is waited for (briefly) rather
|
|
356
|
+
* than refused; callers re-read the saved login once they hold it.
|
|
357
|
+
*/
|
|
358
|
+
async function withLock(params, fn) {
|
|
359
|
+
let release;
|
|
360
|
+
if (!params.credentialsLockHeld) {
|
|
361
|
+
const deadline = Date.now() + LOCK_WAIT_MS;
|
|
362
|
+
for (;;) try {
|
|
363
|
+
release = acquireCliCredentialsLock(params.configDir);
|
|
364
|
+
break;
|
|
365
|
+
} catch (error) {
|
|
366
|
+
if (isLockBusy(error) && Date.now() < deadline) {
|
|
367
|
+
await new Promise((done) => setTimeout(done, LOCK_RETRY_MS));
|
|
368
|
+
continue;
|
|
369
|
+
}
|
|
370
|
+
if (isLockBusy(error)) throw new Errors.CLIError("Another Primitive CLI command is still updating the saved login. Retry in a moment.", { exit: 1 });
|
|
371
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
372
|
+
throw new Errors.CLIError(detail, { exit: 1 });
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
try {
|
|
376
|
+
return await fn();
|
|
377
|
+
} finally {
|
|
378
|
+
release?.();
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
/**
|
|
382
|
+
* Match `selection` (an organization id, or a name) against the person's
|
|
383
|
+
* organizations. Ids pass through without a lookup.
|
|
384
|
+
*/
|
|
385
|
+
function matchOrg(selection, orgs) {
|
|
386
|
+
const wanted = selection.trim();
|
|
387
|
+
const byId = orgs.find((org) => org.id.toLowerCase() === wanted.toLowerCase());
|
|
388
|
+
if (byId) return byId;
|
|
389
|
+
const byName = orgs.filter((org) => (org.name ?? "").trim().toLowerCase() === wanted.toLowerCase());
|
|
390
|
+
if (byName.length === 1) return byName[0];
|
|
391
|
+
if (byName.length > 1) throw new Errors.CLIError(`More than one organization is named ${JSON.stringify(wanted)}. Use its id: ${byName.map((org) => org.id).join(", ")}.`, { exit: 1 });
|
|
392
|
+
throw new Errors.CLIError(`No organization matches ${JSON.stringify(wanted)}. Run \`primitive orgs\` to list yours.`, { exit: 1 });
|
|
393
|
+
}
|
|
394
|
+
/** Resolve an `--org` value to an organization id, listing orgs only for a name. */
|
|
395
|
+
async function resolveOrgSelection(params) {
|
|
396
|
+
const selection = params.selection.trim();
|
|
397
|
+
if (isUuid(selection)) return selection.toLowerCase();
|
|
398
|
+
requireUserGrant(params.credentials);
|
|
399
|
+
return matchOrg(selection, (await fetchCliOrgs(params)).orgs).id;
|
|
400
|
+
}
|
|
401
|
+
/**
|
|
402
|
+
* The org-bound credentials for this command: the current organization, or
|
|
403
|
+
* `orgSelection` (from `--org`) when given. Exchanges when the token for
|
|
404
|
+
* that organization is missing or about to expire.
|
|
405
|
+
*/
|
|
406
|
+
async function ensureUserGrantSession(params) {
|
|
407
|
+
const now = params.now ?? Date.now;
|
|
408
|
+
requireUserGrant(params.credentials);
|
|
409
|
+
const raw = params.orgSelection?.trim() || null;
|
|
410
|
+
const selection = raw === null ? null : await resolveOrgSelection({
|
|
411
|
+
...params,
|
|
412
|
+
credentials: params.credentials,
|
|
413
|
+
now,
|
|
414
|
+
selection: raw
|
|
415
|
+
});
|
|
416
|
+
const fast = usableOrgToken(params.credentials, selection, now);
|
|
417
|
+
if (fast && !params.forceExchange) return fast;
|
|
418
|
+
return withLock(params, async () => {
|
|
419
|
+
const current = loadCliCredentials(params.configDir);
|
|
420
|
+
if (!current) throw new Errors.CLIError(USER_LOGIN_EXPIRED_MESSAGE, { exit: 1 });
|
|
421
|
+
requireUserGrant(current);
|
|
422
|
+
const orgId = selection ?? (current.org_id || null);
|
|
423
|
+
if (!orgId) throw new Errors.CLIError(NO_CURRENT_ORG_MESSAGE, { exit: 1 });
|
|
424
|
+
const saved = usableOrgToken(current, orgId, now);
|
|
425
|
+
if (saved && (!params.forceExchange || params.staleToken !== void 0 && saved.access_token !== params.staleToken)) return saved;
|
|
426
|
+
const { credentials, org } = await exchangeAndSave({
|
|
427
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
428
|
+
configDir: params.configDir,
|
|
429
|
+
credentials: current,
|
|
430
|
+
fetch: params.fetch,
|
|
431
|
+
headers: params.headers,
|
|
432
|
+
makeCurrent: false,
|
|
433
|
+
now,
|
|
434
|
+
orgId
|
|
435
|
+
});
|
|
436
|
+
return credentialsForOrg(credentials, org);
|
|
437
|
+
});
|
|
438
|
+
}
|
|
439
|
+
function usableOrgToken(credentials, selection, now) {
|
|
440
|
+
const grant = credentials.user_grant;
|
|
441
|
+
if (!grant) return null;
|
|
442
|
+
const wanted = selection?.toLowerCase() ?? null;
|
|
443
|
+
if (wanted === null || wanted === credentials.org_id.toLowerCase()) {
|
|
444
|
+
if (!credentials.org_id || !credentials.access_token) return null;
|
|
445
|
+
return expiringSoon(credentials.expires_at, now) ? null : credentials;
|
|
446
|
+
}
|
|
447
|
+
const cached = Object.values(grant.org_tokens).find((token) => token.org_id.toLowerCase() === wanted);
|
|
448
|
+
if (!cached || expiringSoon(cached.expires_at, now)) return null;
|
|
449
|
+
return credentialsForOrg(credentials, cached);
|
|
450
|
+
}
|
|
451
|
+
/** `primitive org use`: exchange for `selection` and make it the current organization. */
|
|
452
|
+
async function switchCurrentOrg(params) {
|
|
453
|
+
const now = params.now ?? Date.now;
|
|
454
|
+
return withLock(params, async () => {
|
|
455
|
+
const current = loadCliCredentials(params.configDir);
|
|
456
|
+
if (!current) throw new Errors.CLIError("Not logged in. Run `primitive login` first.", { exit: 1 });
|
|
457
|
+
requireUserGrant(current);
|
|
458
|
+
const orgId = await resolveOrgSelection({
|
|
459
|
+
...params,
|
|
460
|
+
credentials: current,
|
|
461
|
+
credentialsLockHeld: true,
|
|
462
|
+
now
|
|
463
|
+
});
|
|
464
|
+
const latest = loadCliCredentials(params.configDir);
|
|
465
|
+
if (!latest) throw new Errors.CLIError(USER_LOGIN_EXPIRED_MESSAGE, { exit: 1 });
|
|
466
|
+
return exchangeAndSave({
|
|
467
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
468
|
+
configDir: params.configDir,
|
|
469
|
+
credentials: latest,
|
|
470
|
+
fetch: params.fetch,
|
|
471
|
+
headers: params.headers,
|
|
472
|
+
makeCurrent: true,
|
|
473
|
+
now,
|
|
474
|
+
orgId
|
|
475
|
+
});
|
|
476
|
+
});
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Wrap `fetch` so a 401 on a per-org token is answered by one fresh
|
|
480
|
+
* exchange and one retry, as the server asks. If the exchange is refused,
|
|
481
|
+
* the caller sees a 403 carrying the refusal's reason as its error code.
|
|
482
|
+
*/
|
|
483
|
+
function reexchangingFetch(params) {
|
|
484
|
+
const base = (input, init) => (params.base ?? globalThis.fetch)(input, init);
|
|
485
|
+
let pending = null;
|
|
486
|
+
const wrapped = async (input, init) => {
|
|
487
|
+
const request = new Request(input, init);
|
|
488
|
+
const sent = /^Bearer\s+(\S+)$/i.exec(request.headers.get("authorization") ?? "")?.[1];
|
|
489
|
+
const retry = request.clone();
|
|
490
|
+
const response = await base(request);
|
|
491
|
+
if (response.status !== 401 || !sent) return response;
|
|
492
|
+
if (params.token.current === sent) {
|
|
493
|
+
pending ??= params.reexchange(sent).finally(() => {
|
|
494
|
+
pending = null;
|
|
495
|
+
});
|
|
496
|
+
const outcome = await pending;
|
|
497
|
+
if (!outcome.ok) return Response.json({
|
|
498
|
+
success: false,
|
|
499
|
+
error: {
|
|
500
|
+
code: outcome.code,
|
|
501
|
+
message: outcome.message
|
|
502
|
+
}
|
|
503
|
+
}, { status: outcome.status });
|
|
504
|
+
params.token.current = outcome.token;
|
|
505
|
+
}
|
|
506
|
+
retry.headers.set("authorization", `Bearer ${params.token.current}`);
|
|
507
|
+
return base(retry);
|
|
508
|
+
};
|
|
509
|
+
return wrapped;
|
|
510
|
+
}
|
|
511
|
+
/** The reexchange callback for `reexchangingFetch`, for a saved user login. */
|
|
512
|
+
function savedLoginReexchange(params) {
|
|
513
|
+
return async (staleToken) => {
|
|
514
|
+
try {
|
|
515
|
+
const current = loadCliCredentials(params.configDir);
|
|
516
|
+
if (!current?.user_grant) return {
|
|
517
|
+
ok: false,
|
|
518
|
+
status: 401,
|
|
519
|
+
code: "unauthorized",
|
|
520
|
+
message: USER_LOGIN_EXPIRED_MESSAGE
|
|
521
|
+
};
|
|
522
|
+
return {
|
|
523
|
+
ok: true,
|
|
524
|
+
token: (await ensureUserGrantSession({
|
|
525
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
526
|
+
configDir: params.configDir,
|
|
527
|
+
credentials: current,
|
|
528
|
+
fetch: params.fetch,
|
|
529
|
+
forceExchange: true,
|
|
530
|
+
headers: params.headers,
|
|
531
|
+
now: params.now,
|
|
532
|
+
orgSelection: params.orgId,
|
|
533
|
+
staleToken
|
|
534
|
+
})).access_token
|
|
535
|
+
};
|
|
536
|
+
} catch (error) {
|
|
537
|
+
if (error instanceof CliOrgRefusedError) return {
|
|
538
|
+
ok: false,
|
|
539
|
+
status: 403,
|
|
540
|
+
code: error.reason,
|
|
541
|
+
message: error.message
|
|
542
|
+
};
|
|
543
|
+
return {
|
|
544
|
+
ok: false,
|
|
545
|
+
status: 401,
|
|
546
|
+
code: "unauthorized",
|
|
547
|
+
message: error instanceof Error ? error.message : String(error)
|
|
548
|
+
};
|
|
549
|
+
}
|
|
550
|
+
};
|
|
551
|
+
}
|
|
552
|
+
/** Sign the person out of every organization by revoking the user login. */
|
|
553
|
+
async function revokeUserLogin(params) {
|
|
554
|
+
const result = await postTokenEndpoint({
|
|
555
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
556
|
+
body: {
|
|
557
|
+
client_id: params.credentials.oauth_client_id,
|
|
558
|
+
token: params.credentials.refresh_token,
|
|
559
|
+
token_type_hint: "refresh_token"
|
|
560
|
+
},
|
|
561
|
+
fetch: params.fetch,
|
|
562
|
+
headers: params.headers,
|
|
563
|
+
path: "/oauth/revoke",
|
|
564
|
+
allowEmptySuccess: true
|
|
565
|
+
});
|
|
566
|
+
if (result.ok) return { ok: true };
|
|
567
|
+
return {
|
|
568
|
+
ok: false,
|
|
569
|
+
status: result.status,
|
|
570
|
+
message: result.description ?? result.error ?? `The server answered HTTP ${result.status}.`
|
|
571
|
+
};
|
|
572
|
+
}
|
|
573
|
+
/** Build the saved credentials for a user login from the login poll. */
|
|
574
|
+
function credentialsFromUserLogin(params) {
|
|
575
|
+
const now = params.now ?? Date.now;
|
|
576
|
+
const org = isOrgAccessToken(params.login.org) ? orgTokenFromResponse(params.login.org, now) : null;
|
|
577
|
+
return {
|
|
578
|
+
access_token: org?.access_token ?? "",
|
|
579
|
+
api_base_url: params.apiBaseUrl,
|
|
580
|
+
auth_method: "user_grant",
|
|
581
|
+
created_at: new Date(now()).toISOString(),
|
|
582
|
+
expires_at: org?.expires_at ?? (/* @__PURE__ */ new Date(0)).toISOString(),
|
|
583
|
+
oauth_client_id: params.login.oauth_client_id,
|
|
584
|
+
oauth_grant_id: org?.oauth_grant_id ?? "",
|
|
585
|
+
org_id: org?.org_id ?? "",
|
|
586
|
+
org_name: org?.org_name ?? null,
|
|
587
|
+
refresh_token: params.login.refresh_token,
|
|
588
|
+
token_type: "Bearer",
|
|
589
|
+
user_grant: {
|
|
590
|
+
access_token: params.login.access_token,
|
|
591
|
+
expires_at: cliAccessTokenExpiresAt(params.login.expires_in, now),
|
|
592
|
+
org_role: org?.role ?? null,
|
|
593
|
+
org_tokens: {},
|
|
594
|
+
requested_grant: params.requestedGrant ?? null,
|
|
595
|
+
user_grant_id: params.login.user_grant_id,
|
|
596
|
+
user_id: params.login.user_id
|
|
597
|
+
}
|
|
598
|
+
};
|
|
599
|
+
}
|
|
8
600
|
// src/oclif/api-client.ts
|
|
9
601
|
const API_HEADERS_ENV = "PRIMITIVE_API_HEADERS";
|
|
10
602
|
const OAUTH_REFRESH_SKEW_MS = 6e4;
|
|
@@ -99,8 +691,47 @@ function oauthErrorDescription(value) {
|
|
|
99
691
|
const raw = value.error_description;
|
|
100
692
|
return typeof raw === "string" && raw.trim() ? raw : null;
|
|
101
693
|
}
|
|
694
|
+
/** The organization the global `--org` flag (or PRIMITIVE_CLI_ORG) selects, if any. */
|
|
695
|
+
function selectedOrgFromEnv(env = process.env) {
|
|
696
|
+
return env["PRIMITIVE_CLI_ORG"]?.trim() || null;
|
|
697
|
+
}
|
|
698
|
+
/**
|
|
699
|
+
* Refuse `--org` before a command acts with a connected agent profile's own
|
|
700
|
+
* key: the profile belongs to one organization and cannot act in another.
|
|
701
|
+
*/
|
|
702
|
+
function refuseOrgSelectionForProfile(profileName, env = process.env) {
|
|
703
|
+
const selection = selectedOrgFromEnv(env);
|
|
704
|
+
if (!selection) return;
|
|
705
|
+
const who = profileName ? `Connected agent profile ${profileName}` : "A connected agent credential";
|
|
706
|
+
throw new Errors.CLIError(`--org (PRIMITIVE_CLI_ORG=${selection}) selects an organization for a saved \`primitive login\`. ${who} belongs to one organization; drop --org, or unset PRIMITIVE_CLI_ORG.`, { exit: 1 });
|
|
707
|
+
}
|
|
708
|
+
function orgMatches(credentials, selection) {
|
|
709
|
+
const wanted = selection.toLowerCase();
|
|
710
|
+
return credentials.org_id.toLowerCase() === wanted || (credentials.org_name ?? "").trim().toLowerCase() === wanted;
|
|
711
|
+
}
|
|
712
|
+
/**
|
|
713
|
+
* Refuse `--org` for an org-bound login unless it names that login's own
|
|
714
|
+
* organization: an org-bound login cannot act anywhere else.
|
|
715
|
+
*/
|
|
716
|
+
function assertOrgBoundSelection(credentials, selection) {
|
|
717
|
+
if (!selection || orgMatches(credentials, selection)) return;
|
|
718
|
+
const org = credentials.org_name ? `${credentials.org_name} (${credentials.org_id})` : credentials.org_id;
|
|
719
|
+
throw new Errors.CLIError(`This saved login is bound to organization ${org}, so --org ${selection} cannot be used with it. Run \`primitive logout\`, then \`primitive login\`, to sign in as yourself and switch between organizations.`, { exit: 1 });
|
|
720
|
+
}
|
|
102
721
|
async function refreshStoredCliCredentials(params) {
|
|
103
722
|
const now = params.now ?? Date.now;
|
|
723
|
+
const orgSelection = params.orgSelection === void 0 ? selectedOrgFromEnv() : params.orgSelection;
|
|
724
|
+
if (params.credentials.auth_method === "user_grant") return ensureUserGrantSession({
|
|
725
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
726
|
+
configDir: params.configDir,
|
|
727
|
+
credentials: params.credentials,
|
|
728
|
+
credentialsLockHeld: params.credentialsLockHeld,
|
|
729
|
+
fetch: params.fetch,
|
|
730
|
+
headers: params.headers,
|
|
731
|
+
now,
|
|
732
|
+
orgSelection
|
|
733
|
+
});
|
|
734
|
+
assertOrgBoundSelection(params.credentials, orgSelection);
|
|
104
735
|
if (!shouldRefresh(params.credentials, now)) return params.credentials;
|
|
105
736
|
let releaseLock;
|
|
106
737
|
try {
|
|
@@ -112,6 +743,16 @@ async function refreshStoredCliCredentials(params) {
|
|
|
112
743
|
}
|
|
113
744
|
const current = loadCliCredentials(params.configDir);
|
|
114
745
|
if (!current) throw new Errors.CLIError("Saved Primitive CLI OAuth session is no longer available. Run `primitive signin` to authenticate again.", { exit: 1 });
|
|
746
|
+
if (current.auth_method === "user_grant") return ensureUserGrantSession({
|
|
747
|
+
apiBaseUrl: params.apiBaseUrl,
|
|
748
|
+
configDir: params.configDir,
|
|
749
|
+
credentials: current,
|
|
750
|
+
credentialsLockHeld: true,
|
|
751
|
+
fetch: params.fetch,
|
|
752
|
+
headers: params.headers,
|
|
753
|
+
now,
|
|
754
|
+
orgSelection
|
|
755
|
+
});
|
|
115
756
|
if (!shouldRefresh(current, now)) return current;
|
|
116
757
|
const fetchImpl = params.fetch ?? fetch;
|
|
117
758
|
const body = new URLSearchParams({
|
|
@@ -172,6 +813,9 @@ async function createAuthenticatedCliApiClient(params) {
|
|
|
172
813
|
if (hint) process.stderr.write(`${hint}\n`);
|
|
173
814
|
}
|
|
174
815
|
if (params.requireConnectedAgentKey && (auth.source === "stored" || !auth.apiKey?.startsWith("pconn_"))) throw new ConnectedAgentKeyRequiredError("A connected agent credential is required.");
|
|
816
|
+
const orgSelection = selectedOrgFromEnv(params.env ?? process.env);
|
|
817
|
+
if (orgSelection && auth.source !== "stored") throw new Errors.CLIError(`--org (PRIMITIVE_CLI_ORG=${orgSelection}) selects an organization for a saved \`primitive login\`. ${auth.source === "connected-profile" ? "A connected agent profile" : auth.source === "flag-or-env" ? "An API key" : "This command's credential"} belongs to one organization; drop --org, or unset PRIMITIVE_CLI_ORG.`, { exit: 1 });
|
|
818
|
+
let userLoginToken = null;
|
|
175
819
|
if (auth.source === "stored" && auth.credentials) {
|
|
176
820
|
const refreshed = await refreshStoredCliCredentials({
|
|
177
821
|
apiBaseUrl: auth.apiBaseUrl,
|
|
@@ -180,17 +824,33 @@ async function createAuthenticatedCliApiClient(params) {
|
|
|
180
824
|
credentialsLockHeld: params.credentialsLockHeld,
|
|
181
825
|
fetch: params.fetch,
|
|
182
826
|
headers: requestConfig.headers,
|
|
183
|
-
now: params.now
|
|
827
|
+
now: params.now,
|
|
828
|
+
orgSelection
|
|
184
829
|
});
|
|
185
830
|
auth = {
|
|
186
831
|
...auth,
|
|
187
832
|
apiKey: refreshed.access_token,
|
|
188
833
|
credentials: refreshed
|
|
189
834
|
};
|
|
835
|
+
if (refreshed.auth_method === "user_grant") userLoginToken = { current: refreshed.access_token };
|
|
190
836
|
}
|
|
837
|
+
const tokenHolder = userLoginToken;
|
|
191
838
|
return {
|
|
192
839
|
apiClient: new PrimitiveApiClient({
|
|
193
|
-
|
|
840
|
+
...tokenHolder && auth.credentials ? {
|
|
841
|
+
auth: (security) => security.type === "http" && security.scheme === "bearer" ? tokenHolder.current : void 0,
|
|
842
|
+
fetch: reexchangingFetch({
|
|
843
|
+
reexchange: savedLoginReexchange({
|
|
844
|
+
apiBaseUrl: auth.apiBaseUrl,
|
|
845
|
+
configDir: params.configDir,
|
|
846
|
+
fetch: params.fetch,
|
|
847
|
+
headers: requestConfig.headers,
|
|
848
|
+
now: params.now,
|
|
849
|
+
orgId: auth.credentials.org_id
|
|
850
|
+
}),
|
|
851
|
+
token: tokenHolder
|
|
852
|
+
})
|
|
853
|
+
} : { apiKey: auth.apiKey },
|
|
194
854
|
apiBaseUrl: auth.apiBaseUrl,
|
|
195
855
|
headers: auth.connectedAgent ? void 0 : requestConfig.headers
|
|
196
856
|
}),
|
|
@@ -503,7 +1163,7 @@ function isReplay(data) {
|
|
|
503
1163
|
if (data === null || typeof data !== "object") return false;
|
|
504
1164
|
return data.idempotent_replay === true;
|
|
505
1165
|
}
|
|
506
|
-
function nonEmptyString(value) {
|
|
1166
|
+
function nonEmptyString$1(value) {
|
|
507
1167
|
return typeof value === "string" && value ? value : null;
|
|
508
1168
|
}
|
|
509
1169
|
/**
|
|
@@ -523,8 +1183,8 @@ function readReplayDetail(sent, keyOrigin) {
|
|
|
523
1183
|
else return null;
|
|
524
1184
|
return {
|
|
525
1185
|
kind,
|
|
526
|
-
originalId: nonEmptyString(object.original_sent_email_id) ?? nonEmptyString(sent.id),
|
|
527
|
-
originalCreatedAt: nonEmptyString(object.original_created_at)
|
|
1186
|
+
originalId: nonEmptyString$1(object.original_sent_email_id) ?? nonEmptyString$1(sent.id),
|
|
1187
|
+
originalCreatedAt: nonEmptyString$1(object.original_created_at)
|
|
528
1188
|
};
|
|
529
1189
|
}
|
|
530
1190
|
/** "12s ago", "4m ago", "3h ago", "9d ago"; null when the time is unusable. */
|
|
@@ -541,8 +1201,8 @@ function formatReplayAge(createdAt, now) {
|
|
|
541
1201
|
return `${Math.round(hours / 24)}d ago`;
|
|
542
1202
|
}
|
|
543
1203
|
function statusSuffix(sent) {
|
|
544
|
-
const status = nonEmptyString(sent.status);
|
|
545
|
-
const deliveryStatus = nonEmptyString(sent.delivery_status);
|
|
1204
|
+
const status = nonEmptyString$1(sent.status);
|
|
1205
|
+
const deliveryStatus = nonEmptyString$1(sent.delivery_status);
|
|
546
1206
|
const details = [];
|
|
547
1207
|
if (status) details.push(`status ${status}`);
|
|
548
1208
|
if (deliveryStatus && deliveryStatus !== status) details.push(`delivery status ${deliveryStatus}`);
|
|
@@ -558,7 +1218,7 @@ function formatSpecificNotice(sent, detail, options) {
|
|
|
558
1218
|
switch (detail.kind) {
|
|
559
1219
|
case "auto_content": return `Not sent: identical to ${id}${when}${state}.${advise ? ` Pass ${keyHint} to send again.` : ""}`;
|
|
560
1220
|
case "explicit": {
|
|
561
|
-
const key = nonEmptyString(sent.client_idempotency_key);
|
|
1221
|
+
const key = nonEmptyString$1(sent.client_idempotency_key);
|
|
562
1222
|
return `Not sent: ${key ? `idempotency key ${key}` : "this idempotency key"} was already used for ${id}${when}${state}. A key you pass never expires.${advise ? " Use a different key only for a different message." : ""}`;
|
|
563
1223
|
}
|
|
564
1224
|
case "function_trigger": return `Not sent: this Function already sent this message as ${id}${when}${state}, for the same email or event that invoked it. A Function that runs again for one trigger sends once.`;
|
|
@@ -1213,7 +1873,6 @@ function record$1(value) {
|
|
|
1213
1873
|
function categoryOf(hint, kind, fyi, candidate) {
|
|
1214
1874
|
if (hint === "pending") return candidate ? "pending" : "ordinary";
|
|
1215
1875
|
if (hint === "card") switch (kind) {
|
|
1216
|
-
case "x402.payment/1": return "payment";
|
|
1217
1876
|
case "primitive.contact/1": return "contact";
|
|
1218
1877
|
case "repeat.tick/1": return "repeat";
|
|
1219
1878
|
case "repeat.stop/1": return "repeat_stopped";
|
|
@@ -1228,7 +1887,6 @@ function replyExpectation(category) {
|
|
|
1228
1887
|
case "fyi":
|
|
1229
1888
|
case "signal":
|
|
1230
1889
|
case "repeat_stopped": return "no_reply";
|
|
1231
|
-
case "payment":
|
|
1232
1890
|
case "contact": return "answer_with_command";
|
|
1233
1891
|
case "repeat": return "repeat";
|
|
1234
1892
|
case "unsupported": return "unsupported";
|
|
@@ -1280,19 +1938,6 @@ function interactionNextActions(interaction, emailId, options = {}) {
|
|
|
1280
1938
|
const id = emailId.toLowerCase();
|
|
1281
1939
|
switch (interaction?.category ?? "ordinary") {
|
|
1282
1940
|
case "ordinary": return [replyAction(bin, id)];
|
|
1283
|
-
case "payment": return [buildFollowUpCommand("inspect_payment", "Show the payment request's terms before paying", [
|
|
1284
|
-
bin,
|
|
1285
|
-
"payments",
|
|
1286
|
-
"challenge-from-email",
|
|
1287
|
-
"--id",
|
|
1288
|
-
id
|
|
1289
|
-
]), buildFollowUpCommand("pay", "Pay the payment request (signs with your wallet key and sends the payment)", [
|
|
1290
|
-
bin,
|
|
1291
|
-
"payments",
|
|
1292
|
-
"pay-email",
|
|
1293
|
-
"--in-reply-to",
|
|
1294
|
-
id
|
|
1295
|
-
])];
|
|
1296
1941
|
case "contact": return [buildFollowUpCommand("accept_contact", "Accept the contact request, if the owner's policy allows it", [
|
|
1297
1942
|
bin,
|
|
1298
1943
|
"contacts",
|
|
@@ -1335,11 +1980,6 @@ function interactionHumanLine(interaction, actions) {
|
|
|
1335
1980
|
case "fyi": return "Informational (fyi): no reply needed, not even another fyi.";
|
|
1336
1981
|
case "signal": return `Status signal (${kind}): no reply needed.`;
|
|
1337
1982
|
case "repeat_stopped": return `Notice that a repeating message stopped (${kind}): no reply needed.`;
|
|
1338
|
-
case "payment": {
|
|
1339
|
-
const inspect = command("inspect_payment");
|
|
1340
|
-
const pay = command("pay");
|
|
1341
|
-
return inspect && pay ? `Payment interaction (${kind}). If it requests payment, review it with ${inspect} and pay with ${pay}. A plain reply does not pay or decline it.` : null;
|
|
1342
|
-
}
|
|
1343
1983
|
case "contact": {
|
|
1344
1984
|
const accept = command("accept_contact");
|
|
1345
1985
|
return accept ? `Contact interaction (${kind}). If it is a contact request, accept it under the owner's policy with ${accept}. A plain reply does not accept it.` : null;
|
|
@@ -1414,12 +2054,6 @@ function replyInteractionWarning(detail, emailId) {
|
|
|
1414
2054
|
const expectation = replyExpectation(interaction.category);
|
|
1415
2055
|
if (expectation === "reply" || expectation === "repeat") return null;
|
|
1416
2056
|
switch (interaction.category) {
|
|
1417
|
-
case "payment": return {
|
|
1418
|
-
...base,
|
|
1419
|
-
code: "interaction_not_answered",
|
|
1420
|
-
message: `Warning: email ${id} is a payment interaction (${label}). A plain reply does not pay or decline it; sending anyway. To pay: ${commandFor("pay")}`,
|
|
1421
|
-
expected
|
|
1422
|
-
};
|
|
1423
2057
|
case "contact": return {
|
|
1424
2058
|
...base,
|
|
1425
2059
|
code: "interaction_not_answered",
|
|
@@ -2101,7 +2735,7 @@ function savedConnectedProfiles(configDir, exclude) {
|
|
|
2101
2735
|
*/
|
|
2102
2736
|
async function otherProfiles(configDir, session, current) {
|
|
2103
2737
|
if (session) try {
|
|
2104
|
-
const { connectedProfilesForSession } = await import("./machine-session-
|
|
2738
|
+
const { connectedProfilesForSession } = await import("./machine-session-g-xPd3X0.js").then((n) => n.s);
|
|
2105
2739
|
return {
|
|
2106
2740
|
inSession: true,
|
|
2107
2741
|
profiles: connectedProfilesForSession(configDir, session, current)
|
|
@@ -2213,6 +2847,8 @@ const API_ERROR_CODES = {
|
|
|
2213
2847
|
accessDenied: "access_denied",
|
|
2214
2848
|
authorizationPending: "authorization_pending",
|
|
2215
2849
|
expiredToken: "expired_token",
|
|
2850
|
+
forbidden: "forbidden",
|
|
2851
|
+
grantForbidden: "grant_forbidden",
|
|
2216
2852
|
invalidDeviceCode: "invalid_device_code",
|
|
2217
2853
|
notFound: "not_found",
|
|
2218
2854
|
slowDown: "slow_down",
|
|
@@ -2490,6 +3126,47 @@ function rateLimitHint(response) {
|
|
|
2490
3126
|
const delay = parseRetryAfter(response?.headers.get("retry-after"));
|
|
2491
3127
|
return delay === void 0 ? "Hint: this request was rate limited. Wait before retrying: the API request limits use a 60 second window, and send limits can take longer to reset." : `Hint: this request was rate limited. Retry in ${Math.max(1, Math.ceil(delay / 1e3))} s.`;
|
|
2492
3128
|
}
|
|
3129
|
+
/**
|
|
3130
|
+
* True when the API refused because of the credential's grant or role. The
|
|
3131
|
+
* credential itself was accepted, so this is not an authentication failure.
|
|
3132
|
+
*/
|
|
3133
|
+
function isGrantRefusal(payload) {
|
|
3134
|
+
const code = extractErrorCode(payload);
|
|
3135
|
+
return code === API_ERROR_CODES.grantForbidden || code === API_ERROR_CODES.forbidden;
|
|
3136
|
+
}
|
|
3137
|
+
/** `details` of a refusal, whether the payload is the error or its envelope. */
|
|
3138
|
+
function refusalDetails(payload) {
|
|
3139
|
+
const outer = payload && typeof payload === "object" ? payload : {};
|
|
3140
|
+
const inner = outer.error && typeof outer.error === "object" ? outer.error : {};
|
|
3141
|
+
const details = outer.details ?? inner.details;
|
|
3142
|
+
return details && typeof details === "object" ? details : {};
|
|
3143
|
+
}
|
|
3144
|
+
function nonEmptyString(value) {
|
|
3145
|
+
return typeof value === "string" && value ? value : null;
|
|
3146
|
+
}
|
|
3147
|
+
/**
|
|
3148
|
+
* The hint for a `403 grant_forbidden`: which permission the credential's
|
|
3149
|
+
* grant lacks, and how to check a credential before using it.
|
|
3150
|
+
*/
|
|
3151
|
+
function grantForbiddenHint(payload) {
|
|
3152
|
+
const record = refusalDetails(payload);
|
|
3153
|
+
const permission = nonEmptyString(record.permission);
|
|
3154
|
+
const reason = nonEmptyString(record.reason);
|
|
3155
|
+
const missing = nonEmptyString(record.missing);
|
|
3156
|
+
return `Hint: ${permission ? `This credential's grant does not include ${missing ?? permission}${reason ? ` (${reason})` : ""}.` : "This credential's grant does not allow this operation."} Use a credential whose grant includes it, or ask an owner or admin to widen it.${permission ? ` Check a credential with \`primitive whoami --check ${missing ?? permission}\`.` : ""}`;
|
|
3157
|
+
}
|
|
3158
|
+
/**
|
|
3159
|
+
* The hint for a `403 forbidden` that names a permission, or null when it does
|
|
3160
|
+
* not (a dashboard-only route, say, explains itself in its message). Unlike
|
|
3161
|
+
* `grant_forbidden`, this refusal can come from a route closed to keys and the
|
|
3162
|
+
* CLI altogether, so it never promises that a wider credential opens it.
|
|
3163
|
+
*/
|
|
3164
|
+
function forbiddenHint(payload) {
|
|
3165
|
+
const record = refusalDetails(payload);
|
|
3166
|
+
const permission = nonEmptyString(record.permission);
|
|
3167
|
+
if (!permission || record.reason !== "permission_missing") return null;
|
|
3168
|
+
return `Hint: This operation needs ${permission}, which this credential does not have. Some operations stay closed to keys and the CLI even with it. Check a credential with \`primitive whoami --check ${permission}\`.`;
|
|
3169
|
+
}
|
|
2493
3170
|
function writeErrorWithHints(payload, response) {
|
|
2494
3171
|
process.stderr.write(`${formatErrorPayload(payload)}\n`);
|
|
2495
3172
|
const code = extractErrorCode(payload);
|
|
@@ -2498,6 +3175,15 @@ function writeErrorWithHints(payload, response) {
|
|
|
2498
3175
|
return;
|
|
2499
3176
|
}
|
|
2500
3177
|
if (!code) return;
|
|
3178
|
+
if (code === "grant_forbidden") {
|
|
3179
|
+
process.stderr.write(`${grantForbiddenHint(payload)}\n`);
|
|
3180
|
+
return;
|
|
3181
|
+
}
|
|
3182
|
+
if (code === API_ERROR_CODES.forbidden) {
|
|
3183
|
+
const hint = forbiddenHint(payload);
|
|
3184
|
+
if (hint) process.stderr.write(`${hint}\n`);
|
|
3185
|
+
return;
|
|
3186
|
+
}
|
|
2501
3187
|
if (code in ERROR_CODE_HINTS) {
|
|
2502
3188
|
const hint = ERROR_CODE_HINTS[code];
|
|
2503
3189
|
process.stderr.write(`${hint}\n`);
|
|
@@ -2681,14 +3367,25 @@ function operationJsonDocument(envelope, summary = []) {
|
|
|
2681
3367
|
if (summary.length > 0) document.summary = summary.join("\n");
|
|
2682
3368
|
return document;
|
|
2683
3369
|
}
|
|
2684
|
-
|
|
2685
|
-
|
|
2686
|
-
|
|
2687
|
-
|
|
2688
|
-
|
|
2689
|
-
|
|
2690
|
-
|
|
2691
|
-
|
|
3370
|
+
const SETTLED_DNS_RECORD_STATUSES = /* @__PURE__ */ new Set(["found", "legacy"]);
|
|
3371
|
+
/**
|
|
3372
|
+
* The hint for a failed domain verification: the API answers 400
|
|
3373
|
+
* `dns_verification_failed` with every expected record, each with its
|
|
3374
|
+
* `status`, in `error.details.records`. Lists the records that still need
|
|
3375
|
+
* work, then how to finish.
|
|
3376
|
+
*/
|
|
3377
|
+
function domainVerificationFailedHint(payload) {
|
|
3378
|
+
const body = payload !== null && typeof payload === "object" ? payload : {};
|
|
3379
|
+
const records = body.details?.records ?? body.error?.details?.records;
|
|
3380
|
+
const lines = (Array.isArray(records) ? records : []).filter((record) => record !== null && typeof record === "object" && !SETTLED_DNS_RECORD_STATUSES.has(String(record.status))).map((record) => {
|
|
3381
|
+
const name = record.fqdn ?? record.name ?? "?";
|
|
3382
|
+
return ` ${String(record.type ?? "?")} ${String(name)} ${String(record.value ?? "")} (${String(record.status ?? "unknown")})`;
|
|
3383
|
+
});
|
|
3384
|
+
return [
|
|
3385
|
+
"Domain verification is incomplete.",
|
|
3386
|
+
...lines.length > 0 ? ["Records to add or fix:", ...lines] : [],
|
|
3387
|
+
"Add or fix those DNS records, or run `primitive domains zone-file --id <domain-id>` to download the complete zone file, then retry `primitive domains verify --id <domain-id>`."
|
|
3388
|
+
].join("\n");
|
|
2692
3389
|
}
|
|
2693
3390
|
const OPERATION_HINTS = {
|
|
2694
3391
|
addDomain: "Tip: after this returns a domain id, run `primitive domains zone-file --id <domain-id> --output <domain>.zone` when the user wants an importable DNS zone file.",
|
|
@@ -2715,11 +3412,14 @@ const OPERATION_EXTRA_BODY_FLAGS = { addDomain: { "mail-relay": {
|
|
|
2715
3412
|
} } };
|
|
2716
3413
|
const MAIL_RELAY_NOT_ENABLED_MESSAGE = "Mail relay is not enabled for this account, so the domain was not added. Retry without --mail-relay to point the domain's MX at Primitive directly, or ask Primitive to enable mail relay for your account.";
|
|
2717
3414
|
const MAIL_RELAY_UNAVAILABLE_MESSAGE = "No mail relay is available on this API server right now, so the domain was not added. Retry later, or retry without --mail-relay to point the domain's MX at Primitive directly.";
|
|
2718
|
-
const OPERATION_ERROR_HINTS = {
|
|
2719
|
-
|
|
2720
|
-
|
|
2721
|
-
|
|
2722
|
-
|
|
3415
|
+
const OPERATION_ERROR_HINTS = {
|
|
3416
|
+
verifyDomain: (code, _body, payload) => code === "dns_verification_failed" ? domainVerificationFailedHint(payload) : void 0,
|
|
3417
|
+
addDomain: (code, body) => {
|
|
3418
|
+
if (code === "mail_relay_unavailable") return MAIL_RELAY_UNAVAILABLE_MESSAGE;
|
|
3419
|
+
const mailRelay = body !== null && typeof body === "object" && body.mail_relay === true;
|
|
3420
|
+
if (code === "feature_disabled" && mailRelay) return MAIL_RELAY_NOT_ENABLED_MESSAGE;
|
|
3421
|
+
}
|
|
3422
|
+
};
|
|
2723
3423
|
const OPERATION_SUCCESS_HOOKS = { verifyAgentSignup: ({ envelope, configDir, apiBaseUrl, writeStderr }) => {
|
|
2724
3424
|
const data = envelope?.data;
|
|
2725
3425
|
if (!data?.access_token || !data?.refresh_token) return;
|
|
@@ -2811,7 +3511,7 @@ function createOperationCommand(operation) {
|
|
|
2811
3511
|
if (result.error) {
|
|
2812
3512
|
const errorPayload = extractErrorPayload(result.error);
|
|
2813
3513
|
writeErrorWithHints(errorPayload, result.response);
|
|
2814
|
-
const operationErrorHint = OPERATION_ERROR_HINTS[operation.sdkName]?.(extractErrorCode(errorPayload), body);
|
|
3514
|
+
const operationErrorHint = OPERATION_ERROR_HINTS[operation.sdkName]?.(extractErrorCode(errorPayload), body, errorPayload);
|
|
2815
3515
|
if (operationErrorHint) process.stderr.write(`${operationErrorHint}\n`);
|
|
2816
3516
|
if (replyStateSurface && isAwaitingRejectedError(errorPayload)) process.stderr.write(`${awaitingRejectedError(replyStateSurface).message}\n`);
|
|
2817
3517
|
if (automatedSurface && isAutomatedRejectedError(errorPayload)) process.stderr.write(`${automatedRejectedError(automatedSurface).message}\n`);
|
|
@@ -2915,10 +3615,6 @@ function createOperationCommand(operation) {
|
|
|
2915
3615
|
process.stderr.write(`Warning: ${operation.sdkName} succeeded but its post-success hook threw (${detail}). The response below is still valid; act on it manually.\n`);
|
|
2916
3616
|
}
|
|
2917
3617
|
this.log(JSON.stringify(jsonOutput && withEnvelope ? operationJsonDocument(envelope, summary) : operationOutputPayload(envelope, withEnvelope), null, 2));
|
|
2918
|
-
if (isIncompleteDomainVerification(operation, envelope)) {
|
|
2919
|
-
writeIncompleteDomainVerificationHint();
|
|
2920
|
-
process.exitCode = 1;
|
|
2921
|
-
}
|
|
2922
3618
|
});
|
|
2923
3619
|
}
|
|
2924
3620
|
}
|
|
@@ -2935,4 +3631,4 @@ const EMPTY_RESULT_HINTS = {
|
|
|
2935
3631
|
function canonicalizeCliReferences(description) {
|
|
2936
3632
|
return description.replaceAll("`primitive emails:latest`", "`primitive emails latest`").replaceAll("`primitive describe emails:get-email | jq '.responseSchema.properties'`", "`primitive describe emails:get | jq '.responseSchema.properties'`");
|
|
2937
3633
|
}
|
|
2938
|
-
export {
|
|
3634
|
+
export { followUpCommandPrefix as $, clearConsumedPendingMail as A, ConnectedAgentKeyRequiredError as At, replyInteractionWarning as B, fetchCliOrgs as Bt, serverMuted as C, detectRuntimeSession as Ct, wakeRecipientField as D, ATTACHMENT_WAIT_FLAG_DESCRIPTION as Dt, wakeReadCommand as E, withFlagSuggestion as Et, removePendingMail as F, refuseOrgSelectionForProfile as Ft, apiResultHttpStatus as G, IDEMPOTENCY_KEY_FLAG_DESCRIPTION as H, switchCurrentOrg as Ht, interactionHumanLine as I, resolveCliApiRequestConfig as It, buildThrownSendFailureEnvelope as J, assertValidIdempotencyKey as K, interactionNextActions as L, selectedOrgFromEnv as Lt, pendingMailPath as M, createAuthenticatedCliApiClient as Mt, readPendingMail as N, createCliApiClient as Nt, wakeRelationship as O, ATTACHMENT_WAIT_MAX_MS as Ot, recordPendingMail as P, refreshStoredCliCredentials as Pt, excludeSignalReplies as Q, readEmailInteraction as R, CliOrgRefusedError as Rt, sentInThread as S, detectAgentRuntime as St, skillFirstLine as T, formatAlreadySentNotice as Tt, MAX_SEND_WAIT_TIMEOUT_MS as U, wakeInteractionSentence as V, revokeUserLogin as Vt, SEND_OUTCOME_HELP as W, classifySendError as X, checkPriorReplies as Y, deriveSendIdempotencyKey as Z, describeWake as _, defaultConnectionName as _t, extractErrorCode as a, priorRepliesThatWentOut as at, latestOwnOutbound as b, loadSkillLine as bt, hasControlCharacter as c, sendOutcomeExitCode as ct, readTextFileFlag as d, serializeErrorPayload as dt, formatDeletedEarlierSendNotice as et, runWithTiming as f, successfulSendOutcome as ft, bareAddress$1 as g, connectNextSteps as gt, explainPendingMailNotFound as h, cliInvocation as ht, createOperationCommand as i, formatSendRecordFailureSummary as it, clearReadPendingMail as j, SAVED_CLI_OAUTH_SESSION_EXPIRED_MESSAGE as jt, claimPendingMail as k, withAttachmentWait as kt, headerFlagName as l, sendWaitClampNotice as lt, writeErrorWithHints as m, DEFAULT_ENROLL_NAME as mt, API_ERROR_CODES as n, formatPriorRepliesWarning as nt, extractErrorPayload as o, reportSendCommandResult as ot, surfaceUnauthorizedHint as p, thrownErrorExitCode as pt, buildFollowUpCommand as q, TIME_FLAG_DESCRIPTION as r, formatSendFailureSummary as rt, formatErrorPayload as s, sendIdentityFields as st, API_BASE_URL_FLAG_DESCRIPTION as t, formatPriorRepliesCheckSkipped as tt, isGrantRefusal as u, sentHistoryWindowStart as ut, displayAddress as v, generatedAgentName as vt, serverRelationship as w, runtimeSessionVariableList as wt, readThreadContext as x, currentMailSessionKey as xt, formatMailWakeLine as y, identitySuggestions as yt, replyExpectation as z, credentialsFromUserLogin as zt };
|