@vincentt-xr/cli 0.21.0 → 0.23.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 (51) hide show
  1. package/MIGRATION.md +26 -0
  2. package/dist/cli/args.js +2 -0
  3. package/dist/cli/index.js +50 -8
  4. package/dist/cli/usage-text.js +10 -1
  5. package/dist/cli/verb-surface.d.ts +2 -1
  6. package/dist/cli/verb-surface.js +5 -0
  7. package/dist/dx/command.d.ts +74 -18
  8. package/dist/dx/command.js +137 -86
  9. package/dist/dx/consent.d.ts +25 -6
  10. package/dist/dx/consent.js +58 -25
  11. package/dist/dx/enablement.d.ts +63 -11
  12. package/dist/dx/enablement.js +93 -21
  13. package/dist/dx/journal.d.ts +3 -0
  14. package/dist/dx/journal.js +6 -1
  15. package/dist/dx/report.d.ts +4 -24
  16. package/dist/dx/report.js +32 -25
  17. package/dist/dx/skeleton.js +3 -0
  18. package/dist/editor/app.d.ts +61 -0
  19. package/dist/editor/app.js +180 -0
  20. package/dist/editor/copy.d.ts +22 -0
  21. package/dist/editor/copy.js +95 -0
  22. package/dist/editor/handoffs.d.ts +12 -0
  23. package/dist/editor/handoffs.js +93 -0
  24. package/dist/editor/launcher.d.ts +86 -0
  25. package/dist/editor/launcher.js +255 -0
  26. package/dist/editor/localFiles.d.ts +31 -0
  27. package/dist/editor/localFiles.js +100 -0
  28. package/dist/editor/reads.d.ts +47 -0
  29. package/dist/editor/reads.js +269 -0
  30. package/dist/editor/text.d.ts +7 -0
  31. package/dist/editor/text.js +58 -0
  32. package/dist/editor/tools.d.ts +5 -0
  33. package/dist/editor/tools.js +20 -0
  34. package/dist/lib/version.d.ts +28 -2
  35. package/dist/lib/version.js +30 -3
  36. package/dist/preview/command.js +2 -0
  37. package/dist/preview/copy.d.ts +5 -0
  38. package/dist/preview/copy.js +15 -0
  39. package/dist/preview/runner.d.ts +6 -0
  40. package/dist/preview/runner.js +1 -0
  41. package/dist/publish/command.js +5 -2
  42. package/dist/publish/copy.d.ts +6 -0
  43. package/dist/publish/copy.js +27 -6
  44. package/dist/publish/manifest.d.ts +11 -2
  45. package/dist/publish/manifest.js +45 -9
  46. package/dist/publish/upload.d.ts +10 -0
  47. package/dist/publish/upload.js +23 -0
  48. package/dist/shared/config.d.ts +6 -6
  49. package/dist/tunnel/client.d.ts +11 -3
  50. package/dist/tunnel/client.js +11 -8
  51. package/package.json +2 -2
@@ -0,0 +1,47 @@
1
+ import { type MacSystem } from "./app.js";
2
+ import { type PidRecord } from "./localFiles.js";
3
+ export declare const SELECTION_SCHEMA = "vincentt.editor-selection/1";
4
+ export declare const STATE_SCHEMA = "vincentt.editor-state/1";
5
+ export interface ReadParams {
6
+ system: Pick<MacSystem, "verifyPid" | "startTime">;
7
+ /** The designated requirement a running copy must meet. */
8
+ requirement: string;
9
+ isAlive?: (pid: number) => boolean;
10
+ }
11
+ /**
12
+ * True when the code `pid` is running meets the requirement, from any path. Checked on the
13
+ * running process itself, so no path the OS reports for it is trusted.
14
+ */
15
+ export declare function isVerifiedEditor(pid: number, p: ReadParams): Promise<boolean>;
16
+ /**
17
+ * True when the process now holding `pid` was already running when the record was written. A
18
+ * process that started after `startedAt` reused the pid of the editor that wrote it.
19
+ */
20
+ export declare function startedBeforeRecord(processStart: number | undefined, rec: PidRecord): boolean;
21
+ /** The editor recorded in `pid`, when that process is live, is that record's process, and is a verified editor. */
22
+ export declare function runningEditor(root: string, p: ReadParams): Promise<PidRecord | undefined>;
23
+ export type SchemaMajor = "known" | "foreign" | {
24
+ newer: string;
25
+ };
26
+ /** Whether `schema` is `<family>/1`, a later major of the family, or something else. */
27
+ export declare function schemaMajor(schema: unknown, family: string): SchemaMajor;
28
+ /** The selection as the agent may read it: an allowlist of fields, every string sanitised. */
29
+ export declare function shapeSelection(sel: unknown): Record<string, unknown> | null;
30
+ export interface ReadResult {
31
+ stdout: string[];
32
+ stderr: string[];
33
+ exitCode: number;
34
+ /** For the f7 journal: why the read exited 1. */
35
+ reason?: "not_running" | "too_new";
36
+ }
37
+ export declare function selectionRead(root: string, json: boolean, p: ReadParams): Promise<ReadResult>;
38
+ export declare function humanSelection(sel: Record<string, unknown> | null): string[];
39
+ /**
40
+ * `app` as the agent may read it: exactly `{version, lastOpen}`, each null unless it is one of
41
+ * the shapes the app writes. `trustLastOpen` false nulls `lastOpen` whatever the file says.
42
+ */
43
+ export declare function shapeApp(app: unknown, trustLastOpen: boolean): {
44
+ version: string | null;
45
+ lastOpen: string | null;
46
+ };
47
+ export declare function statusRead(root: string, p: ReadParams): Promise<ReadResult>;
@@ -0,0 +1,269 @@
1
+ // `vincentt editor --selection [--json]` and `--status --json`: read the editor's local files
2
+ // and print them. A read flag never talks to the running app; when none runs for the folder it
3
+ // says so on stderr, still prints one JSON object on stdout, and exits 1. A file in a schema
4
+ // major this CLI does not know is refused whole ("too new"), never printed in part.
5
+ import { pidAlive } from "./app.js";
6
+ import { NOT_RUNNING, tooNew } from "./copy.js";
7
+ import { realpath } from "node:fs/promises";
8
+ import { lastHandoff } from "./handoffs.js";
9
+ import { parsePidFile, readLocalFile, readLocalFileStat, } from "./localFiles.js";
10
+ import { agentSafe, agentSafeValue } from "./text.js";
11
+ export const SELECTION_SCHEMA = "vincentt.editor-selection/1";
12
+ export const STATE_SCHEMA = "vincentt.editor-state/1";
13
+ /**
14
+ * True when the code `pid` is running meets the requirement, from any path. Checked on the
15
+ * running process itself, so no path the OS reports for it is trusted.
16
+ */
17
+ export async function isVerifiedEditor(pid, p) {
18
+ const verdict = await p.system.verifyPid(pid, p.requirement).catch(() => undefined);
19
+ return verdict?.ok === true;
20
+ }
21
+ /** `ps` reports whole seconds and the app writes whole seconds. */
22
+ const START_SLACK_MS = 1_000;
23
+ /**
24
+ * True when the process now holding `pid` was already running when the record was written. A
25
+ * process that started after `startedAt` reused the pid of the editor that wrote it.
26
+ */
27
+ export function startedBeforeRecord(processStart, rec) {
28
+ return processStart !== undefined && processStart <= rec.startedAt + START_SLACK_MS;
29
+ }
30
+ /** The editor recorded in `pid`, when that process is live, is that record's process, and is a verified editor. */
31
+ export async function runningEditor(root, p) {
32
+ const text = await readLocalFile(root, "pid").catch(() => undefined);
33
+ const rec = text === undefined ? undefined : parsePidFile(text);
34
+ if (rec === undefined || !(p.isAlive ?? pidAlive)(rec.pid))
35
+ return undefined;
36
+ const start = await p.system.startTime(rec.pid).catch(() => undefined);
37
+ if (!startedBeforeRecord(start, rec))
38
+ return undefined;
39
+ return (await isVerifiedEditor(rec.pid, p)) ? rec : undefined;
40
+ }
41
+ /** Whether `schema` is `<family>/1`, a later major of the family, or something else. */
42
+ export function schemaMajor(schema, family) {
43
+ if (typeof schema !== "string")
44
+ return "foreign";
45
+ const m = /^(.+)\/(\d+)$/.exec(schema);
46
+ if (m === null || m[1] !== family)
47
+ return "foreign";
48
+ const major = Number(m[2]);
49
+ if (major === 1)
50
+ return "known";
51
+ return major > 1 ? { newer: schema } : "foreign";
52
+ }
53
+ const SELECTION_FAMILY = "vincentt.editor-selection";
54
+ const STATE_FAMILY = "vincentt.editor-state";
55
+ async function readJson(root, name) {
56
+ return (await readJsonStat(root, name))?.value;
57
+ }
58
+ async function readJsonStat(root, name) {
59
+ const file = await readLocalFileStat(root, name).catch(() => undefined);
60
+ if (file === undefined)
61
+ return undefined;
62
+ try {
63
+ const v = JSON.parse(file.text);
64
+ return typeof v === "object" && v !== null && !Array.isArray(v)
65
+ ? { value: v, mtimeMs: file.mtimeMs }
66
+ : undefined;
67
+ }
68
+ catch {
69
+ return undefined;
70
+ }
71
+ }
72
+ /**
73
+ * A snapshot belongs to the running editor when it carries the pid record's session and, when it
74
+ * names a pid, that pid.
75
+ */
76
+ function sameEditor(file, rec) {
77
+ if (typeof file.session !== "string" || file.session !== rec.session)
78
+ return false;
79
+ return typeof file.pid !== "number" || file.pid === rec.pid;
80
+ }
81
+ /** Fields the CLI states itself; file content never supplies them. */
82
+ const RESERVED_STATE_KEYS = ["schema", "running", "pid", "session"];
83
+ const num = (v) => typeof v === "number" && Number.isFinite(v) ? v : undefined;
84
+ /** A string field, sanitised; anything that is not a string reads as empty. */
85
+ const str = (v, max) => typeof v === "string" ? agentSafe(v, max) : "";
86
+ /** The selection as the agent may read it: an allowlist of fields, every string sanitised. */
87
+ export function shapeSelection(sel) {
88
+ if (typeof sel !== "object" || sel === null)
89
+ return null;
90
+ const s = sel;
91
+ const src = (typeof s.source === "object" && s.source !== null ? s.source : {});
92
+ const values = Array.isArray(s.values) ? s.values : [];
93
+ return {
94
+ objectKey: str(s.objectKey, 400),
95
+ label: str(s.label, 200),
96
+ type: str(s.type, 80),
97
+ kind: str(s.kind, 20),
98
+ source: {
99
+ file: str(src.file, 400),
100
+ line: num(src.line) ?? null,
101
+ column: num(src.column) ?? null,
102
+ },
103
+ values: values.slice(0, 256).map((raw) => {
104
+ const v = (typeof raw === "object" && raw !== null ? raw : {});
105
+ const out = {
106
+ path: str(v.path, 200),
107
+ type: str(v.type, 20),
108
+ value: agentSafeValue(v.value),
109
+ };
110
+ if (typeof v.unit === "string")
111
+ out.unit = agentSafe(v.unit, 10);
112
+ out.editable =
113
+ v.editable === true
114
+ ? true
115
+ : agentSafeValue(v.editable ?? { readonly: "unsupported" }, 40);
116
+ if (typeof v.decl === "string")
117
+ out.decl = agentSafe(v.decl, 200);
118
+ if (num(v.sharedBy) !== undefined)
119
+ out.sharedBy = num(v.sharedBy);
120
+ return out;
121
+ }),
122
+ };
123
+ }
124
+ function tooNewResult(schema) {
125
+ return { stdout: [], stderr: tooNew(schema), exitCode: 1, reason: "too_new" };
126
+ }
127
+ export async function selectionRead(root, json, p) {
128
+ const file = await readJson(root, "selection.json");
129
+ const major = schemaMajor(file?.schema, SELECTION_FAMILY);
130
+ if (typeof major === "object")
131
+ return tooNewResult(major.newer);
132
+ const rec = await runningEditor(root, p);
133
+ if (rec === undefined) {
134
+ return {
135
+ stdout: json
136
+ ? [JSON.stringify({ schema: SELECTION_SCHEMA, running: false, selection: null })]
137
+ : [],
138
+ stderr: [NOT_RUNNING],
139
+ exitCode: 1,
140
+ reason: "not_running",
141
+ };
142
+ }
143
+ const selection = file && major === "known" && sameEditor(file, rec)
144
+ ? shapeSelection(file.selection)
145
+ : null;
146
+ if (json) {
147
+ return {
148
+ stdout: [JSON.stringify({ schema: SELECTION_SCHEMA, running: true, selection })],
149
+ stderr: [],
150
+ exitCode: 0,
151
+ };
152
+ }
153
+ return { stdout: humanSelection(selection), stderr: [], exitCode: 0 };
154
+ }
155
+ function display(v) {
156
+ if (Array.isArray(v))
157
+ return v.map(display).join(", ");
158
+ if (v !== null && typeof v === "object") {
159
+ return Object.entries(v)
160
+ .map(([k, x]) => `${k}: ${display(x)}`)
161
+ .join(", ");
162
+ }
163
+ return String(v);
164
+ }
165
+ export function humanSelection(sel) {
166
+ if (sel === null)
167
+ return ["Nothing is selected in the editor."];
168
+ const src = sel.source;
169
+ const kind = sel.kind === "sdk" ? " (Vincentt SDK)" : "";
170
+ const out = [
171
+ `${sel.label} ${sel.type}${kind} ${src.file}${src.line !== null ? `:${src.line}` : ""}`,
172
+ ];
173
+ for (const v of sel.values) {
174
+ const unit = typeof v.unit === "string" ? ` ${v.unit}` : "";
175
+ const ro = v.editable === true
176
+ ? ""
177
+ : ` (read-only: ${display(v.editable?.readonly ?? "unsupported")})`;
178
+ out.push(` ${String(v.path).padEnd(26)} ${display(v.value)}${unit}${ro}`);
179
+ }
180
+ return out;
181
+ }
182
+ const APP_VERSION = /^\d+\.\d+\.\d+([-+][0-9A-Za-z.-]{1,32})?$/;
183
+ const LAST_OPEN = /^(opened|awaiting_trust|cancelled|refused:[A-Za-z0-9_-]{1,16})$/;
184
+ /**
185
+ * `app` as the agent may read it: exactly `{version, lastOpen}`, each null unless it is one of
186
+ * the shapes the app writes. `trustLastOpen` false nulls `lastOpen` whatever the file says.
187
+ */
188
+ export function shapeApp(app, trustLastOpen) {
189
+ const a = (typeof app === "object" && app !== null ? app : {});
190
+ const version = typeof a.version === "string" && APP_VERSION.test(a.version) ? a.version : null;
191
+ const lastOpen = trustLastOpen && typeof a.lastOpen === "string" && LAST_OPEN.test(a.lastOpen)
192
+ ? a.lastOpen
193
+ : null;
194
+ return { version, lastOpen };
195
+ }
196
+ /**
197
+ * A state file no running editor vouches for is believed about the last open only when it
198
+ * changed after this machine's last hand-off of this folder: a committed or copied state.json
199
+ * predates it, and a folder never handed over has no open to report. A time in the future (an
200
+ * unpacked archive keeps whatever mtime it was given) is not after anything.
201
+ */
202
+ async function lastOpenTrusted(root, mtimeMs) {
203
+ const project = await realpath(root).catch(() => undefined);
204
+ if (project === undefined)
205
+ return false;
206
+ const at = await lastHandoff(project).catch(() => undefined);
207
+ return at !== undefined && mtimeMs > at && mtimeMs <= Date.now() + 5_000;
208
+ }
209
+ function appField(file, trustLastOpen) {
210
+ return file !== undefined && file.app !== undefined
211
+ ? { app: shapeApp(file.app, trustLastOpen) }
212
+ : {};
213
+ }
214
+ export async function statusRead(root, p) {
215
+ const read = await readJsonStat(root, "state.json");
216
+ const file = read?.value;
217
+ const major = schemaMajor(file?.schema, STATE_FAMILY);
218
+ if (typeof major === "object")
219
+ return tooNewResult(major.newer);
220
+ const known = major === "known" ? file : undefined;
221
+ const rec = await runningEditor(root, p);
222
+ if (known && rec && known.running === true && sameEditor(known, rec)) {
223
+ // The running, verified editor wrote this file. Sanitised first, so a key that only
224
+ // becomes `pid` once its control characters are stripped is removed too.
225
+ const shaped = agentSafeValue(known, 400);
226
+ for (const k of RESERVED_STATE_KEYS)
227
+ delete shaped[k];
228
+ if (known.app !== undefined)
229
+ shaped.app = shapeApp(known.app, true);
230
+ return {
231
+ stdout: [
232
+ JSON.stringify({ schema: STATE_SCHEMA, running: true, pid: rec.pid, ...shaped }),
233
+ ],
234
+ stderr: [],
235
+ exitCode: 0,
236
+ };
237
+ }
238
+ const trusted = known !== undefined &&
239
+ read !== undefined &&
240
+ (await lastOpenTrusted(root, read.mtimeMs));
241
+ if (rec === undefined) {
242
+ // A refused or cancelled open leaves no running editor behind, so the outcome of the last
243
+ // hand-off is reported here too, when the file is believed.
244
+ return {
245
+ stdout: [
246
+ JSON.stringify({
247
+ schema: STATE_SCHEMA,
248
+ running: false,
249
+ ...appField(known, trusted),
250
+ }),
251
+ ],
252
+ stderr: [NOT_RUNNING],
253
+ exitCode: 1,
254
+ reason: "not_running",
255
+ };
256
+ }
257
+ return {
258
+ stdout: [
259
+ JSON.stringify({
260
+ schema: STATE_SCHEMA,
261
+ running: true,
262
+ pid: rec.pid,
263
+ ...appField(known, trusted),
264
+ }),
265
+ ],
266
+ stderr: [],
267
+ exitCode: 0,
268
+ };
269
+ }
@@ -0,0 +1,7 @@
1
+ export declare function isUnsafeChar(ch: string): boolean;
2
+ /** Unsafe characters removed and the result capped at `max` code points. */
3
+ export declare function agentSafe(s: string, max: number): string;
4
+ /** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
5
+ export declare const MAX_DEPTH = 8;
6
+ /** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
7
+ export declare function agentSafeValue(v: unknown, max?: number, depth?: number): unknown;
@@ -0,0 +1,58 @@
1
+ // Text that reaches the creator's terminal or their agent from the editor's files. The app
2
+ // already strips these characters (B-F21-8); the CLI strips them again because it is the last
3
+ // step before a shell, and a stale or planted file never passed the app.
4
+ // C0 and C1 controls, DEL, the Arabic letter mark, zero-width characters and direction marks,
5
+ // line and paragraph separators, bidi embeddings, overrides and isolates, the word joiner,
6
+ // invisible operators, the byte order mark, the soft hyphen, the combining grapheme joiner,
7
+ // the Mongolian vowel separator, variation selectors and tag characters. Code points rather
8
+ // than a character class, so no bidi character sits in source.
9
+ const UNSAFE = [
10
+ [0x0000, 0x001f],
11
+ [0x007f, 0x009f],
12
+ [0x00ad, 0x00ad],
13
+ [0x034f, 0x034f],
14
+ [0x061c, 0x061c],
15
+ [0x180e, 0x180e],
16
+ [0x200b, 0x200f],
17
+ [0x2028, 0x2029],
18
+ [0x2060, 0x2064],
19
+ [0xfe00, 0xfe0f],
20
+ [0xfeff, 0xfeff],
21
+ [0x202a, 0x202e],
22
+ [0x2066, 0x2069],
23
+ [0xe0000, 0xe007f],
24
+ [0xe0100, 0xe01ef],
25
+ ];
26
+ export function isUnsafeChar(ch) {
27
+ const cp = ch.codePointAt(0) ?? 0;
28
+ return UNSAFE.some(([lo, hi]) => cp >= lo && cp <= hi);
29
+ }
30
+ // CSI and OSC sequences, removed whole so no `[31m` residue is left behind.
31
+ // eslint-disable-next-line no-control-regex
32
+ const ANSI_G = /\u001b\[[0-?]*[ -/]*[@-~]|\u001b\][^\u0007\u001b]*(?:\u0007|\u001b\\)?|\u009b[0-?]*[ -/]*[@-~]/g;
33
+ /** Unsafe characters removed and the result capped at `max` code points. */
34
+ export function agentSafe(s, max) {
35
+ return Array.from(s.replace(ANSI_G, ""))
36
+ .filter((ch) => !isUnsafeChar(ch))
37
+ .slice(0, max)
38
+ .join("");
39
+ }
40
+ /** Nesting kept in a value an agent reads; anything deeper is dropped (null). */
41
+ export const MAX_DEPTH = 8;
42
+ /** A JSON value with every string (keys included) made agent-safe, depth- and size-capped. */
43
+ export function agentSafeValue(v, max = 200, depth = 0) {
44
+ if (typeof v === "string")
45
+ return agentSafe(v, max);
46
+ if (depth >= MAX_DEPTH && v !== null && typeof v === "object")
47
+ return null;
48
+ if (Array.isArray(v))
49
+ return v.slice(0, 64).map((x) => agentSafeValue(x, max, depth + 1));
50
+ if (v !== null && typeof v === "object") {
51
+ const out = {};
52
+ for (const [k, x] of Object.entries(v).slice(0, 256)) {
53
+ out[agentSafe(k, 80)] = agentSafeValue(x, max, depth + 1);
54
+ }
55
+ return out;
56
+ }
57
+ return v;
58
+ }
@@ -0,0 +1,5 @@
1
+ export type Tool = "codesign" | "open" | "sw_vers" | "plutil" | "lsappinfo" | "ps";
2
+ /** Where each tool lives, most likely first. Never a relative path; none off macOS. */
3
+ export declare function toolCandidates(tool: Tool, platform?: NodeJS.Platform): string[];
4
+ /** The tool's absolute path, or undefined when it is not where the OS keeps it. */
5
+ export declare function toolPath(tool: Tool, platform?: NodeJS.Platform, exists?: (p: string) => boolean): string | undefined;
@@ -0,0 +1,20 @@
1
+ // The macOS tools the launcher runs, by absolute path only. A bare name would be resolved from
2
+ // PATH, so a project (or anything earlier on PATH) could plant a `codesign` that answers "this
3
+ // app is signed by Vincentt".
4
+ import { existsSync } from "node:fs";
5
+ const DARWIN = {
6
+ ps: ["/bin/ps"],
7
+ codesign: ["/usr/bin/codesign"],
8
+ open: ["/usr/bin/open"],
9
+ sw_vers: ["/usr/bin/sw_vers"],
10
+ plutil: ["/usr/bin/plutil"],
11
+ lsappinfo: ["/usr/bin/lsappinfo"],
12
+ };
13
+ /** Where each tool lives, most likely first. Never a relative path; none off macOS. */
14
+ export function toolCandidates(tool, platform = process.platform) {
15
+ return platform === "darwin" ? DARWIN[tool] : [];
16
+ }
17
+ /** The tool's absolute path, or undefined when it is not where the OS keeps it. */
18
+ export function toolPath(tool, platform = process.platform, exists = existsSync) {
19
+ return toolCandidates(tool, platform).find((p) => exists(p));
20
+ }
@@ -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
+ }
@@ -338,6 +338,8 @@ export function endingLines(reason, orgName, refusal = {}) {
338
338
  return previewTooOldLines(refusal);
339
339
  case "auth_expired":
340
340
  return copy.endedAuthExpired();
341
+ case "protocol_violation":
342
+ return copy.endedProtocolViolation();
341
343
  case "egress_cap":
342
344
  return copy.egressHard();
343
345
  case "stopped":
@@ -219,6 +219,11 @@ export declare function endedAuthExpired(): string[];
219
219
  * link, and a silent re-mint would break every link already shared.
220
220
  */
221
221
  export declare function endedAuthzLapsed(): string[];
222
+ /**
223
+ * The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
224
+ * remedy is a newer CLI or a later attempt, never anything in the project.
225
+ */
226
+ export declare function endedProtocolViolation(): string[];
222
227
  /**
223
228
  * e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
224
229
  * silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
@@ -18,6 +18,7 @@
18
18
  // product, so f1's "a token is never printed" extends to it verbatim.
19
19
  /** Flow L13 — the start block. */
20
20
  import { escapeControl, terminalSafe } from "../lib/terminalText.js";
21
+ import { INSTALL_COMMAND } from "../shared/upgradeError.js";
21
22
  export function startBlock(i) {
22
23
  const out = [
23
24
  "",
@@ -477,6 +478,20 @@ export function endedAuthzLapsed() {
477
478
  "",
478
479
  ];
479
480
  }
481
+ /**
482
+ * The edge broke the agreed tunnel wire. Only an edge bug causes it, so the
483
+ * remedy is a newer CLI or a later attempt, never anything in the project.
484
+ */
485
+ export function endedProtocolViolation() {
486
+ return [
487
+ "",
488
+ "✗ Preview stopped: the preview server sent an invalid reply. Update the CLI or try again later.",
489
+ "",
490
+ ` Upgrade: ${INSTALL_COMMAND}`,
491
+ " Then try again: vincentt preview",
492
+ "",
493
+ ];
494
+ }
480
495
  /**
481
496
  * e. Abandoned — this machine stopped checking in (the 30-minute heartbeat-
482
497
  * silence flip, `D-Abandoned-sessions-are-judged-on-heartbeat-silence-alone`).
@@ -45,6 +45,12 @@ export type PreviewEndReason = "stopped"
45
45
  * both. No further platform call is made, the DELETE included.
46
46
  */
47
47
  | "upgrade_required"
48
+ /**
49
+ * The edge broke the agreed tunnel wire (a malformed 101 caps header, a
50
+ * CONTROL frame this connection cannot read, or a fatal OPEN). Final: a redial reaches the
51
+ * same edge, so the tunnel client has already stopped dialling.
52
+ */
53
+ | "protocol_violation"
48
54
  /**
49
55
  * The 30-minute heartbeat-silence flip. NEVER reported as `hard_max`: a
50
56
  * session ended at minute 30 has not reached its 12-hour ceiling, and saying
@@ -256,6 +256,7 @@ export async function startPreview(opts) {
256
256
  if (reason !== null)
257
257
  ended(reason);
258
258
  },
259
+ onProtocolViolation: () => ended("protocol_violation"),
259
260
  onRefused: (_reason, minimum, declared) => ended("upgrade_required", {
260
261
  minimum: parseMinimum(minimum),
261
262
  declared: parseDeclared(declared),
@@ -15,7 +15,7 @@ import { buildManifest, ManifestError } from "./manifest.js";
15
15
  import { checkBounds } from "./bounds.js";
16
16
  import { classifyDist } from "./classify.js";
17
17
  import { createProgress } from "./progress.js";
18
- import { UploadExpired, UploadFailed, UploadTargetRejected, uploadAll, } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, UploadTargetRejected, UploadChecksumMismatch, uploadAll, } from "./upload.js";
19
19
  import { escapeTerminal } from "../lib/terminalText.js";
20
20
  /** Re-opens after an expired upload link, per publish, before giving up. */
21
21
  export const MAX_REOPENS = 2;
@@ -342,7 +342,8 @@ async function runUpload(deps, cfg, i) {
342
342
  }
343
343
  const failedPath = err instanceof UploadFailed ||
344
344
  err instanceof UploadExpired ||
345
- err instanceof UploadTargetRejected
345
+ err instanceof UploadTargetRejected ||
346
+ err instanceof UploadChecksumMismatch
346
347
  ? escapeTerminal(err.path)
347
348
  : "a file";
348
349
  deps.print("");
@@ -563,6 +564,8 @@ function outputRefusal(err, output, buildCommand) {
563
564
  switch (err.reason) {
564
565
  case "secret_path":
565
566
  return copy.secretFiles(err.paths, output);
567
+ case "path_escaped":
568
+ return copy.escapedFiles(err.paths, output);
566
569
  case "credential_file":
567
570
  return copy.credentialFiles(err.paths, output);
568
571
  case "file_unreadable":
@@ -176,6 +176,12 @@ export declare function outputOutside(shown: string): string[];
176
176
  export declare function outputInHidden(shown: string, hidden: string): string[];
177
177
  /** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
178
178
  export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
179
+ /**
180
+ * Flow U1 — files whose folder resolved outside the output after the walk: a
181
+ * folder was swapped for a link while publish read it. The whole publish is
182
+ * refused, never just the file. After the header and reading line.
183
+ */
184
+ export declare function escapedFiles(paths: Offender[], output?: OutputLabel): string[];
179
185
  /** Flow U1 — credential files, from the early checks or the hash step. After the header and reading line. */
180
186
  export declare function credentialFiles(paths: Offender[], output?: OutputLabel): string[];
181
187
  /** Flow U1 — a file that changed, became a link, or could not be read while checked or hashed. */