@vincentt-xr/cli 0.17.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 +31 -0
- package/README.md +94 -1
- package/dist/cli/index.js +170 -84
- package/dist/cli/usage-text.js +32 -10
- package/dist/domain/api.d.ts +10 -8
- package/dist/domain/api.js +14 -9
- package/dist/domain/command.js +25 -16
- package/dist/domain/copy.d.ts +6 -3
- package/dist/domain/copy.js +10 -6
- package/dist/dx/command.d.ts +24 -4
- package/dist/dx/command.js +80 -25
- package/dist/dx/consent.js +6 -5
- package/dist/dx/journal.d.ts +17 -2
- package/dist/dx/journal.js +19 -3
- package/dist/dx/report.js +4 -3
- package/dist/dx/reportIo.js +6 -5
- package/dist/dx/send.js +6 -5
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +2 -26
- package/dist/lib/detect.d.ts +87 -0
- package/dist/lib/detect.js +867 -0
- package/dist/lib/diagnostics.js +2 -2
- package/dist/lib/feedback.js +1 -1
- package/dist/lib/feedbackCopy.js +1 -1
- package/dist/lib/initFrom.js +4 -4
- package/dist/lib/lifecycle.d.ts +2 -0
- package/dist/lib/lifecycle.js +9 -3
- package/dist/lib/listProjects.js +2 -1
- package/dist/lib/projects.d.ts +37 -2
- package/dist/lib/projects.js +86 -21
- package/dist/lib/settings.d.ts +113 -0
- package/dist/lib/settings.js +284 -0
- package/dist/lib/settingsCopy.d.ts +6 -0
- package/dist/lib/settingsCopy.js +99 -0
- package/dist/lib/settle.d.ts +61 -0
- package/dist/lib/settle.js +338 -0
- package/dist/lib/template.d.ts +19 -0
- package/dist/lib/template.js +73 -0
- package/dist/lib/terminalText.d.ts +3 -0
- package/dist/lib/terminalText.js +10 -0
- package/dist/lib/version.js +5 -11
- package/dist/lib/whoami.js +10 -3
- package/dist/library/assetsCommand.js +1 -1
- package/dist/library/client.js +8 -3
- package/dist/library/render.js +8 -6
- package/dist/login/authBackend.js +3 -2
- package/dist/login/ensure.d.ts +8 -2
- package/dist/login/ensure.js +21 -10
- package/dist/login/guard.js +5 -4
- package/dist/login/loginCommand.d.ts +2 -2
- package/dist/login/loginCommand.js +29 -5
- package/dist/login/pkce.js +1 -1
- package/dist/login/pkceLogin.js +1 -1
- package/dist/platform/outdated.js +2 -2
- package/dist/platform/upgradeReport.js +16 -12
- package/dist/preview/api.d.ts +5 -11
- package/dist/preview/api.js +13 -15
- package/dist/preview/command.d.ts +29 -0
- package/dist/preview/command.js +61 -1
- package/dist/preview/copy.d.ts +35 -3
- package/dist/preview/copy.js +102 -10
- package/dist/preview/proxy.d.ts +38 -1
- package/dist/preview/proxy.js +151 -2
- package/dist/preview/qrPage.js +1 -1
- package/dist/preview/runner.d.ts +25 -13
- package/dist/preview/runner.js +127 -26
- package/dist/preview/viewers.js +1 -1
- package/dist/publish/api.d.ts +22 -5
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +261 -50
- package/dist/publish/copy.d.ts +78 -5
- package/dist/publish/copy.js +271 -37
- package/dist/publish/fit.d.ts +15 -0
- package/dist/publish/fit.js +103 -0
- package/dist/publish/manifest.d.ts +44 -3
- package/dist/publish/manifest.js +172 -3
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +15 -1
- package/dist/publish/upload.d.ts +59 -5
- package/dist/publish/upload.js +138 -30
- package/dist/publish/visibility.d.ts +21 -0
- package/dist/publish/visibility.js +68 -0
- package/dist/relay/annotations.js +9 -4
- package/dist/relay/server.d.ts +21 -0
- package/dist/relay/server.js +35 -1
- package/dist/shared/authError.d.ts +27 -0
- package/dist/shared/authError.js +48 -4
- package/dist/shared/config.d.ts +21 -1
- package/dist/shared/config.js +39 -8
- package/dist/shared/countLimit.d.ts +7 -2
- package/dist/shared/countLimit.js +12 -5
- package/dist/shared/http.d.ts +23 -0
- package/dist/shared/http.js +58 -0
- package/package.json +7 -3
package/dist/publish/command.js
CHANGED
|
@@ -10,25 +10,69 @@
|
|
|
10
10
|
// branch is assertable without a real API, a real terminal or a real S3.
|
|
11
11
|
import { promises as fs } from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
-
import { PublishApiError, abandonUpload, commitUpload, openUpload, readProject, } from "./api.js";
|
|
13
|
+
import { MalformedCommitResponse, PublishApiError, abandonUpload, commitUpload, openUpload, readProject, } from "./api.js";
|
|
14
14
|
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;
|
|
21
|
+
import { waitUntilServing } from "./visibility.js";
|
|
19
22
|
import * as copy from "./copy.js";
|
|
20
23
|
import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
|
|
24
|
+
import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
|
|
25
|
+
import { EXIT } from "../lib/exitCodes.js";
|
|
26
|
+
import { apiFetch, isTransportError } from "../shared/http.js";
|
|
21
27
|
import { resolveBuiltWith } from "../platform/resolve.js";
|
|
28
|
+
import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, } from "../lib/settings.js";
|
|
29
|
+
import { settingsRefusal } from "../lib/settingsCopy.js";
|
|
30
|
+
import { rootAbsoluteFindings } from "./fit.js";
|
|
22
31
|
const COMMIT_RETRIES = 3;
|
|
23
32
|
const COMMIT_RETRY_DELAY_MS = 2000;
|
|
24
33
|
export async function publishCommand(deps, opts = {}) {
|
|
34
|
+
let committed;
|
|
35
|
+
const code = await runPublish(deps, opts, (r) => {
|
|
36
|
+
committed = r;
|
|
37
|
+
});
|
|
38
|
+
if (opts.json) {
|
|
39
|
+
const report = code === 0 && committed ? committed : { state: "failed", exitCode: code };
|
|
40
|
+
(deps.emit ?? deps.print)(JSON.stringify(report));
|
|
41
|
+
}
|
|
42
|
+
return code;
|
|
43
|
+
}
|
|
44
|
+
async function runPublish(deps, opts, onCommitted) {
|
|
45
|
+
// The binding lookup is local. Its directory is the project root: the settings
|
|
46
|
+
// file is read there and a relative output or --dist resolves against it,
|
|
47
|
+
// never against the working directory.
|
|
48
|
+
const binding = await deps.binding();
|
|
49
|
+
const root = binding?.dir ?? deps.cwd;
|
|
50
|
+
// Settings resolve first, before the sign-in gate and before any request.
|
|
51
|
+
let settings;
|
|
52
|
+
let hasFile;
|
|
53
|
+
try {
|
|
54
|
+
const file = await (deps.readSettings ?? readSettings)(root);
|
|
55
|
+
hasFile = file !== undefined;
|
|
56
|
+
settings = resolveSettings({ buildOutput: opts.distDir }, file, TEMPLATE_DEFAULTS, [
|
|
57
|
+
"build",
|
|
58
|
+
]);
|
|
59
|
+
}
|
|
60
|
+
catch (err) {
|
|
61
|
+
if (err instanceof SettingsError) {
|
|
62
|
+
for (const l of settingsRefusal(err, "publish"))
|
|
63
|
+
deps.print(l);
|
|
64
|
+
return 1;
|
|
65
|
+
}
|
|
66
|
+
throw err;
|
|
67
|
+
}
|
|
68
|
+
// `undefined` → the template's own command; `null` → a file records none.
|
|
69
|
+
const buildCommand = settings.buildCommand?.value ?? (hasFile ? null : undefined);
|
|
25
70
|
// The gate is LOCAL and NOTHING IS ATTEMPTED — no request, no partial upload.
|
|
26
71
|
if (!(await deps.token())) {
|
|
27
72
|
for (const l of copy.notSignedIn())
|
|
28
73
|
deps.print(l);
|
|
29
|
-
return
|
|
74
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
30
75
|
}
|
|
31
|
-
const binding = await deps.binding();
|
|
32
76
|
if (!binding) {
|
|
33
77
|
deps.print("✗ This folder isn't linked to a project.");
|
|
34
78
|
deps.print("");
|
|
@@ -36,18 +80,34 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
36
80
|
deps.print(" vincentt create Register a new project for this folder");
|
|
37
81
|
return 1;
|
|
38
82
|
}
|
|
39
|
-
const distDir = path.resolve(
|
|
83
|
+
const distDir = path.resolve(root, settings.buildOutput.value);
|
|
84
|
+
const output = outputLabelOf(root, distDir, settings.buildOutput.source);
|
|
40
85
|
// Everything below the manifest build happens before a single request. The
|
|
41
86
|
// creator learns about a too-large build in the same second they would
|
|
42
87
|
// otherwise have started a ten-minute upload.
|
|
43
88
|
let manifest;
|
|
44
89
|
try {
|
|
45
|
-
manifest = await buildManifest(distDir);
|
|
90
|
+
manifest = await buildManifest(distDir, { repoRoot: root });
|
|
46
91
|
}
|
|
47
92
|
catch (err) {
|
|
48
93
|
if (err instanceof ManifestError) {
|
|
49
|
-
|
|
94
|
+
if (err.reason === "secret_path" || err.reason === "lfs_pointer") {
|
|
95
|
+
deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
|
|
96
|
+
deps.print(copy.readingLine(err.stats?.files ?? 0, err.stats?.bytes ?? 0, output));
|
|
97
|
+
const lines = err.reason === "secret_path"
|
|
98
|
+
? copy.secretFiles(err.paths, output)
|
|
99
|
+
: copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
|
|
100
|
+
for (const l of lines)
|
|
101
|
+
deps.print(l);
|
|
102
|
+
return 1;
|
|
103
|
+
}
|
|
104
|
+
const origin = {
|
|
105
|
+
value: settings.buildOutput.value,
|
|
106
|
+
source: settings.buildOutput.source,
|
|
107
|
+
};
|
|
108
|
+
for (const l of manifestErrorLines(err, distDir, { root, origin, buildCommand })) {
|
|
50
109
|
deps.print(l);
|
|
110
|
+
}
|
|
51
111
|
return 1;
|
|
52
112
|
}
|
|
53
113
|
throw err;
|
|
@@ -55,13 +115,13 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
55
115
|
const violation = checkBounds(manifest);
|
|
56
116
|
if (violation) {
|
|
57
117
|
deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
|
|
58
|
-
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes));
|
|
118
|
+
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
|
|
59
119
|
for (const l of copy.tooLarge(violation))
|
|
60
120
|
deps.print(l);
|
|
61
121
|
return 1;
|
|
62
122
|
}
|
|
63
123
|
const cfg = await deps.config();
|
|
64
|
-
const fetchImpl = deps.fetchImpl ??
|
|
124
|
+
const fetchImpl = deps.fetchImpl ?? apiFetch;
|
|
65
125
|
// ONE call before the checklist. liveVersion and livePublishedAt ride it, so
|
|
66
126
|
// the republish line costs no second round trip.
|
|
67
127
|
let project;
|
|
@@ -69,6 +129,8 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
69
129
|
project = await readProject(cfg, binding.projectId, fetchImpl);
|
|
70
130
|
}
|
|
71
131
|
catch (err) {
|
|
132
|
+
if (err instanceof AuthExpiredError)
|
|
133
|
+
return authExpired(deps);
|
|
72
134
|
if (err instanceof PublishApiError && err.status === 404) {
|
|
73
135
|
deps.print(`✗ ${err.message}`);
|
|
74
136
|
deps.print("");
|
|
@@ -82,6 +144,20 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
82
144
|
const isFirstPublish = project.firstPublishedAt === null;
|
|
83
145
|
const nextVersion = (project.highestVersion ?? 0) + 1;
|
|
84
146
|
const orgName = await orgOf(deps);
|
|
147
|
+
// Refused here, before the prompt and before a byte moves: the api refuses
|
|
148
|
+
// every publish of a held project, and it used to do so only at commit.
|
|
149
|
+
if (project.publishState === "held_by_platform") {
|
|
150
|
+
deps.print("");
|
|
151
|
+
deps.print(copy.headerLine(project.name, orgName));
|
|
152
|
+
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
|
|
153
|
+
for (const l of copy.heldByPlatform(address, {
|
|
154
|
+
orgSlug: (await deps.orgSlug?.()) || undefined,
|
|
155
|
+
consoleUrl: deps.consoleUrl,
|
|
156
|
+
})) {
|
|
157
|
+
deps.print(l);
|
|
158
|
+
}
|
|
159
|
+
return 1;
|
|
160
|
+
}
|
|
85
161
|
// A non-interactive stdin with no --yes must REFUSE rather than assume yes.
|
|
86
162
|
if (!opts.yes && !deps.isTTY) {
|
|
87
163
|
deps.print("");
|
|
@@ -93,7 +169,9 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
93
169
|
deps.print("");
|
|
94
170
|
return 1;
|
|
95
171
|
}
|
|
96
|
-
const
|
|
172
|
+
const manifestPaths = new Set(manifest.files.map((f) => f.path));
|
|
173
|
+
const findings = await classifyDist(distDir, manifestPaths, (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
|
|
174
|
+
const rootAbsolute = rootAbsoluteFindings(await readHtmlFiles(distDir, manifest), manifestPaths);
|
|
97
175
|
const checklistInput = {
|
|
98
176
|
projectName: project.name,
|
|
99
177
|
orgName,
|
|
@@ -102,6 +180,8 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
102
180
|
totalBytes: manifest.totalBytes,
|
|
103
181
|
nextVersion,
|
|
104
182
|
findings,
|
|
183
|
+
output,
|
|
184
|
+
rootAbsolute,
|
|
105
185
|
};
|
|
106
186
|
let promptLine;
|
|
107
187
|
let defaultYes;
|
|
@@ -119,6 +199,16 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
119
199
|
// accidental Enter must do nothing.
|
|
120
200
|
defaultYes = false;
|
|
121
201
|
}
|
|
202
|
+
else if (project.publishState !== "live") {
|
|
203
|
+
// not_serving, or a state newer than this CLI. Only `live` may say "Live at".
|
|
204
|
+
const known = project.publishState === "not_serving";
|
|
205
|
+
for (const l of copy.notServingChecklist(checklistInput, known))
|
|
206
|
+
deps.print(l);
|
|
207
|
+
promptLine = known
|
|
208
|
+
? copy.republishPrompt(nextVersion)
|
|
209
|
+
: copy.unknownStatePrompt(nextVersion);
|
|
210
|
+
defaultYes = known;
|
|
211
|
+
}
|
|
122
212
|
else {
|
|
123
213
|
for (const l of copy.republishChecklist({
|
|
124
214
|
...checklistInput,
|
|
@@ -154,23 +244,35 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
154
244
|
address,
|
|
155
245
|
liveVersion: project.liveVersion ?? undefined,
|
|
156
246
|
fetchImpl,
|
|
247
|
+
// The S3 leg sets its own per-file deadline, sized to the file.
|
|
248
|
+
uploadFetch: deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)),
|
|
157
249
|
builtWith,
|
|
158
250
|
org: {
|
|
159
251
|
name: orgName || undefined,
|
|
160
252
|
slug: (await deps.orgSlug?.()) || undefined,
|
|
161
253
|
consoleUrl: deps.consoleUrl,
|
|
162
254
|
},
|
|
255
|
+
probeFetch: deps.probeFetch ??
|
|
256
|
+
deps.fetchImpl ??
|
|
257
|
+
((input, init) => globalThis.fetch(input, init)),
|
|
258
|
+
onCommitted,
|
|
163
259
|
});
|
|
164
260
|
}
|
|
165
261
|
async function runUpload(deps, cfg, i) {
|
|
262
|
+
const open = () => openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
|
|
263
|
+
const openFailed = (err) => {
|
|
264
|
+
if (err instanceof AuthExpiredError)
|
|
265
|
+
return authExpired(deps);
|
|
266
|
+
for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
|
|
267
|
+
deps.print(l);
|
|
268
|
+
return 1;
|
|
269
|
+
};
|
|
166
270
|
let opened;
|
|
167
271
|
try {
|
|
168
|
-
opened = await
|
|
272
|
+
opened = await open();
|
|
169
273
|
}
|
|
170
274
|
catch (err) {
|
|
171
|
-
|
|
172
|
-
deps.print(l);
|
|
173
|
-
return 1;
|
|
275
|
+
return openFailed(err);
|
|
174
276
|
}
|
|
175
277
|
deps.print(copy.uploadStart(i.manifest.files.length));
|
|
176
278
|
const progress = createProgress({
|
|
@@ -180,30 +282,59 @@ async function runUpload(deps, cfg, i) {
|
|
|
180
282
|
now: deps.now,
|
|
181
283
|
});
|
|
182
284
|
const startedAt = deps.now();
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
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
|
+
}
|
|
191
316
|
}
|
|
192
|
-
|
|
317
|
+
if (uploadErr !== undefined) {
|
|
318
|
+
const err = uploadErr;
|
|
193
319
|
progress.finish();
|
|
194
|
-
|
|
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);
|
|
195
326
|
if (err instanceof UploadFailed && err.badDigest) {
|
|
196
327
|
for (const l of copy.badDigest(err.path, i.address, i.liveVersion))
|
|
197
328
|
deps.print(l);
|
|
198
329
|
return 1;
|
|
199
330
|
}
|
|
200
|
-
const failedPath = err instanceof UploadFailed ? err.path : "a file";
|
|
331
|
+
const failedPath = err instanceof UploadFailed || err instanceof UploadExpired ? err.path : "a file";
|
|
201
332
|
deps.print("");
|
|
202
333
|
deps.print(`✗ Uploading ${failedPath} failed.`);
|
|
203
334
|
deps.print("");
|
|
204
335
|
deps.print(i.liveVersion === undefined
|
|
205
|
-
? ` Your live address is unchanged
|
|
206
|
-
: ` Your live address is unchanged
|
|
336
|
+
? ` Your live address is unchanged: ${i.address}.`
|
|
337
|
+
: ` Your live address is unchanged: ${i.address} is still serving v${i.liveVersion}.`);
|
|
207
338
|
deps.print("");
|
|
208
339
|
deps.print(" Publish again: vincentt publish");
|
|
209
340
|
deps.print("");
|
|
@@ -215,30 +346,41 @@ async function runUpload(deps, cfg, i) {
|
|
|
215
346
|
return commitWithHealing(deps, cfg, i, opened.uploadId);
|
|
216
347
|
}
|
|
217
348
|
/**
|
|
218
|
-
* The commit, with the healing retry.
|
|
219
|
-
* automatically
|
|
220
|
-
*
|
|
221
|
-
*
|
|
222
|
-
*
|
|
349
|
+
* The commit, with the healing retry. Commit is idempotent on uploadId, so two
|
|
350
|
+
* failures are retried automatically, up to 3 attempts with the wait doubling
|
|
351
|
+
* from 2s: a 502 publish_flip_failed (what the healing path was built for), and
|
|
352
|
+
* a request whose answer never arrived (a dropped connection, a deadline, a 2xx
|
|
353
|
+
* body that is not a commit response). Surfacing either to a creator whose bytes
|
|
354
|
+
* are all safely stored would be manufacturing an incident.
|
|
355
|
+
*
|
|
356
|
+
* When the last attempt's answer never arrived, the outcome is unknown and the
|
|
357
|
+
* copy says so: the version may already be live.
|
|
223
358
|
*/
|
|
224
359
|
async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
225
360
|
let announcedSlow = false;
|
|
226
361
|
for (let attempt = 1; attempt <= COMMIT_RETRIES; attempt++) {
|
|
227
362
|
try {
|
|
228
363
|
const result = await commitUpload(cfg, i.projectId, uploadId, i.fetchImpl);
|
|
229
|
-
|
|
230
|
-
deps.print(l);
|
|
364
|
+
await reportVisibility(deps, i, result);
|
|
231
365
|
return 0;
|
|
232
366
|
}
|
|
233
367
|
catch (err) {
|
|
234
|
-
|
|
235
|
-
|
|
368
|
+
// The token can lapse during a long upload. The files are stored and the
|
|
369
|
+
// live address is unchanged, so signing in and publishing again is safe.
|
|
370
|
+
if (err instanceof AuthExpiredError)
|
|
371
|
+
return authExpired(deps);
|
|
372
|
+
const unanswered = err instanceof MalformedCommitResponse || isTransportError(err);
|
|
236
373
|
// The plan limit is a 409 and falls past this branch deliberately. It is
|
|
237
374
|
// the interlock refusing, not the flip failing, and retrying it three
|
|
238
375
|
// times would be the retry loop the refusal exists to stop.
|
|
239
|
-
|
|
376
|
+
const flipFailed = err instanceof PublishApiError &&
|
|
377
|
+
(err.code === "publish_flip_failed" || err.status === 502);
|
|
378
|
+
if (unanswered || flipFailed) {
|
|
240
379
|
if (attempt === COMMIT_RETRIES) {
|
|
241
|
-
|
|
380
|
+
const lines = unanswered
|
|
381
|
+
? copy.commitOutcomeUnknown(i.address, i.liveVersion)
|
|
382
|
+
: copy.flipFailed(i.address, i.liveVersion);
|
|
383
|
+
for (const l of lines)
|
|
242
384
|
deps.print(l);
|
|
243
385
|
return 1;
|
|
244
386
|
}
|
|
@@ -248,9 +390,11 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
248
390
|
deps.print(copy.takingLonger());
|
|
249
391
|
announcedSlow = true;
|
|
250
392
|
}
|
|
251
|
-
await deps.sleep(COMMIT_RETRY_DELAY_MS);
|
|
393
|
+
await deps.sleep(COMMIT_RETRY_DELAY_MS * 2 ** (attempt - 1));
|
|
252
394
|
continue;
|
|
253
395
|
}
|
|
396
|
+
if (!(err instanceof PublishApiError))
|
|
397
|
+
throw err;
|
|
254
398
|
for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
|
|
255
399
|
deps.print(l);
|
|
256
400
|
return 1;
|
|
@@ -258,6 +402,32 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
258
402
|
}
|
|
259
403
|
return 1;
|
|
260
404
|
}
|
|
405
|
+
/**
|
|
406
|
+
* "Live" is printed only once the public address serves this commit's index.html.
|
|
407
|
+
* Running out the wait is still exit 0: the commit happened, only the edge lags.
|
|
408
|
+
*/
|
|
409
|
+
async function reportVisibility(deps, i, result) {
|
|
410
|
+
const index = i.manifest.files.find((f) => f.path === "index.html");
|
|
411
|
+
const { visible, waitedMs } = index
|
|
412
|
+
? await waitUntilServing(result.liveUrl, index.sha256, {
|
|
413
|
+
fetchImpl: i.probeFetch,
|
|
414
|
+
now: deps.now,
|
|
415
|
+
sleep: deps.sleep,
|
|
416
|
+
onWaiting: () => deps.print(copy.propagating(result.liveUrl, result.version)),
|
|
417
|
+
})
|
|
418
|
+
: { visible: true, waitedMs: 0 };
|
|
419
|
+
const lines = visible
|
|
420
|
+
? copy.liveAt(result.liveUrl, result.version)
|
|
421
|
+
: copy.notYetVisible(result.liveUrl, result.version);
|
|
422
|
+
for (const l of lines)
|
|
423
|
+
deps.print(l);
|
|
424
|
+
i.onCommitted({
|
|
425
|
+
state: visible ? "live" : "committed_not_visible",
|
|
426
|
+
version: result.version,
|
|
427
|
+
liveUrl: result.liveUrl,
|
|
428
|
+
waitedMs,
|
|
429
|
+
});
|
|
430
|
+
}
|
|
261
431
|
/**
|
|
262
432
|
* The plan-limit refusal, from the detail the API sends. It is reachable from
|
|
263
433
|
* open AND from commit, because the interlock lives at the port and every flip
|
|
@@ -284,6 +454,10 @@ function commitErrorLines(err, address, liveVersion, org) {
|
|
|
284
454
|
switch (err.code) {
|
|
285
455
|
case "unavailable_plan_limit":
|
|
286
456
|
return planLimitLines(err, org);
|
|
457
|
+
case "published_limit":
|
|
458
|
+
// Two opens admitted in the same org race to go live; commit re-checks and
|
|
459
|
+
// refuses the loser, whose version the api keeps stored.
|
|
460
|
+
return publishedLimitRefused(resolveCountLimit(err.detail, org), "commit");
|
|
287
461
|
case "publish_expired":
|
|
288
462
|
return copy.publishExpired(address, liveVersion);
|
|
289
463
|
case "publish_contended":
|
|
@@ -298,8 +472,8 @@ function commitErrorLines(err, address, liveVersion, org) {
|
|
|
298
472
|
`✗ ${err.message}`,
|
|
299
473
|
"",
|
|
300
474
|
liveVersion === undefined
|
|
301
|
-
? ` Your live address is unchanged
|
|
302
|
-
: ` Your live address is unchanged
|
|
475
|
+
? ` Your live address is unchanged: ${address}.`
|
|
476
|
+
: ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
|
|
303
477
|
"",
|
|
304
478
|
" Publish again: vincentt publish",
|
|
305
479
|
"",
|
|
@@ -319,12 +493,10 @@ function openErrorLines(err, address, liveVersion, org) {
|
|
|
319
493
|
if (err.code === "unavailable_plan_limit") {
|
|
320
494
|
return planLimitLines(err, org);
|
|
321
495
|
}
|
|
322
|
-
// The two count/ceiling refusals of this change. Both land at OPEN — before a
|
|
323
|
-
// presigned URL is minted and before a byte moves — which is why they are here
|
|
324
|
-
// and not in commitErrorLines: neither is reachable from commit.
|
|
325
496
|
if (err.code === "published_limit") {
|
|
326
|
-
return publishedLimitRefused(resolveCountLimit(err.detail, org));
|
|
497
|
+
return publishedLimitRefused(resolveCountLimit(err.detail, org), "open");
|
|
327
498
|
}
|
|
499
|
+
// Refused only at open, before a presigned URL is minted.
|
|
328
500
|
if (err.code === "storage_ceiling") {
|
|
329
501
|
// It takes no detail. The body is `{"limit":"storage"}` and names nothing
|
|
330
502
|
// else by design — the ceiling is an abuse ceiling, not a plan limit.
|
|
@@ -345,8 +517,8 @@ function openErrorLines(err, address, liveVersion, org) {
|
|
|
345
517
|
`✗ ${err.message}`,
|
|
346
518
|
"",
|
|
347
519
|
liveVersion === undefined
|
|
348
|
-
? ` Your live address is unchanged
|
|
349
|
-
: ` Your live address is unchanged
|
|
520
|
+
? ` Your live address is unchanged: ${address}.`
|
|
521
|
+
: ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
|
|
350
522
|
"",
|
|
351
523
|
" Nothing was uploaded.",
|
|
352
524
|
"",
|
|
@@ -361,13 +533,46 @@ function openErrorLines(err, address, liveVersion, org) {
|
|
|
361
533
|
function numericLimit(limit) {
|
|
362
534
|
return typeof limit === "number" ? limit : 0;
|
|
363
535
|
}
|
|
364
|
-
function manifestErrorLines(err, distDir) {
|
|
536
|
+
function manifestErrorLines(err, distDir, ctx) {
|
|
365
537
|
if (err.reason === "no_dist")
|
|
366
|
-
return copy.noDist(distDir);
|
|
538
|
+
return copy.noDist(distDir, ctx.buildCommand);
|
|
367
539
|
if (err.reason === "no_index_html")
|
|
368
|
-
return copy.noIndexHtml(distDir);
|
|
540
|
+
return copy.noIndexHtml(distDir, ctx.buildCommand);
|
|
541
|
+
if (err.reason === "output_is_repo_root")
|
|
542
|
+
return copy.outputIsRoot(ctx.origin);
|
|
543
|
+
if (err.reason === "output_in_hidden") {
|
|
544
|
+
return copy.outputInHidden(path.relative(ctx.root, distDir).split(path.sep).join("/"), err.offendingPath);
|
|
545
|
+
}
|
|
546
|
+
if (err.reason === "output_outside_repo") {
|
|
547
|
+
return copy.outputOutside(path.relative(ctx.root, distDir).split(path.sep).join("/"));
|
|
548
|
+
}
|
|
369
549
|
return copy.badPath(err.reason, err.offendingPath);
|
|
370
550
|
}
|
|
551
|
+
/** `dist/` for the default; the resolved folder and its source otherwise. */
|
|
552
|
+
function outputLabelOf(root, distDir, source) {
|
|
553
|
+
const rel = path.relative(root, distDir).split(path.sep).join("/");
|
|
554
|
+
const folder = `${rel || "."}/`;
|
|
555
|
+
const sourceNote = source === "vincentt.json"
|
|
556
|
+
? "(from vincentt.json)"
|
|
557
|
+
: source === "flag"
|
|
558
|
+
? "(from --dist)"
|
|
559
|
+
: "";
|
|
560
|
+
return { folder, sourceNote };
|
|
561
|
+
}
|
|
562
|
+
/** Every `.html` in the manifest, for the root-absolute reference check. */
|
|
563
|
+
async function readHtmlFiles(distDir, manifest) {
|
|
564
|
+
const out = [];
|
|
565
|
+
for (const f of manifest.files) {
|
|
566
|
+
if (!f.path.toLowerCase().endsWith(".html"))
|
|
567
|
+
continue;
|
|
568
|
+
const html = await fs
|
|
569
|
+
.readFile(path.join(distDir, ...f.path.split("/")), "utf8")
|
|
570
|
+
.catch(() => undefined);
|
|
571
|
+
if (html !== undefined)
|
|
572
|
+
out.push({ file: f.path, html });
|
|
573
|
+
}
|
|
574
|
+
return out;
|
|
575
|
+
}
|
|
371
576
|
/**
|
|
372
577
|
* The address as a creator reads it: the host, never the scheme. `liveUrl` is
|
|
373
578
|
* server-computed (the apex differs per environment), so it is the authority and
|
|
@@ -383,6 +588,12 @@ function addressOf(project) {
|
|
|
383
588
|
return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
384
589
|
}
|
|
385
590
|
}
|
|
591
|
+
/** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
|
|
592
|
+
function authExpired(deps) {
|
|
593
|
+
for (const l of authExpiredLines())
|
|
594
|
+
deps.print(l);
|
|
595
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
596
|
+
}
|
|
386
597
|
async function orgOf(deps) {
|
|
387
598
|
return (await deps.orgName?.()) ?? "";
|
|
388
599
|
}
|
package/dist/publish/copy.d.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import type { FileFindings } from "./classify.js";
|
|
2
2
|
import type { BoundViolation } from "./bounds.js";
|
|
3
|
+
import type { RootAbsoluteFinding } from "./fit.js";
|
|
4
|
+
import type { Offender } from "./manifest.js";
|
|
3
5
|
export declare function truncate(value: string, max?: number): string;
|
|
4
6
|
/** Bytes as the creator reads them. Decimal MB, matching what a build tool prints. */
|
|
5
7
|
export declare function formatBytes(bytes: number): string;
|
|
@@ -24,7 +26,7 @@ export declare function relativeTime(iso: string, now: number): string;
|
|
|
24
26
|
* A FINDING IS NEVER A COUNT. No "2 issues found", no summary line, and no exit
|
|
25
27
|
* code that varies with findings.
|
|
26
28
|
*/
|
|
27
|
-
export declare function findingLines(byFile: FileFindings[], withSubtitle: boolean): string[];
|
|
29
|
+
export declare function findingLines(byFile: FileFindings[], withSubtitle: boolean, folder?: string): string[];
|
|
28
30
|
/**
|
|
29
31
|
* Mandatory and always present. A screen with a red ✗ on it reads as a gate
|
|
30
32
|
* unless it explicitly says otherwise, and a creator who believes the platform
|
|
@@ -40,8 +42,29 @@ export interface ChecklistInput {
|
|
|
40
42
|
totalBytes: number;
|
|
41
43
|
nextVersion: number;
|
|
42
44
|
findings: FileFindings[];
|
|
45
|
+
/** Absent for the template's default `dist/`. */
|
|
46
|
+
output?: OutputLabel;
|
|
47
|
+
/** Root-absolute references that do not resolve. The section is absent when empty. */
|
|
48
|
+
rootAbsolute?: RootAbsoluteFinding[];
|
|
43
49
|
}
|
|
44
|
-
|
|
50
|
+
/**
|
|
51
|
+
* The `Paths from the root` section: a second section of the checklist, same
|
|
52
|
+
* geometry as the link preview. Absent when there are no findings, never an
|
|
53
|
+
* empty heading. One explanation for the whole section.
|
|
54
|
+
*/
|
|
55
|
+
export declare function pathsFromRootLines(findings: RootAbsoluteFinding[] | undefined, folder?: string): string[];
|
|
56
|
+
/**
|
|
57
|
+
* The output folder as the creator reads it, `dist/` for the template. The
|
|
58
|
+
* source is appended only when it is not the default, so the template's
|
|
59
|
+
* transcript is unchanged.
|
|
60
|
+
*/
|
|
61
|
+
export interface OutputLabel {
|
|
62
|
+
/** Project-relative, with a trailing slash: `dist/`, `build/`. */
|
|
63
|
+
folder: string;
|
|
64
|
+
/** `(from vincentt.json)` / `(from --dist)`, or empty for the default. */
|
|
65
|
+
sourceNote: string;
|
|
66
|
+
}
|
|
67
|
+
export declare function readingLine(fileCount: number, totalBytes: number, output?: OutputLabel): string;
|
|
45
68
|
export declare function headerLine(projectName: string, orgName: string): string;
|
|
46
69
|
/**
|
|
47
70
|
* Flow L22 — the single most consequential screen in the product, and its ORDER
|
|
@@ -88,6 +111,28 @@ export declare function republishPrompt(version: number): string;
|
|
|
88
111
|
*/
|
|
89
112
|
export declare function offlineChecklist(i: ChecklistInput): string[];
|
|
90
113
|
export declare function offlinePrompt(version: number): string;
|
|
114
|
+
/**
|
|
115
|
+
* The address has published before but serves nothing now (`not_serving`), or
|
|
116
|
+
* the api reports a state this CLI does not know. Neither may say "Live at":
|
|
117
|
+
* the creator would share an address that answers with nothing.
|
|
118
|
+
*/
|
|
119
|
+
export declare function notServingChecklist(i: ChecklistInput, stateKnown: boolean): string[];
|
|
120
|
+
/** `[y/N]`: the CLI cannot say what this publish changes, so Enter does nothing. */
|
|
121
|
+
export declare function unknownStatePrompt(version: number): string;
|
|
122
|
+
/**
|
|
123
|
+
* The project is `held_by_platform`: Vincentt took the org's addresses offline
|
|
124
|
+
* over its traffic allowance, and the api refuses every publish until they are
|
|
125
|
+
* restored. Refused BEFORE the upload, because uploading a whole build only to
|
|
126
|
+
* be refused at commit is the retry loop this copy exists to stop.
|
|
127
|
+
*
|
|
128
|
+
* The project read carries no figures, so this names none. It shares
|
|
129
|
+
* planLimitRefused's voice: the platform is the actor, the remedy is outside
|
|
130
|
+
* the CLI, and nothing was lost.
|
|
131
|
+
*/
|
|
132
|
+
export declare function heldByPlatform(address: string, org?: {
|
|
133
|
+
orgSlug?: string;
|
|
134
|
+
consoleUrl?: string;
|
|
135
|
+
}): string[];
|
|
91
136
|
/**
|
|
92
137
|
* f1's line verbatim, with f4's reason. Note what is ABSENT, deliberately: f3's
|
|
93
138
|
* "your local dev loop doesn't need one" paragraph. It is the right copy for
|
|
@@ -110,9 +155,24 @@ export declare function notSignedIn(): string[];
|
|
|
110
155
|
* have.
|
|
111
156
|
*/
|
|
112
157
|
export declare function tooLarge(v: BoundViolation): string[];
|
|
113
|
-
|
|
114
|
-
export declare function
|
|
115
|
-
|
|
158
|
+
export declare function noDist(distDir: string, buildCommand?: string | null): string[];
|
|
159
|
+
export declare function noIndexHtml(distDir: string, buildCommand?: string | null): string[];
|
|
160
|
+
/** How the output was named, for the leak refusals' "why" sentence. */
|
|
161
|
+
export interface OutputOrigin {
|
|
162
|
+
/** The value as given: `build.output` or `--dist`. */
|
|
163
|
+
value: string;
|
|
164
|
+
source: "vincentt.json" | "flag" | "default";
|
|
165
|
+
}
|
|
166
|
+
/** Flow U1 — the output is the project folder itself. */
|
|
167
|
+
export declare function outputIsRoot(o: OutputOrigin): string[];
|
|
168
|
+
/** Flow U1 — the output lies outside the project. `shown` is project-relative. */
|
|
169
|
+
export declare function outputOutside(shown: string): string[];
|
|
170
|
+
/** Flow U1 — the output is, or sits inside, `.git` or `.vincentt`. `shown` is project-relative. */
|
|
171
|
+
export declare function outputInHidden(shown: string, hidden: string): string[];
|
|
172
|
+
/** Flow U1 — hidden names or symlinks that would leak. After the header and reading line. */
|
|
173
|
+
export declare function secretFiles(paths: Offender[], output?: OutputLabel): string[];
|
|
174
|
+
/** Flow U1 — Git LFS pointer files. The recorded build.command, verbatim, when there is one. */
|
|
175
|
+
export declare function lfsPointers(paths: Offender[], output?: OutputLabel, buildCommand?: string): string[];
|
|
116
176
|
/** A local path rule the creator's own build broke. Local, before any request. */
|
|
117
177
|
export declare function badPath(reason: string, offending: string): string[];
|
|
118
178
|
/** The one redrawn line in f4. A bounded progress bar with a known total is not a stream. */
|
|
@@ -130,6 +190,13 @@ export declare function finishing(): string;
|
|
|
130
190
|
* page, decide publish is broken, and publish again.
|
|
131
191
|
*/
|
|
132
192
|
export declare function liveAt(liveUrl: string, version: number): string[];
|
|
193
|
+
/** Prints once the commit succeeded and the address is still serving the previous bytes. */
|
|
194
|
+
export declare function propagating(liveUrl: string, version: number): string;
|
|
195
|
+
/**
|
|
196
|
+
* The wait ran out. The publish DID commit, so this is a success, and it says so
|
|
197
|
+
* plainly: a creator told "not yet" without that will publish again.
|
|
198
|
+
*/
|
|
199
|
+
export declare function notYetVisible(liveUrl: string, version: number): string[];
|
|
133
200
|
/** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
|
|
134
201
|
export declare function takingLonger(): string;
|
|
135
202
|
export interface PlanLimitInput {
|
|
@@ -170,6 +237,12 @@ export declare function planLimitRefused(i: PlanLimitInput): string[];
|
|
|
170
237
|
export declare function publishExpired(address: string, liveVersion?: number): string[];
|
|
171
238
|
/** All three retries failed. "Nothing was lost" is the true and load-bearing sentence. */
|
|
172
239
|
export declare function flipFailed(address: string, liveVersion?: number): string[];
|
|
240
|
+
/**
|
|
241
|
+
* The commit's answer never arrived (the connection dropped or the api stopped
|
|
242
|
+
* answering), so whether the address switched is unknown. It says so, rather than
|
|
243
|
+
* claiming either outcome, and names the one check that settles it.
|
|
244
|
+
*/
|
|
245
|
+
export declare function commitOutcomeUnknown(address: string, liveVersion?: number): string[];
|
|
173
246
|
/**
|
|
174
247
|
* Two sentences, no explanation of version allocation. The real case is a
|
|
175
248
|
* teammate and an agent publishing in the same second, and "someone else
|