@vincentt-xr/cli 0.21.0 → 0.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/MIGRATION.md +26 -0
  2. package/dist/cli/args.js +2 -0
  3. package/dist/cli/index.js +50 -8
  4. package/dist/cli/usage-text.js +10 -1
  5. package/dist/cli/verb-surface.d.ts +2 -1
  6. package/dist/cli/verb-surface.js +5 -0
  7. package/dist/dx/command.d.ts +74 -18
  8. package/dist/dx/command.js +137 -86
  9. package/dist/dx/consent.d.ts +25 -6
  10. package/dist/dx/consent.js +58 -25
  11. package/dist/dx/enablement.d.ts +63 -11
  12. package/dist/dx/enablement.js +93 -21
  13. package/dist/dx/journal.d.ts +3 -0
  14. package/dist/dx/journal.js +6 -1
  15. package/dist/dx/report.d.ts +4 -24
  16. package/dist/dx/report.js +32 -25
  17. package/dist/dx/skeleton.js +3 -0
  18. package/dist/editor/app.d.ts +61 -0
  19. package/dist/editor/app.js +180 -0
  20. package/dist/editor/copy.d.ts +22 -0
  21. package/dist/editor/copy.js +95 -0
  22. package/dist/editor/handoffs.d.ts +12 -0
  23. package/dist/editor/handoffs.js +93 -0
  24. package/dist/editor/launcher.d.ts +86 -0
  25. package/dist/editor/launcher.js +255 -0
  26. package/dist/editor/localFiles.d.ts +31 -0
  27. package/dist/editor/localFiles.js +100 -0
  28. package/dist/editor/reads.d.ts +47 -0
  29. package/dist/editor/reads.js +269 -0
  30. package/dist/editor/text.d.ts +7 -0
  31. package/dist/editor/text.js +58 -0
  32. package/dist/editor/tools.d.ts +5 -0
  33. package/dist/editor/tools.js +20 -0
  34. package/dist/lib/version.d.ts +28 -2
  35. package/dist/lib/version.js +30 -3
  36. package/dist/preview/command.js +2 -0
  37. package/dist/preview/copy.d.ts +5 -0
  38. package/dist/preview/copy.js +15 -0
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/publish/command.js +5 -2
  42. package/dist/publish/copy.d.ts +6 -0
  43. package/dist/publish/copy.js +27 -6
  44. package/dist/publish/manifest.d.ts +11 -2
  45. package/dist/publish/manifest.js +45 -9
  46. package/dist/publish/upload.d.ts +10 -0
  47. package/dist/publish/upload.js +23 -0
  48. package/dist/shared/config.d.ts +6 -6
  49. package/dist/tunnel/client.d.ts +11 -3
  50. package/dist/tunnel/client.js +11 -8
  51. package/package.json +2 -2
@@ -1,9 +1,10 @@
1
1
  import { findClusters, rankClusters } from "./clusters.js";
2
- import { enablementLine } from "./enablement.js";
2
+ import { dxConsentJson, enablementLine, resolveConsentView } from "./enablement.js";
3
3
  import { journalEnabled, journalPath, readJournal } from "./journal.js";
4
4
  import { buildReport, renderPayload } from "./send.js";
5
5
  import { resolveBuiltWith } from "../platform/resolve.js";
6
6
  import { EXIT } from "../lib/exitCodes.js";
7
+ import { isAuthRefused } from "../shared/authError.js";
7
8
  import { UpgradeRequiredError } from "../shared/upgradeError.js";
8
9
  const MINUTE_MS = 60 * 1000;
9
10
  const HOUR_MS = 60 * MINUTE_MS;
@@ -121,19 +122,27 @@ export function dxCommand(deps, options) {
121
122
  return 0;
122
123
  }
123
124
  const clusters = rankClusters(findClusters(entries));
124
- const enablement = deps.enablement?.() ?? { enabled: false };
125
+ const local = deps.enablement?.() ?? { enabled: false };
126
+ const view = deps.consentView?.() ?? {
127
+ shown: local,
128
+ local,
129
+ checked: "machine",
130
+ signedIn: false,
131
+ };
125
132
  if (options.json) {
126
133
  // The JSON object carries enablement as FIELDS because --json is the path an
127
134
  // AGENT reads, and an agent that can send unattended should be able to see
128
- // that it can.
135
+ // that it can. `dxSendEnabled*` stay this machine's flag; `dxConsent` is the
136
+ // account-first answer and says where it was checked.
129
137
  deps.print(JSON.stringify({
130
138
  window: options.window.label,
131
139
  runs: entries.length,
132
140
  clusters,
133
- dxSendEnabled: enablement.enabled,
134
- ...(enablement.enabled && enablement.at !== undefined
135
- ? { dxSendEnabledAt: enablement.at }
141
+ dxSendEnabled: view.local.enabled,
142
+ ...(view.local.enabled && view.local.at !== undefined
143
+ ? { dxSendEnabledAt: view.local.at }
136
144
  : {}),
145
+ dxConsent: dxConsentJson(view),
137
146
  }, null, 2));
138
147
  return 0;
139
148
  }
@@ -163,13 +172,13 @@ export function dxCommand(deps, options) {
163
172
  // Beside it, and ONLY when enabled. Enablement is durable and has no expiry, so
164
173
  // this line is the mitigation rather than a status decoration; printing an
165
174
  // `off` counterpart on every run is how it would get skimmed past.
166
- const line = enablementLine(enablement, deps.now());
175
+ const line = enablementLine(view.shown, deps.now());
167
176
  if (line !== undefined)
168
177
  deps.print(line);
169
178
  return 0;
170
179
  }
171
180
  /** Wire the command to the real journal. */
172
- export function runDx(args, print, enablement = () => ({ enabled: false })) {
181
+ export function runDx(args, print, enablement = () => ({ enabled: false }), consentView) {
173
182
  return dxCommand({
174
183
  entries: (window) => readJournal(window),
175
184
  print,
@@ -177,6 +186,7 @@ export function runDx(args, print, enablement = () => ({ enabled: false })) {
177
186
  enabled: () => journalEnabled(),
178
187
  path: journalPath,
179
188
  enablement,
189
+ consentView,
180
190
  }, parseDxOptions(args));
181
191
  }
182
192
  /** A date a human can read, for the sunset refusal. */
@@ -187,11 +197,13 @@ function sunsetDate(sunset) {
187
197
  * `vincentt dx enable` — grant durable permission to send DX reports without asking
188
198
  * first.
189
199
  *
190
- * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
200
+ * TWO PATHS to ONE account grant, and which one runs is decided by whether there is a
201
+ * terminal to ask in:
191
202
  *
192
- * - At a TTY: the original terminal prompt, unchanged. A person is already here and
193
- * can read the disclosure where they stand; sending them to a browser would be
194
- * ceremony.
203
+ * - At a TTY: the terminal prompt. A person is already here and can read the
204
+ * disclosure where they stand; sending them to a browser would be ceremony. On `y`
205
+ * the CLI POSTs /me/dx-consent with the PAT, and writes the local flag only after
206
+ * the account recorded the grant.
195
207
  * - With no TTY: the BROWSER path. This is the case the feature exists for — a
196
208
  * creator driving the CLI through a coding agent has no terminal, so the old code
197
209
  * refused and the flow dead-ended. It now opens the console, where a human can
@@ -223,44 +235,87 @@ export async function dxEnableCommand(deps, sunset) {
223
235
  // signed out cannot complete either path, and reading a consent disclosure before
224
236
  // being told so is wasted attention.
225
237
  const auth = await deps.auth?.();
226
- if (auth === undefined) {
227
- deps.print("");
228
- deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
229
- deps.print(" this machine is not signed in. Nothing was changed.");
230
- deps.print("");
231
- deps.print(" Run `vincentt login` first.");
232
- deps.print("");
233
- return EXIT.NOT_AUTHENTICATED;
234
- }
238
+ if (auth === undefined)
239
+ return signedOutRefusal(deps);
235
240
  if (!deps.interactive()) {
236
241
  return enableViaBrowser(deps);
237
242
  }
238
243
  // What is being granted, INCLUDING the note risk. The note is the part a
239
244
  // creator does not picture: enablement is what lets an agent compose prose they
240
245
  // have not read and put it on the wire.
246
+ // The wording names the ACCOUNT because the answer reaches every machine on it; a
247
+ // prompt that said "this machine" would ask for less than it grants.
241
248
  deps.print("");
242
249
  deps.print(" Unattended DX submission");
243
250
  deps.print("");
244
- deps.print(" Your agent will be able to send friction reports from this machine without");
245
- deps.print(" asking you first. A report includes a note, which is sent exactly as written.");
246
- deps.print(" An agent may compose a note you have not read.");
251
+ deps.print(" Your agent will be able to send friction reports without asking you first,");
252
+ deps.print(" from any machine signed in to your Vincentt account. A report includes a note,");
253
+ deps.print(" which is sent exactly as written. An agent may compose a note you have not read.");
247
254
  deps.print("");
248
- deps.print(" This stays on until you run `vincentt dx disable`.");
255
+ deps.print(" This stays on until you run `vincentt dx disable` or turn it off in the console.");
249
256
  deps.print("");
250
- if (!(await deps.confirm("Enable on this machine?"))) {
257
+ if (!(await deps.confirm("Allow unattended reports on your account?"))) {
251
258
  deps.print("");
252
259
  deps.print(" Not enabled.");
253
260
  deps.print("");
254
261
  return 0;
255
262
  }
256
- // Written on every enable, so the age reports from the LATEST grant rather than
257
- // the first — a re-enable is a fresh decision and dates from when it was made.
258
- await deps.save({ at: new Date(deps.now()).toISOString() });
263
+ return grantAtTerminal(deps, auth);
264
+ }
265
+ /**
266
+ * The terminal grant: the account first, then this machine's copy.
267
+ *
268
+ * ORDER IS THE PROPERTY. A local flag written before the account recorded the grant is a
269
+ * machine-only grant, which is exactly what this path no longer makes; so every failure
270
+ * arm returns before `save` and leaves config.json untouched. A 426 or 401 propagates,
271
+ * so the caller renders the shared version or sign-in refusal.
272
+ *
273
+ * The local date is the ACCOUNT's, so a re-grant re-dates both and the age always
274
+ * reports from the latest grant.
275
+ */
276
+ async function grantAtTerminal(deps, auth) {
277
+ let result;
278
+ try {
279
+ result =
280
+ deps.grantAccountConsent === undefined
281
+ ? { kind: "unreachable" }
282
+ : await deps.grantAccountConsent(auth);
283
+ }
284
+ catch (err) {
285
+ if (err instanceof UpgradeRequiredError)
286
+ throw err;
287
+ if (isAuthRefused(err))
288
+ return signedOutRefusal(deps);
289
+ result = { kind: "unreachable" };
290
+ }
291
+ if (result.kind !== "granted" || result.enablement.at === undefined) {
292
+ deps.print("");
293
+ deps.print(` ✗ ${GRANT_FAILURE[result.kind === "granted" ? "not_granted" : result.kind]}`);
294
+ deps.print(" Nothing was changed.");
295
+ deps.print("");
296
+ return 1;
297
+ }
298
+ await deps.save({ at: result.enablement.at });
259
299
  deps.print("");
260
- deps.print(" Enabled. Reports may now be sent without a prompt.");
300
+ deps.print(" Enabled for your account. Reports may now be sent without a prompt from any");
301
+ deps.print(" machine signed in to it.");
261
302
  deps.print("");
262
303
  return 0;
263
304
  }
305
+ const GRANT_FAILURE = {
306
+ not_granted: "Vincentt did not record your consent.",
307
+ rate_limited: "Too many consent changes. Wait a minute and try again.",
308
+ unreachable: "Could not reach Vincentt to record your consent.",
309
+ };
310
+ function signedOutRefusal(deps) {
311
+ deps.print("");
312
+ deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
313
+ deps.print(" this machine is not signed in. Nothing was changed.");
314
+ deps.print("");
315
+ deps.print(" Run `vincentt login` first.");
316
+ deps.print("");
317
+ return EXIT.NOT_AUTHENTICATED;
318
+ }
264
319
  /**
265
320
  * The no-TTY arm: hand the decision to a human in a browser.
266
321
  *
@@ -325,8 +380,8 @@ async function enableViaBrowser(deps) {
325
380
  * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
326
381
  * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
327
382
  * clearing only the local flag would leave the account grant standing, so the very next
328
- * `--send` would be authorized by arm (c) and the command would appear not to have
329
- * worked.
383
+ * `--send` would be authorized by the account and the command would appear not to
384
+ * have worked.
330
385
  *
331
386
  * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
332
387
  * cannot fail and the one that stops THIS machine, which is the machine the creator is
@@ -371,6 +426,41 @@ export async function dxDisableCommand(deps) {
371
426
  deps.print("");
372
427
  return 0;
373
428
  }
429
+ /**
430
+ * The one consent gate both send paths call (f7 Scenario 3), checked in order:
431
+ *
432
+ * 1. an interactive session → ask, then send. No /me read at all, so a creator at their
433
+ * own terminal is never blocked by an API being slow or down.
434
+ * 2. signed in and GET /me answers: a strict grant sends (source: account); any other
435
+ * 200 clears this machine's flag and refuses.
436
+ * 3. /me cannot be read and this machine's flag is valid → send (source: machine).
437
+ *
438
+ * An unreadable /me never authorizes by itself; it only lets an existing local flag stand.
439
+ */
440
+ export async function gateSend(deps, auth) {
441
+ if (deps.interactive())
442
+ return { arm: "interactive" };
443
+ const view = await resolveConsentView({
444
+ auth,
445
+ readAccount: deps.accountConsent,
446
+ local: () => deps.enablement?.() ?? { enabled: false },
447
+ clearLocal: () => deps.clearEnablement?.() ?? Promise.resolve(),
448
+ });
449
+ return view.shown.enabled
450
+ ? { arm: "unattended", enablement: view.shown }
451
+ : { arm: "refused" };
452
+ }
453
+ /**
454
+ * The gate's refusal. It NAMES the enabling command: a refusal is an answer to a
455
+ * command the caller typed, which makes it SOLICITED output — the prohibition is on
456
+ * UNBIDDEN output instructing a caller. A refusal naming nothing leaves a human at
457
+ * exit 1 with no path forward, for no security gain.
458
+ */
459
+ export const SEND_REFUSAL_LINES = [
460
+ "Sending a report needs consent that has not been given on this account.",
461
+ "`vincentt dx enable` opens the Vincentt console so you can allow it.",
462
+ "It takes one click, and it works from here.",
463
+ ];
374
464
  /**
375
465
  * `vincentt dx --send`.
376
466
  *
@@ -378,7 +468,7 @@ export async function dxDisableCommand(deps) {
378
468
  * who cannot see what leaves their machine before it leaves has not agreed to
379
469
  * anything:
380
470
  *
381
- * friction → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
471
+ * friction → auth → GATE → RESOLVE → build → SHOW → ask → post
382
472
  */
383
473
  export async function dxSendCommand(deps, options) {
384
474
  if (!deps.enabled()) {
@@ -411,59 +501,20 @@ export async function dxSendCommand(deps, options) {
411
501
  // the check sits on the act being authorized rather than on a flag that only
412
502
  // modifies how it is confirmed.
413
503
  //
414
- // The second arm is the SUPPORTED unattended path: a person, at a terminal,
415
- // ran `vincentt dx enable` on this machine. That is a prior deliberate act
416
- // rather than a per-send answer, which is what makes it consent at all.
504
+ // The unattended path is a prior deliberate act — a person granted it on the
505
+ // account, at a terminal or in the console — rather than a per-send answer, which
506
+ // is what makes it consent at all. See gateSend for the order.
417
507
  //
418
- // The THIRD arm is the same kind of prior deliberate act, made in the console and
419
- // recorded on the ACCOUNT rather than on this machine. It exists because the second
420
- // arm cannot be reached at all by the creator this feature is for: driving the CLI
421
- // through a coding agent, there is no terminal to run `dx enable` in.
422
- const localEnablement = deps.enablement?.() ?? { enabled: false };
423
- const interactive = deps.interactive();
424
- // Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
425
- // /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
508
+ // Auth is resolved BEFORE the gate, because the account read needs a credential.
509
+ // The signed-out refusal still comes AFTER the gate, so a caller with neither a
426
510
  // terminal nor consent gets the refusal about consent — the thing they need to fix —
427
511
  // rather than being sent to `login` first and only then told that logging in was not
428
512
  // sufficient.
429
513
  const auth = await deps.auth();
430
- // Arm (c) is consulted ONLY when the first two have not already allowed the send.
431
- // Skipping it otherwise is not just an optimization: it keeps `--send` from making a
432
- // network call it does not need, so a creator at their own terminal is never blocked
433
- // by an API being slow or down.
434
- let accountEnablement = { enabled: false };
435
- if (!interactive && !localEnablement.enabled && auth !== undefined) {
436
- try {
437
- accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
438
- }
439
- catch {
440
- // A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
441
- // when both have already said no, that means refusing. This is the fail-CLOSED
442
- // direction: an unreachable API can never authorize a send.
443
- //
444
- // The swallow is deliberate and is the whole point of the arm's placement — a
445
- // /me that is down must never BLOCK a send that (a) or (b) already allows, and it
446
- // cannot, because those paths never reach this call at all.
447
- accountEnablement = { enabled: false };
448
- }
449
- }
450
- // The grant that authorizes this send, and the one whose age gets reported. The local
451
- // flag wins when both exist: it is the more specific statement (this machine) and it
452
- // is the one `dx disable` can always clear.
453
- const enablement = localEnablement.enabled
454
- ? localEnablement
455
- : accountEnablement;
456
- if (!interactive && !enablement.enabled) {
457
- // This refusal NAMES the enabling command. A refusal is an answer to a
458
- // command the caller typed, which makes it SOLICITED output — the
459
- // prohibition is on UNBIDDEN output instructing a caller (the nudge names
460
- // neither `enable` nor `--send`, and still must not). A refusal naming
461
- // nothing satisfies the letter of that rule and leaves a human at exit 1
462
- // with no path forward, for no security gain: guessability was never the
463
- // threat.
464
- deps.print("Sending a report needs consent that has not been given on this account.");
465
- deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
466
- deps.print("It takes one click, and it works from here.");
514
+ const gate = await gateSend(deps, auth);
515
+ if (gate.arm === "refused") {
516
+ for (const l of SEND_REFUSAL_LINES)
517
+ deps.print(l);
467
518
  return 1;
468
519
  }
469
520
  if (auth === undefined) {
@@ -492,9 +543,9 @@ export async function dxSendCommand(deps, options) {
492
543
  const report = buildReport(clusters, deps.cliVersion(), options.note, builtWith);
493
544
  // Above the payload, on the unattended path only: name that this is enabled,
494
545
  // how old the grant is, WHERE it was granted, and that nothing is going to ask.
495
- const unattended = !interactive && enablement.enabled;
496
- if (unattended) {
497
- const line = enablementLine(enablement, deps.now());
546
+ const unattended = gate.arm === "unattended";
547
+ if (gate.arm === "unattended") {
548
+ const line = enablementLine(gate.enablement, deps.now());
498
549
  if (line !== undefined) {
499
550
  deps.print("");
500
551
  deps.print(`${line}. Not asking.`);
@@ -512,8 +563,8 @@ export async function dxSendCommand(deps, options) {
512
563
  for (const l of renderPayload(report))
513
564
  deps.print(l);
514
565
  deps.print("");
515
- // The enabled machine does not ask. --yes still means only "skip the question"
516
- // and is inert without one of the two arms above.
566
+ // The unattended path does not ask. --yes still means only "skip the question"
567
+ // and is inert without a grant.
517
568
  if (!unattended && !options.yes && !(await deps.confirm("Send this report?"))) {
518
569
  deps.print("Not sent.");
519
570
  return 0;
@@ -1,16 +1,35 @@
1
- import { type Enablement } from "./enablement.js";
1
+ import { type AccountRead, type Enablement } from "./enablement.js";
2
+ /** POST /me/dx-consent, sorted into the outcomes `dx enable` answers differently. */
3
+ export type GrantResult = {
4
+ kind: "granted";
5
+ enablement: Enablement;
6
+ } | {
7
+ kind: "not_granted";
8
+ } | {
9
+ kind: "rate_limited";
10
+ } | {
11
+ kind: "unreachable";
12
+ };
2
13
  /**
3
14
  * The account-consent seam.
4
15
  *
5
- * `read` returns the parsed Enablement rather than the raw envelope so the strict
16
+ * Both reads return parsed results rather than the raw envelope so the strict
6
17
  * `=== true` interpretation happens in exactly one place and cannot be re-derived
7
18
  * loosely by a second caller.
8
19
  */
9
20
  export interface ConsentBackend {
10
- /** GET /me → the account's dxReportsConsent fields, read strictly. */
11
- read(apiUrl: string, pat: string, now: number): Promise<Enablement>;
12
- /** POST /me/dx-consent — grant. Idempotent; a re-grant refreshes the timestamp. */
13
- grant(apiUrl: string, pat: string, now: number): Promise<Enablement>;
21
+ /**
22
+ * GET /me → the account's consent, read strictly. A 426, a 5xx, a network error or a
23
+ * timeout is `unreadable`: the CLI floor refuses /me while reports stay floor-exempt,
24
+ * so a below-floor CLI must fall back to its local flag rather than stop. A 401 still
25
+ * rejects with the shared sign-in error.
26
+ */
27
+ read(apiUrl: string, pat: string, now: number, timeoutMs?: number): Promise<AccountRead>;
28
+ /**
29
+ * POST /me/dx-consent — grant. A re-grant refreshes the timestamp. Rejects only with
30
+ * the version refusal (426) or the sign-in error (401); every other outcome is a result.
31
+ */
32
+ grant(apiUrl: string, pat: string, now: number): Promise<GrantResult>;
14
33
  /** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
15
34
  revoke(apiUrl: string, pat: string): Promise<void>;
16
35
  }
@@ -1,17 +1,11 @@
1
1
  /**
2
- * The ACCOUNT half of unattended-send authorization.
2
+ * The ACCOUNT grant for unattended sends: the authority, which the local flag caches.
3
3
  *
4
- * `vincentt dx enable` used to require a TTY, which dead-ends the flow this feature is
5
- * actually for: a creator drives the CLI through a coding agent, the agent has no
6
- * terminal, and the command refuses. Consent therefore moves to the console, where a
7
- * human can actually read the disclosure and click — the CLI opens a browser and waits.
8
- *
9
- * The grant is PER-ACCOUNT, not per-machine: one decision covers every machine the
10
- * creator signs in on. That is a wider blast radius than the local flag, which is why
11
- * the console page carries the full disclosure (including that an agent may compose a
12
- * note the user has not read) and why the visibility line names the console as the
13
- * source — a creator who wants it off needs to know that clearing a local flag is not
14
- * enough.
4
+ * A person grants it at a terminal (`dx enable` POSTs /me/dx-consent with the PAT) or,
5
+ * with no terminal, on the console page the CLI opens. Either way the grant is
6
+ * PER-ACCOUNT: one decision covers every machine the creator signs in on, which is why
7
+ * both surfaces carry the full disclosure (including that an agent may compose a note
8
+ * the user has not read) and why the visibility line names where it was granted.
15
9
  *
16
10
  * Every call is isolated behind ConsentBackend so the command layer is unit-testable
17
11
  * with no network.
@@ -21,10 +15,10 @@ import { waitForLoopbackCallback } from "../login/loopback.js";
21
15
  import { browserPage } from "../login/pkceLogin.js";
22
16
  import { stateMatches } from "../login/pkce.js";
23
17
  import { getFreePort } from "../preview/net.js";
24
- import { authFailureMessage } from "../shared/authError.js";
25
- import { apiRequest } from "../shared/http.js";
26
- import { rejectUpgradeRequired } from "../shared/upgradeError.js";
27
- import { readAccountConsent } from "./enablement.js";
18
+ import { AuthExpiredError, authFailureMessage, rejectAuthExpired, } from "../shared/authError.js";
19
+ import { apiFetch, apiRequest, withDeadline } from "../shared/http.js";
20
+ import { rejectUpgradeRequired, UpgradeRequiredError } from "../shared/upgradeError.js";
21
+ import { readAccountEnablement, } from "./enablement.js";
28
22
  async function safeText(res) {
29
23
  try {
30
24
  return (await res.text()).slice(0, 300);
@@ -54,19 +48,58 @@ export function httpConsentBackend() {
54
48
  return res;
55
49
  };
56
50
  return {
57
- async read(apiUrl, pat, now) {
58
- const res = await call(apiUrl, pat, "/me", {});
59
- if (!res.ok) {
60
- throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
51
+ async read(apiUrl, pat, now, timeoutMs) {
52
+ const fetchImpl = timeoutMs === undefined
53
+ ? apiFetch
54
+ : withDeadline((input, init) => globalThis.fetch(input, init), timeoutMs);
55
+ let res;
56
+ let body;
57
+ try {
58
+ res = await apiRequest({ apiUrl, pat }, "/me", {}, { authenticated: true }, fetchImpl);
59
+ if (res.status === 401)
60
+ rejectAuthExpired(res);
61
+ if (!res.ok)
62
+ return { kind: "unreadable" };
63
+ body = await res.json();
61
64
  }
62
- return readAccountConsent(unwrapMe(await res.json()), now);
65
+ catch (err) {
66
+ if (err instanceof AuthExpiredError)
67
+ throw err;
68
+ // A 200 whose body does not parse (a captive portal, a proxy page) is not an
69
+ // account answer, so it must not clear this machine's flag.
70
+ return { kind: "unreadable" };
71
+ }
72
+ const enablement = readAccountEnablement(unwrapMe(body), now);
73
+ return enablement.enabled
74
+ ? { kind: "granted", enablement }
75
+ : { kind: "not_granted" };
63
76
  },
64
77
  async grant(apiUrl, pat, now) {
65
- const res = await call(apiUrl, pat, "/me/dx-consent", { method: "POST" });
66
- if (!res.ok) {
67
- throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
78
+ let res;
79
+ try {
80
+ res = await call(apiUrl, pat, "/me/dx-consent", { method: "POST" });
81
+ }
82
+ catch (err) {
83
+ if (err instanceof UpgradeRequiredError)
84
+ throw err;
85
+ return { kind: "unreachable" };
86
+ }
87
+ rejectAuthExpired(res);
88
+ if (res.status === 429)
89
+ return { kind: "rate_limited" };
90
+ if (!res.ok)
91
+ return { kind: "unreachable" };
92
+ let body;
93
+ try {
94
+ body = await res.json();
95
+ }
96
+ catch {
97
+ return { kind: "not_granted" };
68
98
  }
69
- return readAccountConsent(unwrapMe(await res.json()), now);
99
+ const enablement = readAccountEnablement(unwrapMe(body), now);
100
+ return enablement.enabled
101
+ ? { kind: "granted", enablement }
102
+ : { kind: "not_granted" };
70
103
  },
71
104
  async revoke(apiUrl, pat) {
72
105
  const res = await call(apiUrl, pat, "/me/dx-consent", { method: "DELETE" });
@@ -11,21 +11,21 @@
11
11
  */
12
12
  export declare const DX_UNATTENDED_SUNSET: number;
13
13
  /**
14
- * WHERE the grant was made. Consent can now be given in the console (per-ACCOUNT, so it
15
- * covers every machine the creator signs in on) or at this terminal (per-MACHINE).
16
- *
17
- * The source is carried rather than inferred because the two have different blast radii
18
- * and different places to revoke. A creator reading `ENABLED` on a machine they never
19
- * ran `dx enable` on has no way to act on it without being told the grant lives in their
20
- * account — they would go looking for a local flag that is not there.
14
+ * WHERE the answer came from. The account is the authority whenever GET /me answers;
15
+ * `machine` means this machine's cached copy was used because the account could not be
16
+ * read. The two are reported differently because only one of them was checked just now.
21
17
  */
22
- export type EnablementSource = "console" | "machine";
18
+ export type EnablementSource = "account" | "machine";
19
+ /** Where the person answered the account grant, as the api recorded it. */
20
+ export type EnablementVia = "cli" | "console";
23
21
  export interface Enablement {
24
22
  enabled: boolean;
25
23
  /** The raw ISO8601 grant time. Present only when `enabled` is true. */
26
24
  at?: string;
27
- /** Where the grant came from. Present only when `enabled` is true. */
25
+ /** Where the answer came from. Present only when `enabled` is true. */
28
26
  source?: EnablementSource;
27
+ /** Account grants only, and only when the api recorded it. */
28
+ via?: EnablementVia;
29
29
  }
30
30
  /**
31
31
  * Decide enablement from an already-parsed config object. Pure, and STRICT:
@@ -49,7 +49,57 @@ export declare function readEnablement(cfg: unknown, now: number): Enablement;
49
49
  * stops being honored too, or "forgetting the sunset turns the feature off" would only be
50
50
  * true of the local half.
51
51
  */
52
- export declare function readAccountConsent(raw: unknown, now: number): Enablement;
52
+ export declare function readAccountEnablement(raw: unknown, now: number): Enablement;
53
+ /** GET /me, as the consent gate and the visibility surfaces need it. */
54
+ export type AccountRead = {
55
+ kind: "granted";
56
+ enablement: Enablement;
57
+ } | {
58
+ kind: "not_granted";
59
+ } | {
60
+ kind: "unreadable";
61
+ };
62
+ /**
63
+ * What the consent surfaces show: the answer that decides (`shown`), this machine's copy
64
+ * (`local`, after any clear), and where the answer was checked.
65
+ */
66
+ export interface ConsentView {
67
+ shown: Enablement;
68
+ local: Enablement;
69
+ checked: "account" | "machine";
70
+ signedIn: boolean;
71
+ }
72
+ export interface ConsentViewDeps {
73
+ auth: {
74
+ apiUrl: string;
75
+ pat: string;
76
+ } | undefined;
77
+ /** Allowed to throw; a throw reads as unreadable. */
78
+ readAccount?: (auth: {
79
+ apiUrl: string;
80
+ pat: string;
81
+ }) => Promise<AccountRead>;
82
+ local: () => Promise<Enablement> | Enablement;
83
+ /** Clears this machine's copy. Allowed to throw; see below. */
84
+ clearLocal: () => Promise<unknown>;
85
+ /** Bounds the account read from outside, so a backend that hangs cannot hang the caller. */
86
+ timeoutMs?: number;
87
+ }
88
+ /**
89
+ * The account-first read shared by the send gate, `vincentt dx` and `vincentt version`
90
+ * (f7 Scenario 3, 3c). The account decides when it answers; a "not granted" answer clears
91
+ * this machine's copy so a revoke made elsewhere reaches it. Only when the account cannot
92
+ * be read does the local flag stand on its own.
93
+ */
94
+ export declare function resolveConsentView(deps: ConsentViewDeps): Promise<ConsentView>;
95
+ /** `dxConsent` in `vincentt dx --json` (f7 Scenario 3c). */
96
+ export interface DxConsentJson {
97
+ state: "on" | "off" | "unknown";
98
+ checked: "account" | "machine";
99
+ at?: string;
100
+ via?: EnablementVia;
101
+ }
102
+ export declare function dxConsentJson(view: ConsentView): DxConsentJson;
53
103
  /**
54
104
  * Read enablement from the real machine config.
55
105
  *
@@ -66,8 +116,10 @@ export declare function grantedOn(at: string): string;
66
116
  /**
67
117
  * The one visibility line, in the single pinned shape every human surface uses:
68
118
  *
119
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted from the CLI
69
120
  * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
70
- * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
121
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · on your account
122
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · this machine (account not checked)
71
123
  *
72
124
  * Returns undefined when not enabled. Nothing is printed in that case, on
73
125
  * purpose: a line that prints unconditionally is a line readers learn to skip,