@vincentt-xr/cli 0.18.0 → 0.18.2

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,22 @@
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.2
7
+
8
+ required: false
9
+ No action required. Behaviour that changed:
10
+ - `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.
11
+ - `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.
12
+ - 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.
13
+ - `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.
14
+
15
+ ## 0.18.1
16
+
17
+ required: false
18
+ No action required. Behaviour that changed:
19
+ - `publish` re-opens the upload when an upload link expires (at most twice) instead of failing. Network errors and S3 5xx are retried with backoff. A refused upload reports "changed while uploading" only when S3 says the bytes do not match; other refusals name the failed file.
20
+ - A publish that fails on the network leaves its upload open, so running `vincentt publish` again sends only the files that have not landed yet (on an API that supports it). Files stream from disk instead of being read into memory.
21
+
6
22
  ## 0.18.0
7
23
 
8
24
  required: false
@@ -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,7 @@ 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),
176
178
  });
177
179
  return { preview, started: true };
178
180
  }
@@ -199,6 +201,19 @@ function isAlive(pid) {
199
201
  }
200
202
  /** The bound the caller is held to: one query, no retry, isRelayResponding's window. */
201
203
  const PRESENCE_TIMEOUT_MS = 700;
204
+ /**
205
+ * True only when the marker recorded a start time and the live process holding
206
+ * that pid PROVABLY started at a different time. A marker from an older CLI, or
207
+ * a start time that cannot be read, proves nothing, so the stop goes ahead as it
208
+ * always did. The relay is deliberately not consulted: QA-F14-06 requires the
209
+ * stop to happen whatever the relay answers.
210
+ */
211
+ async function isRecycledPid(info, read) {
212
+ if (info.pidStartTime === undefined)
213
+ return false;
214
+ const live = await read(info.pid);
215
+ return live !== undefined && live !== info.pidStartTime;
216
+ }
202
217
  /**
203
218
  * How many connections are attached, or UNDEFINED when that cannot be
204
219
  * established — and undefined is a first-class answer here, not an error.
@@ -259,7 +274,11 @@ export async function previewStopSignal(projectCwd, deps = {}) {
259
274
  if (!info) {
260
275
  return { stopped: false, message: "No preview is running for this directory." };
261
276
  }
262
- const previewAlive = info.pid !== undefined && isAlive(info.pid);
277
+ // A recycled pid is treated exactly like a dead one: nothing is signalled, the
278
+ // relay is not asked, and the stale marker is cleared.
279
+ const previewAlive = info.pid !== undefined &&
280
+ isAlive(info.pid) &&
281
+ !(await isRecycledPid(info, deps.processStartTime ?? readProcessStartTime));
263
282
  if (previewAlive) {
264
283
  // PRESENCE IS READ BEFORE THE SIGNAL, and the order is the feature. The
265
284
  // creator is about to blank someone's screen, so the count has to describe
@@ -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,13 @@ 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;
20
27
  }
21
28
  export declare function relayInfoPath(cwd: string): string;
22
29
  /**
@@ -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
  "",
@@ -1,3 +1,5 @@
1
+ /** Re-opens after an expired upload link, per publish, before giving up. */
2
+ export declare const MAX_REOPENS = 2;
1
3
  import type { ResolvedConfig } from "../shared/config.js";
2
4
  import { type BuiltWith } from "../platform/resolve.js";
3
5
  import { type VincenttJson } from "../lib/settings.js";
@@ -15,7 +15,9 @@ 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 { UploadFailed, uploadAll } from "./upload.js";
18
+ import { UploadExpired, UploadFailed, uploadAll } from "./upload.js";
19
+ /** Re-opens after an expired upload link, per publish, before giving up. */
20
+ export const MAX_REOPENS = 2;
19
21
  import { waitUntilServing } from "./visibility.js";
20
22
  import * as copy from "./copy.js";
21
23
  import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
@@ -257,16 +259,20 @@ async function runPublish(deps, opts, onCommitted) {
257
259
  });
258
260
  }
259
261
  async function runUpload(deps, cfg, i) {
260
- let opened;
261
- try {
262
- opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
- }
264
- catch (err) {
262
+ const open = () => openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
263
+ const openFailed = (err) => {
265
264
  if (err instanceof AuthExpiredError)
266
265
  return authExpired(deps);
267
266
  for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
268
267
  deps.print(l);
269
268
  return 1;
269
+ };
270
+ let opened;
271
+ try {
272
+ opened = await open();
273
+ }
274
+ catch (err) {
275
+ return openFailed(err);
270
276
  }
271
277
  deps.print(copy.uploadStart(i.manifest.files.length));
272
278
  const progress = createProgress({
@@ -276,24 +282,53 @@ async function runUpload(deps, cfg, i) {
276
282
  now: deps.now,
277
283
  });
278
284
  const startedAt = deps.now();
279
- try {
280
- await uploadAll({
281
- distDir: i.distDir,
282
- targets: opened.uploads,
283
- manifest: i.manifest.files,
284
- onProgress: (p) => progress.update(p),
285
- fetchImpl: i.uploadFetch,
286
- });
285
+ let reopens = 0;
286
+ let uploadErr;
287
+ for (;;) {
288
+ try {
289
+ await uploadAll({
290
+ distDir: i.distDir,
291
+ targets: opened.uploads,
292
+ manifest: i.manifest.files,
293
+ onProgress: (p) => progress.update(p),
294
+ fetchImpl: i.uploadFetch,
295
+ sleep: deps.sleep,
296
+ });
297
+ break;
298
+ }
299
+ catch (err) {
300
+ if (!(err instanceof UploadExpired) || reopens >= MAX_REOPENS) {
301
+ uploadErr = err;
302
+ break;
303
+ }
304
+ }
305
+ // An expired link: re-open for fresh URLs. An api that resumes returns the
306
+ // same upload with only the files still missing; one that does not returns
307
+ // a new upload and every file is sent again.
308
+ reopens += 1;
309
+ try {
310
+ opened = await open();
311
+ }
312
+ catch (err) {
313
+ progress.finish();
314
+ return openFailed(err);
315
+ }
287
316
  }
288
- catch (err) {
317
+ if (uploadErr !== undefined) {
318
+ const err = uploadErr;
289
319
  progress.finish();
290
- await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
320
+ // A retryable failure (the network, S3 5xx, links that kept expiring) keeps
321
+ // the upload open so the next `vincentt publish` resumes it and sends only
322
+ // what is missing. Anything else is abandoned.
323
+ const resumable = err instanceof UploadExpired || (err instanceof UploadFailed && err.retryable);
324
+ if (!resumable)
325
+ await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
291
326
  if (err instanceof UploadFailed && err.badDigest) {
292
327
  for (const l of copy.badDigest(err.path, i.address, i.liveVersion))
293
328
  deps.print(l);
294
329
  return 1;
295
330
  }
296
- const failedPath = err instanceof UploadFailed ? err.path : "a file";
331
+ const failedPath = err instanceof UploadFailed || err instanceof UploadExpired ? err.path : "a file";
297
332
  deps.print("");
298
333
  deps.print(`✗ Uploading ${failedPath} failed.`);
299
334
  deps.print("");
@@ -4,7 +4,23 @@ export declare class UploadFailed extends Error {
4
4
  readonly path: string;
5
5
  readonly status: number;
6
6
  readonly badDigest: boolean;
7
- constructor(path: string, status: number, badDigest: boolean);
7
+ /** S3's own error code, when the response carried one. */
8
+ readonly s3Code?: string | undefined;
9
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
10
+ readonly retryable: boolean;
11
+ constructor(path: string, status: number, badDigest: boolean,
12
+ /** S3's own error code, when the response carried one. */
13
+ s3Code?: string | undefined,
14
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
15
+ retryable?: boolean);
16
+ }
17
+ /**
18
+ * A presigned URL was refused because it expired. The upload has to be
19
+ * re-opened for fresh URLs; nothing about the file is wrong.
20
+ */
21
+ export declare class UploadExpired extends Error {
22
+ readonly path: string;
23
+ constructor(path: string);
8
24
  }
9
25
  export interface UploadProgress {
10
26
  filesDone: number;
@@ -19,18 +35,50 @@ export interface UploadOptions {
19
35
  concurrency?: number;
20
36
  onProgress?: (p: UploadProgress) => void;
21
37
  fetchImpl?: typeof fetch;
22
- /** Per-file retries for a transient network/5xx failure. A BadDigest is retried once. */
38
+ /** Attempts per file for a transport error or 5xx. A BadDigest is retried once. */
23
39
  maxAttempts?: number;
40
+ /** Test seams for the backoff. */
41
+ sleep?: (ms: number) => Promise<void>;
42
+ random?: () => number;
24
43
  }
25
44
  /**
26
45
  * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
27
46
  * deadline would fail every large file on a slow uplink; none at all lets a
28
- * stalled connection hang the publish forever.
47
+ * stalled connection hang the publish forever. The api sizes the upload window
48
+ * at the same rate, so a PUT this deadline still waits for is never refused by
49
+ * an expired link.
29
50
  */
30
51
  export declare function putDeadlineMs(bytes: number): number;
52
+ /** Exponential backoff with full jitter: uniform in [0, min(cap, base * 2^(n-1))). */
53
+ export declare function backoffMs(attempt: number, random?: () => number): number;
54
+ export type S3Outcome = {
55
+ kind: "ok";
56
+ } | {
57
+ kind: "expired";
58
+ } | {
59
+ kind: "bad_digest";
60
+ code: string;
61
+ } | {
62
+ kind: "retry";
63
+ code?: string;
64
+ } | {
65
+ kind: "fatal";
66
+ code?: string;
67
+ };
68
+ /**
69
+ * What a PUT's response means, read from S3's own error code rather than the
70
+ * status alone. A 400 is many things — S3 answers a stalled body with 400
71
+ * RequestTimeout — and only BadDigest means the bytes on disk are not the bytes
72
+ * that were hashed.
73
+ */
74
+ export declare function classifyS3Response(status: number, body: string): S3Outcome;
31
75
  /**
32
- * PUT every file. A re-run that finds files already landed simply uploads
33
- * fewer — resumption is silent, because the creator did not ask for a resume,
34
- * they asked to publish.
76
+ * PUT every target. The api returns targets only for files not yet landed when
77
+ * it resumes an open upload, so a re-run simply uploads fewer — resumption is
78
+ * silent, because the creator did not ask for a resume, they asked to publish.
79
+ *
80
+ * On the first file that cannot land, no new PUT starts; the ones in flight
81
+ * finish, and the first failure is thrown (UploadExpired only when nothing
82
+ * worse happened, since re-opening cannot fix a changed file).
35
83
  */
36
84
  export declare function uploadAll(opts: UploadOptions): Promise<void>;
@@ -7,44 +7,109 @@
7
7
  // makes every published index.html download instead of render in the other
8
8
  // (§2.7's contract traps 3 and 4).
9
9
  //
10
- // The CLI never synthesizes a MIME type and never parses the presigned URL.
11
- import { promises as fs } from "node:fs";
10
+ // The CLI never synthesizes a MIME type and never parses the presigned URL. The
11
+ // one header it adds is Content-Length, which S3 requires on a PUT and which a
12
+ // streamed body does not carry on its own.
13
+ import { createReadStream, promises as fs } from "node:fs";
12
14
  import path from "node:path";
15
+ import { Readable } from "node:stream";
13
16
  import { withDeadline } from "../shared/http.js";
14
17
  export class UploadFailed extends Error {
15
18
  path;
16
19
  status;
17
20
  badDigest;
18
- constructor(path, status, badDigest) {
19
- super(`upload failed for ${path} (${status})`);
21
+ s3Code;
22
+ retryable;
23
+ constructor(path, status, badDigest,
24
+ /** S3's own error code, when the response carried one. */
25
+ s3Code,
26
+ /** A transport error or 5xx outlasted every retry: re-running is the remedy. */
27
+ retryable = false) {
28
+ super(`upload failed for ${path} (${status}${s3Code ? ` ${s3Code}` : ""})`);
20
29
  this.path = path;
21
30
  this.status = status;
22
31
  this.badDigest = badDigest;
32
+ this.s3Code = s3Code;
33
+ this.retryable = retryable;
23
34
  this.name = "UploadFailed";
24
35
  }
25
36
  }
37
+ /**
38
+ * A presigned URL was refused because it expired. The upload has to be
39
+ * re-opened for fresh URLs; nothing about the file is wrong.
40
+ */
41
+ export class UploadExpired extends Error {
42
+ path;
43
+ constructor(path) {
44
+ super(`the upload link for ${path} expired`);
45
+ this.path = path;
46
+ this.name = "UploadExpired";
47
+ }
48
+ }
26
49
  const DEFAULT_CONCURRENCY = 6;
50
+ const DEFAULT_MAX_ATTEMPTS = 5;
51
+ const BACKOFF_BASE_MS = 500;
52
+ const BACKOFF_CAP_MS = 15_000;
27
53
  /**
28
54
  * One PUT's deadline: a minute, plus the time the file takes at 64 KB/s. A fixed
29
55
  * deadline would fail every large file on a slow uplink; none at all lets a
30
- * stalled connection hang the publish forever.
56
+ * stalled connection hang the publish forever. The api sizes the upload window
57
+ * at the same rate, so a PUT this deadline still waits for is never refused by
58
+ * an expired link.
31
59
  */
32
60
  export function putDeadlineMs(bytes) {
33
61
  return 60_000 + Math.ceil(bytes / 64_000) * 1000;
34
62
  }
63
+ /** Exponential backoff with full jitter: uniform in [0, min(cap, base * 2^(n-1))). */
64
+ export function backoffMs(attempt, random = Math.random) {
65
+ const ceiling = Math.min(BACKOFF_CAP_MS, BACKOFF_BASE_MS * 2 ** Math.max(0, attempt - 1));
66
+ return Math.floor(random() * ceiling);
67
+ }
68
+ /**
69
+ * What a PUT's response means, read from S3's own error code rather than the
70
+ * status alone. A 400 is many things — S3 answers a stalled body with 400
71
+ * RequestTimeout — and only BadDigest means the bytes on disk are not the bytes
72
+ * that were hashed.
73
+ */
74
+ export function classifyS3Response(status, body) {
75
+ if (status >= 200 && status < 300)
76
+ return { kind: "ok" };
77
+ const code = /<Code>([^<]+)<\/Code>/.exec(body)?.[1];
78
+ const message = /<Message>([^<]*)<\/Message>/.exec(body)?.[1] ?? "";
79
+ if (status === 403 && code === "AccessDenied" && /request has expired/i.test(message)) {
80
+ return { kind: "expired" };
81
+ }
82
+ if (code === "BadDigest")
83
+ return { kind: "bad_digest", code };
84
+ if (code === "RequestTimeout" || code === "IncompleteBody")
85
+ return { kind: "retry", code };
86
+ if (status >= 500 || status === 408 || status === 429)
87
+ return { kind: "retry", code };
88
+ return { kind: "fatal", code };
89
+ }
35
90
  /**
36
- * PUT every file. A re-run that finds files already landed simply uploads
37
- * fewer — resumption is silent, because the creator did not ask for a resume,
38
- * they asked to publish.
91
+ * PUT every target. The api returns targets only for files not yet landed when
92
+ * it resumes an open upload, so a re-run simply uploads fewer — resumption is
93
+ * silent, because the creator did not ask for a resume, they asked to publish.
94
+ *
95
+ * On the first file that cannot land, no new PUT starts; the ones in flight
96
+ * finish, and the first failure is thrown (UploadExpired only when nothing
97
+ * worse happened, since re-opening cannot fix a changed file).
39
98
  */
40
99
  export async function uploadAll(opts) {
41
100
  const fetchImpl = opts.fetchImpl ?? fetch;
42
101
  const sizes = new Map(opts.manifest.map((f) => [f.path, f.size]));
43
- const bytesTotal = opts.manifest.reduce((n, f) => n + f.size, 0);
102
+ const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
44
103
  const queue = [...opts.targets];
45
- const maxAttempts = opts.maxAttempts ?? 3;
104
+ const retry = {
105
+ maxAttempts: opts.maxAttempts ?? DEFAULT_MAX_ATTEMPTS,
106
+ sleep: opts.sleep ?? ((ms) => new Promise((r) => setTimeout(r, ms))),
107
+ random: opts.random ?? Math.random,
108
+ };
46
109
  let filesDone = 0;
47
110
  let bytesDone = 0;
111
+ let failed = false;
112
+ let failure;
48
113
  const report = () => opts.onProgress?.({
49
114
  filesDone,
50
115
  filesTotal: opts.targets.length,
@@ -53,11 +118,19 @@ export async function uploadAll(opts) {
53
118
  });
54
119
  report();
55
120
  const worker = async () => {
56
- for (;;) {
121
+ while (!failed) {
57
122
  const target = queue.shift();
58
123
  if (!target)
59
124
  return;
60
- await putOne(target, opts.distDir, fetchImpl, maxAttempts);
125
+ try {
126
+ await putOne(target, opts.distDir, fetchImpl, retry);
127
+ }
128
+ catch (err) {
129
+ if (!failed || failure instanceof UploadExpired)
130
+ failure = err;
131
+ failed = true;
132
+ return;
133
+ }
61
134
  filesDone += 1;
62
135
  bytesDone += sizes.get(target.path) ?? 0;
63
136
  report();
@@ -65,39 +138,64 @@ export async function uploadAll(opts) {
65
138
  };
66
139
  const workers = Array.from({ length: Math.min(opts.concurrency ?? DEFAULT_CONCURRENCY, queue.length || 1) }, () => worker());
67
140
  await Promise.all(workers);
141
+ if (failed)
142
+ throw failure;
68
143
  }
69
- async function putOne(target, distDir, fetchImpl, maxAttempts) {
144
+ async function putOne(target, distDir, fetchImpl, retry) {
70
145
  const abs = path.join(distDir, ...target.path.split("/"));
71
- const body = await fs.readFile(abs);
72
- const put = withDeadline(fetchImpl, putDeadlineMs(body.byteLength));
146
+ const { size } = await fs.stat(abs);
147
+ const put = withDeadline(fetchImpl, putDeadlineMs(size));
73
148
  let lastStatus = 0;
149
+ let lastCode;
74
150
  let sawBadDigest = false;
75
- for (let attempt = 1; attempt <= maxAttempts; attempt++) {
151
+ for (let attempt = 1; attempt <= retry.maxAttempts; attempt++) {
152
+ if (attempt > 1)
153
+ await retry.sleep(backoffMs(attempt - 1, retry.random));
154
+ // A fresh stream per attempt: a body cannot be replayed once read.
155
+ const stream = createReadStream(abs);
76
156
  let res;
157
+ let text;
77
158
  try {
78
159
  res = await put(target.url, {
79
160
  method: "PUT",
80
- // VERBATIM. No key dropped, no key added, no key renamed.
81
- headers: { ...target.headers },
82
- body: new Uint8Array(body),
161
+ // VERBATIM. No key dropped, no key renamed; Content-Length is the one
162
+ // addition, required by S3 and absent from a streamed body.
163
+ headers: { ...target.headers, "content-length": String(size) },
164
+ body: Readable.toWeb(stream),
165
+ duplex: "half",
83
166
  });
167
+ text = res.ok ? "" : await res.text().catch(() => "");
84
168
  }
85
169
  catch {
86
170
  lastStatus = 0;
171
+ lastCode = undefined;
87
172
  continue;
88
173
  }
89
- if (res.ok)
90
- return;
91
- lastStatus = res.status;
92
- const text = await res.text().catch(() => "");
93
- if (text.includes("BadDigest") || res.status === 400) {
94
- // The bytes on disk differ from what was hashed. One silent re-PUT covers
95
- // a torn read; a repeat means the file genuinely changed under us.
96
- if (sawBadDigest)
97
- throw new UploadFailed(target.path, res.status, true);
98
- sawBadDigest = true;
99
- continue;
174
+ finally {
175
+ stream.destroy();
176
+ }
177
+ const outcome = classifyS3Response(res.status, text);
178
+ switch (outcome.kind) {
179
+ case "ok":
180
+ return;
181
+ case "expired":
182
+ throw new UploadExpired(target.path);
183
+ case "bad_digest":
184
+ // The bytes on disk differ from what was hashed. One re-PUT covers a
185
+ // torn read; a repeat means the file genuinely changed under us.
186
+ if (sawBadDigest)
187
+ throw new UploadFailed(target.path, res.status, true, outcome.code);
188
+ sawBadDigest = true;
189
+ lastStatus = res.status;
190
+ lastCode = outcome.code;
191
+ continue;
192
+ case "retry":
193
+ lastStatus = res.status;
194
+ lastCode = outcome.code;
195
+ continue;
196
+ case "fatal":
197
+ throw new UploadFailed(target.path, res.status, false, outcome.code);
100
198
  }
101
199
  }
102
- throw new UploadFailed(target.path, lastStatus, sawBadDigest);
200
+ throw new UploadFailed(target.path, lastStatus, sawBadDigest, lastCode, !sawBadDigest);
103
201
  }
@@ -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.0",
3
+ "version": "0.18.2",
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",
@@ -28,7 +28,7 @@
28
28
  "qrcode": "^1.5.4",
29
29
  "ws": "^8.18.0",
30
30
  "zod": "^3.23.8",
31
- "@vincentt-xr/harness": "1.6.0"
31
+ "@vincentt-xr/harness": "1.6.1"
32
32
  },
33
33
  "devDependencies": {
34
34
  "@types/qrcode": "^1.5.5",