@vincentt-xr/cli 0.20.0 → 0.21.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 (59) hide show
  1. package/MIGRATION.md +15 -0
  2. package/dist/cli/index.js +63 -6
  3. package/dist/domain/api.js +16 -12
  4. package/dist/domain/command.js +9 -0
  5. package/dist/dx/command.js +3 -0
  6. package/dist/dx/consent.js +10 -14
  7. package/dist/dx/reportIo.js +6 -4
  8. package/dist/dx/send.js +6 -4
  9. package/dist/lib/backend.js +5 -10
  10. package/dist/lib/exitCodes.d.ts +6 -0
  11. package/dist/lib/exitCodes.js +6 -0
  12. package/dist/lib/terminalText.d.ts +6 -0
  13. package/dist/lib/terminalText.js +10 -0
  14. package/dist/lib/whoami.js +3 -0
  15. package/dist/library/assetsCommand.js +4 -0
  16. package/dist/library/client.js +6 -8
  17. package/dist/login/authBackend.js +18 -15
  18. package/dist/login/loginCommand.js +31 -4
  19. package/dist/login/pkceLogin.d.ts +10 -0
  20. package/dist/login/pkceLogin.js +24 -1
  21. package/dist/platform/releaseFetch.js +36 -69
  22. package/dist/preview/api.js +9 -12
  23. package/dist/preview/command.d.ts +3 -2
  24. package/dist/preview/command.js +8 -5
  25. package/dist/preview/copy.d.ts +8 -1
  26. package/dist/preview/copy.js +16 -3
  27. package/dist/preview/proxy.js +5 -39
  28. package/dist/preview/runner.d.ts +25 -9
  29. package/dist/preview/runner.js +42 -15
  30. package/dist/publish/api.js +14 -14
  31. package/dist/publish/command.d.ts +3 -0
  32. package/dist/publish/command.js +71 -7
  33. package/dist/publish/copy.d.ts +9 -0
  34. package/dist/publish/copy.js +83 -14
  35. package/dist/publish/keyScan.d.ts +25 -0
  36. package/dist/publish/keyScan.js +88 -0
  37. package/dist/publish/manifest.d.ts +51 -19
  38. package/dist/publish/manifest.js +246 -63
  39. package/dist/publish/upload.d.ts +8 -0
  40. package/dist/publish/upload.js +15 -0
  41. package/dist/publish/weight.d.ts +23 -0
  42. package/dist/publish/weight.js +113 -0
  43. package/dist/shared/credentialNames.d.ts +17 -0
  44. package/dist/shared/credentialNames.js +72 -0
  45. package/dist/shared/http.d.ts +34 -0
  46. package/dist/shared/http.js +98 -2
  47. package/dist/shared/upgradeError.d.ts +62 -0
  48. package/dist/shared/upgradeError.js +215 -0
  49. package/dist/tunnel/caps.d.ts +19 -0
  50. package/dist/tunnel/caps.js +52 -0
  51. package/dist/tunnel/cbor.d.ts +39 -12
  52. package/dist/tunnel/cbor.js +84 -10
  53. package/dist/tunnel/client.d.ts +48 -2
  54. package/dist/tunnel/client.js +157 -44
  55. package/dist/tunnel/frame.d.ts +1 -1
  56. package/dist/tunnel/frame.js +8 -4
  57. package/dist/tunnel/index.d.ts +3 -2
  58. package/dist/tunnel/index.js +3 -2
  59. package/package.json +4 -3
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.21.0
7
+
8
+ required: false
9
+ No action required unless a build output holds a credential file. Behaviour that changed:
10
+ - `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).
11
+ - `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.
12
+ - A file that changes or becomes a link while publish reads it is also refused.
13
+ - 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.
14
+ - `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.
15
+ - 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>`).
16
+ - 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.
17
+ - 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.
18
+ - `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.
19
+ - 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.
20
+
6
21
  ## 0.20.0
7
22
 
8
23
  required: false
package/dist/cli/index.js CHANGED
@@ -24,6 +24,7 @@ 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";
@@ -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
@@ -1530,8 +1574,12 @@ function journal(exitCode, error) {
1530
1574
  // a notice is worth nothing if it costs a second on every publish.
1531
1575
  try {
1532
1576
  const cached = loadCache();
1533
- for (const l of updateNotice(cliVersion(), cached?.latest))
1534
- console.error(l);
1577
+ // Suppressed on a version refusal: the ending already names the number that
1578
+ // matters, and a stale cache could name a `latest` below the floor.
1579
+ if (exitCode !== EXIT.UPGRADE_REQUIRED) {
1580
+ for (const l of updateNotice(cliVersion(), cached?.latest))
1581
+ console.error(l);
1582
+ }
1535
1583
  if (!isFresh(cached, Date.now()))
1536
1584
  refreshUpdateCache();
1537
1585
  }
@@ -1623,6 +1671,15 @@ main()
1623
1671
  journal(EXIT.NOT_AUTHENTICATED, err);
1624
1672
  process.exit(EXIT.NOT_AUTHENTICATED);
1625
1673
  }
1674
+ // L22's generic screen, for every verb without its own arm. Prose on the
1675
+ // verb's usual channel, or stderr under --json so stdout stays a document.
1676
+ if (err instanceof UpgradeRequiredError) {
1677
+ const say = has("--json") ? console.error : console.log;
1678
+ for (const l of upgradeRefusalLines(err))
1679
+ say(l);
1680
+ journal(EXIT.UPGRADE_REQUIRED, err);
1681
+ process.exit(EXIT.UPGRADE_REQUIRED);
1682
+ }
1626
1683
  if (isAuthRefused(err)) {
1627
1684
  for (const l of authExpiredLines())
1628
1685
  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("");
@@ -4,6 +4,7 @@ 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 { UpgradeRequiredError } from "../shared/upgradeError.js";
7
8
  const MINUTE_MS = 60 * 1000;
8
9
  const HOUR_MS = 60 * MINUTE_MS;
9
10
  const DAY_MS = 24 * HOUR_MS;
@@ -526,6 +527,8 @@ export async function dxSendCommand(deps, options) {
526
527
  return 0;
527
528
  }
528
529
  catch (err) {
530
+ if (err instanceof UpgradeRequiredError)
531
+ throw err;
529
532
  deps.print("");
530
533
  deps.print(`✗ ${err instanceof Error ? err.message : String(err)}`);
531
534
  return 1;
@@ -22,8 +22,8 @@ import { browserPage } from "../login/pkceLogin.js";
22
22
  import { stateMatches } from "../login/pkce.js";
23
23
  import { getFreePort } from "../preview/net.js";
24
24
  import { authFailureMessage } from "../shared/authError.js";
25
- import { clientHeaders } from "../shared/clientHeader.js";
26
- import { apiFetch } from "../shared/http.js";
25
+ import { apiRequest } from "../shared/http.js";
26
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
27
27
  import { readAccountConsent } from "./enablement.js";
28
28
  async function safeText(res) {
29
29
  try {
@@ -48,32 +48,28 @@ function unwrapMe(raw) {
48
48
  }
49
49
  /** The real HTTP-backed ConsentBackend, pointed at a Vincentt API base URL. */
50
50
  export function httpConsentBackend() {
51
- const strip = (u) => u.replace(/\/$/, "");
51
+ const call = async (apiUrl, pat, path, init) => {
52
+ const res = await apiRequest({ apiUrl, pat }, path, init, { authenticated: true });
53
+ await rejectUpgradeRequired(res);
54
+ return res;
55
+ };
52
56
  return {
53
57
  async read(apiUrl, pat, now) {
54
- const res = await apiFetch(`${strip(apiUrl)}/me`, {
55
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
56
- });
58
+ const res = await call(apiUrl, pat, "/me", {});
57
59
  if (!res.ok) {
58
60
  throw new Error(authFailureMessage("Could not read your account", res.status, await safeText(res)));
59
61
  }
60
62
  return readAccountConsent(unwrapMe(await res.json()), now);
61
63
  },
62
64
  async grant(apiUrl, pat, now) {
63
- const res = await apiFetch(`${strip(apiUrl)}/me/dx-consent`, {
64
- method: "POST",
65
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
66
- });
65
+ const res = await call(apiUrl, pat, "/me/dx-consent", { method: "POST" });
67
66
  if (!res.ok) {
68
67
  throw new Error(authFailureMessage("Could not record consent", res.status, await safeText(res)));
69
68
  }
70
69
  return readAccountConsent(unwrapMe(await res.json()), now);
71
70
  },
72
71
  async revoke(apiUrl, pat) {
73
- const res = await apiFetch(`${strip(apiUrl)}/me/dx-consent`, {
74
- method: "DELETE",
75
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
76
- });
72
+ const res = await call(apiUrl, pat, "/me/dx-consent", { method: "DELETE" });
77
73
  if (!res.ok) {
78
74
  throw new Error(authFailureMessage("Could not revoke consent", res.status, await safeText(res)));
79
75
  }
@@ -1,13 +1,15 @@
1
1
  import { DXSendError } from "./send.js";
2
2
  import { AUTH_EXPIRED_MESSAGE } from "../shared/authError.js";
3
- import { apiFetch } from "../shared/http.js";
3
+ import { apiFetch, apiRequest } from "../shared/http.js";
4
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
4
5
  export async function postObservationReport(apiUrl, pat, report, fetchImpl = apiFetch) {
5
- const res = await fetchImpl(`${apiUrl}/dx-reports`, {
6
+ const res = await apiRequest({ apiUrl, pat }, "/dx-reports", {
6
7
  method: "POST",
7
- headers: { "content-type": "application/json", authorization: `Bearer ${pat}` },
8
+ headers: { "content-type": "application/json" },
8
9
  body: JSON.stringify(report),
9
- });
10
+ }, { authenticated: true }, fetchImpl);
10
11
  if (!res.ok) {
12
+ await rejectUpgradeRequired(res);
11
13
  let detail = "";
12
14
  try {
13
15
  const body = (await res.json());
package/dist/dx/send.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { AUTH_EXPIRED_MESSAGE } from "../shared/authError.js";
2
- import { apiFetch } from "../shared/http.js";
2
+ import { apiFetch, apiRequest } from "../shared/http.js";
3
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
3
4
  import { renderPayloadWithVersionSummary, summarizeBuiltWith, versionDisclosureLines, } from "../platform/versionSummary.js";
4
5
  /**
5
6
  * The server bounds a report at 50 findings and refuses the whole thing past
@@ -105,12 +106,13 @@ export class DXSendError extends Error {
105
106
  * POST the report. The caller has already shown the payload and had it accepted.
106
107
  */
107
108
  export async function postReport(apiUrl, pat, report, fetchImpl = apiFetch) {
108
- const res = await fetchImpl(`${apiUrl}/dx-reports`, {
109
+ const res = await apiRequest({ apiUrl, pat }, "/dx-reports", {
109
110
  method: "POST",
110
- headers: { "content-type": "application/json", authorization: `Bearer ${pat}` },
111
+ headers: { "content-type": "application/json" },
111
112
  body: JSON.stringify(report),
112
- });
113
+ }, { authenticated: true }, fetchImpl);
113
114
  if (!res.ok) {
115
+ await rejectUpgradeRequired(res);
114
116
  let detail = "";
115
117
  try {
116
118
  const body = (await res.json());
@@ -6,9 +6,9 @@ import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { execFile } from "node:child_process";
8
8
  import { promisify } from "node:util";
9
- import { clientHeaders } from "../shared/clientHeader.js";
10
9
  import { authFailureMessage } from "../shared/authError.js";
11
- import { apiFetch } from "../shared/http.js";
10
+ import { apiRequest } from "../shared/http.js";
11
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
12
12
  const execFileAsync = promisify(execFile);
13
13
  export class BackendError extends Error {
14
14
  status;
@@ -19,14 +19,9 @@ export class BackendError extends Error {
19
19
  }
20
20
  }
21
21
  async function authFetch(cfg, pathname, init = {}) {
22
- return apiFetch(`${cfg.apiUrl}${pathname}`, {
23
- ...init,
24
- headers: {
25
- ...(init.headers ?? {}),
26
- ...clientHeaders(),
27
- authorization: `Bearer ${cfg.pat}`,
28
- },
29
- });
22
+ const res = await apiRequest(cfg, pathname, init, { authenticated: true });
23
+ await rejectUpgradeRequired(res);
24
+ return res;
30
25
  }
31
26
  async function safeText(res) {
32
27
  try {
@@ -17,6 +17,12 @@ export declare const EXIT: {
17
17
  readonly DELIVERED_NOT_REGISTERED: 3;
18
18
  /** A platform call needed an account and the command did not answer for it. */
19
19
  readonly NOT_AUTHENTICATED: 4;
20
+ /**
21
+ * The platform refused this CLI's version, and nothing was changed on the
22
+ * platform. A verb such as `create` may have written local source; its screen
23
+ * says where.
24
+ */
25
+ readonly UPGRADE_REQUIRED: 5;
20
26
  /** A bounded wait elapsed with nothing handed over. */
21
27
  readonly WINDOW_ELAPSED: 64;
22
28
  /** The channel ended before anything was handed over. */
@@ -17,6 +17,12 @@ export const EXIT = {
17
17
  DELIVERED_NOT_REGISTERED: 3,
18
18
  /** A platform call needed an account and the command did not answer for it. */
19
19
  NOT_AUTHENTICATED: 4,
20
+ /**
21
+ * The platform refused this CLI's version, and nothing was changed on the
22
+ * platform. A verb such as `create` may have written local source; its screen
23
+ * says where.
24
+ */
25
+ UPGRADE_REQUIRED: 5,
20
26
  /** A bounded wait elapsed with nothing handed over. */
21
27
  WINDOW_ELAPSED: 64,
22
28
  /** The channel ended before anything was handed over. */
@@ -1,5 +1,11 @@
1
1
  export declare const CONTROL_CHARS: RegExp;
2
2
  /** Each C0/C1 control character as a visible `\uXXXX`, so nothing is hidden or redrawn. */
3
3
  export declare function escapeControl(text: string): string;
4
+ /**
5
+ * Each C0/C1 control, bidi embedding, override or isolate, and direction mark
6
+ * as a visible `\uXXXX`: a file name cannot reorder the line, and the creator
7
+ * can still find the file.
8
+ */
9
+ export declare function escapeTerminal(text: string): string;
4
10
  /** Text with every C0/C1 control and bidi override or isolate removed. */
5
11
  export declare function terminalSafe(text: string): string;
@@ -11,6 +11,16 @@ export function escapeControl(text) {
11
11
  // U+202A..U+202E (embeddings, overrides) and U+2066..U+2069 (isolates) can
12
12
  // reorder a terminal line, so a crafted name could disguise what it says.
13
13
  const BIDI_CONTROLS_G = /[\u202a-\u202e\u2066-\u2069]/g;
14
+ // The above, plus the direction marks U+200E, U+200F and U+061C.
15
+ const TERMINAL_UNSAFE_G = /[\u0000-\u001f\u007f-\u009f\u061c\u200e\u200f\u202a-\u202e\u2066-\u2069]/g;
16
+ /**
17
+ * Each C0/C1 control, bidi embedding, override or isolate, and direction mark
18
+ * as a visible `\uXXXX`: a file name cannot reorder the line, and the creator
19
+ * can still find the file.
20
+ */
21
+ export function escapeTerminal(text) {
22
+ return text.replace(TERMINAL_UNSAFE_G, (c) => `\\u${c.charCodeAt(0).toString(16).padStart(4, "0")}`);
23
+ }
14
24
  /** Text with every C0/C1 control and bidi override or isolate removed. */
15
25
  export function terminalSafe(text) {
16
26
  return text.replace(CONTROL_CHARS_G, "").replace(BIDI_CONTROLS_G, "");
@@ -7,6 +7,7 @@
7
7
  // f2's hardest copy rather than cosmetic.
8
8
  import { resolveActiveOrg } from "./org.js";
9
9
  import { authExpiredLines, isAuthRefused } from "../shared/authError.js";
10
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
10
11
  import { EXIT } from "./exitCodes.js";
11
12
  export async function whoamiCommand(deps) {
12
13
  const cfg = await deps.config();
@@ -19,6 +20,8 @@ export async function whoamiCommand(deps) {
19
20
  me = await deps.getMe(cfg);
20
21
  }
21
22
  catch (err) {
23
+ if (err instanceof UpgradeRequiredError)
24
+ throw err;
22
25
  if (isAuthRefused(err)) {
23
26
  for (const l of authExpiredLines())
24
27
  deps.print(l);
@@ -18,6 +18,7 @@ import { DOWNLOAD_DIR, downloadPath, exists, overwriteRefusalLines, writeAsset,
18
18
  import { dedupeForDisplay, parseIdArgs, parseSearchFlags } from "./parse.js";
19
19
  import { addLines, humanBytes, searchLines, showLines, unknownBlockLines, unreachableLines, } from "./render.js";
20
20
  import { isLibraryId } from "./wire.js";
21
+ import { UpgradeRequiredError } from "../shared/upgradeError.js";
21
22
  /**
22
23
  * Map a thrown client error onto the exit-4 screen it deserves.
23
24
  *
@@ -28,6 +29,9 @@ import { isLibraryId } from "./wire.js";
28
29
  * corpus failed to load.
29
30
  */
30
31
  function failureScreen(err, retry) {
32
+ // Rethrown so main() renders the one refusal screen and exits 5.
33
+ if (err instanceof UpgradeRequiredError)
34
+ throw err;
31
35
  if (err instanceof LibraryUnreachableError) {
32
36
  return { lines: unreachableLines("transport", retry), code: 4 };
33
37
  }
@@ -12,9 +12,9 @@
12
12
  // CLI-supplied URL reaches a fetch. That is a security control, not a style choice
13
13
  // (D-add-prints-urls degraded the no-invented-URLs rule from prevention to
14
14
  // detection, and this is the one arm that stayed prevention).
15
- import { clientHeaders } from "../shared/clientHeader.js";
16
15
  import { authFailureMessage } from "../shared/authError.js";
17
- import { apiFetch, withDeadline } from "../shared/http.js";
16
+ import { apiRequest, withDeadline } from "../shared/http.js";
17
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
18
18
  import { parseBatchResponse, parseFetchResponse, parseSearchResponse, } from "./wire.js";
19
19
  // An asset file can be tens of megabytes, so its download gets longer than an api call.
20
20
  const downloadFetch = withDeadline((input, init) => globalThis.fetch(input, init), 5 * 60_000);
@@ -65,18 +65,16 @@ async function readJson(res) {
65
65
  * correct recovery differs between them (retry later vs re-search).
66
66
  */
67
67
  export function httpLibraryClient(cfg) {
68
- const base = cfg.apiUrl.replace(/\/$/, "");
69
- const auth = { ...clientHeaders(), authorization: `Bearer ${cfg.pat}` };
70
68
  const call = async (pathname, init) => {
69
+ let res;
71
70
  try {
72
- return await apiFetch(`${base}${pathname}`, {
73
- ...init,
74
- headers: { ...(init?.headers ?? {}), ...auth },
75
- });
71
+ res = await apiRequest(cfg, pathname, init, { authenticated: true });
76
72
  }
77
73
  catch (err) {
78
74
  throw new LibraryUnreachableError(err);
79
75
  }
76
+ await rejectUpgradeRequired(res);
77
+ return res;
80
78
  };
81
79
  const fail = async (res, what) => {
82
80
  // A 401 keeps its status: assetsCommand maps it to the sign-in screen and exit 4.
@@ -9,8 +9,8 @@
9
9
  // GET /me Bearer <PAT> → identity + orgs (confirm)
10
10
  // and, to end this machine's own sign-in, DELETE /access-tokens/current Bearer <PAT>.
11
11
  import { authFailureMessage } from "../shared/authError.js";
12
- import { clientHeaders } from "../shared/clientHeader.js";
13
- import { apiFetch, withDeadline } from "../shared/http.js";
12
+ import { apiRequest, withDeadline } from "../shared/http.js";
13
+ import { rejectUpgradeRequired } from "../shared/upgradeError.js";
14
14
  /** DELETE /access-tokens/current gives up after this long: sign-out never hangs on the api. */
15
15
  export const REVOKE_CURRENT_TIMEOUT_MS = 5_000;
16
16
  class AuthError extends Error {
@@ -52,12 +52,18 @@ function readEvicted(raw) {
52
52
  }
53
53
  /** The real HTTP-backed AuthBackend, pointed at a Vincentt API base URL. */
54
54
  export function httpAuthBackend(apiBase) {
55
- const base = apiBase.replace(/\/$/, "");
56
- const post = (body) => apiFetch(`${base}/access-tokens/exchange`, {
57
- method: "POST",
58
- headers: { ...clientHeaders(), "content-type": "application/json" },
59
- body: JSON.stringify(body),
60
- });
55
+ const target = { apiUrl: apiBase };
56
+ // The exchange is unauthenticated but judged by the CLI floor, so it carries
57
+ // the tag like every other api request and its 426 is read before any arm.
58
+ const post = async (body) => {
59
+ const res = await apiRequest(target, "/access-tokens/exchange", {
60
+ method: "POST",
61
+ headers: { "content-type": "application/json" },
62
+ body: JSON.stringify(body),
63
+ }, { authenticated: false });
64
+ await rejectUpgradeRequired(res);
65
+ return res;
66
+ };
61
67
  return {
62
68
  async exchange(code, codeVerifier, label) {
63
69
  const body = { code, code_verifier: codeVerifier };
@@ -89,9 +95,10 @@ export function httpAuthBackend(apiBase) {
89
95
  };
90
96
  },
91
97
  async me(pat) {
92
- const res = await apiFetch(`${base}/me`, {
93
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
98
+ const res = await apiRequest({ apiUrl: apiBase, pat }, "/me", {}, {
99
+ authenticated: true,
94
100
  });
101
+ await rejectUpgradeRequired(res);
95
102
  if (!res.ok) {
96
103
  throw new AuthError(authFailureMessage("Identity check failed", res.status, await safeText(res)), res.status);
97
104
  }
@@ -106,12 +113,8 @@ export function httpAuthBackend(apiBase) {
106
113
  * failure, a timeout or a 5xx is "unreachable".
107
114
  */
108
115
  export async function revokeCurrent(apiBase, pat, fetchImpl = (input, init) => globalThis.fetch(input, init), timeoutMs = REVOKE_CURRENT_TIMEOUT_MS) {
109
- const base = apiBase.replace(/\/$/, "");
110
116
  try {
111
- const res = await withDeadline(fetchImpl, timeoutMs)(`${base}/access-tokens/current`, {
112
- method: "DELETE",
113
- headers: { ...clientHeaders(), authorization: `Bearer ${pat}` },
114
- });
117
+ const res = await apiRequest({ apiUrl: apiBase, pat }, "/access-tokens/current", { method: "DELETE" }, { authenticated: true }, withDeadline(fetchImpl, timeoutMs));
115
118
  if (res.ok || [400, 401, 403, 404, 405].includes(res.status))
116
119
  return "done";
117
120
  return "unreachable";