@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
@@ -1,16 +1,16 @@
1
1
  /**
2
2
  * Durable authorization for unattended `vincentt dx --send`.
3
3
  *
4
- * `--send` transmits only when the session is interactive, OR this MACHINE has been
5
- * enabled via `vincentt dx enable`, OR the creator's ACCOUNT carries the grant they
6
- * gave in the console. `enable` collects that consent in the browser by default and
7
- * at the terminal when there is one; either way a person answers, never the caller.
4
+ * `--send` transmits only when the session is interactive, OR the creator's ACCOUNT
5
+ * carries the grant, OR — only when the account cannot be read — this machine's cached
6
+ * copy of that grant is valid. `enable` records the account grant at the terminal or in
7
+ * the browser; either way a person answers, never the caller.
8
8
  *
9
- * The machine half is two fields in ~/.vincentt/config.json, set by `enable`, cleared
10
- * by `disable` and by `logout`. The account half is read from `/me` and cleared by
11
- * `disable` or in the console — NOT by `logout`, since signing out of one machine is
12
- * not a decision about the account. Neither has an expiry: both stay on until the
13
- * creator turns them off.
9
+ * The account grant is read from `/me` and cleared by `disable` or in the console, NOT by
10
+ * `logout`, since signing out of one machine is not a decision about the account. The
11
+ * machine copy is two fields in ~/.vincentt/config.json, written by `enable` only after
12
+ * the account grant succeeded, and cleared by `disable`, by `logout`, and by any `/me`
13
+ * answer that is not a grant. Neither has an expiry.
14
14
  *
15
15
  * Because there is no expiry, VISIBILITY is the entire mitigation, and the AGE is
16
16
  * the load-bearing half of it: `ENABLED` alone reads as configuration, while
@@ -55,9 +55,6 @@ export function readEnablement(cfg, now) {
55
55
  return DISABLED;
56
56
  if (now >= DX_UNATTENDED_SUNSET)
57
57
  return DISABLED;
58
- // The local flag is by definition a grant made on THIS machine — `dx enable` is the
59
- // only writer, whether it took the browser path or the terminal one. An account grant
60
- // never lands in this file; it is read from /me and reported separately.
61
58
  return { enabled: true, at: dxSendEnabledAt, source: "machine" };
62
59
  }
63
60
  /**
@@ -71,10 +68,10 @@ export function readEnablement(cfg, now) {
71
68
  * stops being honored too, or "forgetting the sunset turns the feature off" would only be
72
69
  * true of the local half.
73
70
  */
74
- export function readAccountConsent(raw, now) {
71
+ export function readAccountEnablement(raw, now) {
75
72
  if (typeof raw !== "object" || raw === null)
76
73
  return DISABLED;
77
- const { dxReportsConsent, dxReportsConsentAt } = raw;
74
+ const { dxReportsConsent, dxReportsConsentAt, dxReportsConsentVia } = raw;
78
75
  if (dxReportsConsent !== true)
79
76
  return DISABLED;
80
77
  if (typeof dxReportsConsentAt !== "string")
@@ -83,7 +80,73 @@ export function readAccountConsent(raw, now) {
83
80
  return DISABLED;
84
81
  if (now >= DX_UNATTENDED_SUNSET)
85
82
  return DISABLED;
86
- return { enabled: true, at: dxReportsConsentAt, source: "console" };
83
+ // An unknown via is dropped, not rejected: it only decides the suffix, never the grant.
84
+ return dxReportsConsentVia === "cli" || dxReportsConsentVia === "console"
85
+ ? {
86
+ enabled: true,
87
+ at: dxReportsConsentAt,
88
+ source: "account",
89
+ via: dxReportsConsentVia,
90
+ }
91
+ : { enabled: true, at: dxReportsConsentAt, source: "account" };
92
+ }
93
+ /**
94
+ * The account-first read shared by the send gate, `vincentt dx` and `vincentt version`
95
+ * (f7 Scenario 3, 3c). The account decides when it answers; a "not granted" answer clears
96
+ * this machine's copy so a revoke made elsewhere reaches it. Only when the account cannot
97
+ * be read does the local flag stand on its own.
98
+ */
99
+ export async function resolveConsentView(deps) {
100
+ const signedIn = deps.auth !== undefined;
101
+ if (deps.auth !== undefined && deps.readAccount !== undefined) {
102
+ const { auth, readAccount } = deps;
103
+ const read = await boundedRead(Promise.resolve().then(() => readAccount(auth)), deps.timeoutMs);
104
+ if (read.kind === "granted") {
105
+ return {
106
+ shown: read.enablement,
107
+ local: await deps.local(),
108
+ checked: "account",
109
+ signedIn,
110
+ };
111
+ }
112
+ if (read.kind === "not_granted") {
113
+ // A failed clear still reports off: the account said no, so nothing may send, and
114
+ // the next account read clears again.
115
+ await deps.clearLocal().catch(() => undefined);
116
+ return { shown: DISABLED, local: DISABLED, checked: "account", signedIn };
117
+ }
118
+ }
119
+ const local = await deps.local();
120
+ return { shown: local, local, checked: "machine", signedIn };
121
+ }
122
+ async function boundedRead(read, timeoutMs) {
123
+ const unreadable = { kind: "unreadable" };
124
+ const guarded = read.catch(() => unreadable);
125
+ if (timeoutMs === undefined)
126
+ return guarded;
127
+ let timer;
128
+ const expired = new Promise((resolve) => {
129
+ timer = setTimeout(() => resolve(unreadable), timeoutMs);
130
+ timer.unref?.();
131
+ });
132
+ try {
133
+ return await Promise.race([guarded, expired]);
134
+ }
135
+ finally {
136
+ clearTimeout(timer);
137
+ }
138
+ }
139
+ export function dxConsentJson(view) {
140
+ const { shown } = view;
141
+ if (shown.enabled && shown.at !== undefined) {
142
+ return {
143
+ state: "on",
144
+ checked: view.checked,
145
+ at: shown.at,
146
+ ...(shown.via !== undefined ? { via: shown.via } : {}),
147
+ };
148
+ }
149
+ return { state: view.signedIn ? "off" : "unknown", checked: view.checked };
87
150
  }
88
151
  /**
89
152
  * Read enablement from the real machine config.
@@ -116,8 +179,10 @@ export function grantedOn(at) {
116
179
  /**
117
180
  * The one visibility line, in the single pinned shape every human surface uses:
118
181
  *
182
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · granted from the CLI
119
183
  * Unattended send: ENABLED since 2026-04-01 (145 days) · granted in the console
120
- * Unattended send: ENABLED since 2026-04-01 (145 days) · granted on this machine
184
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · on your account
185
+ * Unattended send: ENABLED since 2026-04-01 (145 days) · this machine (account not checked)
121
186
  *
122
187
  * Returns undefined when not enabled. Nothing is printed in that case, on
123
188
  * purpose: a line that prints unconditionally is a line readers learn to skip,
@@ -136,10 +201,17 @@ export function enablementLine(e, now) {
136
201
  if (!e.enabled || e.at === undefined)
137
202
  return undefined;
138
203
  const days = ageInDays(e.at, now);
139
- const where = e.source === "console"
140
- ? " · granted in the console"
141
- : e.source === "machine"
142
- ? " · granted on this machine"
143
- : "";
204
+ const where = sourceSuffix(e);
144
205
  return `Unattended send: ENABLED since ${grantedOn(e.at)} (${days} ${days === 1 ? "day" : "days"})${where}`;
145
206
  }
207
+ function sourceSuffix(e) {
208
+ if (e.source === "machine")
209
+ return " · this machine (account not checked)";
210
+ if (e.source !== "account")
211
+ return "";
212
+ if (e.via === "cli")
213
+ return " · granted from the CLI";
214
+ if (e.via === "console")
215
+ return " · granted in the console";
216
+ return " · on your account";
217
+ }
@@ -1,25 +1,4 @@
1
- /**
2
- * `vincentt report "<what went wrong>"` — the creator's own words, sent.
3
- *
4
- * This exists because of a gap `dx --send` could not close. That command reports
5
- * DETECTED friction: it reads the journal, finds clusters of retries and errors,
6
- * and refuses when it finds none. But the friction a creator most wants to report
7
- * is routinely the kind the journal cannot see — a command that exits 0 and puts
8
- * the wrong thing on screen produces no error, no retry and no cluster. Asked to
9
- * carry a note about one, `--send` had nothing to attach it to and said "no
10
- * friction detected — nothing to send", which is the tool contradicting the
11
- * person using it.
12
- *
13
- * So the note stops being a rider on a finding and becomes the payload. An
14
- * observation carries no counts because there is nothing to count.
15
- *
16
- * The consent model is INHERITED, not relaxed. Sending is still gated on the
17
- * same three arms as `--send` (a terminal, a local `dx enable`, or an account
18
- * grant), the payload is still shown in full before it goes, and a note is still
19
- * shown under a warning that it is transmitted exactly as written. This verb
20
- * removes a refusal; it does not open a new door.
21
- */
22
- import type { Enablement } from "./enablement.js";
1
+ import type { AccountRead, Enablement } from "./enablement.js";
23
2
  import type { JournalEntry } from "./journal.js";
24
3
  import { type BuiltWith } from "../platform/resolve.js";
25
4
  /** An observation as it goes on the wire. Mirrors the API's dxObservationRequest. */
@@ -114,7 +93,8 @@ export interface ReportDeps {
114
93
  accountConsent?: (auth: {
115
94
  apiUrl: string;
116
95
  pat: string;
117
- }) => Promise<Enablement>;
96
+ }) => Promise<AccountRead>;
97
+ clearEnablement?: () => Promise<unknown>;
118
98
  /**
119
99
  * The version set. Injectable so a fixed set — or a REJECTING promise, the arm
120
100
  * proving a fault omits — can be driven with no filesystem. Defaults to the one
@@ -129,6 +109,6 @@ export interface ReportDeps {
129
109
  * Same order as `dx --send`, for the same reason — a creator who cannot see what
130
110
  * leaves their machine before it leaves has not agreed to anything:
131
111
  *
132
- * bound-check → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
112
+ * bound-check → auth → GATE → RESOLVE → build → SHOW → ask → post
133
113
  */
134
114
  export declare function reportCommand(deps: ReportDeps, options: ReportOptions): Promise<number>;
package/dist/dx/report.js CHANGED
@@ -1,3 +1,25 @@
1
+ /**
2
+ * `vincentt report "<what went wrong>"` — the creator's own words, sent.
3
+ *
4
+ * This exists because of a gap `dx --send` could not close. That command reports
5
+ * DETECTED friction: it reads the journal, finds clusters of retries and errors,
6
+ * and refuses when it finds none. But the friction a creator most wants to report
7
+ * is routinely the kind the journal cannot see — a command that exits 0 and puts
8
+ * the wrong thing on screen produces no error, no retry and no cluster. Asked to
9
+ * carry a note about one, `--send` had nothing to attach it to and said "no
10
+ * friction detected — nothing to send", which is the tool contradicting the
11
+ * person using it.
12
+ *
13
+ * So the note stops being a rider on a finding and becomes the payload. An
14
+ * observation carries no counts because there is nothing to count.
15
+ *
16
+ * The consent model is INHERITED, not relaxed. Sending is gated by the same
17
+ * `gateSend` as `--send` (a terminal, the account grant, or, only when the
18
+ * account cannot be read, this machine's copy of it), the payload is still shown in full before it goes, and a note is still
19
+ * shown under a warning that it is transmitted exactly as written. This verb
20
+ * removes a refusal; it does not open a new door.
21
+ */
22
+ import { gateSend, SEND_REFUSAL_LINES } from "./command.js";
1
23
  import { enablementLine } from "./enablement.js";
2
24
  import { DXSendError, platformTag } from "./send.js";
3
25
  import { resolveBuiltWith } from "../platform/resolve.js";
@@ -110,7 +132,7 @@ export function renderObservation(report) {
110
132
  * Same order as `dx --send`, for the same reason — a creator who cannot see what
111
133
  * leaves their machine before it leaves has not agreed to anything:
112
134
  *
113
- * bound-check → GATE (three arms) → auth → RESOLVE → build → SHOW → ask → post
135
+ * bound-check → auth → GATE → RESOLVE → build → SHOW → ask → post
114
136
  */
115
137
  export async function reportCommand(deps, options) {
116
138
  const note = options.note.trim();
@@ -128,28 +150,13 @@ export async function reportCommand(deps, options) {
128
150
  deps.print(`That note is longer than ${MAX_NOTE} characters. Please shorten it.`);
129
151
  return 1;
130
152
  }
131
- // The gate, inherited from `dx --send` verbatim. An agent composing a note and
132
- // confirming it with --yes on a machine nobody granted is the case this stops.
133
- const localEnablement = deps.enablement?.() ?? { enabled: false };
134
- const interactive = deps.interactive();
153
+ // The gate, shared with `dx --send`. An agent composing a note and confirming it
154
+ // with --yes on an account nobody granted is the case this stops.
135
155
  const auth = await deps.auth();
136
- let accountEnablement = { enabled: false };
137
- if (!interactive && !localEnablement.enabled && auth !== undefined) {
138
- try {
139
- accountEnablement = (await deps.accountConsent?.(auth)) ?? { enabled: false };
140
- }
141
- catch {
142
- // Fail closed. An unreachable API never authorizes a send.
143
- accountEnablement = { enabled: false };
144
- }
145
- }
146
- const enablement = localEnablement.enabled
147
- ? localEnablement
148
- : accountEnablement;
149
- if (!interactive && !enablement.enabled) {
150
- deps.print("Sending a report needs consent that has not been given on this account.");
151
- deps.print("`vincentt dx enable` opens the Vincentt console so you can allow it.");
152
- deps.print("It takes one click, and it works from here.");
156
+ const gate = await gateSend(deps, auth);
157
+ if (gate.arm === "refused") {
158
+ for (const l of SEND_REFUSAL_LINES)
159
+ deps.print(l);
153
160
  return 1;
154
161
  }
155
162
  if (auth === undefined) {
@@ -179,9 +186,9 @@ export async function reportCommand(deps, options) {
179
186
  // omission an empty project yields, and neither can fail a report.
180
187
  const builtWith = await (deps.builtWith ?? (() => resolveBuiltWith(deps.cwd ?? process.cwd())))().catch(() => undefined);
181
188
  const report = buildObservationReport(note, deps.cliVersion(), context, builtWith);
182
- const unattended = !interactive && enablement.enabled;
183
- if (unattended) {
184
- const line = enablementLine(enablement, deps.now());
189
+ const unattended = gate.arm === "unattended";
190
+ if (gate.arm === "unattended") {
191
+ const line = enablementLine(gate.enablement, deps.now());
185
192
  if (line !== undefined) {
186
193
  deps.print("");
187
194
  deps.print(`${line}. Not asking.`);
@@ -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, "");
@@ -1,4 +1,4 @@
1
- import type { Enablement } from "../dx/enablement.js";
1
+ import type { AccountRead, Enablement, EnablementSource, EnablementVia } from "../dx/enablement.js";
2
2
  export interface VersionReport {
3
3
  cliVersion: string;
4
4
  nodeVersion: string;
@@ -9,10 +9,13 @@ export interface VersionReport {
9
9
  ignoredBindingApiUrl?: string;
10
10
  /** True when the resolved host is the built-in fallback (nothing configured). */
11
11
  isDefault: boolean;
12
- /** True when this machine may send DX reports unattended. */
12
+ /** True when DX reports may be sent unattended, from the account-first answer. */
13
13
  dxSendEnabled: boolean;
14
14
  /** ISO8601 UTC of the grant. Present only when enabled. */
15
15
  dxSendEnabledAt?: string;
16
+ /** Where that answer came from, and where the account grant was made. */
17
+ dxSource?: EnablementSource;
18
+ dxVia?: EnablementVia;
16
19
  /** The instant the age is computed against, so renderVersion stays pure. */
17
20
  dxAgeAsOf: number;
18
21
  }
@@ -50,4 +53,27 @@ export declare function renderVersion(r: VersionReport): string[];
50
53
  export declare function versionReport(cwd: string, opts: {
51
54
  cliVersion: string;
52
55
  allowCustomApi?: boolean;
56
+ /** The account read; absent reports this machine's flag only. */
57
+ consent?: VersionConsentDeps;
53
58
  }): Promise<VersionReport>;
59
+ /** The account bound on `version` (f7 Scenario 3c). */
60
+ export declare const VERSION_ACCOUNT_TIMEOUT_MS = 3000;
61
+ export interface VersionConsentDeps {
62
+ auth: () => Promise<{
63
+ apiUrl: string;
64
+ pat: string;
65
+ } | undefined>;
66
+ readAccount: (auth: {
67
+ apiUrl: string;
68
+ pat: string;
69
+ }) => Promise<AccountRead>;
70
+ clearLocal: () => Promise<unknown>;
71
+ local?: () => Promise<Enablement>;
72
+ timeoutMs?: number;
73
+ }
74
+ /**
75
+ * The enablement `version` shows: the account's answer when it can be read within the
76
+ * bound, else this machine's flag. Never throws and never waits past the bound, because
77
+ * `version` is the command people run when something is already broken.
78
+ */
79
+ export declare function versionEnablement(now: number, consent: VersionConsentDeps | undefined): Promise<Enablement>;
@@ -7,7 +7,7 @@
7
7
  // misconfiguration. So this reports the resolved host AND its provenance, using
8
8
  // the same decideApiUrl the real verbs use; a version command that re-derived
9
9
  // the URL could disagree with them.
10
- import { enablementLine, loadEnablement } from "../dx/enablement.js";
10
+ import { enablementLine, loadEnablement, resolveConsentView } from "../dx/enablement.js";
11
11
  import { decideApiUrl, loadMachineConfig, locateProjectBinding, } from "../shared/config.js";
12
12
  /**
13
13
  * Decide what to report. Pure, so the transcript is testable without a home
@@ -45,6 +45,8 @@ export function buildVersionReport(env) {
45
45
  isDefault,
46
46
  dxSendEnabled: enablement.enabled,
47
47
  dxSendEnabledAt: enablement.enabled ? enablement.at : undefined,
48
+ dxSource: enablement.enabled ? enablement.source : undefined,
49
+ dxVia: enablement.enabled ? enablement.via : undefined,
48
50
  dxAgeAsOf: env.now ?? Date.now(),
49
51
  };
50
52
  }
@@ -68,7 +70,7 @@ export function renderVersion(r) {
68
70
  // exactly when a forgotten enablement is most likely to be discovered. Printed
69
71
  // only when enabled — see the note in dx/enablement.ts on why there is no `off`
70
72
  // counterpart.
71
- const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt }, r.dxAgeAsOf);
73
+ const dx = enablementLine({ enabled: r.dxSendEnabled, at: r.dxSendEnabledAt, source: r.dxSource, via: r.dxVia }, r.dxAgeAsOf);
72
74
  if (dx !== undefined)
73
75
  out.push("", ` ${dx}`);
74
76
  out.push("");
@@ -106,7 +108,32 @@ export async function versionReport(cwd, opts) {
106
108
  allowCustomApi: opts.allowCustomApi,
107
109
  nodeVersion: process.version,
108
110
  cliVersion: opts.cliVersion,
109
- enablement: await loadEnablement(now),
111
+ enablement: await versionEnablement(now, opts.consent),
110
112
  now,
111
113
  });
112
114
  }
115
+ /** The account bound on `version` (f7 Scenario 3c). */
116
+ export const VERSION_ACCOUNT_TIMEOUT_MS = 3_000;
117
+ /**
118
+ * The enablement `version` shows: the account's answer when it can be read within the
119
+ * bound, else this machine's flag. Never throws and never waits past the bound, because
120
+ * `version` is the command people run when something is already broken.
121
+ */
122
+ export async function versionEnablement(now, consent) {
123
+ const local = consent?.local ?? (() => loadEnablement(now));
124
+ if (consent === undefined)
125
+ return local();
126
+ try {
127
+ const view = await resolveConsentView({
128
+ auth: await consent.auth().catch(() => undefined),
129
+ readAccount: consent.readAccount,
130
+ local,
131
+ clearLocal: consent.clearLocal,
132
+ timeoutMs: consent.timeoutMs ?? VERSION_ACCOUNT_TIMEOUT_MS,
133
+ });
134
+ return view.shown;
135
+ }
136
+ catch {
137
+ return local().catch(() => ({ enabled: false }));
138
+ }
139
+ }
@@ -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.