@vincentt-xr/cli 0.19.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 (67) hide show
  1. package/MIGRATION.md +25 -0
  2. package/README.md +3 -2
  3. package/dist/cli/index.js +95 -13
  4. package/dist/cli/usage-text.js +3 -2
  5. package/dist/domain/api.js +16 -12
  6. package/dist/domain/command.js +9 -0
  7. package/dist/dx/command.js +3 -0
  8. package/dist/dx/consent.js +10 -14
  9. package/dist/dx/reportIo.js +6 -4
  10. package/dist/dx/send.js +6 -4
  11. package/dist/lib/backend.js +5 -10
  12. package/dist/lib/exitCodes.d.ts +6 -0
  13. package/dist/lib/exitCodes.js +6 -0
  14. package/dist/lib/terminalText.d.ts +6 -0
  15. package/dist/lib/terminalText.js +10 -0
  16. package/dist/lib/whoami.js +3 -0
  17. package/dist/library/assetsCommand.js +4 -0
  18. package/dist/library/client.js +6 -8
  19. package/dist/login/authBackend.d.ts +27 -2
  20. package/dist/login/authBackend.js +86 -17
  21. package/dist/login/loginCommand.d.ts +35 -3
  22. package/dist/login/loginCommand.js +81 -5
  23. package/dist/login/machineLabel.d.ts +46 -0
  24. package/dist/login/machineLabel.js +119 -0
  25. package/dist/login/pkceLogin.d.ts +17 -1
  26. package/dist/login/pkceLogin.js +30 -5
  27. package/dist/platform/releaseFetch.js +36 -69
  28. package/dist/preview/api.js +9 -12
  29. package/dist/preview/command.d.ts +3 -2
  30. package/dist/preview/command.js +8 -5
  31. package/dist/preview/copy.d.ts +8 -1
  32. package/dist/preview/copy.js +17 -4
  33. package/dist/preview/proxy.js +5 -39
  34. package/dist/preview/runner.d.ts +25 -9
  35. package/dist/preview/runner.js +42 -15
  36. package/dist/publish/api.js +14 -14
  37. package/dist/publish/command.d.ts +3 -0
  38. package/dist/publish/command.js +71 -7
  39. package/dist/publish/copy.d.ts +9 -0
  40. package/dist/publish/copy.js +83 -14
  41. package/dist/publish/keyScan.d.ts +25 -0
  42. package/dist/publish/keyScan.js +88 -0
  43. package/dist/publish/manifest.d.ts +51 -19
  44. package/dist/publish/manifest.js +246 -63
  45. package/dist/publish/upload.d.ts +8 -0
  46. package/dist/publish/upload.js +15 -0
  47. package/dist/publish/weight.d.ts +23 -0
  48. package/dist/publish/weight.js +113 -0
  49. package/dist/shared/authError.d.ts +1 -1
  50. package/dist/shared/authError.js +3 -1
  51. package/dist/shared/credentialNames.d.ts +17 -0
  52. package/dist/shared/credentialNames.js +72 -0
  53. package/dist/shared/http.d.ts +34 -0
  54. package/dist/shared/http.js +98 -2
  55. package/dist/shared/upgradeError.d.ts +62 -0
  56. package/dist/shared/upgradeError.js +215 -0
  57. package/dist/tunnel/caps.d.ts +19 -0
  58. package/dist/tunnel/caps.js +52 -0
  59. package/dist/tunnel/cbor.d.ts +39 -12
  60. package/dist/tunnel/cbor.js +84 -10
  61. package/dist/tunnel/client.d.ts +48 -2
  62. package/dist/tunnel/client.js +157 -44
  63. package/dist/tunnel/frame.d.ts +1 -1
  64. package/dist/tunnel/frame.js +8 -4
  65. package/dist/tunnel/index.d.ts +3 -2
  66. package/dist/tunnel/index.js +3 -2
  67. package/package.json +4 -3
package/MIGRATION.md CHANGED
@@ -3,6 +3,31 @@
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
+
21
+ ## 0.20.0
22
+
23
+ required: false
24
+ No action required. Behaviour that changed:
25
+ - `vincentt login` signs in this machine on its own and leaves your other machines signed in. It sends a machine label, `"<OS> <arch>, <d Mon>"` by default (never the hostname), or the name given with `--label "<name>"`; a `--label` the platform would refuse (empty, over 64 characters, control or invisible characters, or a platform-reserved name) exits 1 before any browser opens.
26
+ - The `vincentt login` success line names the machine: `✓ Signed in as <email> on this machine (<label>)`. Against an api older than machine sign-ins the line is unchanged.
27
+ - When a login leaves more than 10 signed-in machines, the least recently used are signed out and each is named on its own line after the workspace line: `Signed out "<label>", the least recently used of your 10 signed-in machines (last used <d Mon>).`
28
+ - `vincentt logout` also ends this machine's sign-in on the platform (best-effort, 5 seconds), then always removes the local token; when the api cannot be reached it says the machine stays listed in the console. `vincentt login --force` keeps the old token until the new one is saved, then ends the old sign-in.
29
+ - A refused token now reads ``✗ This machine is no longer signed in to Vincentt. Run `vincentt login` to sign in again.`` (it read "Your Vincentt access token is no longer valid."). A script matching the old wording exactly needs updating. The exit code is unchanged (4).
30
+
6
31
  ## 0.19.0
7
32
 
8
33
  required: false
package/README.md CHANGED
@@ -53,8 +53,9 @@ registers the project, which is what needs an account.
53
53
 
54
54
  ```
55
55
  Auth
56
- login Sign in; save an access token to ~/.vincentt/config.json
57
- logout Remove the saved access token
56
+ login Sign in this machine; save an access token to ~/.vincentt/config.json
57
+ [--label <name>] names this machine (default: OS, arch, date)
58
+ logout Sign this machine out and remove the saved access token
58
59
  whoami Show the signed-in account
59
60
 
60
61
  Project
package/dist/cli/index.js CHANGED
@@ -15,14 +15,16 @@ import { createRequire } from "node:module";
15
15
  import os from "node:os";
16
16
  import path from "node:path";
17
17
  import { runPkceLogin } from "../login/pkceLogin.js";
18
- import { httpAuthBackend } from "../login/authBackend.js";
19
- import { loginCommand } from "../login/loginCommand.js";
18
+ import { httpAuthBackend, revokeCurrent } from "../login/authBackend.js";
19
+ import { resolveLoginLabel } from "../login/machineLabel.js";
20
+ import { loginCommand, logoutLines as logoutCommand } from "../login/loginCommand.js";
20
21
  import { ensureLoggedIn } from "../login/ensure.js";
21
22
  import { clearMachineToken, loadMachineConfig, locateProjectBinding, machineApiUrl, resolveConfig, writeDxEnablement, MACHINE_CONFIG_PATH, } from "../shared/config.js";
22
23
  import { fileLocationStore } from "../shared/locationStore.js";
23
24
  import { getMe, listProjects, ProjectUnreachableError } from "../lib/backend.js";
24
25
  import { EXIT } from "../lib/exitCodes.js";
25
26
  import { AuthExpiredError, authExpiredLines, isAuthRefused, NOT_SIGNED_IN_LINES, NotSignedInError, } from "../shared/authError.js";
27
+ import { UpgradeRequiredError, upgradeRefusalLines, } from "../shared/upgradeError.js";
26
28
  import { listProjectsCommand } from "../lib/listProjects.js";
27
29
  import { initCommand } from "../lib/initCommand.js";
28
30
  import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
@@ -120,16 +122,33 @@ function machineCredentials(machine) {
120
122
  return pat ? { apiUrl: machineApiUrl(machine), pat } : undefined;
121
123
  }
122
124
  /** The PKCE loopback + code-exchange login (f1). The raw PAT never rides a redirect query. */
123
- async function runLoginFlow() {
125
+ async function runLoginFlow(label) {
124
126
  const { consoleUrl, apiUrl } = await resolveAuthUrls();
125
127
  return runPkceLogin({
126
128
  consoleUrl,
127
129
  apiUrl,
130
+ label,
128
131
  onProgress: (line) => console.log(line),
129
132
  });
130
133
  }
131
- /** `vincentt login` [--force] — the L1/L2/L4 transcripts. */
134
+ /** The label this login sends, or undefined when --label was refused (the refusal is printed). */
135
+ function loginLabel() {
136
+ const eq = valueOfEqualsFlag(argv, "--label");
137
+ const resolved = resolveLoginLabel(has("--label") || eq !== undefined, flag("--label") ?? eq);
138
+ if (!resolved.ok) {
139
+ for (const line of resolved.lines)
140
+ console.log(line);
141
+ return undefined;
142
+ }
143
+ return resolved.label;
144
+ }
145
+ /** `vincentt login` [--force] [--label <name>] — the L1/L2/L4/L5 transcripts. */
132
146
  async function login() {
147
+ const label = loginLabel();
148
+ if (label === undefined) {
149
+ process.exitCode = EXIT.FAILED;
150
+ return;
151
+ }
133
152
  const { apiUrl } = await resolveAuthUrls();
134
153
  const code = await loginCommand({
135
154
  print: (line) => console.log(line),
@@ -140,10 +159,12 @@ async function login() {
140
159
  throw new NotSignedInError();
141
160
  return httpAuthBackend(apiUrl).me(pat);
142
161
  },
143
- runLogin: runLoginFlow,
162
+ runLogin: () => runLoginFlow(label),
144
163
  clearToken: async () => {
145
164
  await clearMachineToken();
146
165
  },
166
+ storedToken: async () => (await loadMachineConfig()).pat,
167
+ revokeToken: (pat) => revokeCurrent(apiUrl, pat),
147
168
  home: os.homedir(),
148
169
  configPath: MACHINE_CONFIG_PATH,
149
170
  }, { force: has("--force") });
@@ -169,8 +190,13 @@ async function requireLogin() {
169
190
  return ok;
170
191
  }
171
192
  async function logout() {
172
- const { path: p, hadToken } = await clearMachineToken();
173
- console.log(hadToken ? `✓ Signed out. Token removed from ${p}` : "Already signed out.");
193
+ const machine = await loadMachineConfig();
194
+ const outcome = machine.pat
195
+ ? await revokeCurrent(machineApiUrl(machine), machine.pat)
196
+ : "done";
197
+ const lines = await logoutCommand({ clear: clearMachineToken, outcome });
198
+ for (const line of lines)
199
+ console.log(line);
174
200
  }
175
201
  /**
176
202
  * `whoami` reads the MACHINE config's API host, never the folder's binding — it
@@ -315,6 +341,7 @@ async function create() {
315
341
  dir = safe;
316
342
  }
317
343
  const interactive = Boolean(process.stdin.isTTY) && Boolean(process.stdout.isTTY);
344
+ const consoleUrl = (await resolveAuthUrls()).consoleUrl;
318
345
  const result = await projectCreate(projectDir(), {
319
346
  name: flag("--name"),
320
347
  slug: flag("--slug"),
@@ -329,8 +356,18 @@ async function create() {
329
356
  await login();
330
357
  },
331
358
  orgSource,
332
- consoleUrl: (await resolveAuthUrls()).consoleUrl,
359
+ consoleUrl,
333
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 };
334
371
  });
335
372
  if (result.exitCode !== 0) {
336
373
  process.exitCode = result.exitCode;
@@ -602,7 +639,18 @@ async function preview() {
602
639
  }
603
640
  }
604
641
  let ending = null;
642
+ let endingRefusal;
605
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
+ };
606
654
  const done = new Promise((r) => {
607
655
  resolveDone = r;
608
656
  });
@@ -631,8 +679,9 @@ async function preview() {
631
679
  onOriginError: () => ui.originError(),
632
680
  onOriginRecovered: () => ui.originRecovered(),
633
681
  onExpiring: () => ui.checkHardMax(running?.expiresAt ?? ""),
634
- onEnded: (reason) => {
682
+ onEnded: (reason, refusal) => {
635
683
  ending = reason;
684
+ endingRefusal = refusal;
636
685
  resolveDone?.();
637
686
  },
638
687
  });
@@ -644,6 +693,11 @@ async function preview() {
644
693
  }
645
694
  }
646
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
+ }
647
701
  // The local org is the FALLBACK only. A count refusal names the org that
648
702
  // owns this project, and the wire's name wins over this one wherever both
649
703
  // exist — see resolveCountLimit.
@@ -653,6 +707,13 @@ async function preview() {
653
707
  process.exitCode = err instanceof AuthExpiredError ? EXIT.NOT_AUTHENTICATED : 1;
654
708
  return;
655
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
+ }
656
717
  const qr = shouldRenderQr({
657
718
  isTTY: Boolean(process.stdout.isTTY),
658
719
  noQr: has("--no-qr"),
@@ -689,11 +750,19 @@ async function preview() {
689
750
  await done;
690
751
  clearInterval(ceiling);
691
752
  if (ending !== null) {
692
- ui.endedBy(ending, (await activeOrgName()) || "your workspace");
753
+ ui.endedBy(ending, (await activeOrgName()) || "your workspace", endingRefusal);
693
754
  await previewStop();
694
- process.exitCode = ending === "auth_expired" ? EXIT.NOT_AUTHENTICATED : 1;
755
+ process.exitCode = previewExitCode(ending);
695
756
  }
696
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
+ }
697
766
  /** A `--port` or `--dev-command` value the schema refuses, before anything runs. */
698
767
  function previewFlagRefusal(port, devCommand) {
699
768
  const portMessage = port === undefined
@@ -1505,8 +1574,12 @@ function journal(exitCode, error) {
1505
1574
  // a notice is worth nothing if it costs a second on every publish.
1506
1575
  try {
1507
1576
  const cached = loadCache();
1508
- for (const l of updateNotice(cliVersion(), cached?.latest))
1509
- 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
+ }
1510
1583
  if (!isFresh(cached, Date.now()))
1511
1584
  refreshUpdateCache();
1512
1585
  }
@@ -1598,6 +1671,15 @@ main()
1598
1671
  journal(EXIT.NOT_AUTHENTICATED, err);
1599
1672
  process.exit(EXIT.NOT_AUTHENTICATED);
1600
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
+ }
1601
1683
  if (isAuthRefused(err)) {
1602
1684
  for (const l of authExpiredLines())
1603
1685
  console.error(l);
@@ -11,9 +11,10 @@ export const USAGE_TEXT = `Vincentt CLI: the local-first AR dev loop\n\n` +
11
11
  ` Wait for the developer to describe what to build.\n` +
12
12
  ` Full guide: npm view @vincentt-xr/cli readme\n\n` +
13
13
  `Auth\n` +
14
- ` login Sign in; save an access token to ~/.vincentt/config.json\n` +
14
+ ` login Sign in this machine; save an access token to ~/.vincentt/config.json\n` +
15
+ ` [--label <name>] names this machine (default: OS, arch, date)\n` +
15
16
  ` [--api <url>] [--console <url>] to target a non-prod env\n` +
16
- ` logout Remove the saved access token\n` +
17
+ ` logout Sign this machine out and remove the saved access token\n` +
17
18
  ` whoami Show the signed-in account\n\n` +
18
19
  `Project\n` +
19
20
  ` init [dir] Scaffold a Vincentt XR app; no account needed, ever\n` +
@@ -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);