@vincentt-xr/cli 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -1,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,
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Durable authorization for unattended `vincentt dx --send`.
3
3
  *
4
- * `--send` transmits only when the session is interactive, OR this MACHINE has been
5
- * enabled via `vincentt dx enable`, OR the creator's ACCOUNT carries the grant they
6
- * gave in the console. `enable` collects that consent in the browser by default and
7
- * at the terminal when there is one; either way a person answers, never the caller.
4
+ * `--send` transmits only when the session is interactive, OR the creator's ACCOUNT
5
+ * carries the grant, OR — only when the account cannot be read — this machine's cached
6
+ * copy of that grant is valid. `enable` records the account grant at the terminal or in
7
+ * the browser; either way a person answers, never the caller.
8
8
  *
9
- * The machine half is two fields in ~/.vincentt/config.json, set by `enable`, cleared
10
- * by `disable` and by `logout`. The account half is read from `/me` and cleared by
11
- * `disable` or in the console — NOT by `logout`, since signing out of one machine is
12
- * not a decision about the account. Neither has an expiry: both stay on until the
13
- * creator turns them off.
9
+ * The account grant is read from `/me` and cleared by `disable` or in the console, NOT by
10
+ * `logout`, since signing out of one machine is not a decision about the account. The
11
+ * machine copy is two fields in ~/.vincentt/config.json, written by `enable` only after
12
+ * the account grant succeeded, and cleared by `disable`, by `logout`, and by any `/me`
13
+ * answer that is not a grant. Neither has an expiry.
14
14
  *
15
15
  * Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
16
16
  * the load-bearing half of it: `ENABLED` alone reads as configuration, while
@@ -55,9 +55,6 @@ export function readEnablement(cfg, now) {
55
55
  return DISABLED;
56
56
  if (now >= DX_UNATTENDED_SUNSET)
57
57
  return DISABLED;
58
- // The local flag is by definition a grant made on THIS machine — `dx enable` is the
59
- // only writer, whether it took the browser path or the terminal one. An account grant
60
- // never lands in this file; it is read from /me and reported separately.
61
58
  return { enabled: true, at: dxSendEnabledAt, source: "machine" };
62
59
  }
63
60
  /**
@@ -71,10 +68,10 @@ export function readEnablement(cfg, now) {
71
68
  * stops being honored too, or "forgetting the sunset turns the feature off" would only be
72
69
  * true of the local half.
73
70
  */
74
- export function readAccountConsent(raw, now) {
71
+ export function readAccountEnablement(raw, now) {
75
72
  if (typeof raw !== "object" || raw === null)
76
73
  return DISABLED;
77
- const { dxReportsConsent, dxReportsConsentAt } = raw;
74
+ const { dxReportsConsent, dxReportsConsentAt, dxReportsConsentVia } = raw;
78
75
  if (dxReportsConsent !== true)
79
76
  return DISABLED;
80
77
  if (typeof dxReportsConsentAt !== "string")
@@ -83,7 +80,73 @@ export function readAccountConsent(raw, now) {
83
80
  return DISABLED;
84
81
  if (now >= DX_UNATTENDED_SUNSET)
85
82
  return DISABLED;
86
- return { enabled: true, at: dxReportsConsentAt, source: "console" };
83
+ // An unknown via is dropped, not rejected: it only decides the suffix, never the grant.
84
+ return dxReportsConsentVia === "cli" || dxReportsConsentVia === "console"
85
+ ? {
86
+ enabled: true,
87
+ at: dxReportsConsentAt,
88
+ source: "account",
89
+ via: dxReportsConsentVia,
90
+ }
91
+ : { enabled: true, at: dxReportsConsentAt, source: "account" };
92
+ }
93
+ /**
94
+ * The account-first read shared by the send gate, `vincentt dx` and `vincentt version`
95
+ * (f7 Scenario 3, 3c). The account decides when it answers; a "not granted" answer clears
96
+ * this machine's copy so a revoke made elsewhere reaches it. Only when the account cannot
97
+ * be read does the local flag stand on its own.
98
+ */
99
+ export async function resolveConsentView(deps) {
100
+ const signedIn = deps.auth !== undefined;
101
+ if (deps.auth !== undefined && deps.readAccount !== undefined) {
102
+ const { auth, readAccount } = deps;
103
+ const read = await boundedRead(Promise.resolve().then(() => readAccount(auth)), deps.timeoutMs);
104
+ if (read.kind === "granted") {
105
+ return {
106
+ shown: read.enablement,
107
+ local: await deps.local(),
108
+ checked: "account",
109
+ signedIn,
110
+ };
111
+ }
112
+ if (read.kind === "not_granted") {
113
+ // A failed clear still reports off: the account said no, so nothing may send, and
114
+ // the next account read clears again.
115
+ await deps.clearLocal().catch(() => undefined);
116
+ return { shown: DISABLED, local: DISABLED, checked: "account", signedIn };
117
+ }
118
+ }
119
+ const local = await deps.local();
120
+ return { shown: local, local, checked: "machine", signedIn };
121
+ }
122
+ async function boundedRead(read, timeoutMs) {
123
+ const unreadable = { kind: "unreadable" };
124
+ const guarded = read.catch(() => unreadable);
125
+ if (timeoutMs === undefined)
126
+ return guarded;
127
+ let timer;
128
+ const expired = new Promise((resolve) => {
129
+ timer = setTimeout(() => resolve(unreadable), timeoutMs);
130
+ timer.unref?.();
131
+ });
132
+ try {
133
+ return await Promise.race([guarded, expired]);
134
+ }
135
+ finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ export function dxConsentJson(view) {
140
+ const { shown } = view;
141
+ if (shown.enabled && shown.at !== undefined) {
142
+ return {
143
+ state: "on",
144
+ checked: view.checked,
145
+ at: shown.at,
146
+ ...(shown.via !== undefined ? { via: shown.via } : {}),
147
+ };
148
+ }
149
+ return { state: view.signedIn ? "off" : "unknown", checked: view.checked };
87
150
  }
88
151
  /**
89
152
  * Read enablement from the real machine config.
@@ -116,8 +179,10 @@ export function grantedOn(at) {
116
179
  /**
117
180
  * The one visibility line, in the single pinned shape every human surface uses:
118
181
  *
182
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted from the CLI
119
183
  * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
120
- * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
184
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · on your account
185
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · this machine (account not checked)
121
186
  *
122
187
  * Returns undefined when not enabled. Nothing is printed in that case, on
123
188
  * purpose: a line that prints unconditionally is a line readers learn to skip,
@@ -136,10 +201,17 @@ export function enablementLine(e, now) {
136
201
  if (!e.enabled || e.at === undefined)
137
202
  return undefined;
138
203
  const days = ageInDays(e.at, now);
139
- const where = e.source === "console"
140
- ? " · granted in the console"
141
- : e.source === "machine"
142
- ? " · granted on this machine"
143
- : "";
204
+ const where = sourceSuffix(e);
144
205
  return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})${where}`;
145
206
  }
207
+ function sourceSuffix(e) {
208
+ if (e.source === "machine")
209
+ return " · this machine (account not checked)";
210
+ if (e.source !== "account")
211
+ return "";
212
+ if (e.via === "cli")
213
+ return " · granted from the CLI";
214
+ if (e.via === "console")
215
+ return " · granted in the console";
216
+ return " · on your account";
217
+ }
@@ -1,25 +1,4 @@
1
- /**
2
- * `vincentt report "<what went wrong>"` — the creator's own words, sent.
3
- *
4
- * This exists because of a gap `dx --send` could not close. That command reports
5
- * DETECTED friction: it reads the journal, finds clusters of retries and errors,
6
- * and refuses when it finds none. But the friction a creator most wants to report
7
- * is routinely the kind the journal cannot see — a command that exits 0 and puts
8
- * the wrong thing on screen produces no error, no retry and no cluster. Asked to
9
- * carry a note about one, `--send` had nothing to attach it to and said "no
10
- * friction detected — nothing to send", which is the tool contradicting the
11
- * person using it.
12
- *
13
- * So the note stops being a rider on a finding and becomes the payload. An
14
- * observation carries no counts because there is nothing to count.
15
- *
16
- * The consent model is INHERITED, not relaxed. Sending is still gated on the
17
- * same three arms as `--send` (a terminal, a local `dx enable`, or an account
18
- * grant), the payload is still shown in full before it goes, and a note is still
19
- * shown under a warning that it is transmitted exactly as written. This verb
20
- * removes a refusal; it does not open a new door.
21
- */
22
- import type { Enablement } from "./enablement.js";
1
+ import type { AccountRead, Enablement } from "./enablement.js";
23
2
  import type { JournalEntry } from "./journal.js";
24
3
  import { type BuiltWith } from "../platform/resolve.js";
25
4
  /** An observation as it goes on the wire. Mirrors the API's dxObservationRequest. */
@@ -114,7 +93,8 @@ export interface ReportDeps {
114
93
  accountConsent?: (auth: {
115
94
  apiUrl: string;
116
95
  pat: string;
117
- }) => Promise<Enablement>;
96
+ }) => Promise<AccountRead>;
97
+ clearEnablement?: () => Promise<unknown>;
118
98
  /**
119
99
  * The version set. Injectable so a fixed set — or a REJECTING promise, the arm
120
100
  * proving a fault omits — can be driven with no filesystem. Defaults to the one
@@ -129,6 +109,6 @@ export interface ReportDeps {
129
109
  * Same order as `dx --send`, for the same reason — a creator who cannot see what
130
110
  * leaves their machine before it leaves has not agreed to anything:
131
111
  *
132
- * bound-check → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
112
+ * bound-check → auth → GATE → RESOLVE → build → SHOW → ask → post
133
113
  */
134
114
  export declare function reportCommand(deps: ReportDeps, options: ReportOptions): Promise<number>;
package/dist/dx/report.js CHANGED
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `vincentt report "<what went wrong>"` — the creator's own words, sent.
3
+ *
4
+ * This exists because of a gap `dx --send` could not close. That command reports
5
+ * DETECTED friction: it reads the journal, finds clusters of retries and errors,
6
+ * and refuses when it finds none. But the friction a creator most wants to report
7
+ * is routinely the kind the journal cannot see — a command that exits 0 and puts
8
+ * the wrong thing on screen produces no error, no retry and no cluster. Asked to
9
+ * carry a note about one, `--send` had nothing to attach it to and said "no
10
+ * friction detected — nothing to send", which is the tool contradicting the
11
+ * person using it.
12
+ *
13
+ * So the note stops being a rider on a finding and becomes the payload. An
14
+ * observation carries no counts because there is nothing to count.
15
+ *
16
+ * The consent model is INHERITED, not relaxed. Sending is gated by the same
17
+ * `gateSend` as `--send` (a terminal, the account grant, or, only when the
18
+ * account cannot be read, this machine's copy of it), the payload is still shown in full before it goes, and a note is still
19
+ * shown under a warning that it is transmitted exactly as written. This verb
20
+ * removes a refusal; it does not open a new door.
21
+ */
22
+ import { gateSend, SEND_REFUSAL_LINES } from "./command.js";
1
23
  import { enablementLine } from "./enablement.js";
2
24
  import { DXSendError, platformTag } from "./send.js";
3
25
  import { resolveBuiltWith } from "../platform/resolve.js";
@@ -110,7 +132,7 @@ export function renderObservation(report) {
110
132
  * Same order as `dx --send`, for the same reason — a creator who cannot see what
111
133
  * leaves their machine before it leaves has not agreed to anything:
112
134
  *
113
- * bound-check → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
135
+ * bound-check → auth → GATE → RESOLVE → build → SHOW → ask → post
114
136
  */
115
137
  export async function reportCommand(deps, options) {
116
138
  const note = options.note.trim();
@@ -128,28 +150,13 @@ export async function reportCommand(deps, options) {
128
150
  deps.print(`That note is longer than ${MAX_NOTE} characters. Please shorten it.`);
129
151
  return 1;
130
152
  }
131
- // The gate, inherited from `dx --send` verbatim. An agent composing a note and
132
- // confirming it with --yes on a machine nobody granted is the case this stops.
133
- const localEnablement = deps.enablement?.() ?? { enabled: false };
134
- const interactive = deps.interactive();
153
+ // The gate, shared with `dx --send`. An agent composing a note and confirming it
154
+ // with --yes on an account nobody granted is the case this stops.
135
155
  const auth = await deps.auth();
136
- let accountEnablement = { enabled: false };
137
- if (!interactive && !localEnablement.enabled && auth !== undefined) {
138
- try {
139
- accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
140
- }
141
- catch {
142
- // Fail closed. An unreachable API never authorizes a send.
143
- accountEnablement = { enabled: false };
144
- }
145
- }
146
- const enablement = localEnablement.enabled
147
- ? localEnablement
148
- : accountEnablement;
149
- if (!interactive && !enablement.enabled) {
150
- deps.print("Sending a report needs consent that has not been given on this account.");
151
- deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
152
- deps.print("It takes one click, and it works from here.");
156
+ const gate = await gateSend(deps, auth);
157
+ if (gate.arm === "refused") {
158
+ for (const l of SEND_REFUSAL_LINES)
159
+ deps.print(l);
153
160
  return 1;
154
161
  }
155
162
  if (auth === undefined) {
@@ -179,9 +186,9 @@ export async function reportCommand(deps, options) {
179
186
  // omission an empty project yields, and neither can fail a report.
180
187
  const builtWith = await (deps.builtWith ?? (() => resolveBuiltWith(deps.cwd ?? process.cwd())))().catch(() => undefined);
181
188
  const report = buildObservationReport(note, deps.cliVersion(), context, builtWith);
182
- const unattended = !interactive && enablement.enabled;
183
- if (unattended) {
184
- const line = enablementLine(enablement, deps.now());
189
+ const unattended = gate.arm === "unattended";
190
+ if (gate.arm === "unattended") {
191
+ const line = enablementLine(gate.enablement, deps.now());
185
192
  if (line !== undefined) {
186
193
  deps.print("");
187
194
  deps.print(`${line}. Not asking.`);
@@ -1,4 +1,4 @@
1
- import type { Enablement } from "../dx/enablement.js";
1
+ import type { AccountRead, Enablement, EnablementSource, EnablementVia } from "../dx/enablement.js";
2
2
  export interface VersionReport {
3
3
  cliVersion: string;
4
4
  nodeVersion: string;
@@ -9,10 +9,13 @@ export interface VersionReport {
9
9
  ignoredBindingApiUrl?: string;
10
10
  /** True when the resolved host is the built-in fallback (nothing configured). */
11
11
  isDefault: boolean;
12
- /** True when this machine may send DX reports unattended. */
12
+ /** True when DX reports may be sent unattended, from the account-first answer. */
13
13
  dxSendEnabled: boolean;
14
14
  /** ISO8601 UTC of the grant. Present only when enabled. */
15
15
  dxSendEnabledAt?: string;
16
+ /** Where that answer came from, and where the account grant was made. */
17
+ dxSource?: EnablementSource;
18
+ dxVia?: EnablementVia;
16
19
  /** The instant the age is computed against, so renderVersion stays pure. */
17
20
  dxAgeAsOf: number;
18
21
  }
@@ -50,4 +53,27 @@ export declare function renderVersion(r: VersionReport): string[];
50
53
  export declare function versionReport(cwd: string, opts: {
51
54
  cliVersion: string;
52
55
  allowCustomApi?: boolean;
56
+ /** The account read; absent reports this machine's flag only. */
57
+ consent?: VersionConsentDeps;
53
58
  }): Promise<VersionReport>;
59
+ /** The account bound on `version` (f7 Scenario 3c). */
60
+ export declare const VERSION_ACCOUNT_TIMEOUT_MS = 3000;
61
+ export interface VersionConsentDeps {
62
+ auth: () => Promise<{
63
+ apiUrl: string;
64
+ pat: string;
65
+ } | undefined>;
66
+ readAccount: (auth: {
67
+ apiUrl: string;
68
+ pat: string;
69
+ }) => Promise<AccountRead>;
70
+ clearLocal: () => Promise<unknown>;
71
+ local?: () => Promise<Enablement>;
72
+ timeoutMs?: number;
73
+ }
74
+ /**
75
+ * The enablement `version` shows: the account's answer when it can be read within the
76
+ * bound, else this machine's flag. Never throws and never waits past the bound, because
77
+ * `version` is the command people run when something is already broken.
78
+ */
79
+ export declare function versionEnablement(now: number, consent: VersionConsentDeps | undefined): Promise<Enablement>;
@@ -7,7 +7,7 @@
7
7
  // misconfiguration. So this reports the resolved host AND its provenance, using
8
8
  // the same decideApiUrl the real verbs use; a version command that re-derived
9
9
  // the URL could disagree with them.
10
- import { enablementLine, loadEnablement } from "../dx/enablement.js";
10
+ import { enablementLine, loadEnablement, resolveConsentView } from "../dx/enablement.js";
11
11
  import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
12
12
  /**
13
13
  * Decide what to report. Pure, so the transcript is testable without a home
@@ -45,6 +45,8 @@ export function buildVersionReport(env) {
45
45
  isDefault,
46
46
  dxSendEnabled: enablement.enabled,
47
47
  dxSendEnabledAt: enablement.enabled ? enablement.at : undefined,
48
+ dxSource: enablement.enabled ? enablement.source : undefined,
49
+ dxVia: enablement.enabled ? enablement.via : undefined,
48
50
  dxAgeAsOf: env.now ?? Date.now(),
49
51
  };
50
52
  }
@@ -68,7 +70,7 @@ export function renderVersion(r) {
68
70
  // exactly when a forgotten enablement is most likely to be discovered. Printed
69
71
  // only when enabled — see the note in dx/enablement.ts on why there is no `off`
70
72
  // counterpart.
71
- const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt }, r.dxAgeAsOf);
73
+ const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt, source: r.dxSource, via: r.dxVia }, r.dxAgeAsOf);
72
74
  if (dx !== undefined)
73
75
  out.push("", ` ${dx}`);
74
76
  out.push("");
@@ -106,7 +108,32 @@ export async function versionReport(cwd, opts) {
106
108
  allowCustomApi: opts.allowCustomApi,
107
109
  nodeVersion: process.version,
108
110
  cliVersion: opts.cliVersion,
109
- enablement: await loadEnablement(now),
111
+ enablement: await versionEnablement(now, opts.consent),
110
112
  now,
111
113
  });
112
114
  }
115
+ /** The account bound on `version` (f7 Scenario 3c). */
116
+ export const VERSION_ACCOUNT_TIMEOUT_MS = 3_000;
117
+ /**
118
+ * The enablement `version` shows: the account's answer when it can be read within the
119
+ * bound, else this machine's flag. Never throws and never waits past the bound, because
120
+ * `version` is the command people run when something is already broken.
121
+ */
122
+ export async function versionEnablement(now, consent) {
123
+ const local = consent?.local ?? (() => loadEnablement(now));
124
+ if (consent === undefined)
125
+ return local();
126
+ try {
127
+ const view = await resolveConsentView({
128
+ auth: await consent.auth().catch(() => undefined),
129
+ readAccount: consent.readAccount,
130
+ local,
131
+ clearLocal: consent.clearLocal,
132
+ timeoutMs: consent.timeoutMs ?? VERSION_ACCOUNT_TIMEOUT_MS,
133
+ });
134
+ return view.shown;
135
+ }
136
+ catch {
137
+ return local().catch(() => ({ enabled: false }));
138
+ }
139
+ }