@vincentt-xr/cli 0.16.0 → 0.18.0
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 +24 -0
- package/README.md +94 -1
- package/dist/cli/index.js +196 -87
- 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 +24 -11
- package/dist/preview/api.js +42 -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 +39 -6
- package/dist/publish/api.js +80 -9
- package/dist/publish/command.d.ts +25 -0
- package/dist/publish/command.js +230 -32
- 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 +6 -0
- package/dist/publish/upload.js +11 -1
- 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 +114 -0
- package/dist/shared/countLimit.js +176 -0
- 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,24 +10,67 @@
|
|
|
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
18
|
import { UploadFailed, uploadAll } from "./upload.js";
|
|
19
|
+
import { waitUntilServing } from "./visibility.js";
|
|
19
20
|
import * as copy from "./copy.js";
|
|
21
|
+
import { publishedLimitRefused, resolveCountLimit, storageCeilingRefused, } from "../shared/countLimit.js";
|
|
22
|
+
import { AuthExpiredError, authExpiredLines } from "../shared/authError.js";
|
|
23
|
+
import { EXIT } from "../lib/exitCodes.js";
|
|
24
|
+
import { apiFetch, isTransportError } from "../shared/http.js";
|
|
20
25
|
import { resolveBuiltWith } from "../platform/resolve.js";
|
|
26
|
+
import { readSettings, resolveSettings, SettingsError, TEMPLATE_DEFAULTS, } from "../lib/settings.js";
|
|
27
|
+
import { settingsRefusal } from "../lib/settingsCopy.js";
|
|
28
|
+
import { rootAbsoluteFindings } from "./fit.js";
|
|
21
29
|
const COMMIT_RETRIES = 3;
|
|
22
30
|
const COMMIT_RETRY_DELAY_MS = 2000;
|
|
23
31
|
export async function publishCommand(deps, opts = {}) {
|
|
32
|
+
let committed;
|
|
33
|
+
const code = await runPublish(deps, opts, (r) => {
|
|
34
|
+
committed = r;
|
|
35
|
+
});
|
|
36
|
+
if (opts.json) {
|
|
37
|
+
const report = code === 0 && committed ? committed : { state: "failed", exitCode: code };
|
|
38
|
+
(deps.emit ?? deps.print)(JSON.stringify(report));
|
|
39
|
+
}
|
|
40
|
+
return code;
|
|
41
|
+
}
|
|
42
|
+
async function runPublish(deps, opts, onCommitted) {
|
|
43
|
+
// The binding lookup is local. Its directory is the project root: the settings
|
|
44
|
+
// file is read there and a relative output or --dist resolves against it,
|
|
45
|
+
// never against the working directory.
|
|
46
|
+
const binding = await deps.binding();
|
|
47
|
+
const root = binding?.dir ?? deps.cwd;
|
|
48
|
+
// Settings resolve first, before the sign-in gate and before any request.
|
|
49
|
+
let settings;
|
|
50
|
+
let hasFile;
|
|
51
|
+
try {
|
|
52
|
+
const file = await (deps.readSettings ?? readSettings)(root);
|
|
53
|
+
hasFile = file !== undefined;
|
|
54
|
+
settings = resolveSettings({ buildOutput: opts.distDir }, file, TEMPLATE_DEFAULTS, [
|
|
55
|
+
"build",
|
|
56
|
+
]);
|
|
57
|
+
}
|
|
58
|
+
catch (err) {
|
|
59
|
+
if (err instanceof SettingsError) {
|
|
60
|
+
for (const l of settingsRefusal(err, "publish"))
|
|
61
|
+
deps.print(l);
|
|
62
|
+
return 1;
|
|
63
|
+
}
|
|
64
|
+
throw err;
|
|
65
|
+
}
|
|
66
|
+
// `undefined` → the template's own command; `null` → a file records none.
|
|
67
|
+
const buildCommand = settings.buildCommand?.value ?? (hasFile ? null : undefined);
|
|
24
68
|
// The gate is LOCAL and NOTHING IS ATTEMPTED — no request, no partial upload.
|
|
25
69
|
if (!(await deps.token())) {
|
|
26
70
|
for (const l of copy.notSignedIn())
|
|
27
71
|
deps.print(l);
|
|
28
|
-
return
|
|
72
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
29
73
|
}
|
|
30
|
-
const binding = await deps.binding();
|
|
31
74
|
if (!binding) {
|
|
32
75
|
deps.print("✗ This folder isn't linked to a project.");
|
|
33
76
|
deps.print("");
|
|
@@ -35,18 +78,34 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
35
78
|
deps.print(" vincentt create Register a new project for this folder");
|
|
36
79
|
return 1;
|
|
37
80
|
}
|
|
38
|
-
const distDir = path.resolve(
|
|
81
|
+
const distDir = path.resolve(root, settings.buildOutput.value);
|
|
82
|
+
const output = outputLabelOf(root, distDir, settings.buildOutput.source);
|
|
39
83
|
// Everything below the manifest build happens before a single request. The
|
|
40
84
|
// creator learns about a too-large build in the same second they would
|
|
41
85
|
// otherwise have started a ten-minute upload.
|
|
42
86
|
let manifest;
|
|
43
87
|
try {
|
|
44
|
-
manifest = await buildManifest(distDir);
|
|
88
|
+
manifest = await buildManifest(distDir, { repoRoot: root });
|
|
45
89
|
}
|
|
46
90
|
catch (err) {
|
|
47
91
|
if (err instanceof ManifestError) {
|
|
48
|
-
|
|
92
|
+
if (err.reason === "secret_path" || err.reason === "lfs_pointer") {
|
|
93
|
+
deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
|
|
94
|
+
deps.print(copy.readingLine(err.stats?.files ?? 0, err.stats?.bytes ?? 0, output));
|
|
95
|
+
const lines = err.reason === "secret_path"
|
|
96
|
+
? copy.secretFiles(err.paths, output)
|
|
97
|
+
: copy.lfsPointers(err.paths, output, buildCommand ?? undefined);
|
|
98
|
+
for (const l of lines)
|
|
99
|
+
deps.print(l);
|
|
100
|
+
return 1;
|
|
101
|
+
}
|
|
102
|
+
const origin = {
|
|
103
|
+
value: settings.buildOutput.value,
|
|
104
|
+
source: settings.buildOutput.source,
|
|
105
|
+
};
|
|
106
|
+
for (const l of manifestErrorLines(err, distDir, { root, origin, buildCommand })) {
|
|
49
107
|
deps.print(l);
|
|
108
|
+
}
|
|
50
109
|
return 1;
|
|
51
110
|
}
|
|
52
111
|
throw err;
|
|
@@ -54,13 +113,13 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
54
113
|
const violation = checkBounds(manifest);
|
|
55
114
|
if (violation) {
|
|
56
115
|
deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
|
|
57
|
-
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes));
|
|
116
|
+
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
|
|
58
117
|
for (const l of copy.tooLarge(violation))
|
|
59
118
|
deps.print(l);
|
|
60
119
|
return 1;
|
|
61
120
|
}
|
|
62
121
|
const cfg = await deps.config();
|
|
63
|
-
const fetchImpl = deps.fetchImpl ??
|
|
122
|
+
const fetchImpl = deps.fetchImpl ?? apiFetch;
|
|
64
123
|
// ONE call before the checklist. liveVersion and livePublishedAt ride it, so
|
|
65
124
|
// the republish line costs no second round trip.
|
|
66
125
|
let project;
|
|
@@ -68,6 +127,8 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
68
127
|
project = await readProject(cfg, binding.projectId, fetchImpl);
|
|
69
128
|
}
|
|
70
129
|
catch (err) {
|
|
130
|
+
if (err instanceof AuthExpiredError)
|
|
131
|
+
return authExpired(deps);
|
|
71
132
|
if (err instanceof PublishApiError && err.status === 404) {
|
|
72
133
|
deps.print(`✗ ${err.message}`);
|
|
73
134
|
deps.print("");
|
|
@@ -81,6 +142,20 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
81
142
|
const isFirstPublish = project.firstPublishedAt === null;
|
|
82
143
|
const nextVersion = (project.highestVersion ?? 0) + 1;
|
|
83
144
|
const orgName = await orgOf(deps);
|
|
145
|
+
// Refused here, before the prompt and before a byte moves: the api refuses
|
|
146
|
+
// every publish of a held project, and it used to do so only at commit.
|
|
147
|
+
if (project.publishState === "held_by_platform") {
|
|
148
|
+
deps.print("");
|
|
149
|
+
deps.print(copy.headerLine(project.name, orgName));
|
|
150
|
+
deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes, output));
|
|
151
|
+
for (const l of copy.heldByPlatform(address, {
|
|
152
|
+
orgSlug: (await deps.orgSlug?.()) || undefined,
|
|
153
|
+
consoleUrl: deps.consoleUrl,
|
|
154
|
+
})) {
|
|
155
|
+
deps.print(l);
|
|
156
|
+
}
|
|
157
|
+
return 1;
|
|
158
|
+
}
|
|
84
159
|
// A non-interactive stdin with no --yes must REFUSE rather than assume yes.
|
|
85
160
|
if (!opts.yes && !deps.isTTY) {
|
|
86
161
|
deps.print("");
|
|
@@ -92,7 +167,9 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
92
167
|
deps.print("");
|
|
93
168
|
return 1;
|
|
94
169
|
}
|
|
95
|
-
const
|
|
170
|
+
const manifestPaths = new Set(manifest.files.map((f) => f.path));
|
|
171
|
+
const findings = await classifyDist(distDir, manifestPaths, (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
|
|
172
|
+
const rootAbsolute = rootAbsoluteFindings(await readHtmlFiles(distDir, manifest), manifestPaths);
|
|
96
173
|
const checklistInput = {
|
|
97
174
|
projectName: project.name,
|
|
98
175
|
orgName,
|
|
@@ -101,6 +178,8 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
101
178
|
totalBytes: manifest.totalBytes,
|
|
102
179
|
nextVersion,
|
|
103
180
|
findings,
|
|
181
|
+
output,
|
|
182
|
+
rootAbsolute,
|
|
104
183
|
};
|
|
105
184
|
let promptLine;
|
|
106
185
|
let defaultYes;
|
|
@@ -118,6 +197,16 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
118
197
|
// accidental Enter must do nothing.
|
|
119
198
|
defaultYes = false;
|
|
120
199
|
}
|
|
200
|
+
else if (project.publishState !== "live") {
|
|
201
|
+
// not_serving, or a state newer than this CLI. Only `live` may say "Live at".
|
|
202
|
+
const known = project.publishState === "not_serving";
|
|
203
|
+
for (const l of copy.notServingChecklist(checklistInput, known))
|
|
204
|
+
deps.print(l);
|
|
205
|
+
promptLine = known
|
|
206
|
+
? copy.republishPrompt(nextVersion)
|
|
207
|
+
: copy.unknownStatePrompt(nextVersion);
|
|
208
|
+
defaultYes = known;
|
|
209
|
+
}
|
|
121
210
|
else {
|
|
122
211
|
for (const l of copy.republishChecklist({
|
|
123
212
|
...checklistInput,
|
|
@@ -153,12 +242,18 @@ export async function publishCommand(deps, opts = {}) {
|
|
|
153
242
|
address,
|
|
154
243
|
liveVersion: project.liveVersion ?? undefined,
|
|
155
244
|
fetchImpl,
|
|
245
|
+
// The S3 leg sets its own per-file deadline, sized to the file.
|
|
246
|
+
uploadFetch: deps.fetchImpl ?? ((input, init) => globalThis.fetch(input, init)),
|
|
156
247
|
builtWith,
|
|
157
248
|
org: {
|
|
158
249
|
name: orgName || undefined,
|
|
159
250
|
slug: (await deps.orgSlug?.()) || undefined,
|
|
160
251
|
consoleUrl: deps.consoleUrl,
|
|
161
252
|
},
|
|
253
|
+
probeFetch: deps.probeFetch ??
|
|
254
|
+
deps.fetchImpl ??
|
|
255
|
+
((input, init) => globalThis.fetch(input, init)),
|
|
256
|
+
onCommitted,
|
|
162
257
|
});
|
|
163
258
|
}
|
|
164
259
|
async function runUpload(deps, cfg, i) {
|
|
@@ -167,6 +262,8 @@ async function runUpload(deps, cfg, i) {
|
|
|
167
262
|
opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl, i.builtWith);
|
|
168
263
|
}
|
|
169
264
|
catch (err) {
|
|
265
|
+
if (err instanceof AuthExpiredError)
|
|
266
|
+
return authExpired(deps);
|
|
170
267
|
for (const l of openErrorLines(err, i.address, i.liveVersion, i.org))
|
|
171
268
|
deps.print(l);
|
|
172
269
|
return 1;
|
|
@@ -185,7 +282,7 @@ async function runUpload(deps, cfg, i) {
|
|
|
185
282
|
targets: opened.uploads,
|
|
186
283
|
manifest: i.manifest.files,
|
|
187
284
|
onProgress: (p) => progress.update(p),
|
|
188
|
-
fetchImpl: i.
|
|
285
|
+
fetchImpl: i.uploadFetch,
|
|
189
286
|
});
|
|
190
287
|
}
|
|
191
288
|
catch (err) {
|
|
@@ -201,8 +298,8 @@ async function runUpload(deps, cfg, i) {
|
|
|
201
298
|
deps.print(`✗ Uploading ${failedPath} failed.`);
|
|
202
299
|
deps.print("");
|
|
203
300
|
deps.print(i.liveVersion === undefined
|
|
204
|
-
? ` Your live address is unchanged
|
|
205
|
-
: ` Your live address is unchanged
|
|
301
|
+
? ` Your live address is unchanged: ${i.address}.`
|
|
302
|
+
: ` Your live address is unchanged: ${i.address} is still serving v${i.liveVersion}.`);
|
|
206
303
|
deps.print("");
|
|
207
304
|
deps.print(" Publish again: vincentt publish");
|
|
208
305
|
deps.print("");
|
|
@@ -214,30 +311,41 @@ async function runUpload(deps, cfg, i) {
|
|
|
214
311
|
return commitWithHealing(deps, cfg, i, opened.uploadId);
|
|
215
312
|
}
|
|
216
313
|
/**
|
|
217
|
-
* The commit, with the healing retry.
|
|
218
|
-
* automatically
|
|
219
|
-
*
|
|
220
|
-
*
|
|
221
|
-
*
|
|
314
|
+
* The commit, with the healing retry. Commit is idempotent on uploadId, so two
|
|
315
|
+
* failures are retried automatically, up to 3 attempts with the wait doubling
|
|
316
|
+
* from 2s: a 502 publish_flip_failed (what the healing path was built for), and
|
|
317
|
+
* a request whose answer never arrived (a dropped connection, a deadline, a 2xx
|
|
318
|
+
* body that is not a commit response). Surfacing either to a creator whose bytes
|
|
319
|
+
* are all safely stored would be manufacturing an incident.
|
|
320
|
+
*
|
|
321
|
+
* When the last attempt's answer never arrived, the outcome is unknown and the
|
|
322
|
+
* copy says so: the version may already be live.
|
|
222
323
|
*/
|
|
223
324
|
async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
224
325
|
let announcedSlow = false;
|
|
225
326
|
for (let attempt = 1; attempt <= COMMIT_RETRIES; attempt++) {
|
|
226
327
|
try {
|
|
227
328
|
const result = await commitUpload(cfg, i.projectId, uploadId, i.fetchImpl);
|
|
228
|
-
|
|
229
|
-
deps.print(l);
|
|
329
|
+
await reportVisibility(deps, i, result);
|
|
230
330
|
return 0;
|
|
231
331
|
}
|
|
232
332
|
catch (err) {
|
|
233
|
-
|
|
234
|
-
|
|
333
|
+
// The token can lapse during a long upload. The files are stored and the
|
|
334
|
+
// live address is unchanged, so signing in and publishing again is safe.
|
|
335
|
+
if (err instanceof AuthExpiredError)
|
|
336
|
+
return authExpired(deps);
|
|
337
|
+
const unanswered = err instanceof MalformedCommitResponse || isTransportError(err);
|
|
235
338
|
// The plan limit is a 409 and falls past this branch deliberately. It is
|
|
236
339
|
// the interlock refusing, not the flip failing, and retrying it three
|
|
237
340
|
// times would be the retry loop the refusal exists to stop.
|
|
238
|
-
|
|
341
|
+
const flipFailed = err instanceof PublishApiError &&
|
|
342
|
+
(err.code === "publish_flip_failed" || err.status === 502);
|
|
343
|
+
if (unanswered || flipFailed) {
|
|
239
344
|
if (attempt === COMMIT_RETRIES) {
|
|
240
|
-
|
|
345
|
+
const lines = unanswered
|
|
346
|
+
? copy.commitOutcomeUnknown(i.address, i.liveVersion)
|
|
347
|
+
: copy.flipFailed(i.address, i.liveVersion);
|
|
348
|
+
for (const l of lines)
|
|
241
349
|
deps.print(l);
|
|
242
350
|
return 1;
|
|
243
351
|
}
|
|
@@ -247,9 +355,11 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
247
355
|
deps.print(copy.takingLonger());
|
|
248
356
|
announcedSlow = true;
|
|
249
357
|
}
|
|
250
|
-
await deps.sleep(COMMIT_RETRY_DELAY_MS);
|
|
358
|
+
await deps.sleep(COMMIT_RETRY_DELAY_MS * 2 ** (attempt - 1));
|
|
251
359
|
continue;
|
|
252
360
|
}
|
|
361
|
+
if (!(err instanceof PublishApiError))
|
|
362
|
+
throw err;
|
|
253
363
|
for (const l of commitErrorLines(err, i.address, i.liveVersion, i.org))
|
|
254
364
|
deps.print(l);
|
|
255
365
|
return 1;
|
|
@@ -257,6 +367,32 @@ async function commitWithHealing(deps, cfg, i, uploadId) {
|
|
|
257
367
|
}
|
|
258
368
|
return 1;
|
|
259
369
|
}
|
|
370
|
+
/**
|
|
371
|
+
* "Live" is printed only once the public address serves this commit's index.html.
|
|
372
|
+
* Running out the wait is still exit 0: the commit happened, only the edge lags.
|
|
373
|
+
*/
|
|
374
|
+
async function reportVisibility(deps, i, result) {
|
|
375
|
+
const index = i.manifest.files.find((f) => f.path === "index.html");
|
|
376
|
+
const { visible, waitedMs } = index
|
|
377
|
+
? await waitUntilServing(result.liveUrl, index.sha256, {
|
|
378
|
+
fetchImpl: i.probeFetch,
|
|
379
|
+
now: deps.now,
|
|
380
|
+
sleep: deps.sleep,
|
|
381
|
+
onWaiting: () => deps.print(copy.propagating(result.liveUrl, result.version)),
|
|
382
|
+
})
|
|
383
|
+
: { visible: true, waitedMs: 0 };
|
|
384
|
+
const lines = visible
|
|
385
|
+
? copy.liveAt(result.liveUrl, result.version)
|
|
386
|
+
: copy.notYetVisible(result.liveUrl, result.version);
|
|
387
|
+
for (const l of lines)
|
|
388
|
+
deps.print(l);
|
|
389
|
+
i.onCommitted({
|
|
390
|
+
state: visible ? "live" : "committed_not_visible",
|
|
391
|
+
version: result.version,
|
|
392
|
+
liveUrl: result.liveUrl,
|
|
393
|
+
waitedMs,
|
|
394
|
+
});
|
|
395
|
+
}
|
|
260
396
|
/**
|
|
261
397
|
* The plan-limit refusal, from the detail the API sends. It is reachable from
|
|
262
398
|
* open AND from commit, because the interlock lives at the port and every flip
|
|
@@ -283,6 +419,10 @@ function commitErrorLines(err, address, liveVersion, org) {
|
|
|
283
419
|
switch (err.code) {
|
|
284
420
|
case "unavailable_plan_limit":
|
|
285
421
|
return planLimitLines(err, org);
|
|
422
|
+
case "published_limit":
|
|
423
|
+
// Two opens admitted in the same org race to go live; commit re-checks and
|
|
424
|
+
// refuses the loser, whose version the api keeps stored.
|
|
425
|
+
return publishedLimitRefused(resolveCountLimit(err.detail, org), "commit");
|
|
286
426
|
case "publish_expired":
|
|
287
427
|
return copy.publishExpired(address, liveVersion);
|
|
288
428
|
case "publish_contended":
|
|
@@ -297,8 +437,8 @@ function commitErrorLines(err, address, liveVersion, org) {
|
|
|
297
437
|
`✗ ${err.message}`,
|
|
298
438
|
"",
|
|
299
439
|
liveVersion === undefined
|
|
300
|
-
? ` Your live address is unchanged
|
|
301
|
-
: ` Your live address is unchanged
|
|
440
|
+
? ` Your live address is unchanged: ${address}.`
|
|
441
|
+
: ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
|
|
302
442
|
"",
|
|
303
443
|
" Publish again: vincentt publish",
|
|
304
444
|
"",
|
|
@@ -318,8 +458,18 @@ function openErrorLines(err, address, liveVersion, org) {
|
|
|
318
458
|
if (err.code === "unavailable_plan_limit") {
|
|
319
459
|
return planLimitLines(err, org);
|
|
320
460
|
}
|
|
461
|
+
if (err.code === "published_limit") {
|
|
462
|
+
return publishedLimitRefused(resolveCountLimit(err.detail, org), "open");
|
|
463
|
+
}
|
|
464
|
+
// Refused only at open, before a presigned URL is minted.
|
|
465
|
+
if (err.code === "storage_ceiling") {
|
|
466
|
+
// It takes no detail. The body is `{"limit":"storage"}` and names nothing
|
|
467
|
+
// else by design — the ceiling is an abuse ceiling, not a plan limit.
|
|
468
|
+
return storageCeilingRefused();
|
|
469
|
+
}
|
|
321
470
|
if (err.code === "publish_too_large") {
|
|
322
|
-
|
|
471
|
+
// `limit` is the MAGNITUDE on this code. See ApiErrorDetail.limit.
|
|
472
|
+
return copy.tooLargeFromServer(err.detail.observed ?? 0, numericLimit(err.detail.limit));
|
|
323
473
|
}
|
|
324
474
|
if (err.code === "rate_limited") {
|
|
325
475
|
return copy.rateLimited(err.retryAfterSec ?? 60);
|
|
@@ -332,20 +482,62 @@ function openErrorLines(err, address, liveVersion, org) {
|
|
|
332
482
|
`✗ ${err.message}`,
|
|
333
483
|
"",
|
|
334
484
|
liveVersion === undefined
|
|
335
|
-
? ` Your live address is unchanged
|
|
336
|
-
: ` Your live address is unchanged
|
|
485
|
+
? ` Your live address is unchanged: ${address}.`
|
|
486
|
+
: ` Your live address is unchanged: ${address} is still serving v${liveVersion}.`,
|
|
337
487
|
"",
|
|
338
488
|
" Nothing was uploaded.",
|
|
339
489
|
"",
|
|
340
490
|
];
|
|
341
491
|
}
|
|
342
|
-
|
|
492
|
+
/**
|
|
493
|
+
* `detail.limit` on a code whose `limit` is a MAGNITUDE. A string there is the
|
|
494
|
+
* axis-name meaning arriving on a body that should not carry it, and it reads as
|
|
495
|
+
* 0 rather than as `NaN` — a bound printed as "NaN MB" is worse than one printed
|
|
496
|
+
* as a figure the copy already handles.
|
|
497
|
+
*/
|
|
498
|
+
function numericLimit(limit) {
|
|
499
|
+
return typeof limit === "number" ? limit : 0;
|
|
500
|
+
}
|
|
501
|
+
function manifestErrorLines(err, distDir, ctx) {
|
|
343
502
|
if (err.reason === "no_dist")
|
|
344
|
-
return copy.noDist(distDir);
|
|
503
|
+
return copy.noDist(distDir, ctx.buildCommand);
|
|
345
504
|
if (err.reason === "no_index_html")
|
|
346
|
-
return copy.noIndexHtml(distDir);
|
|
505
|
+
return copy.noIndexHtml(distDir, ctx.buildCommand);
|
|
506
|
+
if (err.reason === "output_is_repo_root")
|
|
507
|
+
return copy.outputIsRoot(ctx.origin);
|
|
508
|
+
if (err.reason === "output_in_hidden") {
|
|
509
|
+
return copy.outputInHidden(path.relative(ctx.root, distDir).split(path.sep).join("/"), err.offendingPath);
|
|
510
|
+
}
|
|
511
|
+
if (err.reason === "output_outside_repo") {
|
|
512
|
+
return copy.outputOutside(path.relative(ctx.root, distDir).split(path.sep).join("/"));
|
|
513
|
+
}
|
|
347
514
|
return copy.badPath(err.reason, err.offendingPath);
|
|
348
515
|
}
|
|
516
|
+
/** `dist/` for the default; the resolved folder and its source otherwise. */
|
|
517
|
+
function outputLabelOf(root, distDir, source) {
|
|
518
|
+
const rel = path.relative(root, distDir).split(path.sep).join("/");
|
|
519
|
+
const folder = `${rel || "."}/`;
|
|
520
|
+
const sourceNote = source === "vincentt.json"
|
|
521
|
+
? "(from vincentt.json)"
|
|
522
|
+
: source === "flag"
|
|
523
|
+
? "(from --dist)"
|
|
524
|
+
: "";
|
|
525
|
+
return { folder, sourceNote };
|
|
526
|
+
}
|
|
527
|
+
/** Every `.html` in the manifest, for the root-absolute reference check. */
|
|
528
|
+
async function readHtmlFiles(distDir, manifest) {
|
|
529
|
+
const out = [];
|
|
530
|
+
for (const f of manifest.files) {
|
|
531
|
+
if (!f.path.toLowerCase().endsWith(".html"))
|
|
532
|
+
continue;
|
|
533
|
+
const html = await fs
|
|
534
|
+
.readFile(path.join(distDir, ...f.path.split("/")), "utf8")
|
|
535
|
+
.catch(() => undefined);
|
|
536
|
+
if (html !== undefined)
|
|
537
|
+
out.push({ file: f.path, html });
|
|
538
|
+
}
|
|
539
|
+
return out;
|
|
540
|
+
}
|
|
349
541
|
/**
|
|
350
542
|
* The address as a creator reads it: the host, never the scheme. `liveUrl` is
|
|
351
543
|
* server-computed (the apex differs per environment), so it is the authority and
|
|
@@ -361,6 +553,12 @@ function addressOf(project) {
|
|
|
361
553
|
return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
|
|
362
554
|
}
|
|
363
555
|
}
|
|
556
|
+
/** The api refused the token: the sign-in remedy, and the not-signed-in exit code. */
|
|
557
|
+
function authExpired(deps) {
|
|
558
|
+
for (const l of authExpiredLines())
|
|
559
|
+
deps.print(l);
|
|
560
|
+
return EXIT.NOT_AUTHENTICATED;
|
|
561
|
+
}
|
|
364
562
|
async function orgOf(deps) {
|
|
365
563
|
return (await deps.orgName?.()) ?? "";
|
|
366
564
|
}
|
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
|