@vincentt-xr/cli 0.12.3 → 0.14.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/README.md CHANGED
@@ -67,7 +67,8 @@ Project
67
67
  [--force] [--no-install]
68
68
  link <project-id> Link this directory to an existing project
69
69
  unlink Detach this directory from its project
70
- projects List the workspace's projects and their ids
70
+ projects List the workspace's projects, their ids, and where each
71
+ one lives on this machine
71
72
  [--json] [--org <s>]
72
73
 
73
74
  Dev loop
@@ -119,5 +120,23 @@ In a directory that is not linked to anything, `npx vincentt projects` lists the
119
120
  workspace's projects with their ids, which is what `vincentt link <project-id>`
120
121
  needs. `--json` prints the same rows for a program to read.
121
122
 
123
+ It also reports **where each project lives on this machine**. Any command run in
124
+ a linked folder records that folder against the project, so a project you have
125
+ worked on here is listed with its directory on an indented line under its row:
126
+
127
+ ```
128
+ 66f1c2a9e4b7 museum-ar-tour Museum AR Tour museum-ar-tour.vincentt.app
129
+ ↳ /Users/alice/code/museum-ar-tour
130
+ ```
131
+
132
+ A project this machine has no folder for reads `no folder known on this machine`
133
+ rather than leaving a gap, and a project checked out in several places lists
134
+ every one without picking. The folder is checked when it is read — it is only
135
+ reported if it still holds that project's binding — so a path that has moved or
136
+ been unbound is never handed back. Under `--json` every project carries a
137
+ `locations` array: empty when unknown, one member when located, several when the
138
+ project is in more than one place. Nothing about this leaves the machine, and
139
+ deleting `~/.vincentt/locations.ndjson` costs nothing but the shortcut.
140
+
122
141
  `vincentt` is a project devDependency in the scaffold, so invoke it as
123
142
  `npx vincentt <verb>` and it resolves to the local copy — no global install.
package/dist/cli/index.js CHANGED
@@ -19,6 +19,7 @@ import { httpAuthBackend } from "../login/authBackend.js";
19
19
  import { loginCommand } from "../login/loginCommand.js";
20
20
  import { ensureLoggedIn } from "../login/ensure.js";
21
21
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
+ import { fileLocationStore } from "../shared/locationStore.js";
22
23
  import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
23
24
  import { listProjectsCommand } from "../lib/listProjects.js";
24
25
  import { initCommand } from "../lib/initCommand.js";
@@ -41,6 +42,7 @@ import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
41
42
  import * as feedbackCopy from "../lib/feedbackCopy.js";
42
43
  import { record as recordInvocation } from "../dx/journal.js";
43
44
  import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, } from "../dx/command.js";
45
+ import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
44
46
  import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
45
47
  import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
46
48
  import { postReport } from "../dx/send.js";
@@ -395,6 +397,7 @@ async function projects() {
395
397
  return resolveOrgForCommand(cfg, { flag: flag("--org") });
396
398
  },
397
399
  list: listProjects,
400
+ store: fileLocationStore,
398
401
  }, { json: has("--json") });
399
402
  }
400
403
  /**
@@ -946,6 +949,25 @@ async function version() {
946
949
  console.log(line);
947
950
  }
948
951
  // ---- dx ---------------------------------------------------------------------
952
+ /**
953
+ * The API host + PAT for a dx call, or undefined when signed out.
954
+ *
955
+ * resolveConfig THROWS when there is no PAT, and its message is written for a verb that
956
+ * cannot proceed at all. Everything dx does is optional, so a signed-out creator gets
957
+ * one plain line from the command layer instead of a config-file stack trace. Shared by
958
+ * `enable`, `disable`, and `--send` so all three read the credential identically.
959
+ */
960
+ async function dxAuth() {
961
+ try {
962
+ const cfg = await resolveConfig(projectDir(), {
963
+ allowCustomApi: has("--allow-custom-api"),
964
+ });
965
+ return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
966
+ }
967
+ catch {
968
+ return undefined;
969
+ }
970
+ }
949
971
  async function dx() {
950
972
  const options = parseDxOptions(argv);
951
973
  const now = Date.now();
@@ -954,18 +976,30 @@ async function dx() {
954
976
  // precisely the case where nobody reads what was disclosed.
955
977
  const interactive = () => Boolean(process.stdin.isTTY && process.stdout.isTTY);
956
978
  if (options.enable || options.disable) {
979
+ const consent = httpConsentBackend();
957
980
  const deps = {
958
981
  print: (l) => console.log(l),
959
982
  now: () => now,
960
983
  interactive,
961
984
  confirm: (q) => stdinConfirm()(` ${q} [y/N] `, false),
962
- // `enable` does NOT require being signed in. --send refuses without a PAT
963
- // regardless, so a config holding only enablement keys is inert; and
964
- // ordering the two verbs ("you must log in before you may state a
965
- // preference") would be a rule with no safety behind it.
985
+ // `enable` NOW REQUIRES being signed in, reversing f7's
986
+ // D-Enable-does-not-require-being-signed-in. The browser path records consent on
987
+ // the ACCOUNT, so there has to be an account to record it against and a credential
988
+ // to reach it with — the precondition is load-bearing now rather than ceremonial.
989
+ auth: dxAuth,
966
990
  save: async (next) => {
967
991
  await writeDxEnablement(next);
968
992
  },
993
+ // The no-TTY consent round trip. Defaults are the real seams; the whole thing is
994
+ // injected as ONE function so dxEnableCommand needs no socket to test.
995
+ consentInBrowser: async () => {
996
+ const { consoleUrl } = await resolveAuthUrls();
997
+ return runConsentInBrowser({
998
+ consoleUrl,
999
+ onProgress: (l) => console.log(l),
1000
+ });
1001
+ },
1002
+ revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
969
1003
  };
970
1004
  process.exitCode = options.enable
971
1005
  ? await dxEnableCommand(deps, DX_UNATTENDED_SUNSET)
@@ -988,25 +1022,15 @@ async function dx() {
988
1022
  enabled: () => journalEnabled(),
989
1023
  path: journalPath,
990
1024
  cliVersion,
991
- auth: async () => {
992
- // resolveConfig THROWS when there is no PAT, and its message is written
993
- // for a verb that cannot proceed at all. Sending a DX report is
994
- // optional, so a signed-out creator gets one plain line from
995
- // dxSendCommand instead of a config-file stack trace.
996
- try {
997
- const cfg = await resolveConfig(projectDir(), {
998
- allowCustomApi: has("--allow-custom-api"),
999
- });
1000
- return cfg.pat ? { apiUrl: cfg.apiUrl, pat: cfg.pat } : undefined;
1001
- }
1002
- catch {
1003
- return undefined;
1004
- }
1005
- },
1025
+ auth: dxAuth,
1006
1026
  post: (apiUrl, pat, report) => postReport(apiUrl, pat, report),
1007
1027
  confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
1008
1028
  interactive,
1009
1029
  enablement: () => enablement,
1030
+ // The third arm: the account-wide grant, read from /me. Consulted only when the
1031
+ // first two have not already allowed the send, and allowed to throw — a /me that
1032
+ // is unreachable falls back to the other two arms rather than blocking.
1033
+ accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1010
1034
  }, options);
1011
1035
  }
1012
1036
  // ---- assets (f5) ------------------------------------------------------------
@@ -30,7 +30,10 @@ export const USAGE_TEXT = `Vincentt CLI — local-first AR dev loop\n\n` +
30
30
  ` [--org <s>] [--preview] [--force] [--no-install]\n` +
31
31
  ` link <project-id> Link this folder to an existing project\n` +
32
32
  ` unlink Disconnect this folder from its project\n` +
33
- ` projects List the workspace's projects and their ids [--json]\n` +
33
+ ` projects List the workspace's projects, their ids, and where each\n` +
34
+ ` one lives on this machine [--json]\n` +
35
+ ` A project this machine has no folder for says\n` +
36
+ ` "no folder known on this machine" rather than leaving a gap.\n` +
34
37
  ` [--org <s>] to list a workspace other than the active one\n\n` +
35
38
  `Dev loop\n` +
36
39
  ` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
@@ -1,3 +1,4 @@
1
+ import type { ConsentDecision } from "./consent.js";
1
2
  import type { Enablement } from "./enablement.js";
2
3
  import type { JournalEntry } from "./journal.js";
3
4
  import type { WireReport } from "./send.js";
@@ -46,30 +47,83 @@ export interface DxEnableDeps {
46
47
  save: (next: {
47
48
  at: string;
48
49
  } | undefined) => Promise<void>;
50
+ /**
51
+ * Resolved API host + PAT, or undefined when signed out.
52
+ *
53
+ * `enable` now REQUIRES this, which REVERSES f7's
54
+ * D-Enable-does-not-require-being-signed-in. That decision reasoned that ordering the
55
+ * two verbs would be "a rule with no safety behind it" — true while the grant lived
56
+ * only in a local file. It no longer does: the browser path records consent against
57
+ * the ACCOUNT, so there has to be an account to record it against, and the CLI has to
58
+ * hold a credential to reach it. The precondition is now load-bearing rather than
59
+ * ceremonial, so the refusal names `vincentt login`.
60
+ */
61
+ auth?: () => Promise<{
62
+ apiUrl: string;
63
+ pat: string;
64
+ } | undefined>;
65
+ /**
66
+ * Run the console consent round trip: open the browser, wait for the loopback
67
+ * callback, and return the human's answer. Injected whole so the command layer needs
68
+ * no socket, no browser, and no clock to test.
69
+ */
70
+ consentInBrowser?: () => Promise<ConsentDecision>;
71
+ /**
72
+ * DELETE /me/dx-consent. Used only by `disable`, and allowed to throw — the caller
73
+ * treats a network failure as "local revoke still stands", never as a reason to leave
74
+ * the capability on.
75
+ */
76
+ revokeAccountConsent?: (auth: {
77
+ apiUrl: string;
78
+ pat: string;
79
+ }) => Promise<void>;
49
80
  }
50
81
  /**
51
- * `vincentt dx enable` — grant this machine durable permission to send DX reports
52
- * without asking first.
82
+ * `vincentt dx enable` — grant durable permission to send DX reports without asking
83
+ * first.
84
+ *
85
+ * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
86
+ *
87
+ * - At a TTY: the original terminal prompt, unchanged. A person is already here and
88
+ * can read the disclosure where they stand; sending them to a browser would be
89
+ * ceremony.
90
+ * - With no TTY: the BROWSER path. This is the case the feature exists for — a
91
+ * creator driving the CLI through a coding agent has no terminal, so the old code
92
+ * refused and the flow dead-ended. It now opens the console, where a human can
93
+ * actually give consent.
53
94
  *
54
- * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
55
- * such so a later reader does not build on a guarantee that is not there. It
56
- * stops the reflex — an agent that meets a prompt, infers there must be a way
57
- * past it, and tries the obvious thing. It does not stop a caller that allocates
58
- * a PTY or writes config.json directly.
95
+ * The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
96
+ * old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
97
+ * that allocated a PTY or wrote config.json directly); what replaces it is stronger,
98
+ * because the browser path puts the decision in front of a human on a page the agent
99
+ * cannot see, click, or forge — the agent can start the flow but cannot finish it.
59
100
  *
60
- * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
61
- * all: this file holds the PAT, and a refusal that round-trips the config through
62
- * a write can lose the credential if it dies mid-write.
101
+ * The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
102
+ * all: this file holds the PAT, and a refusal that round-trips the config through a
103
+ * write can lose the credential if it dies mid-write.
63
104
  */
64
105
  export declare function dxEnableCommand(deps: DxEnableDeps, sunset: number): Promise<number>;
65
106
  /**
66
107
  * `vincentt dx disable` — the obvious opposite command.
67
108
  *
68
- * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
69
- * report its age from the first grant, which is the kind of thing that ships
70
- * wrong and is never noticed. Needs no terminal and no confirmation: turning a
71
- * capability OFF is always safe to do, and requiring a TTY here would strand a
72
- * creator who wanted to revoke from a script.
109
+ * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
110
+ * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
111
+ * clearing only the local flag would leave the account grant standing, so the very next
112
+ * `--send` would be authorized by arm (c) and the command would appear not to have
113
+ * worked.
114
+ *
115
+ * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
116
+ * cannot fail and the one that stops THIS machine, which is the machine the creator is
117
+ * standing at.
118
+ *
119
+ * A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
120
+ * so the failure path still clears locally, still exits 0, and says plainly that the
121
+ * account grant may remain and where to clear it. The opposite design — fail the command
122
+ * so the creator "knows it did not work" — leaves them with the capability still on and
123
+ * a non-zero exit that suggests nothing happened at all.
124
+ *
125
+ * Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
126
+ * wanted to revoke from a script, and this is the direction that is always safe.
73
127
  */
74
128
  export declare function dxDisableCommand(deps: DxEnableDeps): Promise<number>;
75
129
  export interface DxSendDeps extends DxDeps {
@@ -95,6 +149,16 @@ export interface DxSendDeps extends DxDeps {
95
149
  interactive: () => boolean;
96
150
  /** Durable unattended-send state, read strictly. Absent reads as not enabled. */
97
151
  enablement?: () => Enablement;
152
+ /**
153
+ * The ACCOUNT's consent, read from GET /me. The third arm of the gate.
154
+ *
155
+ * Allowed to throw, and its throw is not fatal — see the gate below. Absent (the dep
156
+ * not wired at all) reads as not consented, never as "unknown, so allow".
157
+ */
158
+ accountConsent?: (auth: {
159
+ apiUrl: string;
160
+ pat: string;
161
+ }) => Promise<Enablement>;
98
162
  }
99
163
  /**
100
164
  * `vincentt dx --send`.
@@ -131,18 +131,28 @@ function sunsetDate(sunset) {
131
131
  return new Date(sunset).toISOString().slice(0, 10);
132
132
  }
133
133
  /**
134
- * `vincentt dx enable` — grant this machine durable permission to send DX reports
135
- * without asking first.
134
+ * `vincentt dx enable` — grant durable permission to send DX reports without asking
135
+ * first.
136
136
  *
137
- * The TTY requirement is a SPEED BUMP, NOT A BOUNDARY, and is written down as
138
- * such so a later reader does not build on a guarantee that is not there. It
139
- * stops the reflex — an agent that meets a prompt, infers there must be a way
140
- * past it, and tries the obvious thing. It does not stop a caller that allocates
141
- * a PTY or writes config.json directly.
137
+ * TWO PATHS, and which one runs is decided by whether there is a terminal to ask in:
142
138
  *
143
- * The refusal path writes NOTHING. Not "writes the same bytes back" — nothing at
144
- * all: this file holds the PAT, and a refusal that round-trips the config through
145
- * a write can lose the credential if it dies mid-write.
139
+ * - At a TTY: the original terminal prompt, unchanged. A person is already here and
140
+ * can read the disclosure where they stand; sending them to a browser would be
141
+ * ceremony.
142
+ * - With no TTY: the BROWSER path. This is the case the feature exists for — a
143
+ * creator driving the CLI through a coding agent has no terminal, so the old code
144
+ * refused and the flow dead-ended. It now opens the console, where a human can
145
+ * actually give consent.
146
+ *
147
+ * The no-TTY path is no longer a refusal, but that is NOT a relaxation of the gate. The
148
+ * old TTY check was explicitly a SPEED BUMP, NOT A BOUNDARY (it never stopped a caller
149
+ * that allocated a PTY or wrote config.json directly); what replaces it is stronger,
150
+ * because the browser path puts the decision in front of a human on a page the agent
151
+ * cannot see, click, or forge — the agent can start the flow but cannot finish it.
152
+ *
153
+ * The refusal paths still write NOTHING. Not "write the same bytes back" — nothing at
154
+ * all: this file holds the PAT, and a refusal that round-trips the config through a
155
+ * write can lose the credential if it dies mid-write.
146
156
  */
147
157
  export async function dxEnableCommand(deps, sunset) {
148
158
  if (deps.now() >= sunset) {
@@ -152,15 +162,26 @@ export async function dxEnableCommand(deps, sunset) {
152
162
  deps.print("");
153
163
  return 1;
154
164
  }
155
- if (!deps.interactive()) {
165
+ // Being signed in is now a PRECONDITION, checked before either path and before any
166
+ // disclosure is printed. This REVERSES f7's D-Enable-does-not-require-being-signed-in
167
+ // — see the note on DxEnableDeps.auth for why that reversal is justified rather than
168
+ // merely convenient: the browser path records consent on the ACCOUNT, so there must be
169
+ // one, and reaching it needs a credential. Checked FIRST because a creator who is
170
+ // signed out cannot complete either path, and reading a consent disclosure before
171
+ // being told so is wasted attention.
172
+ const auth = await deps.auth?.();
173
+ if (auth === undefined) {
156
174
  deps.print("");
157
- deps.print(" Enabling unattended sending needs a terminal to ask in, and this");
158
- deps.print(" session has none. Nothing was changed.");
175
+ deps.print(" Allowing unattended reports is recorded on your Vincentt account, and");
176
+ deps.print(" this machine is not signed in. Nothing was changed.");
159
177
  deps.print("");
160
- deps.print(" Run `vincentt dx enable` yourself, in your own terminal.");
178
+ deps.print(" Run `vincentt login` first.");
161
179
  deps.print("");
162
180
  return 1;
163
181
  }
182
+ if (!deps.interactive()) {
183
+ return enableViaBrowser(deps);
184
+ }
164
185
  // What is being granted, INCLUDING the note risk. The note is the part a
165
186
  // creator does not picture: enablement is what lets an agent compose prose they
166
187
  // have not read and put it on the wire.
@@ -187,17 +208,111 @@ export async function dxEnableCommand(deps, sunset) {
187
208
  deps.print("");
188
209
  return 0;
189
210
  }
211
+ /**
212
+ * The no-TTY arm: hand the decision to a human in a browser.
213
+ *
214
+ * The disclosure itself is deliberately NOT printed here. It lives on the console page,
215
+ * where the person deciding will actually see it; printing it into an agent's transcript
216
+ * would put the warning everywhere except in front of the human it is written for. What
217
+ * is printed here is only what a reader of the transcript needs — that a browser is
218
+ * being opened, and the URL.
219
+ *
220
+ * Note the CLI's PAT is NOT passed into this round trip and never rides the console URL.
221
+ * The console holds its own session and POSTs /me/dx-consent itself, so the browser
222
+ * carries only a `state` and a decision. The PAT's role was to prove there is an account
223
+ * at all, which the caller has already checked.
224
+ */
225
+ async function enableViaBrowser(deps) {
226
+ if (deps.consentInBrowser === undefined) {
227
+ // No browser seam wired: refuse rather than silently falling back to a path that
228
+ // asks nobody. Reachable only from a caller that built deps by hand.
229
+ deps.print("");
230
+ deps.print(" Enabling unattended sending needs either a terminal to ask in or a");
231
+ deps.print(" browser to open, and this session has neither. Nothing was changed.");
232
+ deps.print("");
233
+ return 1;
234
+ }
235
+ let decision;
236
+ try {
237
+ decision = await deps.consentInBrowser();
238
+ }
239
+ catch (err) {
240
+ // A timeout, a cancelled window, an unreachable console. Nothing is written on any
241
+ // of them — an enable that could not be confirmed is not an enable.
242
+ deps.print("");
243
+ deps.print(` ✗ ${err instanceof Error ? err.message : String(err)}`);
244
+ deps.print(" Nothing was changed.");
245
+ deps.print("");
246
+ return 1;
247
+ }
248
+ if (decision !== "granted") {
249
+ // Denied writes NOTHING and exits 0. Exit 0 because declining is a successful
250
+ // outcome of the command the caller ran, not a malfunction — an agent that reads a
251
+ // non-zero exit here would reasonably retry, which is how a refused consent prompt
252
+ // becomes a loop the human has to keep dismissing.
253
+ deps.print("");
254
+ deps.print(" Not enabled.");
255
+ deps.print("");
256
+ return 0;
257
+ }
258
+ // The grant is already recorded on the account by the console (the page POSTs
259
+ // /me/dx-consent before it redirects). The local flag is cached alongside it so this
260
+ // machine can decide without a network round trip — and so a later --send is not
261
+ // blocked by an API that happens to be unreachable.
262
+ await deps.save({ at: new Date(deps.now()).toISOString() });
263
+ deps.print("");
264
+ deps.print(" Enabled. Reports may now be sent without a prompt.");
265
+ deps.print(" Turn it off with `vincentt dx disable`, or in the console.");
266
+ deps.print("");
267
+ return 0;
268
+ }
190
269
  /**
191
270
  * `vincentt dx disable` — the obvious opposite command.
192
271
  *
193
- * Clears BOTH keys. Leaving the timestamp behind would make a later re-enable
194
- * report its age from the first grant, which is the kind of thing that ships
195
- * wrong and is never noticed. Needs no terminal and no confirmation: turning a
196
- * capability OFF is always safe to do, and requiring a TTY here would strand a
197
- * creator who wanted to revoke from a script.
272
+ * Clears BOTH local keys AND revokes the account grant when there is a PAT to do it
273
+ * with. Both halves are needed: a creator who runs `disable` means "stop sending", and
274
+ * clearing only the local flag would leave the account grant standing, so the very next
275
+ * `--send` would be authorized by arm (c) and the command would appear not to have
276
+ * worked.
277
+ *
278
+ * ORDER IS DELIBERATE: local first, network second. The local clear is the one that
279
+ * cannot fail and the one that stops THIS machine, which is the machine the creator is
280
+ * standing at.
281
+ *
282
+ * A NETWORK FAILURE MUST NEVER BLOCK A REVOKE. Turning a capability off is always safe,
283
+ * so the failure path still clears locally, still exits 0, and says plainly that the
284
+ * account grant may remain and where to clear it. The opposite design — fail the command
285
+ * so the creator "knows it did not work" — leaves them with the capability still on and
286
+ * a non-zero exit that suggests nothing happened at all.
287
+ *
288
+ * Needs no terminal and no confirmation: requiring a TTY here would strand a creator who
289
+ * wanted to revoke from a script, and this is the direction that is always safe.
198
290
  */
199
291
  export async function dxDisableCommand(deps) {
200
292
  await deps.save(undefined);
293
+ // Signed out: there is no account grant this machine can reach, and nothing to say
294
+ // about one. `logout` already cleared the local flag on its way out.
295
+ const auth = await deps.auth?.();
296
+ if (auth !== undefined && deps.revokeAccountConsent !== undefined) {
297
+ try {
298
+ await deps.revokeAccountConsent(auth);
299
+ }
300
+ catch (err) {
301
+ deps.print("");
302
+ deps.print(" Unattended sending is off on this machine.");
303
+ deps.print("");
304
+ deps.print(` ! Could not reach Vincentt to clear the account-wide grant:`);
305
+ deps.print(` ${err instanceof Error ? err.message : String(err)}`);
306
+ deps.print("");
307
+ deps.print(" If you allowed reports in the console, that grant may still be");
308
+ deps.print(" active for your other machines. Clear it in the console settings.");
309
+ deps.print("");
310
+ // Exit 0: the revoke the creator asked for HAPPENED here, and the part that did
311
+ // not is named with the place to finish it. A non-zero exit would read as "the
312
+ // command failed", which would be false about the machine they are on.
313
+ return 0;
314
+ }
315
+ }
201
316
  deps.print("");
202
317
  deps.print(" Unattended sending is off. Reports need your confirmation again.");
203
318
  deps.print("");
@@ -245,8 +360,45 @@ export async function dxSendCommand(deps, options) {
245
360
  // The second arm is the SUPPORTED unattended path: a person, at a terminal,
246
361
  // ran `vincentt dx enable` on this machine. That is a prior deliberate act
247
362
  // rather than a per-send answer, which is what makes it consent at all.
248
- const enablement = deps.enablement?.() ?? { enabled: false };
363
+ //
364
+ // The THIRD arm is the same kind of prior deliberate act, made in the console and
365
+ // recorded on the ACCOUNT rather than on this machine. It exists because the second
366
+ // arm cannot be reached at all by the creator this feature is for: driving the CLI
367
+ // through a coding agent, there is no terminal to run `dx enable` in.
368
+ const localEnablement = deps.enablement?.() ?? { enabled: false };
249
369
  const interactive = deps.interactive();
370
+ // Auth is resolved BEFORE the gate now, because arm (c) needs a credential to read
371
+ // /me. The signed-out refusal still comes AFTER the gate, so a caller with neither a
372
+ // terminal nor consent gets the refusal about consent — the thing they need to fix —
373
+ // rather than being sent to `login` first and only then told that logging in was not
374
+ // sufficient.
375
+ const auth = await deps.auth();
376
+ // Arm (c) is consulted ONLY when the first two have not already allowed the send.
377
+ // Skipping it otherwise is not just an optimization: it keeps `--send` from making a
378
+ // network call it does not need, so a creator at their own terminal is never blocked
379
+ // by an API being slow or down.
380
+ let accountEnablement = { enabled: false };
381
+ if (!interactive && !localEnablement.enabled && auth !== undefined) {
382
+ try {
383
+ accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
384
+ }
385
+ catch {
386
+ // A NETWORK FAILURE FALLS BACK TO THE OTHER TWO ARMS, and since we only get here
387
+ // when both have already said no, that means refusing. This is the fail-CLOSED
388
+ // direction: an unreachable API can never authorize a send.
389
+ //
390
+ // The swallow is deliberate and is the whole point of the arm's placement — a
391
+ // /me that is down must never BLOCK a send that (a) or (b) already allows, and it
392
+ // cannot, because those paths never reach this call at all.
393
+ accountEnablement = { enabled: false };
394
+ }
395
+ }
396
+ // The grant that authorizes this send, and the one whose age gets reported. The local
397
+ // flag wins when both exist: it is the more specific statement (this machine) and it
398
+ // is the one `dx disable` can always clear.
399
+ const enablement = localEnablement.enabled
400
+ ? localEnablement
401
+ : accountEnablement;
250
402
  if (!interactive && !enablement.enabled) {
251
403
  // This refusal NAMES the enabling command. A refusal is an answer to a
252
404
  // command the caller typed, which makes it SOLICITED output — the
@@ -260,14 +412,13 @@ export async function dxSendCommand(deps, options) {
260
412
  deps.print("machine, or run `vincentt dx --send` in your own terminal.");
261
413
  return 1;
262
414
  }
263
- const auth = await deps.auth();
264
415
  if (auth === undefined) {
265
416
  deps.print("Not signed in. Run `vincentt login` to send a report.");
266
417
  return 1;
267
418
  }
268
419
  const report = buildReport(clusters, deps.cliVersion(), options.note);
269
420
  // Above the payload, on the unattended path only: name that this is enabled,
270
- // how old the grant is, and that nothing is going to ask.
421
+ // how old the grant is, WHERE it was granted, and that nothing is going to ask.
271
422
  const unattended = !interactive && enablement.enabled;
272
423
  if (unattended) {
273
424
  const line = enablementLine(enablement, deps.now());
@@ -0,0 +1,77 @@
1
+ import { type Enablement } from "./enablement.js";
2
+ /**
3
+ * The account-consent seam.
4
+ *
5
+ * `read` returns the parsed Enablement rather than the raw envelope so the strict
6
+ * `=== true` interpretation happens in exactly one place and cannot be re-derived
7
+ * loosely by a second caller.
8
+ */
9
+ 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>;
14
+ /** DELETE /me/dx-consent — revoke. Never fails on already-revoked. */
15
+ revoke(apiUrl: string, pat: string): Promise<void>;
16
+ }
17
+ /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
18
+ export declare function httpConsentBackend(): ConsentBackend;
19
+ /**
20
+ * The console URL the browser opens for the consent decision:
21
+ * `<console>/cli-dx-consent?redirect_uri=<loopback>&state=…`.
22
+ *
23
+ * Mirrors buildCliAuthUrl deliberately, including the param NAMES — the console's page
24
+ * reads exactly `redirect_uri` and `state`, and it validates that redirect_uri is a
25
+ * 127.0.0.1 loopback. No PKCE material rides here: unlike login, nothing is being
26
+ * redeemed. The callback carries only a decision, and `state` is what binds that
27
+ * decision to this CLI invocation.
28
+ */
29
+ export declare function buildConsentUrl(consoleBase: string, redirectUri: string, state: string): string;
30
+ export type ConsentDecision = "granted" | "denied";
31
+ export type ConsentCallback = {
32
+ ok: true;
33
+ decision: ConsentDecision;
34
+ } | {
35
+ ok: false;
36
+ reason: "state_mismatch" | "missing_result";
37
+ message: string;
38
+ };
39
+ /**
40
+ * Interpret the loopback callback (`?state=…&result=granted|denied`).
41
+ *
42
+ * State is verified constant-time FIRST, before `result` is even read, so a stray or
43
+ * forged callback can never grant consent — the same discipline as parseCallback, and
44
+ * for a higher-stakes decision: here the forged value would not be a code that still has
45
+ * to survive a PKCE exchange, it would be the answer itself.
46
+ *
47
+ * Anything that is not exactly "granted" reads as DENIED rather than as an error, so an
48
+ * unrecognized result can only ever fail closed.
49
+ */
50
+ export declare function parseConsentCallback(params: URLSearchParams, expectedState: string, stateMatches: (expected: string, actual: string | null) => boolean): ConsentCallback;
51
+ /** Thrown when the human never answers in the browser. */
52
+ export declare class ConsentTimeoutError extends Error {
53
+ constructor();
54
+ }
55
+ export interface ConsentBrowserOptions {
56
+ /** Console origin that serves /cli-dx-consent. */
57
+ consoleUrl: string;
58
+ /** Abort if the browser round trip does not complete in time. Default 5 min. */
59
+ timeoutMs?: number;
60
+ pickPort?: () => Promise<number>;
61
+ openBrowser?: (url: string) => void;
62
+ /** Mint the anti-CSRF state. Injected only so a test can pin it. */
63
+ mintState?: () => string;
64
+ onProgress?: (line: string) => void;
65
+ }
66
+ /**
67
+ * Open the console's consent page and wait for the human's answer.
68
+ *
69
+ * The mechanics are the SHARED loopback helper, not a second copy of it: listener up
70
+ * before the browser, `state` verified constant-time on every callback, a stray callback
71
+ * answered but not accepted, and a bounded wait. The only thing that differs from login
72
+ * is what comes back — a decision instead of an auth code.
73
+ *
74
+ * Nothing is written here. This function reports what the human said; persisting it is
75
+ * the command layer's job, so the "denied writes nothing" rule lives in one place.
76
+ */
77
+ export declare function runConsentInBrowser(opts: ConsentBrowserOptions): Promise<ConsentDecision>;