@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 +16 -0
- package/dist/lib/initCommand.js +3 -1
- package/dist/lib/initFrom.js +1 -1
- package/dist/lib/lifecycle.d.ts +3 -0
- package/dist/lib/lifecycle.js +20 -1
- package/dist/lib/processStartTime.d.ts +12 -0
- package/dist/lib/processStartTime.js +33 -0
- package/dist/lib/relayInfo.d.ts +7 -0
- package/dist/platform/releaseCache.d.ts +7 -0
- package/dist/platform/releaseCache.js +4 -1
- package/dist/preview/copy.js +6 -6
- package/dist/publish/command.d.ts +2 -0
- package/dist/publish/command.js +52 -17
- package/dist/publish/upload.d.ts +54 -6
- package/dist/publish/upload.js +129 -31
- package/dist/scaffold/index.d.ts +11 -0
- package/dist/scaffold/index.js +35 -3
- package/dist/scaffold/releasedCommit.d.ts +37 -0
- package/dist/scaffold/releasedCommit.js +71 -0
- package/package.json +2 -2
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
|
package/dist/lib/initCommand.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// `vincentt init [dir]` — scaffold a Vincentt XR app. On the BARE path: NO
|
|
2
|
-
// account, NO
|
|
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
|
package/dist/lib/initFrom.js
CHANGED
|
@@ -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.
|
|
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.");
|
package/dist/lib/lifecycle.d.ts
CHANGED
|
@@ -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
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -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
|
-
|
|
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
|
+
}
|
package/dist/lib/relayInfo.d.ts
CHANGED
|
@@ -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 };
|
package/dist/preview/copy.js
CHANGED
|
@@ -331,7 +331,7 @@ export function stoppedElsewhere() {
|
|
|
331
331
|
export function endedDisconnected() {
|
|
332
332
|
return [
|
|
333
333
|
"",
|
|
334
|
-
"✗ Preview ended
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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";
|
package/dist/publish/command.js
CHANGED
|
@@ -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
|
-
|
|
261
|
-
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
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
|
-
|
|
317
|
+
if (uploadErr !== undefined) {
|
|
318
|
+
const err = uploadErr;
|
|
289
319
|
progress.finish();
|
|
290
|
-
|
|
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("");
|
package/dist/publish/upload.d.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
/**
|
|
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
|
|
33
|
-
*
|
|
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>;
|
package/dist/publish/upload.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
19
|
-
|
|
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
|
|
37
|
-
*
|
|
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.
|
|
102
|
+
const bytesTotal = opts.targets.reduce((n, t) => n + (sizes.get(t.path) ?? 0), 0);
|
|
44
103
|
const queue = [...opts.targets];
|
|
45
|
-
const
|
|
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
|
-
|
|
121
|
+
while (!failed) {
|
|
57
122
|
const target = queue.shift();
|
|
58
123
|
if (!target)
|
|
59
124
|
return;
|
|
60
|
-
|
|
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,
|
|
144
|
+
async function putOne(target, distDir, fetchImpl, retry) {
|
|
70
145
|
const abs = path.join(distDir, ...target.path.split("/"));
|
|
71
|
-
const
|
|
72
|
-
const put = withDeadline(fetchImpl, putDeadlineMs(
|
|
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
|
|
81
|
-
|
|
82
|
-
|
|
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
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
throw new
|
|
98
|
-
|
|
99
|
-
|
|
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
|
}
|
package/dist/scaffold/index.d.ts
CHANGED
|
@@ -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
|
package/dist/scaffold/index.js
CHANGED
|
@@ -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(
|
|
144
|
-
`
|
|
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.
|
|
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.
|
|
31
|
+
"@vincentt-xr/harness": "1.6.1"
|
|
32
32
|
},
|
|
33
33
|
"devDependencies": {
|
|
34
34
|
"@types/qrcode": "^1.5.5",
|