@vincentt-xr/cli 0.20.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.
Files changed (68) hide show
  1. package/MIGRATION.md +30 -0
  2. package/dist/cli/index.js +86 -12
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.d.ts +74 -18
  6. package/dist/dx/command.js +140 -86
  7. package/dist/dx/consent.d.ts +25 -6
  8. package/dist/dx/consent.js +64 -35
  9. package/dist/dx/enablement.d.ts +63 -11
  10. package/dist/dx/enablement.js +93 -21
  11. package/dist/dx/report.d.ts +4 -24
  12. package/dist/dx/report.js +32 -25
  13. package/dist/dx/reportIo.js +6 -4
  14. package/dist/dx/send.js +6 -4
  15. package/dist/lib/backend.js +5 -10
  16. package/dist/lib/exitCodes.d.ts +6 -0
  17. package/dist/lib/exitCodes.js +6 -0
  18. package/dist/lib/terminalText.d.ts +6 -0
  19. package/dist/lib/terminalText.js +10 -0
  20. package/dist/lib/version.d.ts +28 -2
  21. package/dist/lib/version.js +30 -3
  22. package/dist/lib/whoami.js +3 -0
  23. package/dist/library/assetsCommand.js +4 -0
  24. package/dist/library/client.js +6 -8
  25. package/dist/login/authBackend.js +18 -15
  26. package/dist/login/loginCommand.js +31 -4
  27. package/dist/login/pkceLogin.d.ts +10 -0
  28. package/dist/login/pkceLogin.js +24 -1
  29. package/dist/platform/releaseFetch.js +36 -69
  30. package/dist/preview/api.js +9 -12
  31. package/dist/preview/command.d.ts +3 -2
  32. package/dist/preview/command.js +10 -5
  33. package/dist/preview/copy.d.ts +13 -1
  34. package/dist/preview/copy.js +31 -3
  35. package/dist/preview/proxy.js +5 -39
  36. package/dist/preview/runner.d.ts +31 -9
  37. package/dist/preview/runner.js +43 -15
  38. package/dist/publish/api.js +14 -14
  39. package/dist/publish/command.d.ts +3 -0
  40. package/dist/publish/command.js +74 -7
  41. package/dist/publish/copy.d.ts +15 -0
  42. package/dist/publish/copy.js +109 -19
  43. package/dist/publish/keyScan.d.ts +25 -0
  44. package/dist/publish/keyScan.js +88 -0
  45. package/dist/publish/manifest.d.ts +60 -19
  46. package/dist/publish/manifest.js +290 -71
  47. package/dist/publish/upload.d.ts +18 -0
  48. package/dist/publish/upload.js +38 -0
  49. package/dist/publish/weight.d.ts +23 -0
  50. package/dist/publish/weight.js +113 -0
  51. package/dist/shared/config.d.ts +6 -6
  52. package/dist/shared/credentialNames.d.ts +17 -0
  53. package/dist/shared/credentialNames.js +72 -0
  54. package/dist/shared/http.d.ts +34 -0
  55. package/dist/shared/http.js +98 -2
  56. package/dist/shared/upgradeError.d.ts +62 -0
  57. package/dist/shared/upgradeError.js +215 -0
  58. package/dist/tunnel/caps.d.ts +19 -0
  59. package/dist/tunnel/caps.js +52 -0
  60. package/dist/tunnel/cbor.d.ts +39 -12
  61. package/dist/tunnel/cbor.js +84 -10
  62. package/dist/tunnel/client.d.ts +56 -2
  63. package/dist/tunnel/client.js +160 -44
  64. package/dist/tunnel/frame.d.ts +1 -1
  65. package/dist/tunnel/frame.js +8 -4
  66. package/dist/tunnel/index.d.ts +3 -2
  67. package/dist/tunnel/index.js +3 -2
  68. package/package.json +4 -3
package/MIGRATION.md CHANGED
@@ -3,6 +3,36 @@
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
+
21
+ ## 0.21.0
22
+
23
+ required: false
24
+ No action required unless a build output holds a credential file. Behaviour that changed:
25
+ - `vincentt publish` refuses a build output that holds a file or folder named like a credential (`credentials.json`, `service-account*.json`, `*firebase-adminsdk*.json`, `*.key`, `*.p12`, `*.pfx`, `*.p8`, `*.ppk`, `id_rsa*`, `id_dsa*`, `id_ed25519*`, `id_ecdsa*`, `*.jks`, `*.keystore`, `*.env`, `*.tfstate`, `*.tfvars`, and their `~`, `.bak`, `.gz`, `.br` or `.zst` copies, in any letter case).
26
+ - `vincentt publish` also refuses a build output holding a file with a private key (including one a bundler inlined into JavaScript from an environment variable), or a Google service-account or user credential JSON.
27
+ - A file that changes or becomes a link while publish reads it is also refused.
28
+ - A publish that worked before can now be refused, with no override: move the file out of the build output (or rename an asset such as a Keynote `.key`) and rebuild. Public `.pem` certificates are still published.
29
+ - `vincentt publish` can print a new "Heavy files" warning section in the checklist (a model over 10 MB, or an image over 2048 px on its long side); exit codes and prompts are unchanged.
30
+ - Every request the CLI makes to the platform, including a preview's start, check-in and stop, and every preview tunnel connection, now says which CLI version is calling (`X-Vincentt-Client: cli/<version>`).
31
+ - A new exit code, 5: the platform refused this CLI's version and nothing was changed on the platform. `create` may already have written the project, and its screen says where. No minimum version is set by this release, so nothing is refused yet.
32
+ - On exit 5 the screen names this CLI's version, the minimum it needs and how to upgrade; `publish --json` prints `{"state":"failed","exitCode":5}`; the newer-version notice is not printed after it. When the platform received no version at all, the screen asks for a report instead of an upgrade.
33
+ - `vincentt preview` has two new endings: the CLI is too old (exit 5), and "Vincentt stopped confirming it" when the platform stops confirming a running preview (exit 1, and no new preview is started on its own). The "Couldn't confirm your access to this project." ending is gone.
34
+ - A request body sent to a preview with no declared length (chunked, or HTTP/2 without Content-Length) now reaches the dev server whole, sent with `Transfer-Encoding: chunked`, and a request with no body completes at once. Against a platform edge that predates this release the previous behaviour stays. Each forwarded request uses its own connection to the dev server, and a request whose Content-Length disagrees with its body is reset on its own without ending the preview.
35
+
6
36
  ## 0.20.0
7
37
 
8
38
  required: false
package/dist/cli/index.js CHANGED
@@ -24,11 +24,12 @@ import { fileLocationStore } from "../shared/locationStore.js";
24
24
  import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
25
25
  import { EXIT } from "../lib/exitCodes.js";
26
26
  import { AuthExpiredError, authExpiredLines, isAuthRefused, NOT_SIGNED_IN_LINES, NotSignedInError, } from "../shared/authError.js";
27
+ import { UpgradeRequiredError, upgradeRefusalLines, } from "../shared/upgradeError.js";
27
28
  import { listProjectsCommand } from "../lib/listProjects.js";
28
29
  import { initCommand } from "../lib/initCommand.js";
29
30
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
30
31
  import { whoamiCommand } from "../lib/whoami.js";
31
- import { renderVersion, versionReport } from "../lib/version.js";
32
+ import { renderVersion, VERSION_ACCOUNT_TIMEOUT_MS, versionReport, } from "../lib/version.js";
32
33
  import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
33
34
  import { publishCommand } from "../publish/command.js";
34
35
  import { stdinAsk, stdinConfirm } from "../publish/prompt.js";
@@ -52,7 +53,7 @@ import * as feedbackCopy from "../lib/feedbackCopy.js";
52
53
  import { record as recordInvocation } from "../dx/journal.js";
53
54
  import { dxDisableCommand, dxEnableCommand, dxSendCommand, parseDxOptions, runDx, sinceUsageLines, } from "../dx/command.js";
54
55
  import { httpConsentBackend, runConsentInBrowser } from "../dx/consent.js";
55
- import { DX_UNATTENDED_SUNSET, loadEnablement } from "../dx/enablement.js";
56
+ import { DX_UNATTENDED_SUNSET, loadEnablement, resolveConsentView, } from "../dx/enablement.js";
56
57
  import { journalEnabled, journalPath, readJournal } from "../dx/journal.js";
57
58
  import { postReport } from "../dx/send.js";
58
59
  import { nudgeFor } from "../dx/nudge.js";
@@ -340,6 +341,7 @@ async function create() {
340
341
  dir = safe;
341
342
  }
342
343
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
344
+ const consoleUrl = (await resolveAuthUrls()).consoleUrl;
343
345
  const result = await projectCreate(projectDir(), {
344
346
  name: flag("--name"),
345
347
  slug: flag("--slug"),
@@ -354,8 +356,18 @@ async function create() {
354
356
  await login();
355
357
  },
356
358
  orgSource,
357
- consoleUrl: (await resolveAuthUrls()).consoleUrl,
359
+ consoleUrl,
358
360
  onLine: (l) => console.log(l),
361
+ }).catch((err) => {
362
+ // Registration is refused after the source is already on disk, so the
363
+ // screen says where it is.
364
+ if (!(err instanceof UpgradeRequiredError))
365
+ throw err;
366
+ const where = dir === undefined ? undefined : `./${dir}`;
367
+ for (const l of upgradeRefusalLines(err, { kind: "create", dir: where })) {
368
+ console.log(l);
369
+ }
370
+ return { message: "", exitCode: EXIT.UPGRADE_REQUIRED };
359
371
  });
360
372
  if (result.exitCode !== 0) {
361
373
  process.exitCode = result.exitCode;
@@ -627,7 +639,18 @@ async function preview() {
627
639
  }
628
640
  }
629
641
  let ending = null;
642
+ let endingRefusal;
630
643
  let resolveDone;
644
+ const refusedAtStart = (facts) => {
645
+ for (const l of upgradeRefusalLines(facts, {
646
+ kind: "preview_start",
647
+ devCommand,
648
+ localUrl: handRunUrl,
649
+ })) {
650
+ console.log(l);
651
+ }
652
+ process.exitCode = EXIT.UPGRADE_REQUIRED;
653
+ };
631
654
  const done = new Promise((r) => {
632
655
  resolveDone = r;
633
656
  });
@@ -656,8 +679,9 @@ async function preview() {
656
679
  onOriginError: () => ui.originError(),
657
680
  onOriginRecovered: () => ui.originRecovered(),
658
681
  onExpiring: () => ui.checkHardMax(running?.expiresAt ?? ""),
659
- onEnded: (reason) => {
682
+ onEnded: (reason, refusal) => {
660
683
  ending = reason;
684
+ endingRefusal = refusal;
661
685
  resolveDone?.();
662
686
  },
663
687
  });
@@ -669,6 +693,11 @@ async function preview() {
669
693
  }
670
694
  }
671
695
  catch (err) {
696
+ // Refused by the mint, before the dev server was spawned: nothing to stop.
697
+ if (err instanceof UpgradeRequiredError) {
698
+ refusedAtStart(err);
699
+ return;
700
+ }
672
701
  // The local org is the FALLBACK only. A count refusal names the org that
673
702
  // owns this project, and the wire's name wins over this one wherever both
674
703
  // exist — see resolveCountLimit.
@@ -678,6 +707,13 @@ async function preview() {
678
707
  process.exitCode = err instanceof AuthExpiredError ? EXIT.NOT_AUTHENTICATED : 1;
679
708
  return;
680
709
  }
710
+ // A first dial refused before the start block printed: the creator never saw
711
+ // a link, so this is the start-time screen, not the running preview's ending.
712
+ if (ending === "upgrade_required") {
713
+ await previewStop();
714
+ refusedAtStart(endingRefusal ?? {});
715
+ return;
716
+ }
681
717
  const qr = shouldRenderQr({
682
718
  isTTY: Boolean(process.stdout.isTTY),
683
719
  noQr: has("--no-qr"),
@@ -714,11 +750,19 @@ async function preview() {
714
750
  await done;
715
751
  clearInterval(ceiling);
716
752
  if (ending !== null) {
717
- ui.endedBy(ending, (await activeOrgName()) || "your workspace");
753
+ ui.endedBy(ending, (await activeOrgName()) || "your workspace", endingRefusal);
718
754
  await previewStop();
719
- process.exitCode = ending === "auth_expired" ? EXIT.NOT_AUTHENTICATED : 1;
755
+ process.exitCode = previewExitCode(ending);
720
756
  }
721
757
  }
758
+ /** A preview's ending → its exit: 5 for a version refusal, 4 for a refused token, else 1. */
759
+ function previewExitCode(ending) {
760
+ if (ending === "upgrade_required")
761
+ return EXIT.UPGRADE_REQUIRED;
762
+ if (ending === "auth_expired")
763
+ return EXIT.NOT_AUTHENTICATED;
764
+ return EXIT.FAILED;
765
+ }
722
766
  /** A `--port` or `--dev-command` value the schema refuses, before anything runs. */
723
767
  function previewFlagRefusal(port, devCommand) {
724
768
  const portMessage = port === undefined
@@ -1095,14 +1139,22 @@ function cliVersion() {
1095
1139
  }
1096
1140
  }
1097
1141
  async function version() {
1142
+ const now = Date.now();
1098
1143
  const report = await versionReport(projectDir(), {
1099
1144
  cliVersion: cliVersion(),
1100
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
+ },
1101
1151
  });
1102
1152
  for (const line of renderVersion(report))
1103
1153
  console.log(line);
1104
1154
  }
1105
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;
1106
1158
  /**
1107
1159
  * The API host + PAT for a dx call, or undefined when signed out.
1108
1160
  *
@@ -1153,6 +1205,7 @@ async function dx() {
1153
1205
  onProgress: (l) => console.log(l),
1154
1206
  });
1155
1207
  },
1208
+ grantAccountConsent: (auth) => consent.grant(auth.apiUrl, auth.pat, now),
1156
1209
  revokeAccountConsent: (auth) => consent.revoke(auth.apiUrl, auth.pat),
1157
1210
  };
1158
1211
  process.exitCode = options.enable
@@ -1170,7 +1223,15 @@ async function dx() {
1170
1223
  // relaxation, not throw through it.
1171
1224
  const enablement = await loadEnablement(now);
1172
1225
  if (!options.send) {
1173
- 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);
1174
1235
  return;
1175
1236
  }
1176
1237
  // `--send` is the one path out of this machine, so it resolves auth the way
@@ -1187,10 +1248,9 @@ async function dx() {
1187
1248
  confirm: (q) => stdinConfirm()(`${q} [y/N] `, false),
1188
1249
  interactive,
1189
1250
  enablement: () => enablement,
1190
- // The third arm: the account-wide grant, read from /me. Consulted only when the
1191
- // first two have not already allowed the send, and allowed to throw — a /me that
1192
- // 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.
1193
1252
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1253
+ clearEnablement: () => writeDxEnablement(undefined),
1194
1254
  // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1195
1255
  // works on a send as it does everywhere else.
1196
1256
  cwd: projectDir(),
@@ -1232,6 +1292,7 @@ async function report() {
1232
1292
  interactive,
1233
1293
  enablement: () => enablement,
1234
1294
  accountConsent: (auth) => httpConsentBackend().read(auth.apiUrl, auth.pat, now),
1295
+ clearEnablement: () => writeDxEnablement(undefined),
1235
1296
  // `--dir`-or-cwd, the same resolution every other verb uses, so `--dir`
1236
1297
  // works on a report as it does everywhere else.
1237
1298
  cwd: projectDir(),
@@ -1530,8 +1591,12 @@ function journal(exitCode, error) {
1530
1591
  // a notice is worth nothing if it costs a second on every publish.
1531
1592
  try {
1532
1593
  const cached = loadCache();
1533
- for (const l of updateNotice(cliVersion(), cached?.latest))
1534
- console.error(l);
1594
+ // Suppressed on a version refusal: the ending already names the number that
1595
+ // matters, and a stale cache could name a `latest` below the floor.
1596
+ if (exitCode !== EXIT.UPGRADE_REQUIRED) {
1597
+ for (const l of updateNotice(cliVersion(), cached?.latest))
1598
+ console.error(l);
1599
+ }
1535
1600
  if (!isFresh(cached, Date.now()))
1536
1601
  refreshUpdateCache();
1537
1602
  }
@@ -1623,6 +1688,15 @@ main()
1623
1688
  journal(EXIT.NOT_AUTHENTICATED, err);
1624
1689
  process.exit(EXIT.NOT_AUTHENTICATED);
1625
1690
  }
1691
+ // L22's generic screen, for every verb without its own arm. Prose on the
1692
+ // verb's usual channel, or stderr under --json so stdout stays a document.
1693
+ if (err instanceof UpgradeRequiredError) {
1694
+ const say = has("--json") ? console.error : console.log;
1695
+ for (const l of upgradeRefusalLines(err))
1696
+ say(l);
1697
+ journal(EXIT.UPGRADE_REQUIRED, err);
1698
+ process.exit(EXIT.UPGRADE_REQUIRED);
1699
+ }
1626
1700
  if (isAuthRefused(err)) {
1627
1701
  for (const l of authExpiredLines())
1628
1702
  console.error(l);
@@ -12,9 +12,9 @@
12
12
  // ordinary indistinguishable 404 before the hostname is read at all. The CLI
13
13
  // therefore cannot distinguish "no such project" from "not your project" — and
14
14
  // must not try.
15
- import { clientHeaders } from "../shared/clientHeader.js";
16
15
  import { rejectAuthExpired } from "../shared/authError.js";
17
- import { apiFetch } from "../shared/http.js";
16
+ import { apiFetch, apiRequest } from "../shared/http.js";
17
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
18
18
  import { parseDomain, parseDomainList, } from "./wire.js";
19
19
  export class DomainApiError extends Error {
20
20
  status;
@@ -30,8 +30,12 @@ export class DomainApiError extends Error {
30
30
  this.name = "DomainApiError";
31
31
  }
32
32
  }
33
- /** Rejects with AuthExpiredError on a 401; every other status becomes a DomainApiError. */
33
+ /**
34
+ * Rejects with UpgradeRequiredError on a 426 and AuthExpiredError on a 401;
35
+ * every other status becomes a DomainApiError.
36
+ */
34
37
  async function readError(res) {
38
+ await rejectUpgradeRequired(res);
35
39
  rejectAuthExpired(res);
36
40
  let body = {};
37
41
  try {
@@ -44,8 +48,8 @@ async function readError(res) {
44
48
  const retryAfter = res.headers.get("retry-after");
45
49
  return new DomainApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, { attached: num(body.attached), entitled: num(body.entitled) }, retryAfter ? Number(retryAfter) : undefined);
46
50
  }
47
- function authHeaders(cfg) {
48
- return { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
51
+ function call(cfg, path, init, fetchImpl) {
52
+ return apiRequest(cfg, path, init, { authenticated: true }, fetchImpl);
49
53
  }
50
54
  function rolesOf(raw) {
51
55
  const o = typeof raw === "object" && raw !== null ? raw : {};
@@ -57,11 +61,11 @@ function rolesOf(raw) {
57
61
  }
58
62
  /** POST /projects/{id}/domains — returns the records to publish. Rate-limited. */
59
63
  export async function attachDomain(cfg, projectId, domain, fetchImpl = apiFetch) {
60
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains`, {
64
+ const res = await call(cfg, `/projects/${encodeURIComponent(projectId)}/domains`, {
61
65
  method: "POST",
62
- headers: { ...authHeaders(cfg), "content-type": "application/json" },
66
+ headers: { "content-type": "application/json" },
63
67
  body: JSON.stringify({ domain }),
64
- });
68
+ }, fetchImpl);
65
69
  if (!res.ok)
66
70
  throw await readError(res);
67
71
  const raw = await res.json();
@@ -82,7 +86,7 @@ export async function attachDomain(cfg, projectId, domain, fetchImpl = apiFetch)
82
86
  * a defect, and its copy names no limit family.
83
87
  */
84
88
  export async function verifyDomain(cfg, projectId, domain, fetchImpl = apiFetch) {
85
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}/verify`, { method: "POST", headers: authHeaders(cfg) });
89
+ const res = await call(cfg, `/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}/verify`, { method: "POST" }, fetchImpl);
86
90
  if (!res.ok)
87
91
  throw await readError(res);
88
92
  const raw = await res.json();
@@ -98,7 +102,7 @@ export async function verifyDomain(cfg, projectId, domain, fetchImpl = apiFetch)
98
102
  }
99
103
  /** GET /projects/{id}/domains — this project's domains with state + findings. */
100
104
  export async function listProjectDomains(cfg, projectId, fetchImpl = apiFetch) {
101
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains`, { headers: authHeaders(cfg) });
105
+ const res = await call(cfg, `/projects/${encodeURIComponent(projectId)}/domains`, {}, fetchImpl);
102
106
  if (!res.ok)
103
107
  throw await readError(res);
104
108
  return parseDomainList(await res.json());
@@ -112,14 +116,14 @@ export async function listProjectDomains(cfg, projectId, fetchImpl = apiFetch) {
112
116
  * here, and this route only ever returns their own.
113
117
  */
114
118
  export async function listOrgDomains(cfg, orgSlug, fetchImpl = apiFetch) {
115
- const res = await fetchImpl(`${cfg.apiUrl}/orgs/${encodeURIComponent(orgSlug)}/domains`, { headers: authHeaders(cfg) });
119
+ const res = await call(cfg, `/orgs/${encodeURIComponent(orgSlug)}/domains`, {}, fetchImpl);
116
120
  if (!res.ok)
117
121
  throw await readError(res);
118
122
  return parseDomainList(await res.json());
119
123
  }
120
124
  /** DELETE /projects/{id}/domains/{domain} — detach. The row is never deleted server-side. */
121
125
  export async function detachDomain(cfg, projectId, domain, fetchImpl = apiFetch) {
122
- const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`, { method: "DELETE", headers: authHeaders(cfg) });
126
+ const res = await call(cfg, `/projects/${encodeURIComponent(projectId)}/domains/${encodeURIComponent(domain)}`, { method: "DELETE" }, fetchImpl);
123
127
  // 404 is a SILENT RESOLVE on detach: the creator asked for a state the world is
124
128
  // already in. It is not distinguishable from "not your project", and it does
125
129
  // not need to be — either way this project is not serving that name.
@@ -22,6 +22,7 @@ import { EXIT } from "../lib/exitCodes.js";
22
22
  import { VALIDATION_RECORD_GONE } from "./wire.js";
23
23
  import { DomainApiError } from "./api.js";
24
24
  import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
25
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
25
26
  import { localRefusal, normalizeArgument } from "./hostname.js";
26
27
  import * as copy from "./copy.js";
27
28
  /** The canonical subcommands. */
@@ -178,6 +179,8 @@ function authExpired(deps) {
178
179
  return EXIT.NOT_AUTHENTICATED;
179
180
  }
180
181
  function printAttachError(deps, err, hostname) {
182
+ if (err instanceof UpgradeRequiredError)
183
+ throw err;
181
184
  if (err instanceof AuthExpiredError)
182
185
  return authExpired(deps);
183
186
  if (!(err instanceof DomainApiError)) {
@@ -239,6 +242,8 @@ async function listVerb(deps, cfg, opts) {
239
242
  view = await deps.listOrg(cfg, org.org.slug);
240
243
  }
241
244
  catch (err) {
245
+ if (err instanceof UpgradeRequiredError)
246
+ throw err;
242
247
  if (err instanceof AuthExpiredError)
243
248
  return authExpired(deps);
244
249
  deps.print("");
@@ -293,6 +298,8 @@ async function verifyVerb(deps, cfg, opts) {
293
298
  result = await deps.verify(cfg, binding.projectId, hostname);
294
299
  }
295
300
  catch (err) {
301
+ if (err instanceof UpgradeRequiredError)
302
+ throw err;
296
303
  if (err instanceof AuthExpiredError)
297
304
  return authExpired(deps);
298
305
  if (err instanceof DomainApiError && err.status === 404) {
@@ -459,6 +466,8 @@ async function detachVerb(deps, cfg, opts) {
459
466
  await deps.detach(cfg, binding.projectId, hostname);
460
467
  }
461
468
  catch (err) {
469
+ if (err instanceof UpgradeRequiredError)
470
+ throw err;
462
471
  if (err instanceof AuthExpiredError)
463
472
  return authExpired(deps);
464
473
  deps.print("");
@@ -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>;