@vincentt-xr/cli 0.17.0 → 0.18.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 (95) hide show
  1. package/MIGRATION.md +24 -0
  2. package/README.md +94 -1
  3. package/dist/cli/index.js +170 -84
  4. package/dist/cli/usage-text.js +32 -10
  5. package/dist/domain/api.d.ts +10 -8
  6. package/dist/domain/api.js +14 -9
  7. package/dist/domain/command.js +25 -16
  8. package/dist/domain/copy.d.ts +6 -3
  9. package/dist/domain/copy.js +10 -6
  10. package/dist/dx/command.d.ts +24 -4
  11. package/dist/dx/command.js +80 -25
  12. package/dist/dx/consent.js +6 -5
  13. package/dist/dx/journal.d.ts +17 -2
  14. package/dist/dx/journal.js +19 -3
  15. package/dist/dx/report.js +4 -3
  16. package/dist/dx/reportIo.js +6 -5
  17. package/dist/dx/send.js +6 -5
  18. package/dist/lib/backend.d.ts +0 -16
  19. package/dist/lib/backend.js +2 -26
  20. package/dist/lib/detect.d.ts +87 -0
  21. package/dist/lib/detect.js +867 -0
  22. package/dist/lib/diagnostics.js +2 -2
  23. package/dist/lib/feedback.js +1 -1
  24. package/dist/lib/feedbackCopy.js +1 -1
  25. package/dist/lib/initFrom.js +4 -4
  26. package/dist/lib/lifecycle.d.ts +2 -0
  27. package/dist/lib/lifecycle.js +9 -3
  28. package/dist/lib/listProjects.js +2 -1
  29. package/dist/lib/projects.d.ts +37 -2
  30. package/dist/lib/projects.js +86 -21
  31. package/dist/lib/settings.d.ts +113 -0
  32. package/dist/lib/settings.js +284 -0
  33. package/dist/lib/settingsCopy.d.ts +6 -0
  34. package/dist/lib/settingsCopy.js +99 -0
  35. package/dist/lib/settle.d.ts +61 -0
  36. package/dist/lib/settle.js +338 -0
  37. package/dist/lib/template.d.ts +19 -0
  38. package/dist/lib/template.js +73 -0
  39. package/dist/lib/terminalText.d.ts +3 -0
  40. package/dist/lib/terminalText.js +10 -0
  41. package/dist/lib/version.js +5 -11
  42. package/dist/lib/whoami.js +10 -3
  43. package/dist/library/assetsCommand.js +1 -1
  44. package/dist/library/client.js +8 -3
  45. package/dist/library/render.js +8 -6
  46. package/dist/login/authBackend.js +3 -2
  47. package/dist/login/ensure.d.ts +8 -2
  48. package/dist/login/ensure.js +21 -10
  49. package/dist/login/guard.js +5 -4
  50. package/dist/login/loginCommand.d.ts +2 -2
  51. package/dist/login/loginCommand.js +29 -5
  52. package/dist/login/pkce.js +1 -1
  53. package/dist/login/pkceLogin.js +1 -1
  54. package/dist/platform/outdated.js +2 -2
  55. package/dist/platform/upgradeReport.js +16 -12
  56. package/dist/preview/api.d.ts +5 -11
  57. package/dist/preview/api.js +13 -15
  58. package/dist/preview/command.d.ts +29 -0
  59. package/dist/preview/command.js +61 -1
  60. package/dist/preview/copy.d.ts +35 -3
  61. package/dist/preview/copy.js +102 -10
  62. package/dist/preview/proxy.d.ts +38 -1
  63. package/dist/preview/proxy.js +151 -2
  64. package/dist/preview/qrPage.js +1 -1
  65. package/dist/preview/runner.d.ts +25 -13
  66. package/dist/preview/runner.js +127 -26
  67. package/dist/preview/viewers.js +1 -1
  68. package/dist/publish/api.d.ts +22 -5
  69. package/dist/publish/api.js +80 -9
  70. package/dist/publish/command.d.ts +25 -0
  71. package/dist/publish/command.js +211 -35
  72. package/dist/publish/copy.d.ts +78 -5
  73. package/dist/publish/copy.js +271 -37
  74. package/dist/publish/fit.d.ts +15 -0
  75. package/dist/publish/fit.js +103 -0
  76. package/dist/publish/manifest.d.ts +44 -3
  77. package/dist/publish/manifest.js +172 -3
  78. package/dist/publish/prompt.d.ts +4 -0
  79. package/dist/publish/prompt.js +15 -1
  80. package/dist/publish/upload.d.ts +6 -0
  81. package/dist/publish/upload.js +11 -1
  82. package/dist/publish/visibility.d.ts +21 -0
  83. package/dist/publish/visibility.js +68 -0
  84. package/dist/relay/annotations.js +9 -4
  85. package/dist/relay/server.d.ts +21 -0
  86. package/dist/relay/server.js +35 -1
  87. package/dist/shared/authError.d.ts +27 -0
  88. package/dist/shared/authError.js +48 -4
  89. package/dist/shared/config.d.ts +21 -1
  90. package/dist/shared/config.js +39 -8
  91. package/dist/shared/countLimit.d.ts +7 -2
  92. package/dist/shared/countLimit.js +12 -5
  93. package/dist/shared/http.d.ts +23 -0
  94. package/dist/shared/http.js +58 -0
  95. package/package.json +7 -3
@@ -16,6 +16,8 @@
16
16
  // session id, the edge hostname, or the workingTreeKey. The grant is the
17
17
  // sharpest — it transits an agent's tool-output transcript by default in this
18
18
  // product, so f1's "a token is never printed" extends to it verbatim.
19
+ /** Flow L13 — the start block. */
20
+ import { escapeControl } from "../lib/terminalText.js";
19
21
  export function startBlock(i) {
20
22
  const out = [
21
23
  "",
@@ -59,7 +61,7 @@ export function startBlock(i) {
59
61
  * without an account, which is false. It is offered as a CAPABILITY, not as
60
62
  * consolation — no "unfortunately", no "you can still".
61
63
  */
62
- export function notSignedIn() {
64
+ export function notSignedIn(devCommand = "npm run dev", localUrl = "http://localhost:5173") {
63
65
  return [
64
66
  "",
65
67
  "✗ You're not signed in.",
@@ -68,12 +70,68 @@ export function notSignedIn() {
68
70
  "",
69
71
  " Sign in: vincentt login",
70
72
  "",
71
- " Your local dev loop does not need one — `npm run dev` and open",
72
- " http://localhost:5173 on this machine. The camera works there.",
73
+ ` Your local dev loop does not need one. Run \`${devCommand}\` and open`,
74
+ ` ${localUrl} on this machine. The camera works there.`,
73
75
  " What signing in adds is the phone.",
74
76
  "",
75
77
  ];
76
78
  }
79
+ /**
80
+ * Flow P1 — the two settings lines, printed right after resolution and before
81
+ * the sign-in check, the mint and the spawn. They are the ONLY disclosure of the
82
+ * dev command before it runs; no second line repeats it.
83
+ */
84
+ export function settingsBanner(i) {
85
+ // The command is schema-checked to hold no control character; escaping here
86
+ // as well keeps this line honest even for a caller that skipped the check.
87
+ const row = (label, raw, source) => {
88
+ const value = escapeControl(raw);
89
+ return `→ ${label.padEnd(14)}${value.padEnd(Math.max(27, value.length + 2))}${source}`;
90
+ };
91
+ const out = [row("Dev command", i.devCommand, i.devCommandSource)];
92
+ if (i.reused) {
93
+ out.push(`${" ".repeat(16)}not started: something is already listening on port ${i.devPort}`);
94
+ }
95
+ out.push(row("Dev port", String(i.devPort), i.devPortSource));
96
+ return out;
97
+ }
98
+ /** Flow P3, static: `package.json` does not list the harness. Precedes the start block. */
99
+ export function noDiagnosticsStatic() {
100
+ return [
101
+ "",
102
+ "! This app has no Vincentt diagnostics, so `vincentt logs`, `network` and `trace`",
103
+ " will stay empty. The preview itself works.",
104
+ "",
105
+ " To add them, install the harness and put one line at the top of your entry file:",
106
+ " npm i @vincentt-xr/harness",
107
+ ' import "@vincentt-xr/harness/auto";',
108
+ ];
109
+ }
110
+ /** Flow P3, arrival: once per device class, only when the static line was shown. */
111
+ export function deviceArrived(descriptor) {
112
+ return descriptor
113
+ ? `A device opened the preview · ${descriptor}`
114
+ : "A device opened the preview";
115
+ }
116
+ /**
117
+ * Flow P3, runtime: the harness is listed but nothing said hello. Names the
118
+ * device class (a tester exists only after a hello) and asserts neither cause.
119
+ * The window's length is deliberately not printed.
120
+ */
121
+ export function noDiagnosticsRuntime(descriptor) {
122
+ const opened = descriptor
123
+ ? `A device opened the preview (${descriptor}), but no diagnostics have arrived`
124
+ : "A device opened the preview, but no diagnostics have arrived";
125
+ return [
126
+ `! ${opened}`,
127
+ " from the app. It may not load @vincentt-xr/harness/auto, or it may have",
128
+ " stopped before that line ran.",
129
+ ];
130
+ }
131
+ /** Flow P1 — the proxy refused a hidden path. Once per preview, naming the first. */
132
+ export function hiddenPathRefused(requestPath) {
133
+ return `! Answered 404 for ${requestPath}. A preview never serves hidden files from your project.`;
134
+ }
77
135
  /** Flow L16 — `--viewer`, a second terminal, not a second preview. */
78
136
  export function followingBlock(label, descriptor) {
79
137
  return [
@@ -96,12 +154,32 @@ export function followingFooter() {
96
154
  * The middle line splits the wire in two, because the creator's first instinct
97
155
  * on a 502 is to suspect us.
98
156
  */
99
- export function originDown(targetUrl) {
157
+ export function originDown(targetUrl, settings) {
158
+ if (settings === undefined) {
159
+ return [
160
+ `! Your dev server isn't responding on ${targetUrl}.`,
161
+ "",
162
+ " The preview is still live. The phone is reaching us; we can't reach your app.",
163
+ " Start it and reload the phone: npm run dev",
164
+ "",
165
+ ];
166
+ }
167
+ const source = settings.portSource === "vincentt.json"
168
+ ? " (port from vincentt.json)"
169
+ : settings.portSource === "flag"
170
+ ? " (port from --port)"
171
+ : "";
172
+ const fix = settings.portSource === "vincentt.json"
173
+ ? " If it runs on another port, set dev.port in vincentt.json."
174
+ : settings.portSource === "flag"
175
+ ? " If it runs on another port, pass that port with --port."
176
+ : " If it runs on another port, pass --port or add vincentt.json.";
100
177
  return [
101
- `! Your dev server isn't responding on ${targetUrl}.`,
178
+ `! Your dev server isn't responding on ${targetUrl}${source}.`,
102
179
  "",
103
- " The preview is still live — the phone is reaching us, we can't reach your app.",
104
- " Start it and reload the phone: npm run dev",
180
+ " The preview is still live. The phone is reaching us; we can't reach your app.",
181
+ ` Start it and reload the phone: ${settings.devCommand}`,
182
+ fix,
105
183
  "",
106
184
  ];
107
185
  }
@@ -283,7 +361,7 @@ export function hardMaxWarning(clockTime, minutesLeft) {
283
361
  `! This preview reaches its 12-hour maximum at ${clockTime} (in ${minutesLeft}m) and will stop.`,
284
362
  "",
285
363
  " A preview link is deliberately short-lived. To keep going, start a new one",
286
- " then — you'll get a new link, so any QR code on a desk will need re-scanning.",
364
+ " then. You'll get a new link, so any QR code on a desk will need re-scanning.",
287
365
  "",
288
366
  ];
289
367
  }
@@ -310,7 +388,7 @@ export function supersededLoser() {
310
388
  "",
311
389
  "✗ This preview was replaced by a newer one for this folder.",
312
390
  "",
313
- " Its link no longer works. The new preview has a different link — whoever",
391
+ " Its link no longer works. The new preview has a different link, so whoever",
314
392
  " is holding a phone will need it again.",
315
393
  "",
316
394
  ];
@@ -363,6 +441,20 @@ export function endedUnreachable(orgName) {
363
441
  "",
364
442
  ];
365
443
  }
444
+ /**
445
+ * The heartbeat's token was refused. Distinct from both causes above: the
446
+ * project and the access are fine, and the one thing to do is sign in again.
447
+ */
448
+ export function endedAuthExpired() {
449
+ return [
450
+ "",
451
+ "✗ Preview ended. Your Vincentt access token is no longer valid.",
452
+ "",
453
+ " Sign in again: vincentt login",
454
+ " Then start a new preview: vincentt preview",
455
+ "",
456
+ ];
457
+ }
366
458
  export function endedUnconfirmed() {
367
459
  return [
368
460
  "",
@@ -451,7 +543,7 @@ export function mintRateLimited(retryAfterSec) {
451
543
  "",
452
544
  `✗ Too many previews started recently. Try again in ${approxWait(retryAfterSec)}.`,
453
545
  "",
454
- " A running preview does not count against this — if one is already running",
546
+ " A running preview does not count against this. If one is already running",
455
547
  " for this folder, you don't need a new one.",
456
548
  "",
457
549
  ];
@@ -12,4 +12,41 @@ import type { Duplex } from "node:stream";
12
12
  * under a live preview, not by reading the code.
13
13
  */
14
14
  export declare function proxyWeb(req: IncomingMessage, res: ServerResponse, targetPort: number, onOriginError?: () => void): void;
15
- export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buffer, targetPort: number): void;
15
+ export interface ProxyWsOptions {
16
+ /**
17
+ * Rewrite `Origin` to the dev server's own loopback origin iff the request's
18
+ * Origin is BYTE-EQUAL to this value. It is the minted preview origin, fixed at
19
+ * mint and never read from a request. A dev server refuses a foreign Origin on
20
+ * its live-reload socket; equality keeps that refusal for every other page.
21
+ */
22
+ rewriteOriginFrom?: string;
23
+ }
24
+ export declare function proxyWs(req: IncomingMessage, socket: Duplex, head: Buffer, targetPort: number, opts?: ProxyWsOptions): void;
25
+ /**
26
+ * Where the front proxy sends a request: to the relay only on the exact paths
27
+ * the phone-side harness uses, and nowhere at all for anything else under the
28
+ * harness prefix. The harness opens its WebSocket at `harnessPath` and POSTs
29
+ * annotations to `harnessPath/annotation`; nothing else of the relay is
30
+ * tunnel-facing (`/query` and `feedback/next` belong to the local CLI).
31
+ *
32
+ * Exact comparison of the raw path is what makes `/__harness/../query` and
33
+ * `/__harness/%2e%2e/query` land on `refuse` rather than on the relay, where
34
+ * URL normalisation would have turned them into `/query`.
35
+ */
36
+ export declare function relayRoute(url: string, harnessPath: string, kind: "http" | "upgrade"): "relay" | "refuse" | "app";
37
+ /**
38
+ * Whether the front proxy must answer 404 itself for this app-route URL, so a
39
+ * file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`
40
+ * or the binding to any holder of a preview link.
41
+ *
42
+ * The path is decoded once, and an encoded `/` or `\` is refused outright (a
43
+ * separator the dev server would split on and this check would not). A segment
44
+ * still carrying an encoded dot after that one decode is judged as the dev
45
+ * server would read it after a second decode, so `%252eenv` cannot slip past.
46
+ * `.` and `..` segments are always refused, which is what makes the `/@fs/`
47
+ * text containment below real containment.
48
+ *
49
+ * `projectRoot` is the realpath of the binding directory, taken once at preview
50
+ * start; inside-ness is a segment-aligned text prefix, with no disk call.
51
+ */
52
+ export declare function isHiddenAppPath(url: string, projectRoot: string): boolean;
@@ -3,12 +3,48 @@
3
3
  // relay behind one origin); not a general proxy. No deps.
4
4
  import { connect } from "node:net";
5
5
  import { request, } from "node:http";
6
+ import { createGzip } from "node:zlib";
6
7
  // Rewrite Host to the loopback target. cloudflared forwards the public Host, and
7
8
  // esbuild's `serve` 403s any Host it doesn't recognize — so the phone would get a
8
9
  // 403. The origin only needs a Host it accepts; the browser never sees this value.
9
10
  function localHeaders(headers, targetPort) {
10
11
  return { ...headers, host: `localhost:${targetPort}` };
11
12
  }
13
+ const COMPRESSIBLE_TYPE = /^(text\/(?!event-stream)|application\/(javascript|json|xml)|image\/svg\+xml)|\+json\b|\+xml\b/i;
14
+ function acceptsGzip(header) {
15
+ if (header === undefined)
16
+ return false;
17
+ const value = Array.isArray(header) ? header.join(",") : header;
18
+ for (const part of value.split(",")) {
19
+ const [coding, ...params] = part.trim().split(";");
20
+ const name = coding?.trim().toLowerCase();
21
+ if (name !== "gzip" && name !== "*")
22
+ continue;
23
+ const q = params.map((p) => p.trim().toLowerCase()).find((p) => p.startsWith("q="));
24
+ if (q !== undefined && !(Number(q.slice(2)) > 0))
25
+ continue;
26
+ return true;
27
+ }
28
+ return false;
29
+ }
30
+ function shouldGzip(req, proxyRes, status) {
31
+ if (req.method === "HEAD" || status < 200 || status === 204 || status === 206 || status === 304)
32
+ return false;
33
+ if (proxyRes.headers["content-encoding"] !== undefined)
34
+ return false;
35
+ const cacheControl = String(proxyRes.headers["cache-control"] ?? "");
36
+ if (/\bno-transform\b/i.test(cacheControl))
37
+ return false;
38
+ if (!COMPRESSIBLE_TYPE.test(String(proxyRes.headers["content-type"] ?? "")))
39
+ return false;
40
+ return acceptsGzip(req.headers["accept-encoding"]);
41
+ }
42
+ function appendVary(existing) {
43
+ const current = Array.isArray(existing) ? existing.join(", ") : (existing ?? "");
44
+ if (/(^|,)\s*(accept-encoding|\*)\s*(,|$)/i.test(current))
45
+ return current;
46
+ return current === "" ? "Accept-Encoding" : `${current}, Accept-Encoding`;
47
+ }
12
48
  /**
13
49
  * onOriginError is where the creator's dev server being down is ACTUALLY
14
50
  * OBSERVED.
@@ -35,7 +71,19 @@ export function proxyWeb(req, res, targetPort, onOriginError) {
35
71
  const headers = { ...proxyRes.headers, "cache-control": "no-store" };
36
72
  delete headers.etag;
37
73
  delete headers["last-modified"];
38
- res.writeHead(proxyRes.statusCode ?? 502, headers);
74
+ const status = proxyRes.statusCode ?? 502;
75
+ if (shouldGzip(req, proxyRes, status)) {
76
+ delete headers["content-length"];
77
+ headers["content-encoding"] = "gzip";
78
+ headers.vary = appendVary(headers.vary);
79
+ res.writeHead(status, headers);
80
+ const gzip = createGzip();
81
+ gzip.on("error", () => res.destroy());
82
+ proxyRes.on("error", () => gzip.destroy());
83
+ proxyRes.pipe(gzip).pipe(res);
84
+ return;
85
+ }
86
+ res.writeHead(status, headers);
39
87
  proxyRes.pipe(res);
40
88
  });
41
89
  proxyReq.on("error", () => {
@@ -46,9 +94,12 @@ export function proxyWeb(req, res, targetPort, onOriginError) {
46
94
  });
47
95
  req.pipe(proxyReq);
48
96
  }
49
- export function proxyWs(req, socket, head, targetPort) {
97
+ export function proxyWs(req, socket, head, targetPort, opts = {}) {
50
98
  // Re-issue the upgrade handshake against the target and splice the sockets.
51
99
  const headers = localHeaders(req.headers, targetPort);
100
+ if (opts.rewriteOriginFrom !== undefined && headers.origin === opts.rewriteOriginFrom) {
101
+ headers.origin = `http://localhost:${targetPort}`;
102
+ }
52
103
  const upstream = connect(targetPort, "127.0.0.1", () => {
53
104
  const headerLines = [
54
105
  `${req.method} ${req.url} HTTP/1.1`,
@@ -66,3 +117,101 @@ export function proxyWs(req, socket, head, targetPort) {
66
117
  upstream.on("error", bail);
67
118
  socket.on("error", bail);
68
119
  }
120
+ /**
121
+ * Where the front proxy sends a request: to the relay only on the exact paths
122
+ * the phone-side harness uses, and nowhere at all for anything else under the
123
+ * harness prefix. The harness opens its WebSocket at `harnessPath` and POSTs
124
+ * annotations to `harnessPath/annotation`; nothing else of the relay is
125
+ * tunnel-facing (`/query` and `feedback/next` belong to the local CLI).
126
+ *
127
+ * Exact comparison of the raw path is what makes `/__harness/../query` and
128
+ * `/__harness/%2e%2e/query` land on `refuse` rather than on the relay, where
129
+ * URL normalisation would have turned them into `/query`.
130
+ */
131
+ export function relayRoute(url, harnessPath, kind) {
132
+ const raw = url.split("?")[0] ?? "";
133
+ if (kind === "upgrade" && raw === harnessPath)
134
+ return "relay";
135
+ if (kind === "http" && raw === `${harnessPath}/annotation`)
136
+ return "relay";
137
+ return raw.startsWith(harnessPath) ? "refuse" : "app";
138
+ }
139
+ /**
140
+ * Whether the front proxy must answer 404 itself for this app-route URL, so a
141
+ * file-server dev tool serving the repo root cannot hand `/.env`, `/.git/config`
142
+ * or the binding to any holder of a preview link.
143
+ *
144
+ * The path is decoded once, and an encoded `/` or `\` is refused outright (a
145
+ * separator the dev server would split on and this check would not). A segment
146
+ * still carrying an encoded dot after that one decode is judged as the dev
147
+ * server would read it after a second decode, so `%252eenv` cannot slip past.
148
+ * `.` and `..` segments are always refused, which is what makes the `/@fs/`
149
+ * text containment below real containment.
150
+ *
151
+ * `projectRoot` is the realpath of the binding directory, taken once at preview
152
+ * start; inside-ness is a segment-aligned text prefix, with no disk call.
153
+ */
154
+ export function isHiddenAppPath(url, projectRoot) {
155
+ const raw = (url.split("#")[0] ?? "").split("?")[0] ?? "";
156
+ if (/%(2f|5c)/i.test(raw) || raw.includes("\\"))
157
+ return true;
158
+ let decoded;
159
+ try {
160
+ decoded = decodeURIComponent(raw);
161
+ }
162
+ catch {
163
+ return true;
164
+ }
165
+ const segments = decoded.split("/").slice(1);
166
+ if (segments.some((s) => s === "." || s === ".."))
167
+ return true;
168
+ const checked = checkedSegmentIndexes(segments, projectRoot);
169
+ for (const i of checked) {
170
+ const seg = segments[i];
171
+ if (!startsWithDot(seg))
172
+ continue;
173
+ if (isAllowedDotSegment(segments, i))
174
+ continue;
175
+ return true;
176
+ }
177
+ return false;
178
+ }
179
+ function startsWithDot(segment) {
180
+ if (segment.startsWith("."))
181
+ return true;
182
+ try {
183
+ return decodeURIComponent(segment).startsWith(".");
184
+ }
185
+ catch {
186
+ return false;
187
+ }
188
+ }
189
+ function checkedSegmentIndexes(segments, projectRoot) {
190
+ const all = segments.map((_, i) => i);
191
+ if (segments[0] !== "@fs")
192
+ return all;
193
+ const disk = segments.slice(1);
194
+ const rootSegments = projectRoot.split("/").filter((s) => s !== "");
195
+ const inside = disk.length >= rootSegments.length && rootSegments.every((s, i) => disk[i] === s);
196
+ if (inside)
197
+ return all.slice(1 + rootSegments.length);
198
+ const lastNodeModules = disk.lastIndexOf("node_modules");
199
+ if (lastNodeModules !== -1)
200
+ return all.slice(1 + lastNodeModules + 1);
201
+ return all.slice(1);
202
+ }
203
+ /** The four anchored exceptions (D-Preview-hides-dot-paths). Case-sensitive. */
204
+ function isAllowedDotSegment(segments, i) {
205
+ const seg = segments[i];
206
+ const prev = segments[i - 1];
207
+ const next = segments[i + 1];
208
+ if (seg === ".well-known")
209
+ return i === 0;
210
+ if (seg === ".vite" || seg === ".pnpm")
211
+ return prev === "node_modules";
212
+ if (seg === ".svelte-kit")
213
+ return next === "generated" && segments[i + 2] !== "server";
214
+ if (seg === ".yarn")
215
+ return next === "cache" || next === "__virtual__" || next === "unplugged";
216
+ return false;
217
+ }
@@ -23,7 +23,7 @@ export async function buildQrPage(url) {
23
23
  <head>
24
24
  <meta charset="utf-8" />
25
25
  <meta name="viewport" content="width=device-width, initial-scale=1" />
26
- <title>Scan to preview — ${safe}</title>
26
+ <title>Scan to preview: ${safe}</title>
27
27
  <style>
28
28
  :root { color-scheme: light dark; }
29
29
  body { margin:0; min-height:100vh; display:grid; place-items:center;
@@ -1,7 +1,9 @@
1
1
  import { type ControlKind, type TunnelState, type UAClass } from "@vincentt-xr/harness/tunnel";
2
2
  import type { DiagEvent } from "@vincentt-xr/harness/events";
3
3
  import { type EventTags } from "../relay/server.js";
4
- import type { ResolvedConfig } from "../shared/config.js";
4
+ import { type ResolvedConfig } from "../shared/config.js";
5
+ /** The path a 404 advisory names: the request's own path, without its query. */
6
+ export declare function requestPathOf(url: string | undefined): string;
5
7
  /** Why a preview ended. Each maps to exactly one screen (Flow L20/L21). */
6
8
  export type PreviewEndReason = "stopped"
7
9
  /**
@@ -29,7 +31,13 @@ export type PreviewEndReason = "stopped"
29
31
  * session ended at minute 30 has not reached its 12-hour ceiling, and saying
30
32
  * so is a false statement about the product's own behaviour.
31
33
  */
32
- | "abandoned" | "egress_cap";
34
+ | "abandoned" | "egress_cap"
35
+ /**
36
+ * The heartbeat's token was refused (401). Not a network blip: every later
37
+ * beat is refused the same way, so the preview ends with the sign-in remedy
38
+ * instead of heartbeating until the edge drains it.
39
+ */
40
+ | "auth_expired";
33
41
  export interface StartPreviewOptions {
34
42
  /** Project directory — its .vincentt binding + dev script drive the preview. */
35
43
  projectCwd: string;
@@ -55,7 +63,13 @@ export interface StartPreviewOptions {
55
63
  frontPort?: number;
56
64
  /** Path routed to the relay instead of the app (default /__harness). */
57
65
  harnessPath?: string;
58
- devCommand?: string[];
66
+ /**
67
+ * The dev command. A string (the resolved `dev.command`) runs through the
68
+ * platform shell, because creator-authored commands carry arguments and `&&`.
69
+ */
70
+ devCommand?: string[] | string;
71
+ /** A hidden path was refused by the front proxy (the 404 advisory). */
72
+ onHiddenPath?: (requestPath: string) => void;
59
73
  appStdio?: "ignore" | "inherit";
60
74
  appReadyTimeoutMs?: number;
61
75
  /** Progress sink. Never write app stdout to an MCP server's stdout. */
@@ -158,7 +172,8 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
158
172
  /**
159
173
  * One heartbeat. A 410 is the session ending on the server's terms; a 404/403
160
174
  * is authorization lost. They are DELIBERATELY NOT MERGED, because the remedies
161
- * differ: one is "find out who you are", the other is "try again".
175
+ * differ: one is "find out who you are", the other is "try again". A 401 is the
176
+ * token itself refused, and its remedy is signing in again.
162
177
  *
163
178
  * A transport failure is neither — the API being briefly unreachable must not
164
179
  * end a healthy preview, because the edge already fails closed on its own
@@ -170,20 +185,17 @@ export declare function refusalToReason(status: number): PreviewEndReason | null
170
185
  * superseded, or stopped elsewhere, all answer 410, and the hard-max screen is a
171
186
  * `✓` telling the creator the preview did exactly what it promised over 12 hours.
172
187
  *
173
- * The API's 410 body carries ONE opaque code (`preview_session_ended`) for every
174
- * terminal reason — verified against the running stack: an `abandoned` row and an
175
- * `expired` row return byte-identical bodies — so the CLI CANNOT tell them apart
176
- * and must not guess. `session_ended` is the honest, cause-free rendering until
177
- * the wire carries the discriminator. See the note on PreviewEndedError.
188
+ * The API's 410 carries the terminal reason under `detail.endReason`, and each
189
+ * reason maps to its own screen. A 410 with no reason gets the cause-free
190
+ * ending. See the note on PreviewEndedError.
178
191
  */
179
192
  export declare function runHeartbeat(config: ResolvedConfig, sessionId: string, fetchImpl: typeof fetch, onEnded?: (reason: PreviewEndReason) => void): Promise<void>;
180
193
  /**
181
194
  * The API's `endReason` → the CLI's ending screen.
182
195
  *
183
- * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 —
184
- * which is every 410 the API sends today — maps to `grace_exhausted`, the one
185
- * ending whose copy states only what is actually known: the preview has ended
186
- * and the link no longer works. It asserts no cause.
196
+ * `hard_max` is returned ONLY for an explicit `expired`. An UNLABELLED 410 maps
197
+ * to `grace_exhausted`, the one ending whose copy states only what is actually
198
+ * known: the preview has ended and the link no longer works. It asserts no cause.
187
199
  *
188
200
  * That default is the conservative choice and it is deliberate. `hard_max` is a
189
201
  * `✓` claiming the preview ran its full 12 hours; rendering it for a session the