primcli 1.75.0 → 3.0.0-preview.1

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,10 +1,602 @@
1
- import { $ as AutomatedFilterUnsupportedError, Cn as sdk_gen_exports, F as assertAwaitingFilterRows, Ft as getEmail, G as WEBHOOK_EVENT_TYPES, I as awaitingRejectedError, L as ensureSearchReportsReplyState, P as ReplyStateUnsupportedError, U as replyStateSurfaceForOperation, V as isAwaitingRejectedError, W as SENT_EMAIL_EVENT_TYPES, Wt as getThread, et as assertAutomatedVerdict, it as expectedAutomatedVerdicts, nn as listEndpoints, nt as automatedSurfaceForOperation, ot as isAutomatedRejectedError, rt as ensureSearchReportsAutomated, tt as automatedRejectedError, v as parseRetryAfter } from "./repeats-Dj_o6c-X-LyCkKYjp.js";
2
- import { C as resolveCliAuth, G as PrimitiveApiClient, I as agentProfilesDirectory, M as AGENT_PROFILE_ENV, P as agentProfileDirectory, S as normalizeApiBaseUrl, T as saveSignupCredentials, U as requireDefaultLoginProfile, _ as deleteCliCredentials, c as resolveConfigEnvironment, d as validateCliHeaderName, f as validateCliHeaderValue, i as loadCliConfig, m as cliAccessTokenExpiresAt, p as acquireCliCredentialsLock, w as saveCliCredentials, x as loadCliCredentials, y as detectPrimitiveKeyEnvMisname, z as loadConnectedAgentProfile } from "./cli-config-B80p0pLk.js";
1
+ import { $ as AutomatedFilterUnsupportedError, An as sdk_gen_exports, F as assertAwaitingFilterRows, G as WEBHOOK_EVENT_TYPES, I as awaitingRejectedError, It as getEmail, Kt as getThread, L as ensureSearchReportsReplyState, P as ReplyStateUnsupportedError, U as replyStateSurfaceForOperation, V as isAwaitingRejectedError, W as SENT_EMAIL_EVENT_TYPES, et as assertAutomatedVerdict, it as expectedAutomatedVerdicts, nt as automatedSurfaceForOperation, on as listEndpoints, ot as isAutomatedRejectedError, rt as ensureSearchReportsAutomated, tt as automatedRejectedError, v as parseRetryAfter } from "./repeats-Dj_o6c-X-Bf9G0xds.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
- apiKey: auth.apiKey,
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-Cq_nD5eF.js").then((n) => n.s);
2738
+ const { connectedProfilesForSession } = await import("./machine-session-Cp_eQ9t_.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
- function isIncompleteDomainVerification(operation, envelope) {
2685
- if (operation.sdkName !== "verifyDomain") return false;
2686
- const data = envelope?.data;
2687
- if (!data || typeof data !== "object") return false;
2688
- return data.verified === false;
2689
- }
2690
- function writeIncompleteDomainVerificationHint() {
2691
- process.stderr.write("Domain verification is incomplete. Add or fix the DNS records shown above, or run `primitive domains zone-file --id <domain-id>` to download the complete zone file, then retry `primitive domains verify --id <domain-id>`.\n");
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 = { addDomain: (code, body) => {
2719
- if (code === "mail_relay_unavailable") return MAIL_RELAY_UNAVAILABLE_MESSAGE;
2720
- const mailRelay = body !== null && typeof body === "object" && body.mail_relay === true;
2721
- if (code === "feature_disabled" && mailRelay) return MAIL_RELAY_NOT_ENABLED_MESSAGE;
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 { formatDeletedEarlierSendNotice as $, clearReadPendingMail as A, ConnectedAgentKeyRequiredError as At, wakeInteractionSentence as B, serverRelationship as C, runtimeSessionVariableList as Ct, wakeRelationship as D, ATTACHMENT_WAIT_FLAG_DESCRIPTION as Dt, wakeRecipientField as E, withFlagSuggestion as Et, interactionHumanLine as F, resolveCliApiRequestConfig as Ft, assertValidIdempotencyKey as G, MAX_SEND_WAIT_TIMEOUT_MS as H, interactionNextActions as I, checkPriorReplies as J, buildFollowUpCommand as K, readEmailInteraction as L, readPendingMail as M, createAuthenticatedCliApiClient as Mt, recordPendingMail as N, createCliApiClient as Nt, claimPendingMail as O, ATTACHMENT_WAIT_MAX_MS as Ot, removePendingMail as P, refreshStoredCliCredentials as Pt, followUpCommandPrefix as Q, replyExpectation as R, serverMuted as S, detectRuntimeSession as St, wakeReadCommand as T, writeIdempotentReplayBannerIfReplay as Tt, SEND_OUTCOME_HELP as U, IDEMPOTENCY_KEY_FLAG_DESCRIPTION as V, apiResultHttpStatus as W, deriveSendIdempotencyKey as X, classifySendError as Y, excludeSignalReplies as Z, displayAddress as _, generatedAgentName as _t, extractErrorCode as a, reportSendCommandResult as at, readThreadContext as b, currentMailSessionKey as bt, hasControlCharacter as c, sendWaitClampNotice as ct, runWithTiming as d, successfulSendOutcome as dt, formatPriorRepliesCheckSkipped as et, surfaceUnauthorizedHint as f, thrownErrorExitCode as ft, describeWake as g, defaultConnectionName as gt, bareAddress$1 as h, connectNextSteps as ht, createOperationCommand as i, priorRepliesThatWentOut as it, pendingMailPath as j, SAVED_CLI_OAUTH_SESSION_EXPIRED_MESSAGE as jt, clearConsumedPendingMail as k, withAttachmentWait as kt, headerFlagName as l, sentHistoryWindowStart as lt, explainPendingMailNotFound as m, cliInvocation as mt, API_ERROR_CODES as n, formatSendFailureSummary as nt, extractErrorPayload as o, sendIdentityFields as ot, writeErrorWithHints as p, DEFAULT_ENROLL_NAME as pt, buildThrownSendFailureEnvelope as q, TIME_FLAG_DESCRIPTION as r, formatSendRecordFailureSummary as rt, formatErrorPayload as s, sendOutcomeExitCode as st, API_BASE_URL_FLAG_DESCRIPTION as t, formatPriorRepliesWarning as tt, readTextFileFlag as u, serializeErrorPayload as ut, formatMailWakeLine as v, identitySuggestions as vt, skillFirstLine as w, formatAlreadySentNotice as wt, sentInThread as x, detectAgentRuntime as xt, latestOwnOutbound as y, loadSkillLine as yt, replyInteractionWarning as z };
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 };