@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.
package/MIGRATION.md CHANGED
@@ -3,6 +3,21 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.22.0
7
+
8
+ required: false
9
+ No action required. Behaviour that changed:
10
+ - `vincentt dx enable` at a terminal now asks `Allow unattended reports on your account? [y/N]` and, on `y`, records the grant on your Vincentt account first, then sets this machine. Success reads `Enabled for your account. Reports may now be sent without a prompt from any machine signed in to it.`
11
+ - If the account does not record the grant, `vincentt dx enable` changes nothing and exits 1 with `✗ Vincentt did not record your consent.`, `✗ Too many consent changes. Wait a minute and try again.`, or `✗ Could not reach Vincentt to record your consent.` A refused sign-in exits 4 and a too-old CLI exits 5, as on other verbs.
12
+ - Unattended `vincentt dx --send` and `vincentt report` now check the account first. An account grant sends; an account that is not granted refuses (exit 1) and clears this machine's setting, so turning reports off in the console or on another machine reaches this one. This machine's setting is used on its own only when the account cannot be reached. An interactive session still asks and makes no account check.
13
+ - The `Unattended send: ENABLED since <day> (<n> days)` line now ends with where the answer came from: `· granted from the CLI`, `· granted in the console`, `· on your account`, or `· this machine (account not checked)`. `vincentt dx` and `vincentt version` check the account for up to 3 seconds first; `vincentt version` never fails because of that check.
14
+ - `vincentt dx --json` adds `dxConsent`: `{"state": "on" | "off" | "unknown", "checked": "account" | "machine", "at"?, "via"?}`. `dxSendEnabled` and `dxSendEnabledAt` still describe this machine's setting.
15
+ - `vincentt preview` has a new ending: when the preview server answers the tunnel with a reply this CLI cannot accept, the preview ends once with `✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.` (exit 1) instead of reconnecting every few seconds indefinitely. The screen names the upgrade command and `vincentt preview`.
16
+ - `vincentt publish` refuses the whole publish, listing the files, when a folder in the build output turns into a link to somewhere outside it while publish reads it. Let the build finish, then publish again.
17
+ - `vincentt publish` refuses to upload when the platform's upload link does not carry the checksum publish computed for that file; nothing is sent, and the screen names the file.
18
+ - A folder or file in the build output that publish cannot read now ends the publish with the "changed while publish was reading it" screen naming the path inside the build output, instead of a raw error message holding the full path.
19
+ - File names printed on publish refusal screens show bidirectional and direction-mark characters as visible `\uXXXX` escapes.
20
+
6
21
  ## 0.21.0
7
22
 
8
23
  required: false
package/dist/cli/index.js CHANGED
@@ -29,7 +29,7 @@ import { listProjectsCommand } from "../lib/listProjects.js";
29
29
  import { initCommand } from "../lib/initCommand.js";
30
30
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
31
31
  import { whoamiCommand } from "../lib/whoami.js";
32
- import { renderVersion, versionReport } from "../lib/version.js";
32
+ import { renderVersion, VERSION_ACCOUNT_TIMEOUT_MS, versionReport, } from "../lib/version.js";
33
33
  import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
34
34
  import { publishCommand } from "../publish/command.js";
35
35
  import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
@@ -53,7 +53,7 @@ import * as feedbackCopy from "../lib/feedbackCopy.js";
53
53
  import { record as recordInvocation } from "../dx/journal.js";
54
54
  import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, sinceUsageLines, } from "../dx/command.js";
55
55
  import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
56
- import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
56
+ import { DX_UNATTENDED_SUNSET, loadEnablement, resolveConsentView, } from "../dx/enablement.js";
57
57
  import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
58
58
  import { postReport } from "../dx/send.js";
59
59
  import { nudgeFor } from "../dx/nudge.js";
@@ -1139,14 +1139,22 @@ function cliVersion() {
1139
1139
  }
1140
1140
  }
1141
1141
  async function version() {
1142
+ const now = Date.now();
1142
1143
  const report = await versionReport(projectDir(), {
1143
1144
  cliVersion: cliVersion(),
1144
1145
  allowCustomApi: has("--allow-custom-api"),
1146
+ consent: {
1147
+ auth: dxAuth,
1148
+ readAccount: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now, VERSION_ACCOUNT_TIMEOUT_MS),
1149
+ clearLocal: () => writeDxEnablement(undefined),
1150
+ },
1145
1151
  });
1146
1152
  for (const line of renderVersion(report))
1147
1153
  console.log(line);
1148
1154
  }
1149
1155
  // ---- dx ---------------------------------------------------------------------
1156
+ /** The account bound on `vincentt dx` (f7 Scenario 3c); the send gate uses the default. */
1157
+ const DX_ACCOUNT_TIMEOUT_MS = 3_000;
1150
1158
  /**
1151
1159
  * The API host + PAT for a dx call, or undefined when signed out.
1152
1160
  *
@@ -1197,6 +1205,7 @@ async function dx() {
1197
1205
  onProgress: (l) => console.log(l),
1198
1206
  });
1199
1207
  },
1208
+ grantAccountConsent: (auth) => consent.grant(auth.apiUrl, auth.pat, now),
1200
1209
  revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
1201
1210
  };
1202
1211
  process.exitCode = options.enable
@@ -1214,7 +1223,15 @@ async function dx() {
1214
1223
  // relaxation, not throw through it.
1215
1224
  const enablement = await loadEnablement(now);
1216
1225
  if (!options.send) {
1217
- process.exitCode = runDx(argv, (l) => console.log(l), () => enablement);
1226
+ // The report tail and `dxConsent` read the account first, bounded at 3 s.
1227
+ const view = await resolveConsentView({
1228
+ auth: await dxAuth(),
1229
+ readAccount: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now, DX_ACCOUNT_TIMEOUT_MS),
1230
+ local: () => enablement,
1231
+ clearLocal: () => writeDxEnablement(undefined),
1232
+ timeoutMs: DX_ACCOUNT_TIMEOUT_MS,
1233
+ });
1234
+ process.exitCode = runDx(argv, (l) => console.log(l), () => view.local, () => view);
1218
1235
  return;
1219
1236
  }
1220
1237
  // `--send` is the one path out of this machine, so it resolves auth the way
@@ -1231,10 +1248,9 @@ async function dx() {
1231
1248
  confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
1232
1249
  interactive,
1233
1250
  enablement: () => enablement,
1234
- // The third arm: the account-wide grant, read from /me. Consulted only when the
1235
- // first two have not already allowed the send, and allowed to throw — a /me that
1236
- // is unreachable falls back to the other two arms rather than blocking.
1251
+ // The account decides when it answers; never read on an interactive session.
1237
1252
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1253
+ clearEnablement: () => writeDxEnablement(undefined),
1238
1254
  // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1239
1255
  // works on a send as it does everywhere else.
1240
1256
  cwd: projectDir(),
@@ -1276,6 +1292,7 @@ async function report() {
1276
1292
  interactive,
1277
1293
  enablement: () => enablement,
1278
1294
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1295
+ clearEnablement: () => writeDxEnablement(undefined),
1279
1296
  // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1280
1297
  // works on a report as it does everywhere else.
1281
1298
  cwd: projectDir(),
@@ -1,5 +1,5 @@
1
- import type { ConsentDecision } from "./consent.js";
2
- import type { Enablement } from "./enablement.js";
1
+ import type { ConsentDecision, GrantResult } from "./consent.js";
2
+ import type { AccountRead, ConsentView, Enablement } from "./enablement.js";
3
3
  import type { JournalEntry, JournalWindow } from "./journal.js";
4
4
  import type { WireReport } from "./send.js";
5
5
  import { type BuiltWith } from "../platform/resolve.js";
@@ -11,10 +11,15 @@ export interface DxDeps {
11
11
  enabled: () => boolean;
12
12
  path: () => string;
13
13
  /**
14
- * Durable unattended-send state. Optional so the many callers that only read
15
- * the journal need not resolve it; absent reads as not enabled.
14
+ * This machine's copy of the grant (the local flag). Optional so the many callers that
15
+ * only read the journal need not resolve it; absent reads as not enabled.
16
16
  */
17
17
  enablement?: () => Enablement;
18
+ /**
19
+ * The account-first answer the report tail and `dxConsent` show. Absent falls back to
20
+ * the local flag, reported as checked on this machine only.
21
+ */
22
+ consentView?: () => ConsentView;
18
23
  }
19
24
  /** How far back a report looks. */
20
25
  export interface DxWindow {
@@ -53,7 +58,7 @@ export declare function sinceUsageLines(raw: string): string[];
53
58
  export declare function parseDxOptions(args: string[]): DxOptions;
54
59
  export declare function dxCommand(deps: DxDeps, options: DxOptions): number;
55
60
  /** Wire the command to the real journal. */
56
- export declare function runDx(args: string[], print: (line: string) => void, enablement?: () => Enablement): number;
61
+ export declare function runDx(args: string[], print: (line: string) => void, enablement?: () => Enablement, consentView?: () => ConsentView): number;
57
62
  export interface DxEnableDeps {
58
63
  print: (line: string) => void;
59
64
  now: () => number;
@@ -89,6 +94,14 @@ export interface DxEnableDeps {
89
94
  * no socket, no browser, and no clock to test.
90
95
  */
91
96
  consentInBrowser?: () => Promise<ConsentDecision>;
97
+ /**
98
+ * POST /me/dx-consent with the PAT, on the terminal path. Rejects only with the version
99
+ * refusal or the sign-in error; every other outcome is a GrantResult.
100
+ */
101
+ grantAccountConsent?: (auth: {
102
+ apiUrl: string;
103
+ pat: string;
104
+ }) => Promise<GrantResult>;
92
105
  /**
93
106
  * DELETE /me/dx-consent. Used only by `disable`, and allowed to throw — the caller
94
107
  * treats a network failure as "local revoke still stands", never as a reason to leave
@@ -103,11 +116,13 @@ export interface DxEnableDeps {
103
116
  * `vincentt dx enable` — grant durable permission to send DX reports without asking
104
117
  * first.
105
118
  *
106
- * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
119
+ * TWO PATHS to ONE account grant, and which one runs is decided by whether there is a
120
+ * terminal to ask in:
107
121
  *
108
- * - At a TTY: the original terminal prompt, unchanged. A person is already here and
109
- * can read the disclosure where they stand; sending them to a browser would be
110
- * ceremony.
122
+ * - At a TTY: the terminal prompt. A person is already here and can read the
123
+ * disclosure where they stand; sending them to a browser would be ceremony. On `y`
124
+ * the CLI POSTs /me/dx-consent with the PAT, and writes the local flag only after
125
+ * the account recorded the grant.
111
126
  * - With no TTY: the BROWSER path. This is the case the feature exists for — a
112
127
  * creator driving the CLI through a coding agent has no terminal, so the old code
113
128
  * refused and the flow dead-ended. It now opens the console, where a human can
@@ -130,8 +145,8 @@ export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Pro
130
145
  * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
131
146
  * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
132
147
  * clearing only the local flag would leave the account grant standing, so the very next
133
- * `--send` would be authorized by arm (c) and the command would appear not to have
134
- * worked.
148
+ * `--send` would be authorized by the account and the command would appear not to
149
+ * have worked.
135
150
  *
136
151
  * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
137
152
  * cannot fail and the one that stops THIS machine, which is the machine the creator is
@@ -168,18 +183,19 @@ export interface DxSendDeps extends DxDeps {
168
183
  * convenience, not authorization.
169
184
  */
170
185
  interactive: () => boolean;
171
- /** Durable unattended-send state, read strictly. Absent reads as not enabled. */
186
+ /** This machine's flag, read strictly. Absent reads as not enabled. */
172
187
  enablement?: () => Enablement;
173
188
  /**
174
- * The ACCOUNT's consent, read from GET /me. The third arm of the gate.
175
- *
176
- * Allowed to throw, and its throw is not fatal — see the gate below. Absent (the dep
177
- * not wired at all) reads as not consented, never as "unknown, so allow".
189
+ * The ACCOUNT's consent, read from GET /me. Allowed to throw; a throw reads as
190
+ * unreadable. Absent reads as unreadable too: it can only let a valid local flag stand,
191
+ * never authorize on its own.
178
192
  */
179
193
  accountConsent?: (auth: {
180
194
  apiUrl: string;
181
195
  pat: string;
182
- }) => Promise<Enablement>;
196
+ }) => Promise<AccountRead>;
197
+ /** Clears this machine's flag when the account answers "not granted". */
198
+ clearEnablement?: () => Promise<unknown>;
183
199
  /**
184
200
  * The version set. Injectable so a fixed set — or a REJECTING promise, the arm
185
201
  * proving a fault omits — can be driven with no filesystem. Defaults to the one
@@ -188,6 +204,46 @@ export interface DxSendDeps extends DxDeps {
188
204
  builtWith?: () => Promise<BuiltWith | undefined>;
189
205
  cwd?: string;
190
206
  }
207
+ /** What the send gate needs. `dx --send` and `report` both satisfy it. */
208
+ export interface SendGateDeps {
209
+ interactive: () => boolean;
210
+ enablement?: () => Enablement;
211
+ accountConsent?: (auth: {
212
+ apiUrl: string;
213
+ pat: string;
214
+ }) => Promise<AccountRead>;
215
+ clearEnablement?: () => Promise<unknown>;
216
+ }
217
+ export type SendGate = {
218
+ arm: "interactive";
219
+ } | {
220
+ arm: "unattended";
221
+ enablement: Enablement;
222
+ } | {
223
+ arm: "refused";
224
+ };
225
+ /**
226
+ * The one consent gate both send paths call (f7 Scenario 3), checked in order:
227
+ *
228
+ * 1. an interactive session → ask, then send. No /me read at all, so a creator at their
229
+ * own terminal is never blocked by an API being slow or down.
230
+ * 2. signed in and GET /me answers: a strict grant sends (source: account); any other
231
+ * 200 clears this machine's flag and refuses.
232
+ * 3. /me cannot be read and this machine's flag is valid → send (source: machine).
233
+ *
234
+ * An unreadable /me never authorizes by itself; it only lets an existing local flag stand.
235
+ */
236
+ export declare function gateSend(deps: SendGateDeps, auth: {
237
+ apiUrl: string;
238
+ pat: string;
239
+ } | undefined): Promise<SendGate>;
240
+ /**
241
+ * The gate's refusal. It NAMES the enabling command: a refusal is an answer to a
242
+ * command the caller typed, which makes it SOLICITED output — the prohibition is on
243
+ * UNBIDDEN output instructing a caller. A refusal naming nothing leaves a human at
244
+ * exit 1 with no path forward, for no security gain.
245
+ */
246
+ export declare const SEND_REFUSAL_LINES: readonly string[];
191
247
  /**
192
248
  * `vincentt dx --send`.
193
249
  *
@@ -195,6 +251,6 @@ export interface DxSendDeps extends DxDeps {
195
251
  * who cannot see what leaves their machine before it leaves has not agreed to
196
252
  * anything:
197
253
  *
198
- * friction → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
254
+ * friction → auth → GATE → RESOLVE → build → SHOW → ask → post
199
255
  */
200
256
  export declare function dxSendCommand(deps: DxSendDeps, options: DxOptions): Promise<number>;
@@ -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;