@vincentt-xr/cli 0.18.1 → 0.18.3

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.
package/MIGRATION.md CHANGED
@@ -3,6 +3,23 @@
3
3
  Per version, newest first. Read by the platform's own version surface and served to
4
4
  creators' agents. Accurate or absent, never speculative (B-F5-2).
5
5
 
6
+ ## 0.18.3
7
+
8
+ required: false
9
+ No action required when the preview and the commands reading it (`logs`, `feedback`, `preview --stop`) come from the same CLI version. Behaviour that changed:
10
+ - The local preview relay refuses web pages other than the preview itself. It answers only requests whose Host is `127.0.0.1:<port>` or `localhost:<port>`, accepts the diagnostics socket and feedback posts only from the preview's own address (or from a client that sends no Origin), and refuses a POST that is not `application/json` with 415.
11
+ - The relay's diagnostics read and feedback long-poll now require a per-session token, which the preview writes to `.vincentt/relay.json` (now readable by the owner only). The CLI's own commands send it; a direct request to `/query` or `feedback/next` without the `x-vincentt-relay-token` header is refused with 401. A command from CLI 0.18.2 or older cannot read a preview started by this version, and the reverse still works.
12
+ - `vincentt relay` prints the token it requires and accepts `--origin <url>` for a page it should accept; without it, only pages served from localhost or 127.0.0.1 can connect.
13
+
14
+ ## 0.18.2
15
+
16
+ required: false
17
+ No action required. Behaviour that changed:
18
+ - `init` and `create` read the public release list once and clone the template version it names, then check the cloned commit against the platform's record. A commit that does not match exits 1 and writes nothing. When the platform cannot be reached, or has no commit on record, they clone the `latest` tag as before and print one warning on stderr. Plain `init` still needs no account.
19
+ - `vincentt create` in a folder that already holds an app, with no account and no terminal, now exits 4 (not signed in) with the `vincentt login` remedy, where it exited 1. Every not-signed-in path now exits 4.
20
+ - The six preview ending lines no longer use em-dashes: each reads `✓ Preview stopped.` or `✗ Preview ended.` followed by its cause, for example `✗ Preview ended. It reached the 5 GB ceiling.` A script matching the old wording exactly needs updating.
21
+ - `preview --stop` no longer signals a process that merely reuses the preview's old process id: the preview records its start time, and when the process now holding that id started at a different time, nothing is signalled and the command exits 1 as "nothing stopped", clearing the stale record. A preview started by an older CLI, or on Windows, has no start time on record and stops as before.
22
+
6
23
  ## 0.18.1
7
24
 
8
25
  required: false
package/dist/cli/index.js CHANGED
@@ -727,7 +727,7 @@ async function followPreview(info, ui, filterTester, filterViewer) {
727
727
  console.log("✓ Following this preview");
728
728
  }
729
729
  console.log("");
730
- const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
730
+ const relay = httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
731
731
  let since = -1;
732
732
  let warnedMissing = false;
733
733
  let stop = false;
@@ -1357,13 +1357,19 @@ async function domain() {
1357
1357
  // ---- servers ----------------------------------------------------------------
1358
1358
  async function relay() {
1359
1359
  const { startRelay } = await import("../relay/server.js");
1360
+ const { mintRelayToken, RELAY_TOKEN_HEADER } = await import("../relay/guard.js");
1360
1361
  const port = numFlag("--port") ?? 7331;
1361
1362
  const dir = flag("--dir");
1363
+ const origin = flag("--origin");
1364
+ const token = mintRelayToken();
1362
1365
  startRelay({
1363
1366
  port,
1364
1367
  projectDir: dir ? path.resolve(dir) : undefined,
1368
+ allowedOrigins: origin ? [origin] : undefined,
1369
+ token,
1365
1370
  onLog: (m) => console.log(`[relay] ${m}`),
1366
1371
  });
1372
+ console.log(`[relay] /query and feedback/next require ${RELAY_TOKEN_HEADER}: ${token}`);
1367
1373
  }
1368
1374
  // ---- dispatch ---------------------------------------------------------------
1369
1375
  export function usage() {
@@ -1,9 +1,10 @@
1
1
  import type { DiagEvent, LogEvent, NetworkEvent, RelayQuery, RelayResult, TraceEvent } from "@vincentt-xr/harness/events";
2
+ export declare function relayAuthHeaders(token: string | undefined): Record<string, string>;
2
3
  export interface RelayClient {
3
4
  query(q: RelayQuery): Promise<RelayResult>;
4
5
  }
5
6
  /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
- export declare function httpRelayClient(baseUrl: string): RelayClient;
7
+ export declare function httpRelayClient(baseUrl: string, token?: string): RelayClient;
7
8
  export declare function formatLog(e: LogEvent, now: number): string;
8
9
  export declare function formatNetwork(e: NetworkEvent, now: number): string;
9
10
  export declare function formatTrace(e: TraceEvent, now: number): string;
@@ -2,13 +2,17 @@
2
2
  // the relay and format them into the compact text an agent reads. Kept separate
3
3
  // from the SDK wiring (server.ts) so the formatting is unit-testable and the SDK
4
4
  // version can churn without touching this.
5
+ import { RELAY_TOKEN_HEADER } from "../relay/guard.js";
6
+ export function relayAuthHeaders(token) {
7
+ return token === undefined ? {} : { [RELAY_TOKEN_HEADER]: token };
8
+ }
5
9
  /** A RelayClient backed by the relay's HTTP /query endpoint. */
6
- export function httpRelayClient(baseUrl) {
10
+ export function httpRelayClient(baseUrl, token) {
7
11
  return {
8
12
  async query(q) {
9
13
  const res = await fetch(`${baseUrl}/query`, {
10
14
  method: "POST",
11
- headers: { "content-type": "application/json" },
15
+ headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
12
16
  body: JSON.stringify(q),
13
17
  });
14
18
  if (!res.ok)
@@ -11,6 +11,7 @@
11
11
  import { promises as fs } from "node:fs";
12
12
  import path from "node:path";
13
13
  import { isProcessAlive, isRelayResponding, readRelayInfo, relayInfoDir, } from "./relayInfo.js";
14
+ import { relayAuthHeaders } from "./diagnostics.js";
14
15
  const CURSOR_FILE = ".vincentt/annotations/offset";
15
16
  /**
16
17
  * A cursor that reads too LOW re-delivers, which is the accepted cost of
@@ -96,13 +97,13 @@ export async function fetchNext(cwd, opts) {
96
97
  u.searchParams.set("timeout", String(opts.timeoutMs));
97
98
  let res;
98
99
  try {
99
- res = await fetch(u);
100
+ res = await fetch(u, { headers: relayAuthHeaders(info.token) });
100
101
  }
101
102
  catch {
102
103
  // The socket died under us. The pid gate + relay-identity probe is what
103
104
  // separates "the preview ended" from "the request failed but a relay is
104
105
  // still there" — only the latter is a fault worth an exit 1.
105
- if (await relayGone(info.pid, info.relayPort)) {
106
+ if (await relayGone(info.pid, info.relayPort, info.token)) {
106
107
  return { kind: "closed", reason: "unreachable" };
107
108
  }
108
109
  throw new Error(`Could not reach the preview relay on :${info.relayPort}. The relay is answering, so this is not a session end.`);
@@ -117,8 +118,8 @@ export async function fetchNext(cwd, opts) {
117
118
  throw new Error(`relay feedback/next returned ${res.status}`);
118
119
  return { kind: "annotation", annotation: (await res.json()) };
119
120
  }
120
- async function relayGone(pid, port) {
121
+ async function relayGone(pid, port, token) {
121
122
  if (pid !== undefined && !isProcessAlive(pid))
122
123
  return true;
123
- return !(await isRelayResponding(port));
124
+ return !(await isRelayResponding(port, undefined, undefined, token));
124
125
  }
@@ -1,5 +1,7 @@
1
1
  // `vincentt init [dir]` — scaffold a Vincentt XR app. On the BARE path: NO
2
- // account, NO network call to the API, NO binding written.
2
+ // account, NO binding written, and no API call beyond the scaffold's anonymous
3
+ // read of the public release list (to verify the template commit; its failure
4
+ // only warns).
3
5
  //
4
6
  // This is the acquisition wedge: local-first is a smaller room, not a wall, so the
5
7
  // template must be reachable without ever touching the auth gate. `create` (which
@@ -93,7 +93,7 @@ export async function registerAfterScaffold(deps, opts = {}) {
93
93
  }
94
94
  deps.print("");
95
95
  deps.print(closing);
96
- return scaffolded ? EXIT.DELIVERED_NOT_REGISTERED : EXIT.FAILED;
96
+ return scaffolded ? EXIT.DELIVERED_NOT_REGISTERED : EXIT.NOT_AUTHENTICATED;
97
97
  }
98
98
  deps.print("");
99
99
  deps.print("→ Now registering the project so it gets an address.");
@@ -2,6 +2,7 @@ import type { OrgSource } from "./initFrom.js";
2
2
  import type { Ask } from "../publish/prompt.js";
3
3
  import { type ResolvedOrg } from "./org.js";
4
4
  import { type RelayClient } from "./diagnostics.js";
5
+ import { type ProcessStartTimeReader } from "./processStartTime.js";
5
6
  import { type ResolvedConfig } from "../shared/config.js";
6
7
  import { type RunningPreview, type StartPreviewOptions } from "../preview/index.js";
7
8
  import type { DiagEventKind, RelayResult } from "@vincentt-xr/harness/events";
@@ -125,6 +126,8 @@ export interface PreviewStopDeps {
125
126
  relay?: RelayClient;
126
127
  /** Injected so the SIGTERM is observable without signalling a real process. */
127
128
  kill?: (pid: number, signal: NodeJS.Signals) => void;
129
+ /** Injected so a recycled pid can be simulated without a real process. */
130
+ processStartTime?: ProcessStartTimeReader;
128
131
  }
129
132
  /**
130
133
  * Stop a preview started by a *different* process (the common case: `vincentt
@@ -10,6 +10,7 @@ import { publishCommand } from "../publish/command.js";
10
10
  import { activeOrgSlug, resolveActiveOrg } from "./org.js";
11
11
  import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
12
12
  import { clearRelayInfo, readLivePreview, readRelayInfo, writeRelayInfo, } from "./relayInfo.js";
13
+ import { readProcessStartTime } from "./processStartTime.js";
13
14
  import { loadMachineConfig, locateProjectBinding, resolveConfig, } from "../shared/config.js";
14
15
  import { startPreview, } from "../preview/index.js";
15
16
  // The SHIPPED stopping line, reused rather than reworded: the interrupt path and
@@ -173,6 +174,8 @@ export async function previewStart(projectCwd, onLog, extra = {}) {
173
174
  pid: process.pid,
174
175
  harnessPath: "/__harness",
175
176
  expiresAt: preview.expiresAt,
177
+ pidStartTime: await readProcessStartTime(process.pid),
178
+ token: preview.relayToken,
176
179
  });
177
180
  return { preview, started: true };
178
181
  }
@@ -199,6 +202,19 @@ function isAlive(pid) {
199
202
  }
200
203
  /** The bound the caller is held to: one query, no retry, isRelayResponding's window. */
201
204
  const PRESENCE_TIMEOUT_MS = 700;
205
+ /**
206
+ * True only when the marker recorded a start time and the live process holding
207
+ * that pid PROVABLY started at a different time. A marker from an older CLI, or
208
+ * a start time that cannot be read, proves nothing, so the stop goes ahead as it
209
+ * always did. The relay is deliberately not consulted: QA-F14-06 requires the
210
+ * stop to happen whatever the relay answers.
211
+ */
212
+ async function isRecycledPid(info, read) {
213
+ if (info.pidStartTime === undefined)
214
+ return false;
215
+ const live = await read(info.pid);
216
+ return live !== undefined && live !== info.pidStartTime;
217
+ }
202
218
  /**
203
219
  * How many connections are attached, or UNDEFINED when that cannot be
204
220
  * established — and undefined is a first-class answer here, not an error.
@@ -259,7 +275,11 @@ export async function previewStopSignal(projectCwd, deps = {}) {
259
275
  if (!info) {
260
276
  return { stopped: false, message: "No preview is running for this directory." };
261
277
  }
262
- const previewAlive = info.pid !== undefined && isAlive(info.pid);
278
+ // A recycled pid is treated exactly like a dead one: nothing is signalled, the
279
+ // relay is not asked, and the stale marker is cleared.
280
+ const previewAlive = info.pid !== undefined &&
281
+ isAlive(info.pid) &&
282
+ !(await isRecycledPid(info, deps.processStartTime ?? readProcessStartTime));
263
283
  if (previewAlive) {
264
284
  // PRESENCE IS READ BEFORE THE SIGNAL, and the order is the feature. The
265
285
  // creator is about to blank someone's screen, so the count has to describe
@@ -267,7 +287,7 @@ export async function previewStopSignal(projectCwd, deps = {}) {
267
287
  // relay's own teardown and print a number that was true of a preview which
268
288
  // no longer exists. A dead pid is never queried at all — the marker is stale
269
289
  // and there is nothing on that port to ask.
270
- const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
290
+ const relay = deps.relay ?? httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
271
291
  const connected = await readLiveConnections(relay);
272
292
  try {
273
293
  (deps.kill ?? ((pid, sig) => process.kill(pid, sig)))(info.pid, "SIGTERM");
@@ -314,7 +334,7 @@ async function relayForProject(projectCwd, override) {
314
334
  throw new Error("No running preview for this project. Run `vincentt preview` first.");
315
335
  }
316
336
  // The relay listens on 127.0.0.1 only; `localhost` may resolve to ::1 first.
317
- return httpRelayClient(`http://127.0.0.1:${info.relayPort}`);
337
+ return httpRelayClient(`http://127.0.0.1:${info.relayPort}`, info.token);
318
338
  }
319
339
  /** Raw diagnostics query against the running relay (used by CLI --json). */
320
340
  export async function diagQuery(projectCwd, kind, opts = {}, relay) {
@@ -0,0 +1,12 @@
1
+ export type ProcessStartTimeReader = (pid: number) => Promise<string | undefined>;
2
+ /**
3
+ * macOS and Linux both answer `ps -o lstart= -p <pid>` (BSD ps and procps), run
4
+ * under the C locale so the text does not vary with the creator's language.
5
+ * One-second resolution is enough: the process that held the pid had to exit
6
+ * before it could be reused. Windows returns undefined: no marker then carries
7
+ * a start time, and `preview --stop` behaves as it did before the check.
8
+ *
9
+ * Undefined also means "could not read" (no `ps`, a ps without `lstart`), never
10
+ * "different".
11
+ */
12
+ export declare function readProcessStartTime(pid: number, platform?: NodeJS.Platform): Promise<string | undefined>;
@@ -0,0 +1,33 @@
1
+ // A process's start time, read as an opaque token that is only ever compared for
2
+ // equality. Together with the pid it identifies ONE process: a recycled pid has
3
+ // the same number and a different start time.
4
+ import { execFile } from "node:child_process";
5
+ import { promisify } from "node:util";
6
+ const execFileP = promisify(execFile);
7
+ /**
8
+ * macOS and Linux both answer `ps -o lstart= -p <pid>` (BSD ps and procps), run
9
+ * under the C locale so the text does not vary with the creator's language.
10
+ * One-second resolution is enough: the process that held the pid had to exit
11
+ * before it could be reused. Windows returns undefined: no marker then carries
12
+ * a start time, and `preview --stop` behaves as it did before the check.
13
+ *
14
+ * Undefined also means "could not read" (no `ps`, a ps without `lstart`), never
15
+ * "different".
16
+ */
17
+ export async function readProcessStartTime(pid, platform = process.platform) {
18
+ if (!Number.isInteger(pid) || pid <= 0)
19
+ return undefined;
20
+ if (platform === "win32")
21
+ return undefined;
22
+ try {
23
+ const { stdout } = await execFileP("ps", ["-o", "lstart=", "-p", String(pid)], {
24
+ env: { ...process.env, LC_ALL: "C" },
25
+ timeout: 2000,
26
+ });
27
+ const lstart = stdout.trim().replace(/\s+/g, " ");
28
+ return lstart === "" ? undefined : lstart;
29
+ }
30
+ catch {
31
+ return undefined;
32
+ }
33
+ }
@@ -17,6 +17,19 @@ export interface RelayInfo {
17
17
  * only assumes is live. Optional: markers written by an older CLI lack it.
18
18
  */
19
19
  expiresAt?: string;
20
+ /**
21
+ * The preview process's start time (an opaque token from
22
+ * `readProcessStartTime`), so `preview --stop` can tell the preview from an
23
+ * unrelated process that was later given the same pid. Absent from markers
24
+ * written by an older CLI or on a platform with no reader.
25
+ */
26
+ pidStartTime?: string;
27
+ /**
28
+ * The relay's per-session secret for `/query` and `feedback/next`. The marker
29
+ * is written 0600 because of it. Absent from markers written by an older CLI,
30
+ * whose relay asks for none.
31
+ */
32
+ token?: string;
20
33
  }
21
34
  export declare function relayInfoPath(cwd: string): string;
22
35
  /**
@@ -57,7 +70,7 @@ export declare function isProcessAlive(pid: number): boolean;
57
70
  * runs fifty times a day, and the fallback (mint a preview) is what they asked
58
71
  * for anyway.
59
72
  */
60
- export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch): Promise<boolean>;
73
+ export declare function isRelayResponding(port: number, timeoutMs?: number, fetchImpl?: typeof fetch, token?: string): Promise<boolean>;
61
74
  /**
62
75
  * readLivePreview returns the marker ONLY when it still describes a preview that
63
76
  * is genuinely running, and CLEARS it otherwise.
@@ -74,5 +87,5 @@ export declare function isRelayResponding(port: number, timeoutMs?: number, fetc
74
87
  */
75
88
  export declare function readLivePreview(cwd: string, deps?: {
76
89
  processAlive?: (pid: number) => boolean;
77
- relayResponding?: (port: number) => Promise<boolean>;
90
+ relayResponding?: (port: number, token?: string) => Promise<boolean>;
78
91
  }): Promise<RelayInfo | undefined>;
@@ -5,6 +5,7 @@
5
5
  import { promises as fs } from "node:fs";
6
6
  import path from "node:path";
7
7
  import { locateProjectBinding } from "../shared/config.js";
8
+ import { relayAuthHeaders } from "./diagnostics.js";
8
9
  const RELAY_INFO_FILE = "relay.json";
9
10
  const VINCENTT_DIR = ".vincentt";
10
11
  export function relayInfoPath(cwd) {
@@ -28,7 +29,15 @@ export async function relayInfoDir(cwd) {
28
29
  export async function writeRelayInfo(cwd, info) {
29
30
  const p = relayInfoPath(cwd);
30
31
  await fs.mkdir(path.dirname(p), { recursive: true });
31
- await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", "utf8");
32
+ // Removed first so an older 0644 marker is not rewritten in place: `mode`
33
+ // only applies when the file is created, and the token must never be
34
+ // readable by another user.
35
+ await fs.rm(p, { force: true });
36
+ await fs.writeFile(p, JSON.stringify(info, null, 2) + "\n", {
37
+ encoding: "utf8",
38
+ mode: 0o600,
39
+ flag: "wx",
40
+ });
32
41
  return p;
33
42
  }
34
43
  /** Reads the marker for the TREE containing cwd, not only the folder itself. */
@@ -83,7 +92,7 @@ export function isProcessAlive(pid) {
83
92
  * runs fifty times a day, and the fallback (mint a preview) is what they asked
84
93
  * for anyway.
85
94
  */
86
- export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch) {
95
+ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch, token) {
87
96
  if (!Number.isInteger(port) || port <= 0 || port > 65535)
88
97
  return false;
89
98
  const ac = new AbortController();
@@ -91,7 +100,7 @@ export async function isRelayResponding(port, timeoutMs = 700, fetchImpl = fetch
91
100
  try {
92
101
  const res = await fetchImpl(`http://127.0.0.1:${port}/query`, {
93
102
  method: "POST",
94
- headers: { "content-type": "application/json" },
103
+ headers: { "content-type": "application/json", ...relayAuthHeaders(token) },
95
104
  body: JSON.stringify({ kind: "log", limit: 0 }),
96
105
  signal: ac.signal,
97
106
  });
@@ -132,14 +141,15 @@ export async function readLivePreview(cwd, deps = {}) {
132
141
  if (!info)
133
142
  return undefined;
134
143
  const processAlive = deps.processAlive ?? isProcessAlive;
135
- const relayResponding = deps.relayResponding ?? ((p) => isRelayResponding(p));
144
+ const relayResponding = deps.relayResponding ??
145
+ ((p, t) => isRelayResponding(p, undefined, undefined, t));
136
146
  // The cheap gate first: a dead pid needs no network round trip.
137
147
  if (info.pid !== undefined && !processAlive(info.pid)) {
138
148
  await clearRelayInfo(cwd);
139
149
  return undefined;
140
150
  }
141
151
  // The identity gate: alive-but-not-ours (a recycled pid) fails here.
142
- if (!(await relayResponding(info.relayPort))) {
152
+ if (!(await relayResponding(info.relayPort, info.token))) {
143
153
  await clearRelayInfo(cwd);
144
154
  return undefined;
145
155
  }
@@ -8,6 +8,13 @@ export interface ReleaseComponent {
8
8
  * cannot tell blank from forgotten, so the platform must say which one it is.
9
9
  */
10
10
  migration?: ReleaseMigration;
11
+ /**
12
+ * The released commit (40 lowercase hex). ABSENT for rows released before the
13
+ * platform recorded it, and for a value of any other shape: the scaffold
14
+ * verifies a clone against it, so a malformed value must read as "nothing to
15
+ * verify", never as a value to compare.
16
+ */
17
+ commit?: string;
11
18
  }
12
19
  export interface ReleaseMigration {
13
20
  required: boolean;
@@ -19,6 +19,7 @@ import path from "node:path";
19
19
  import { CHECK_TTL_MS } from "../dx/updateCheck.js";
20
20
  import { platformCheckEnabled } from "./offSwitch.js";
21
21
  const CACHE_FILE = "platform-release.json";
22
+ const COMMIT_SHA = /^[0-9a-f]{40}$/;
22
23
  /**
23
24
  * Freshness, with a clock that moved backwards treated as STALE rather than as
24
25
  * fresh forever. Same rule and same TTL as the CLI update check — reused rather
@@ -75,13 +76,15 @@ export function readPlatformRelease(raw) {
75
76
  for (const [name, value] of Object.entries(components)) {
76
77
  if (typeof value !== "object" || value === null)
77
78
  continue;
78
- const { version, migration } = value;
79
+ const { version, migration, commit } = value;
79
80
  if (typeof version !== "string" || version === "")
80
81
  continue;
81
82
  const component = { version };
82
83
  const note = readMigration(migration);
83
84
  if (note !== undefined)
84
85
  component.migration = note;
86
+ if (typeof commit === "string" && COMMIT_SHA.test(commit))
87
+ component.commit = commit;
85
88
  out[name] = component;
86
89
  }
87
90
  const parsed = { release, components: out };
@@ -331,7 +331,7 @@ export function stoppedElsewhere() {
331
331
  export function endedDisconnected() {
332
332
  return [
333
333
  "",
334
- "✗ Preview ended — the connection to Vincentt closed.",
334
+ "✗ Preview ended. The connection to Vincentt closed.",
335
335
  "",
336
336
  " This window can't tell whether that was your network, your machine going to",
337
337
  " sleep, or something on our side. Nothing is wrong with your project.",
@@ -375,7 +375,7 @@ export function hardMaxWarning(clockTime, minutesLeft) {
375
375
  export function hardMaxReached() {
376
376
  return [
377
377
  "",
378
- "✓ Preview stopped — 12-hour maximum reached. The link no longer works.",
378
+ "✓ Preview stopped. 12-hour maximum reached. The link no longer works.",
379
379
  "",
380
380
  " Start a new one: vincentt preview",
381
381
  " For an address that lasts, publish it: vincentt publish",
@@ -435,7 +435,7 @@ export function supersededWinner() {
435
435
  export function endedUnreachable(orgName) {
436
436
  return [
437
437
  "",
438
- "✗ Preview ended — this project is no longer available to you.",
438
+ "✗ Preview ended. This project is no longer available to you.",
439
439
  "",
440
440
  ` Your access to ${orgName} may have changed. Check who you are: vincentt whoami`,
441
441
  "",
@@ -458,7 +458,7 @@ export function endedAuthExpired() {
458
458
  export function endedUnconfirmed() {
459
459
  return [
460
460
  "",
461
- "✗ Preview ended — couldn't confirm your access to this project.",
461
+ "✗ Preview ended. Couldn't confirm your access to this project.",
462
462
  "",
463
463
  " Try again: vincentt preview",
464
464
  "",
@@ -498,7 +498,7 @@ export function endedUnconfirmed() {
498
498
  export function endedAbandoned() {
499
499
  return [
500
500
  "",
501
- "✗ Preview ended — this machine stopped checking in with Vincentt.",
501
+ "✗ Preview ended. This machine stopped checking in with Vincentt.",
502
502
  "",
503
503
  " The preview needed this computer to stay in touch and it went quiet, so the",
504
504
  " link was released. Nothing is wrong with your project.",
@@ -526,7 +526,7 @@ export function egressSoft() {
526
526
  export function egressHard() {
527
527
  return [
528
528
  "",
529
- "✗ Preview ended — it reached the 5 GB ceiling.",
529
+ "✗ Preview ended. It reached the 5 GB ceiling.",
530
530
  "",
531
531
  " Start a new one: vincentt preview",
532
532
  "",
@@ -136,6 +136,8 @@ export interface RunningPreview {
136
136
  /** ISO8601, the hard max. Never extended by a heartbeat. */
137
137
  expiresAt: string;
138
138
  relayPort: number;
139
+ /** The relay's `/query` and `feedback/next` secret. Goes only into the 0600 marker. */
140
+ relayToken: string;
139
141
  appPort: number;
140
142
  /** Whether the app was responding when we returned. */
141
143
  appReady: boolean;
@@ -18,6 +18,7 @@ import { realpath } from "node:fs/promises";
18
18
  import { createServer } from "node:http";
19
19
  import { TunnelClient, resolveForwardTarget, } from "@vincentt-xr/harness/tunnel";
20
20
  import { startRelay } from "../relay/server.js";
21
+ import { mintRelayToken } from "../relay/guard.js";
21
22
  import { isHiddenAppPath, proxyWeb, proxyWs, relayRoute } from "./proxy.js";
22
23
  import { getFreePort, isPortListening, waitForApp } from "./net.js";
23
24
  import { endPreviewSession, heartbeatPreviewSession, mintPreviewSession, workingTreeKey, PreviewEndedError, } from "./api.js";
@@ -37,6 +38,7 @@ export async function startPreview(opts) {
37
38
  const relayPort = opts.relayPort ?? (await getFreePort());
38
39
  const frontPort = opts.frontPort ?? (await getFreePort());
39
40
  const reuseApp = await isPortListening(appPort);
41
+ const relayToken = mintRelayToken();
40
42
  const hiddenRoot = await realpath(projectRoot).catch(() => projectRoot);
41
43
  let app;
42
44
  let killApp;
@@ -136,9 +138,13 @@ export async function startPreview(opts) {
136
138
  process.once("exit", killApp);
137
139
  }
138
140
  }
141
+ // Fixed here, from the server-computed URL, and never from a request.
142
+ const previewOrigin = new URL(minted.url).origin;
139
143
  relay = startRelay({
140
144
  port: relayPort,
141
145
  path: harnessPath,
146
+ allowedOrigins: [previewOrigin],
147
+ token: relayToken,
142
148
  // Annotations land beside the binding, so `vincentt feedback` finds them
143
149
  // from anywhere in the tree rather than only from the folder that started
144
150
  // the preview.
@@ -149,8 +155,6 @@ export async function startPreview(opts) {
149
155
  onCapReached: opts.onCapReached,
150
156
  onAnnotation: opts.onAnnotation,
151
157
  });
152
- // Fixed here, from the server-computed URL, and never from a request.
153
- const previewOrigin = new URL(minted.url).origin;
154
158
  front = createServer((req, res) => {
155
159
  const route = relayRoute(req.url ?? "/", harnessPath, "http");
156
160
  if (route === "refuse") {
@@ -253,6 +257,7 @@ export async function startPreview(opts) {
253
257
  sessionId: minted.sessionId,
254
258
  expiresAt: minted.expiresAt,
255
259
  relayPort,
260
+ relayToken,
256
261
  appPort,
257
262
  appReady,
258
263
  stop,
@@ -0,0 +1,23 @@
1
+ /** The header carrying the per-session token on the local-only routes. */
2
+ export declare const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
3
+ export declare function mintRelayToken(): string;
4
+ /**
5
+ * Host must name the loopback socket the relay bound. A DNS-rebinding page
6
+ * reaches the same socket under its own hostname, and this is where it stops.
7
+ */
8
+ export declare function isAllowedHost(raw: string | string[] | undefined, port: number, bindHost?: string): boolean;
9
+ /**
10
+ * No Origin is a non-browser caller (the CLI, the tunnel's own requests).
11
+ * Otherwise only an exact configured origin passes, or, when none is
12
+ * configured, a page served from this machine's loopback. The literal `null`
13
+ * origin (sandboxed frames, file pages) is a browser and is always refused.
14
+ */
15
+ export declare function isAllowedOrigin(raw: string | string[] | undefined, allowed: readonly string[] | undefined): boolean;
16
+ export declare function isLoopbackOrigin(raw: string): boolean;
17
+ /**
18
+ * A cross-site form or `no-cors` fetch can only send `text/plain`, form or
19
+ * multipart bodies without a preflight. Requiring JSON forces a preflight the
20
+ * relay never answers.
21
+ */
22
+ export declare function isJsonContentType(raw: string | undefined): boolean;
23
+ export declare function hasRelayToken(raw: string | string[] | undefined, token: string): boolean;
@@ -0,0 +1,73 @@
1
+ // The relay's request gate. Every caller the relay serves is on this machine:
2
+ // the CLI (no Origin), the tunnel client and the front proxy (both rewrite Host
3
+ // to the loopback target), and the preview page itself (its own minted origin).
4
+ // A web page the creator happens to have open is none of those, and these checks
5
+ // are what tell it apart.
6
+ import { randomBytes, timingSafeEqual } from "node:crypto";
7
+ /** The header carrying the per-session token on the local-only routes. */
8
+ export const RELAY_TOKEN_HEADER = "x-vincentt-relay-token";
9
+ export function mintRelayToken() {
10
+ return randomBytes(32).toString("base64url");
11
+ }
12
+ /**
13
+ * Host must name the loopback socket the relay bound. A DNS-rebinding page
14
+ * reaches the same socket under its own hostname, and this is where it stops.
15
+ */
16
+ export function isAllowedHost(raw, port, bindHost = "127.0.0.1") {
17
+ if (typeof raw !== "string")
18
+ return false;
19
+ const host = raw.toLowerCase();
20
+ const allowed = new Set([`127.0.0.1:${port}`, `localhost:${port}`]);
21
+ if (bindHost === "::1")
22
+ allowed.add(`[::1]:${port}`);
23
+ else
24
+ allowed.add(`${bindHost.toLowerCase()}:${port}`);
25
+ return allowed.has(host);
26
+ }
27
+ /**
28
+ * No Origin is a non-browser caller (the CLI, the tunnel's own requests).
29
+ * Otherwise only an exact configured origin passes, or, when none is
30
+ * configured, a page served from this machine's loopback. The literal `null`
31
+ * origin (sandboxed frames, file pages) is a browser and is always refused.
32
+ */
33
+ export function isAllowedOrigin(raw, allowed) {
34
+ if (raw === undefined)
35
+ return true;
36
+ if (typeof raw !== "string")
37
+ return false;
38
+ if (allowed !== undefined)
39
+ return allowed.includes(raw);
40
+ return isLoopbackOrigin(raw);
41
+ }
42
+ export function isLoopbackOrigin(raw) {
43
+ let url;
44
+ try {
45
+ url = new URL(raw);
46
+ }
47
+ catch {
48
+ return false;
49
+ }
50
+ if (url.origin !== raw)
51
+ return false;
52
+ if (url.protocol !== "http:" && url.protocol !== "https:")
53
+ return false;
54
+ return ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
55
+ }
56
+ /**
57
+ * A cross-site form or `no-cors` fetch can only send `text/plain`, form or
58
+ * multipart bodies without a preflight. Requiring JSON forces a preflight the
59
+ * relay never answers.
60
+ */
61
+ export function isJsonContentType(raw) {
62
+ if (raw === undefined)
63
+ return false;
64
+ const type = raw.split(";")[0]?.trim().toLowerCase();
65
+ return type === "application/json";
66
+ }
67
+ export function hasRelayToken(raw, token) {
68
+ if (typeof raw !== "string")
69
+ return false;
70
+ const a = Buffer.from(raw);
71
+ const b = Buffer.from(token);
72
+ return a.length === b.length && timingSafeEqual(a, b);
73
+ }
@@ -57,6 +57,19 @@ export interface RelayOptions {
57
57
  onAnnotation?: (tags: EventTags) => void;
58
58
  /** Injected for tests, so cap behavior is drivable without 64 real clients. */
59
59
  testerRegistry?: TesterRegistry;
60
+ /**
61
+ * Browser origins allowed on the WebSocket and the annotation POST: the
62
+ * preview's own minted origin. A request with no Origin (the CLI) always
63
+ * passes; any other origin is refused. Omitted, only pages served from
64
+ * loopback pass, which is what a standalone `vincentt relay` serves.
65
+ */
66
+ allowedOrigins?: readonly string[];
67
+ /**
68
+ * The per-session secret `/query` and `feedback/next` require in
69
+ * `x-vincentt-relay-token`. The preview always sets it; without it those
70
+ * routes are still loopback-Host and no-Origin only.
71
+ */
72
+ token?: string;
60
73
  }
61
74
  /** Who produced a batch: the connection's viewer label and its resolved tester. */
62
75
  export interface EventTags {
@@ -9,6 +9,7 @@ import { WebSocketServer } from "ws";
9
9
  import { EventStore } from "./store.js";
10
10
  import { TesterRegistry, isTesterId } from "./testers.js";
11
11
  import { AnnotationStore } from "./annotations.js";
12
+ import { RELAY_TOKEN_HEADER, hasRelayToken, isAllowedHost, isAllowedOrigin, isJsonContentType, } from "./guard.js";
12
13
  import { VIEWER_LABEL_RE, VIEWER_STAMP_HEADER } from "@vincentt-xr/harness/tunnel";
13
14
  /** Cap an annotation POST body so a bad/huge upload can't exhaust memory. */
14
15
  const MAX_ANNOTATION_BYTES = 25 * 1024 * 1024;
@@ -45,7 +46,17 @@ export function startRelay(opts = {}) {
45
46
  }
46
47
  return { connections, testers: open.size };
47
48
  };
49
+ const host = opts.host ?? "127.0.0.1";
50
+ const allowedOrigins = opts.allowedOrigins;
51
+ const boundPort = () => {
52
+ const addr = http.address();
53
+ return typeof addr === "object" && addr !== null ? addr.port : port;
54
+ };
48
55
  const http = createServer((req, res) => handleHttp(req, res, {
56
+ host,
57
+ port: boundPort(),
58
+ allowedOrigins,
59
+ token: opts.token,
49
60
  store,
50
61
  annotations,
51
62
  testers,
@@ -56,7 +67,23 @@ export function startRelay(opts = {}) {
56
67
  onCapReached: opts.onCapReached,
57
68
  onAnnotation: opts.onAnnotation,
58
69
  }));
59
- const wss = new WebSocketServer({ server: http, path: wsPath });
70
+ const wss = new WebSocketServer({
71
+ server: http,
72
+ path: wsPath,
73
+ verifyClient: ({ req }, done) => {
74
+ if (!isAllowedHost(req.headers.host, boundPort(), host)) {
75
+ log("refused a WebSocket with a foreign Host");
76
+ done(false, 403);
77
+ return;
78
+ }
79
+ if (!isAllowedOrigin(req.headers.origin, allowedOrigins)) {
80
+ log("refused a WebSocket from a foreign Origin");
81
+ done(false, 403);
82
+ return;
83
+ }
84
+ done(true);
85
+ },
86
+ });
60
87
  wss.on("connection", (ws, req) => {
61
88
  // The label comes from the CONNECTION, stamped by the tunnel client on this
62
89
  // machine after it re-validated the edge's value. It is re-validated ONCE
@@ -117,7 +144,6 @@ export function startRelay(opts = {}) {
117
144
  });
118
145
  ws.on("close", () => log("client disconnected"));
119
146
  });
120
- const host = opts.host ?? "127.0.0.1";
121
147
  http.listen(port, host, () => log(`relay listening on ${host}:${port} (ws ${wsPath}, http /query)`));
122
148
  return {
123
149
  store,
@@ -177,6 +203,28 @@ function handleHttp(req, res, ctx) {
177
203
  res.writeHead(404).end();
178
204
  return;
179
205
  }
206
+ if (!isAllowedHost(req.headers.host, ctx.port, ctx.host)) {
207
+ ctx.log("refused a request with a foreign Host");
208
+ res.writeHead(403).end();
209
+ return;
210
+ }
211
+ // The local-only routes have no browser caller at all, so any Origin is foreign.
212
+ const origins = localOnly ? [] : ctx.allowedOrigins;
213
+ if (!isAllowedOrigin(req.headers.origin, origins)) {
214
+ ctx.log("refused a request from a foreign Origin");
215
+ res.writeHead(403).end();
216
+ return;
217
+ }
218
+ if (localOnly && ctx.token !== undefined) {
219
+ if (!hasRelayToken(req.headers[RELAY_TOKEN_HEADER], ctx.token)) {
220
+ res.writeHead(401).end();
221
+ return;
222
+ }
223
+ }
224
+ if (req.method === "POST" && !isJsonContentType(req.headers["content-type"])) {
225
+ res.writeHead(415).end();
226
+ return;
227
+ }
180
228
  if (p === "/query") {
181
229
  handleQuery(req, res, url, ctx);
182
230
  return;
@@ -191,8 +239,8 @@ function handleHttp(req, res, ctx) {
191
239
  }
192
240
  res.writeHead(404).end();
193
241
  }
194
- // The MCP server / CLI (same laptop) GETs/POSTs /query with a RelayQuery. Localhost
195
- // only; no auth in the local dev loop (the platform phase adds it).
242
+ // The CLI (same laptop) GETs/POSTs /query with a RelayQuery, carrying the
243
+ // session token from the 0600 relay marker.
196
244
  function handleQuery(req, res, url, ctx) {
197
245
  readQuery(req, url)
198
246
  .then((q) => {
@@ -9,6 +9,11 @@ export declare const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template
9
9
  *
10
10
  * A creator pins a known version with `--template 1.0.0`; version tags are
11
11
  * immutable, which is what makes that an escape hatch rather than a coin flip.
12
+ *
13
+ * Asked for this ref, the scaffold clones the platform's released version tag
14
+ * instead when the platform records that release's commit, and refuses a clone
15
+ * that lands anywhere else (see `releasedCommit.ts`). The moving tag is cloned
16
+ * only when there is nothing to check it against.
12
17
  */
13
18
  export declare const DEFAULT_TEMPLATE_REF = "latest";
14
19
  /**
@@ -51,6 +56,12 @@ export interface ScaffoldOptions {
51
56
  ref?: string;
52
57
  /** Test seam — defaults to spawning real git. */
53
58
  runGit?: GitRunner;
59
+ /** API origin for the release lookup. Defaults to the machine's configured one. */
60
+ apiUrl?: string;
61
+ /** Test seam for the release lookup — defaults to global `fetch`. */
62
+ fetchImpl?: typeof fetch;
63
+ /** Where the unverified-template warning goes. Defaults to stderr. */
64
+ warn?: (line: string) => void;
54
65
  }
55
66
  /**
56
67
  * The concrete version tag the clone actually landed on, or `undefined` when the
@@ -8,6 +8,7 @@ import { promises as fs } from "node:fs";
8
8
  import os from "node:os";
9
9
  import path from "node:path";
10
10
  import { promisify } from "node:util";
11
+ import { TemplateCommitMismatchError, UNVERIFIED_TEMPLATE_WARNING, resolveTemplatePin, } from "./releasedCommit.js";
11
12
  const execFileAsync = promisify(execFile);
12
13
  export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
13
14
  /**
@@ -20,6 +21,11 @@ export const TEMPLATE_REPO = "https://github.com/vincentt-xr/v2-template.git";
20
21
  *
21
22
  * A creator pins a known version with `--template 1.0.0`; version tags are
22
23
  * immutable, which is what makes that an escape hatch rather than a coin flip.
24
+ *
25
+ * Asked for this ref, the scaffold clones the platform's released version tag
26
+ * instead when the platform records that release's commit, and refuses a clone
27
+ * that lands anywhere else (see `releasedCommit.ts`). The moving tag is cloned
28
+ * only when there is nothing to check it against.
23
29
  */
24
30
  export const DEFAULT_TEMPLATE_REF = "latest";
25
31
  const realGit = async (args) => {
@@ -128,10 +134,21 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
128
134
  const repo = opts.repo ?? TEMPLATE_REPO;
129
135
  const ref = opts.ref ?? DEFAULT_TEMPLATE_REF;
130
136
  const runGit = opts.runGit ?? realGit;
137
+ // Only the built-in template has a platform release to check against; a
138
+ // gallery source (`init --from`) is another repository entirely.
139
+ const pin = repo === TEMPLATE_REPO
140
+ ? await resolveTemplatePin(ref, DEFAULT_TEMPLATE_REF, {
141
+ apiUrl: opts.apiUrl,
142
+ fetchImpl: opts.fetchImpl,
143
+ })
144
+ : { ref, unverified: false };
145
+ if (pin.unverified) {
146
+ (opts.warn ?? ((line) => process.stderr.write(`${line}\n`)))(UNVERIFIED_TEMPLATE_WARNING);
147
+ }
131
148
  const tmp = await fs.mkdtemp(path.join(os.tmpdir(), "vincentt-tmpl-"));
132
149
  try {
133
150
  try {
134
- await runGit(["clone", "--depth", "1", "--branch", ref, repo, tmp]);
151
+ await runGit(["clone", "--depth", "1", "--branch", pin.ref, repo, tmp]);
135
152
  }
136
153
  catch (err) {
137
154
  const msg = err instanceof Error ? err.message : String(err);
@@ -140,11 +157,26 @@ export async function scaffoldFromTemplate(targetDir, opts = {}) {
140
157
  // real cause is a typo'd version). git names the ref in this specific case,
141
158
  // so split on that rather than reporting one cause for both.
142
159
  if (/(remote branch|Remote branch).*not found|couldn't find remote ref/i.test(msg)) {
143
- throw new ScaffoldRefError(`The template has no version "${ref}". ` +
144
- `Check the tag exists, or omit --template to use the latest release.`);
160
+ throw new ScaffoldRefError(pin.ref === ref
161
+ ? `The template has no version "${ref}". ` +
162
+ `Check the tag exists, or omit --template to use the latest release.`
163
+ : `The template has no tag for its released version "${pin.ref}". ` +
164
+ `Report this to the Vincentt team.`);
145
165
  }
146
166
  throw new Error(`Could not clone the ${repo} template (check your git access): ${msg}`);
147
167
  }
168
+ if (pin.expectedCommit !== undefined) {
169
+ let actual;
170
+ try {
171
+ actual = String((await runGit(["-C", tmp, "rev-parse", "HEAD"])) ?? "").trim();
172
+ }
173
+ catch {
174
+ actual = "";
175
+ }
176
+ if (actual !== pin.expectedCommit) {
177
+ throw new TemplateCommitMismatchError(pin.releasedVersion ?? pin.ref, pin.expectedCommit, actual || "(could not be read)");
178
+ }
179
+ }
148
180
  // BEFORE the strip below — `.git` is the only place the concrete tag is
149
181
  // readable, and the copy into the target deliberately does not carry it.
150
182
  // A failure here must not fail the scaffold: the files are correct either
@@ -0,0 +1,37 @@
1
+ export interface TemplatePin {
2
+ /** The tag to clone. */
3
+ ref: string;
4
+ /** Present when the clone must be checked; the clone fails on any other commit. */
5
+ expectedCommit?: string;
6
+ /** The version the platform released, named in the mismatch error. */
7
+ releasedVersion?: string;
8
+ /** True when the default ref is cloned with nothing to check it against. */
9
+ unverified: boolean;
10
+ }
11
+ export interface PinOptions {
12
+ /** The API origin. Defaults to the machine's configured one. */
13
+ apiUrl?: string;
14
+ /** Test seam, passed through to `fetchRelease`. */
15
+ fetchImpl?: typeof fetch;
16
+ }
17
+ /**
18
+ * Resolve the pin for a scaffold of the built-in template.
19
+ *
20
+ * `defaultRef` is the moving tag. Asked for it, the platform's release version
21
+ * is cloned in its place when that release carries a commit; otherwise the
22
+ * moving tag is cloned as before and the result says it went unverified. An
23
+ * explicit version is checked only when it is the version the platform
24
+ * released, since older version tags are protected by the tag ruleset and the
25
+ * platform holds no commit for them.
26
+ */
27
+ export declare function resolveTemplatePin(requested: string, defaultRef: string, opts?: PinOptions): Promise<TemplatePin>;
28
+ /** The one-line stderr warning for a default-ref scaffold with nothing to check. */
29
+ export declare const UNVERIFIED_TEMPLATE_WARNING = "! The template could not be verified against the platform release; cloned the latest tag unchecked.";
30
+ /**
31
+ * The cloned commit is not the one the platform released. Nothing is written
32
+ * and there is no fallback: a moved tag is exactly what this check exists to
33
+ * catch, and retrying `latest` would clone the same suspect tree unchecked.
34
+ */
35
+ export declare class TemplateCommitMismatchError extends Error {
36
+ constructor(version: string, expected: string, actual: string);
37
+ }
@@ -0,0 +1,71 @@
1
+ // Which template ref to clone, and which commit it must land on.
2
+ //
3
+ // The template's tags live in a public repo whose admins can move them by hand,
4
+ // so the clone alone proves nothing. The platform records each template
5
+ // release's commit independently (`GET /platform/versions`), and a scaffold of
6
+ // the default ref clones THAT release's version tag and checks the commit.
7
+ import { TEMPLATE_COMPONENT } from "../platform/allowlist.js";
8
+ import { fetchRelease } from "../platform/releaseFetch.js";
9
+ import { loadMachineConfig, machineApiUrl } from "../shared/config.js";
10
+ /** Same shape the scaffold accepts as a concrete version tag. */
11
+ const CONCRETE_VERSION = /^[0-9]+\.[0-9]+\.[0-9]+$/;
12
+ /**
13
+ * Resolve the pin for a scaffold of the built-in template.
14
+ *
15
+ * `defaultRef` is the moving tag. Asked for it, the platform's release version
16
+ * is cloned in its place when that release carries a commit; otherwise the
17
+ * moving tag is cloned as before and the result says it went unverified. An
18
+ * explicit version is checked only when it is the version the platform
19
+ * released, since older version tags are protected by the tag ruleset and the
20
+ * platform holds no commit for them.
21
+ */
22
+ export async function resolveTemplatePin(requested, defaultRef, opts = {}) {
23
+ const row = await fetchTemplateRow(opts);
24
+ const verifiable = row !== undefined && row.commit !== undefined && CONCRETE_VERSION.test(row.version);
25
+ if (requested === defaultRef) {
26
+ if (!verifiable)
27
+ return { ref: defaultRef, unverified: true };
28
+ return {
29
+ ref: row.version,
30
+ expectedCommit: row.commit,
31
+ releasedVersion: row.version,
32
+ unverified: false,
33
+ };
34
+ }
35
+ if (verifiable && requested === row.version) {
36
+ return {
37
+ ref: requested,
38
+ expectedCommit: row.commit,
39
+ releasedVersion: row.version,
40
+ unverified: false,
41
+ };
42
+ }
43
+ return { ref: requested, unverified: false };
44
+ }
45
+ async function fetchTemplateRow(opts) {
46
+ try {
47
+ const apiUrl = opts.apiUrl ?? machineApiUrl(await loadMachineConfig());
48
+ const fetched = await fetchRelease({ apiUrl, fetchImpl: opts.fetchImpl });
49
+ return fetched?.release.components[TEMPLATE_COMPONENT];
50
+ }
51
+ catch {
52
+ // An unreadable machine config is the same as an unreachable platform.
53
+ return undefined;
54
+ }
55
+ }
56
+ /** The one-line stderr warning for a default-ref scaffold with nothing to check. */
57
+ export const UNVERIFIED_TEMPLATE_WARNING = "! The template could not be verified against the platform release; cloned the latest tag unchecked.";
58
+ /**
59
+ * The cloned commit is not the one the platform released. Nothing is written
60
+ * and there is no fallback: a moved tag is exactly what this check exists to
61
+ * catch, and retrying `latest` would clone the same suspect tree unchecked.
62
+ */
63
+ export class TemplateCommitMismatchError extends Error {
64
+ constructor(version, expected, actual) {
65
+ super(`The template did not match the released version ${version}.\n` +
66
+ ` Expected commit ${expected}\n` +
67
+ ` Actual commit ${actual}\n\n` +
68
+ ` Nothing was written. Report this to the Vincentt team.`);
69
+ this.name = "TemplateCommitMismatchError";
70
+ }
71
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@vincentt-xr/cli",
3
- "version": "0.18.1",
3
+ "version": "0.18.3",
4
4
  "description": "Vincentt CLI — the vincentt command-line dev loop (login, create, publish, preview, diagnostics, feedback, relay)",
5
5
  "license": "UNLICENSED",
6
6
  "type": "module",