@vincentt-xr/cli 0.18.0 → 0.18.1
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 +7 -0
- 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/package.json +2 -2
package/MIGRATION.md
CHANGED
|
@@ -3,6 +3,13 @@
|
|
|
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.1
|
|
7
|
+
|
|
8
|
+
required: false
|
|
9
|
+
No action required. Behaviour that changed:
|
|
10
|
+
- `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.
|
|
11
|
+
- 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.
|
|
12
|
+
|
|
6
13
|
## 0.18.0
|
|
7
14
|
|
|
8
15
|
required: false
|
|
@@ -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/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@vincentt-xr/cli",
|
|
3
|
-
"version": "0.18.
|
|
3
|
+
"version": "0.18.1",
|
|
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",
|