@vincentt-xr/cli 0.2.0 → 0.3.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/dist/cli/args.d.ts +1 -0
- package/dist/cli/args.js +10 -0
- package/dist/cli/index.js +57 -11
- package/dist/lib/backend.d.ts +0 -16
- package/dist/lib/backend.js +0 -34
- package/dist/lib/lifecycle.d.ts +12 -4
- package/dist/lib/lifecycle.js +36 -30
- package/dist/mcp/server.js +5 -5
- package/dist/publish/api.d.ts +67 -0
- package/dist/publish/api.js +96 -0
- package/dist/publish/bounds.d.ts +25 -0
- package/dist/publish/bounds.js +53 -0
- package/dist/publish/classify.d.ts +51 -0
- package/dist/publish/classify.js +138 -0
- package/dist/publish/command.d.ts +27 -0
- package/dist/publish/command.js +328 -0
- package/dist/publish/consoleVerbs.d.ts +17 -0
- package/dist/publish/consoleVerbs.js +26 -0
- package/dist/publish/copy.d.ts +180 -0
- package/dist/publish/copy.js +606 -0
- package/dist/publish/defaults.d.ts +16 -0
- package/dist/publish/defaults.js +52 -0
- package/dist/publish/manifest.d.ts +27 -0
- package/dist/publish/manifest.js +81 -0
- package/dist/publish/paths.d.ts +25 -0
- package/dist/publish/paths.js +78 -0
- package/dist/publish/progress.d.ts +17 -0
- package/dist/publish/progress.js +45 -0
- package/dist/publish/prompt.d.ts +4 -0
- package/dist/publish/prompt.js +31 -0
- package/dist/publish/qa.f4.fixtures.d.ts +25 -0
- package/dist/publish/qa.f4.fixtures.js +140 -0
- package/dist/publish/upload.d.ts +30 -0
- package/dist/publish/upload.js +93 -0
- package/package.json +1 -1
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
// Every line `vincentt publish` prints (Flows L22-L31). Pure functions returning
|
|
2
|
+
// string arrays, so the transcripts are assertable without a terminal.
|
|
3
|
+
//
|
|
4
|
+
// Five vocabulary rules govern all of it:
|
|
5
|
+
// - "Permanent" is the word for the address, never "locked", "frozen" or
|
|
6
|
+
// "reserved". Locked and frozen imply a keyholder; there isn't one.
|
|
7
|
+
// - A version is "v5", always, everywhere. Never "version 5", never a hash.
|
|
8
|
+
// - "Live" is the word and "latest" is not a word this product has.
|
|
9
|
+
// - The checklist "found" things; it never "failed", "flagged" or "blocked" them.
|
|
10
|
+
// - Unpublish "takes it offline"; it never "removes" or "deletes".
|
|
11
|
+
//
|
|
12
|
+
// NEVER PRINTED, at any point, in any state: the project id, the org id, the
|
|
13
|
+
// uploadId, the PAT, any presigned URL or fragment of one, the bucket, any S3
|
|
14
|
+
// key, or a per-file upload log.
|
|
15
|
+
/** The only horizontal rules in any f4 output. They exist here and nowhere else. */
|
|
16
|
+
const RULE = " ─────────────────────────────────────────────────────────────────────";
|
|
17
|
+
const LABELS = {
|
|
18
|
+
title: "Title",
|
|
19
|
+
description: "Description",
|
|
20
|
+
image: "Social image",
|
|
21
|
+
};
|
|
22
|
+
const VALUE_MAX = 60;
|
|
23
|
+
/** `Social image` is the longest label; the column is sized to it plus a gap. */
|
|
24
|
+
const LABEL_WIDTH = 13;
|
|
25
|
+
export function truncate(value, max = VALUE_MAX) {
|
|
26
|
+
return value.length <= max ? value : `${value.slice(0, max - 1)}…`;
|
|
27
|
+
}
|
|
28
|
+
// ---- units ------------------------------------------------------------------
|
|
29
|
+
/** Bytes as the creator reads them. Decimal MB, matching what a build tool prints. */
|
|
30
|
+
export function formatBytes(bytes) {
|
|
31
|
+
if (bytes < 1000)
|
|
32
|
+
return `${bytes} B`;
|
|
33
|
+
const kb = bytes / 1000;
|
|
34
|
+
if (kb < 1000)
|
|
35
|
+
return `${round1(kb)} kB`;
|
|
36
|
+
const mb = kb / 1000;
|
|
37
|
+
if (mb < 1000)
|
|
38
|
+
return `${round1(mb)} MB`;
|
|
39
|
+
return `${round1(mb / 1000)} GB`;
|
|
40
|
+
}
|
|
41
|
+
function round1(n) {
|
|
42
|
+
const r = Math.round(n * 10) / 10;
|
|
43
|
+
return Number.isInteger(r) ? String(r) : r.toFixed(1);
|
|
44
|
+
}
|
|
45
|
+
/** The limit, printed as a plain number and never as policy. */
|
|
46
|
+
export function formatLimit(bytes) {
|
|
47
|
+
return `${Math.round(bytes / 1_048_576)} MB`;
|
|
48
|
+
}
|
|
49
|
+
export function relativeTime(iso, now) {
|
|
50
|
+
const then = Date.parse(iso);
|
|
51
|
+
if (Number.isNaN(then))
|
|
52
|
+
return "";
|
|
53
|
+
const seconds = Math.max(0, Math.round((now - then) / 1000));
|
|
54
|
+
if (seconds < 60)
|
|
55
|
+
return "just now";
|
|
56
|
+
const minutes = Math.round(seconds / 60);
|
|
57
|
+
if (minutes < 60)
|
|
58
|
+
return `${minutes} minute${minutes === 1 ? "" : "s"} ago`;
|
|
59
|
+
const hours = Math.round(minutes / 60);
|
|
60
|
+
if (hours < 24)
|
|
61
|
+
return `${hours} hour${hours === 1 ? "" : "s"} ago`;
|
|
62
|
+
const days = Math.round(hours / 24);
|
|
63
|
+
if (days < 30)
|
|
64
|
+
return `${days} day${days === 1 ? "" : "s"} ago`;
|
|
65
|
+
const months = Math.round(days / 30);
|
|
66
|
+
return `${months} month${months === 1 ? "" : "s"} ago`;
|
|
67
|
+
}
|
|
68
|
+
// ---- the findings block -----------------------------------------------------
|
|
69
|
+
/**
|
|
70
|
+
* Flow L25. Three registers of copy because the platform's confidence genuinely
|
|
71
|
+
* differs:
|
|
72
|
+
* - TEMPLATE-DEFAULT is flat and assertive. The CLI matched a string in a
|
|
73
|
+
* corpus it ships; it knows.
|
|
74
|
+
* - LIKELY-DEFAULT is hedged. The heuristic will be wrong sometimes, and a
|
|
75
|
+
* creator whose experience is genuinely called "AR" must not be told they
|
|
76
|
+
* made a mistake.
|
|
77
|
+
* - The dead og:image is the only ✗ the checklist ever emits, and it earns it:
|
|
78
|
+
* the manifest either contains the path or it does not.
|
|
79
|
+
*
|
|
80
|
+
* BOTH default tiers print the offending value. "Still the template default"
|
|
81
|
+
* without the string is a claim the creator has to go and verify; with it they
|
|
82
|
+
* either recognise it instantly or spot that we are wrong.
|
|
83
|
+
*
|
|
84
|
+
* A FINDING IS NEVER A COUNT. No "2 issues found", no summary line, and no exit
|
|
85
|
+
* code that varies with findings.
|
|
86
|
+
*/
|
|
87
|
+
export function findingLines(byFile, withSubtitle) {
|
|
88
|
+
const out = [];
|
|
89
|
+
out.push(withSubtitle
|
|
90
|
+
? " Link preview (what Slack, iMessage and WhatsApp will show)"
|
|
91
|
+
: " Link preview");
|
|
92
|
+
out.push("");
|
|
93
|
+
const multi = byFile.length > 1;
|
|
94
|
+
for (const file of byFile) {
|
|
95
|
+
// A dist with index.html and gallery.html prints two blocks with the
|
|
96
|
+
// filename as a sub-heading, rather than repeating the path on every line.
|
|
97
|
+
if (multi) {
|
|
98
|
+
out.push(` ${file.file}`);
|
|
99
|
+
out.push("");
|
|
100
|
+
}
|
|
101
|
+
for (const finding of file.findings) {
|
|
102
|
+
out.push(...findingLine(finding, file.file, multi));
|
|
103
|
+
}
|
|
104
|
+
if (multi)
|
|
105
|
+
out.push("");
|
|
106
|
+
}
|
|
107
|
+
if (!multi)
|
|
108
|
+
out.push("");
|
|
109
|
+
return out;
|
|
110
|
+
}
|
|
111
|
+
function findingLine(f, file, indented) {
|
|
112
|
+
const pad = indented ? " " : "";
|
|
113
|
+
const label = LABELS[f.field].padEnd(LABEL_WIDTH);
|
|
114
|
+
// The file · tag sits on a continuation line indented to the VALUE column, so
|
|
115
|
+
// the eye reads three labels and three values and the fix detail stays out of
|
|
116
|
+
// the scan. 4 (gutter) + 3 (glyph) + the label width + 1.
|
|
117
|
+
const detail = `${pad}${" ".repeat(4 + 3 + LABEL_WIDTH + 1)}${file} · ${f.tag}`;
|
|
118
|
+
switch (f.tier) {
|
|
119
|
+
case "present":
|
|
120
|
+
return [`${pad} ✓ ${label} ${truncate(f.value ?? "")}`];
|
|
121
|
+
case "missing":
|
|
122
|
+
return [`${pad} ! ${label} not found`, detail];
|
|
123
|
+
case "template-default":
|
|
124
|
+
return [
|
|
125
|
+
`${pad} ! ${label} still the template default: "${truncate(f.value ?? "")}"`,
|
|
126
|
+
detail,
|
|
127
|
+
];
|
|
128
|
+
case "likely-default":
|
|
129
|
+
return [`${pad} ! ${label} looks unset: "${truncate(f.value ?? "")}"`, detail];
|
|
130
|
+
case "dead-image":
|
|
131
|
+
return [`${pad} ✗ ${label} ${f.value} is not in dist/`, detail];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
/**
|
|
135
|
+
* Mandatory and always present. A screen with a red ✗ on it reads as a gate
|
|
136
|
+
* unless it explicitly says otherwise, and a creator who believes the platform
|
|
137
|
+
* is gating their publish on our opinion of their <meta> tags has learned
|
|
138
|
+
* something false about who owns the dist.
|
|
139
|
+
*/
|
|
140
|
+
export function nothingBlocksYou() {
|
|
141
|
+
return [
|
|
142
|
+
" Scrapers don't run JavaScript, so these have to be in the HTML your",
|
|
143
|
+
" build produces. Fix them in your source and publish again — or publish",
|
|
144
|
+
" now and fix them later. Nothing here blocks you.",
|
|
145
|
+
"",
|
|
146
|
+
];
|
|
147
|
+
}
|
|
148
|
+
export function readingLine(fileCount, totalBytes) {
|
|
149
|
+
return `→ Reading dist/ … ${fileCount} files · ${formatBytes(totalBytes)}`;
|
|
150
|
+
}
|
|
151
|
+
export function headerLine(projectName, orgName) {
|
|
152
|
+
return orgName ? `→ ${projectName} · ${orgName}` : `→ ${projectName}`;
|
|
153
|
+
}
|
|
154
|
+
/**
|
|
155
|
+
* Flow L22 — the single most consequential screen in the product, and its ORDER
|
|
156
|
+
* is the design.
|
|
157
|
+
*
|
|
158
|
+
* The address block is FIRST and it is fenced: a creator who reads nothing else
|
|
159
|
+
* reads the address and the sentence under it. The findings come AFTER, never
|
|
160
|
+
* before — the address is the irreversible thing and the metadata is fixable
|
|
161
|
+
* forever, so putting a red ✗ above the permanence block would make the eye land
|
|
162
|
+
* on the recoverable problem and skim the unrecoverable one.
|
|
163
|
+
*/
|
|
164
|
+
export function firstPublishChecklist(i) {
|
|
165
|
+
return [
|
|
166
|
+
"",
|
|
167
|
+
headerLine(i.projectName, i.orgName),
|
|
168
|
+
readingLine(i.fileCount, i.totalBytes),
|
|
169
|
+
"",
|
|
170
|
+
RULE,
|
|
171
|
+
" FIRST PUBLISH",
|
|
172
|
+
"",
|
|
173
|
+
" This address becomes permanent:",
|
|
174
|
+
"",
|
|
175
|
+
// Indented, alone, unadorned. No label, no backticks, no https:// — this is
|
|
176
|
+
// not a link to copy, it is a string to READ, and the creator is about to
|
|
177
|
+
// type `y` on it. Bare and surrounded by whitespace is what makes a typo
|
|
178
|
+
// visible.
|
|
179
|
+
` ${i.address}`,
|
|
180
|
+
"",
|
|
181
|
+
" After this it is yours forever, and can never be changed or reused —",
|
|
182
|
+
" not by you, not by anyone else. Publishing again replaces what it",
|
|
183
|
+
" serves; it never changes the address.",
|
|
184
|
+
"",
|
|
185
|
+
RULE,
|
|
186
|
+
"",
|
|
187
|
+
...findingLines(i.findings, true),
|
|
188
|
+
...nothingBlocksYou(),
|
|
189
|
+
` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
|
|
190
|
+
"",
|
|
191
|
+
];
|
|
192
|
+
}
|
|
193
|
+
/** The prompt line restates the address AND the version. `[y/N]` — an accidental Enter does nothing. */
|
|
194
|
+
export function firstPublishPrompt(address, version) {
|
|
195
|
+
return ` Make ${address} permanent and publish v${version}? [y/N] `;
|
|
196
|
+
}
|
|
197
|
+
/**
|
|
198
|
+
* Declining. Glyphless, exit 0, zero network calls beyond the project read.
|
|
199
|
+
* Declining is not a failure and is not an error; it is the prompt working.
|
|
200
|
+
*
|
|
201
|
+
* The second line is the one that matters — a creator who backed out because the
|
|
202
|
+
* address was wrong needs to know the address is still theirs to change.
|
|
203
|
+
*/
|
|
204
|
+
export function declined() {
|
|
205
|
+
return [
|
|
206
|
+
"",
|
|
207
|
+
" Nothing was published. The address is still yours to change:",
|
|
208
|
+
" vincentt publish when you're ready.",
|
|
209
|
+
"",
|
|
210
|
+
];
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Flow L24. No fence, no permanence block, no disclosure: the address is already
|
|
214
|
+
* permanent and the creator already knows, and presenting a frozen fact as a
|
|
215
|
+
* choice is worse than not presenting it at all.
|
|
216
|
+
*
|
|
217
|
+
* `(v5, published 3 days ago)` is rendered from the project read ALONE —
|
|
218
|
+
* liveVersion and livePublishedAt both ride GET /projects/{id}.
|
|
219
|
+
*/
|
|
220
|
+
export function republishChecklist(i) {
|
|
221
|
+
const when = i.livePublishedAt ? relativeTime(i.livePublishedAt, i.now) : "";
|
|
222
|
+
const context = when ? `(v${i.liveVersion}, published ${when})` : `(v${i.liveVersion})`;
|
|
223
|
+
return [
|
|
224
|
+
"",
|
|
225
|
+
headerLine(i.projectName, i.orgName),
|
|
226
|
+
readingLine(i.fileCount, i.totalBytes),
|
|
227
|
+
"",
|
|
228
|
+
` Live at ${i.address} ${context}`,
|
|
229
|
+
` Publishing v${i.nextVersion} · ${i.fileCount} files · ${formatBytes(i.totalBytes)}`,
|
|
230
|
+
"",
|
|
231
|
+
...findingLines(i.findings, false),
|
|
232
|
+
];
|
|
233
|
+
}
|
|
234
|
+
/** `[Y/n]` — on a republish an accidental Enter does the thing the creator typed the command to do. */
|
|
235
|
+
export function republishPrompt(version) {
|
|
236
|
+
return ` Publish v${version} and make it live? [Y/n] `;
|
|
237
|
+
}
|
|
238
|
+
// ---- Flow L31, the offline address -------------------------------------------
|
|
239
|
+
/**
|
|
240
|
+
* A publish onto an offline address brings it online, because publish always
|
|
241
|
+
* goes live. The creator must be told: they took it offline on purpose and might
|
|
242
|
+
* not intend to reverse that, so the default reverts to [y/N].
|
|
243
|
+
*/
|
|
244
|
+
export function offlineChecklist(i) {
|
|
245
|
+
return [
|
|
246
|
+
"",
|
|
247
|
+
headerLine(i.projectName, i.orgName),
|
|
248
|
+
readingLine(i.fileCount, i.totalBytes),
|
|
249
|
+
"",
|
|
250
|
+
`! ${i.address} is currently offline.`,
|
|
251
|
+
"",
|
|
252
|
+
` Publishing v${i.nextVersion} will put it back online and make v${i.nextVersion} live.`,
|
|
253
|
+
"",
|
|
254
|
+
...findingLines(i.findings, false),
|
|
255
|
+
];
|
|
256
|
+
}
|
|
257
|
+
export function offlinePrompt(version) {
|
|
258
|
+
return ` Publish v${version} and bring the address back online? [y/N] `;
|
|
259
|
+
}
|
|
260
|
+
// ---- Flow L31, not signed in -------------------------------------------------
|
|
261
|
+
/**
|
|
262
|
+
* f1's line verbatim, with f4's reason. Note what is ABSENT, deliberately: f3's
|
|
263
|
+
* "your local dev loop doesn't need one" paragraph. It is the right copy for
|
|
264
|
+
* preview and the wrong copy here — a creator trying to publish is not looking
|
|
265
|
+
* for a smaller room, and offering one at this gate reads as a deflection.
|
|
266
|
+
*/
|
|
267
|
+
export function notSignedIn() {
|
|
268
|
+
return [
|
|
269
|
+
"",
|
|
270
|
+
"✗ You're not signed in.",
|
|
271
|
+
"",
|
|
272
|
+
" Publishing puts your build at a permanent address, and that needs an account.",
|
|
273
|
+
"",
|
|
274
|
+
" Sign in: vincentt login",
|
|
275
|
+
"",
|
|
276
|
+
];
|
|
277
|
+
}
|
|
278
|
+
// ---- Flow L28, the bounds ----------------------------------------------------
|
|
279
|
+
/**
|
|
280
|
+
* Loud, local, instant, and it carries the real number. This fires before a
|
|
281
|
+
* single byte moves and before a single request is made.
|
|
282
|
+
*
|
|
283
|
+
* The two numbers are STACKED AND ALIGNED — the judgement the creator must make
|
|
284
|
+
* is a comparison, and prose defeats it. The three largest files are named
|
|
285
|
+
* because that is almost always the entire diagnosis: one uncompressed asset is
|
|
286
|
+
* nearly always the cause.
|
|
287
|
+
*
|
|
288
|
+
* The limit is printed as a number, never as policy. No "on your plan", no
|
|
289
|
+
* "upgrade" — it is a correctness limit and not a quota, and copy implying
|
|
290
|
+
* otherwise would be the first sentence of a billing model this feature does not
|
|
291
|
+
* have.
|
|
292
|
+
*/
|
|
293
|
+
export function tooLarge(v) {
|
|
294
|
+
if (v.kind === "file") {
|
|
295
|
+
return [
|
|
296
|
+
"",
|
|
297
|
+
"✗ One file is too large to publish.",
|
|
298
|
+
"",
|
|
299
|
+
...alignedPair([v.path ?? "", formatBytes(v.observed)], ["the per-file limit", formatLimit(v.limit)]),
|
|
300
|
+
"",
|
|
301
|
+
" Compress or externalise it, rebuild, then publish again.",
|
|
302
|
+
"",
|
|
303
|
+
" Nothing was uploaded.",
|
|
304
|
+
"",
|
|
305
|
+
];
|
|
306
|
+
}
|
|
307
|
+
if (v.kind === "count") {
|
|
308
|
+
return [
|
|
309
|
+
"",
|
|
310
|
+
"✗ This build has too many files to publish.",
|
|
311
|
+
"",
|
|
312
|
+
...alignedPair(["your dist/", `${v.observed} files`], ["the limit", `${v.limit} files`]),
|
|
313
|
+
"",
|
|
314
|
+
" Bundle or externalise the extras, rebuild, then publish again.",
|
|
315
|
+
"",
|
|
316
|
+
" Nothing was uploaded.",
|
|
317
|
+
"",
|
|
318
|
+
];
|
|
319
|
+
}
|
|
320
|
+
return [
|
|
321
|
+
"",
|
|
322
|
+
"✗ This build is too large to publish.",
|
|
323
|
+
"",
|
|
324
|
+
...alignedPair(["your dist/", formatBytes(v.observed)], ["the limit", formatLimit(v.limit)]),
|
|
325
|
+
"",
|
|
326
|
+
" The largest files are:",
|
|
327
|
+
"",
|
|
328
|
+
...largestBlock(v),
|
|
329
|
+
"",
|
|
330
|
+
// The second verb matters: a 214 MB scan may belong on the creator's own CDN
|
|
331
|
+
// rather than inside the bundle, and a creator told only to compress will
|
|
332
|
+
// spend an hour on the wrong fix.
|
|
333
|
+
" Compress or externalise the big ones, rebuild, then publish again.",
|
|
334
|
+
"",
|
|
335
|
+
" Nothing was uploaded.",
|
|
336
|
+
"",
|
|
337
|
+
];
|
|
338
|
+
}
|
|
339
|
+
function alignedPair(a, b) {
|
|
340
|
+
const labelWidth = Math.max(a[0].length, b[0].length);
|
|
341
|
+
const valueWidth = Math.max(a[1].length, b[1].length);
|
|
342
|
+
return [
|
|
343
|
+
` ${a[0].padEnd(labelWidth)} ${a[1].padStart(valueWidth)}`,
|
|
344
|
+
` ${b[0].padEnd(labelWidth)} ${b[1].padStart(valueWidth)}`,
|
|
345
|
+
];
|
|
346
|
+
}
|
|
347
|
+
function largestBlock(v) {
|
|
348
|
+
const width = Math.max(0, ...v.largest.map((f) => f.path.length));
|
|
349
|
+
const sizeWidth = Math.max(0, ...v.largest.map((f) => formatBytes(f.size).length));
|
|
350
|
+
return v.largest.map((f) => ` ${f.path.padEnd(width)} ${formatBytes(f.size).padStart(sizeWidth)}`);
|
|
351
|
+
}
|
|
352
|
+
/** No dist at all, or no index.html at its root. Zero network calls. */
|
|
353
|
+
export function noDist(distDir) {
|
|
354
|
+
return [
|
|
355
|
+
"",
|
|
356
|
+
`✗ There's no build to publish at ${distDir}.`,
|
|
357
|
+
"",
|
|
358
|
+
" Publishing uploads what your build produced — did the build run?",
|
|
359
|
+
"",
|
|
360
|
+
" Build it, then publish: npm run build",
|
|
361
|
+
"",
|
|
362
|
+
" Nothing was uploaded.",
|
|
363
|
+
"",
|
|
364
|
+
];
|
|
365
|
+
}
|
|
366
|
+
export function noIndexHtml(distDir) {
|
|
367
|
+
return [
|
|
368
|
+
"",
|
|
369
|
+
"✗ There's no index.html at the root of your build.",
|
|
370
|
+
"",
|
|
371
|
+
` ${distDir} exists, but nothing there answers the address's own URL —`,
|
|
372
|
+
" did the build run?",
|
|
373
|
+
"",
|
|
374
|
+
" Build it, then publish: npm run build",
|
|
375
|
+
"",
|
|
376
|
+
" Nothing was uploaded.",
|
|
377
|
+
"",
|
|
378
|
+
];
|
|
379
|
+
}
|
|
380
|
+
/** A local path rule the creator's own build broke. Local, before any request. */
|
|
381
|
+
export function badPath(reason, offending) {
|
|
382
|
+
const why = {
|
|
383
|
+
path_traversal: "has a path that points outside your build",
|
|
384
|
+
absolute_path: "has an absolute path",
|
|
385
|
+
duplicate_path: "has the same path twice",
|
|
386
|
+
case_duplicate: "has two files whose names differ only by capitalisation, which your machine treats as one file and the web does not",
|
|
387
|
+
bad_encoding: "has a path we can't publish safely",
|
|
388
|
+
reserved_path: "has a top-level folder starting with `_`, which is reserved",
|
|
389
|
+
};
|
|
390
|
+
return [
|
|
391
|
+
"",
|
|
392
|
+
`✗ This build ${why[reason] ?? "has a path we can't publish"}.`,
|
|
393
|
+
"",
|
|
394
|
+
` ${offending}`,
|
|
395
|
+
"",
|
|
396
|
+
" Rename it in your source, rebuild, then publish again.",
|
|
397
|
+
"",
|
|
398
|
+
" Nothing was uploaded.",
|
|
399
|
+
"",
|
|
400
|
+
];
|
|
401
|
+
}
|
|
402
|
+
// ---- Flow L27, the upload ----------------------------------------------------
|
|
403
|
+
const BAR_WIDTH = 28;
|
|
404
|
+
/** The one redrawn line in f4. A bounded progress bar with a known total is not a stream. */
|
|
405
|
+
export function progressBar(done, total, bytesDone, bytesTotal) {
|
|
406
|
+
const filled = total === 0 ? BAR_WIDTH : Math.round((done / total) * BAR_WIDTH);
|
|
407
|
+
const bar = "█".repeat(filled) + "░".repeat(Math.max(0, BAR_WIDTH - filled));
|
|
408
|
+
return ` ${bar} ${done}/${total} · ${formatBytes(bytesDone)} / ${formatBytes(bytesTotal)}`;
|
|
409
|
+
}
|
|
410
|
+
/** The non-TTY degrade: periodic plain lines, at most one per 2s. */
|
|
411
|
+
export function progressPlain(done, total, bytesDone) {
|
|
412
|
+
return `→ Uploaded ${done}/${total} · ${formatBytes(bytesDone)}`;
|
|
413
|
+
}
|
|
414
|
+
export function uploadStart(fileCount) {
|
|
415
|
+
return `→ Uploading ${fileCount} files…`;
|
|
416
|
+
}
|
|
417
|
+
export function uploadDone(fileCount, totalBytes, ms) {
|
|
418
|
+
const seconds = Math.max(1, Math.round(ms / 1000));
|
|
419
|
+
return `✓ Uploaded ${fileCount} files · ${formatBytes(totalBytes)} in ${seconds}s`;
|
|
420
|
+
}
|
|
421
|
+
/** Covers the commit. The pause between the last byte and the live URL is real. */
|
|
422
|
+
export function finishing() {
|
|
423
|
+
return "→ Finishing…";
|
|
424
|
+
}
|
|
425
|
+
/**
|
|
426
|
+
* The payload of the entire command: the live URL, full and clickable, with the
|
|
427
|
+
* version in parentheses. The caching sentence is one line and ALWAYS present —
|
|
428
|
+
* a creator refreshing a phone that has the old page in memory will see the old
|
|
429
|
+
* page, decide publish is broken, and publish again.
|
|
430
|
+
*/
|
|
431
|
+
export function liveAt(liveUrl, version) {
|
|
432
|
+
return [
|
|
433
|
+
`✓ Live at ${liveUrl} (v${version})`,
|
|
434
|
+
"",
|
|
435
|
+
" It may take a few seconds to appear on a device that has it cached.",
|
|
436
|
+
"",
|
|
437
|
+
];
|
|
438
|
+
}
|
|
439
|
+
/** Prints only if a retry is needed. It says nothing about a failure, because nothing has failed yet. */
|
|
440
|
+
export function takingLonger() {
|
|
441
|
+
return "! Taking a moment longer than usual…";
|
|
442
|
+
}
|
|
443
|
+
// ---- Flow L29, the server-side failures ---------------------------------------
|
|
444
|
+
/**
|
|
445
|
+
* The cause is NOT named. Every non-healing arm of the commit state machine
|
|
446
|
+
* returns this identically by design, and the CLI must not invent a distinction
|
|
447
|
+
* the wire does not carry. "Had to be restarted" is honest about what happened
|
|
448
|
+
* and is correct for all three arms.
|
|
449
|
+
*/
|
|
450
|
+
export function publishExpired(address, liveVersion) {
|
|
451
|
+
return [
|
|
452
|
+
"",
|
|
453
|
+
"✗ This publish took too long and had to be restarted.",
|
|
454
|
+
"",
|
|
455
|
+
...unchangedLines(address, liveVersion),
|
|
456
|
+
"",
|
|
457
|
+
" Publish again: vincentt publish",
|
|
458
|
+
"",
|
|
459
|
+
];
|
|
460
|
+
}
|
|
461
|
+
/** All three retries failed. "Nothing was lost" is the true and load-bearing sentence. */
|
|
462
|
+
export function flipFailed(address, liveVersion) {
|
|
463
|
+
return [
|
|
464
|
+
"",
|
|
465
|
+
"✗ Your files uploaded, but the address didn't switch over.",
|
|
466
|
+
"",
|
|
467
|
+
liveVersion === undefined
|
|
468
|
+
? ` ${address} is unchanged. Nothing was lost.`
|
|
469
|
+
: ` ${address} is still serving v${liveVersion}. Nothing was lost.`,
|
|
470
|
+
"",
|
|
471
|
+
" Try again: vincentt publish",
|
|
472
|
+
"",
|
|
473
|
+
];
|
|
474
|
+
}
|
|
475
|
+
/**
|
|
476
|
+
* Two sentences, no explanation of version allocation. The real case is a
|
|
477
|
+
* teammate and an agent publishing in the same second, and "someone else
|
|
478
|
+
* published this project" tells the creator to check with a person before
|
|
479
|
+
* re-running — the correct next action, and not something a retry loop can do
|
|
480
|
+
* for them.
|
|
481
|
+
*/
|
|
482
|
+
export function publishContended() {
|
|
483
|
+
return [
|
|
484
|
+
"",
|
|
485
|
+
"✗ Someone else published this project at the same moment.",
|
|
486
|
+
"",
|
|
487
|
+
" Your live address is unchanged. Publish again: vincentt publish",
|
|
488
|
+
"",
|
|
489
|
+
];
|
|
490
|
+
}
|
|
491
|
+
/**
|
|
492
|
+
* The commit found fewer objects than the manifest declared. The response
|
|
493
|
+
* carries COUNTS, never paths, so the CLI diffs against its OWN manifest and
|
|
494
|
+
* prints its own strings.
|
|
495
|
+
*/
|
|
496
|
+
export function publishIncomplete(address, counts, liveVersion) {
|
|
497
|
+
const missing = counts.missing ?? 0;
|
|
498
|
+
const mismatched = counts.mismatched ?? 0;
|
|
499
|
+
const what = mismatched > 0 && missing > 0
|
|
500
|
+
? `${missing} file${missing === 1 ? "" : "s"} didn't arrive and ${mismatched} changed while uploading`
|
|
501
|
+
: mismatched > 0
|
|
502
|
+
? `${mismatched} file${mismatched === 1 ? "" : "s"} changed while uploading`
|
|
503
|
+
: `${missing} file${missing === 1 ? "" : "s"} didn't arrive`;
|
|
504
|
+
return [
|
|
505
|
+
"",
|
|
506
|
+
`✗ The upload didn't complete — ${what}.`,
|
|
507
|
+
"",
|
|
508
|
+
...unchangedLines(address, liveVersion),
|
|
509
|
+
"",
|
|
510
|
+
" Rebuild if anything changed, then publish again: vincentt publish",
|
|
511
|
+
"",
|
|
512
|
+
];
|
|
513
|
+
}
|
|
514
|
+
/** A file's bytes changed under the CLI between hashing and PUT. Names the file. */
|
|
515
|
+
export function badDigest(filePath, address, liveVersion) {
|
|
516
|
+
return [
|
|
517
|
+
"",
|
|
518
|
+
`✗ ${filePath} changed while uploading — rebuild and publish again.`,
|
|
519
|
+
"",
|
|
520
|
+
...unchangedLines(address, liveVersion),
|
|
521
|
+
"",
|
|
522
|
+
" Rebuild, then publish again: npm run build",
|
|
523
|
+
"",
|
|
524
|
+
];
|
|
525
|
+
}
|
|
526
|
+
/**
|
|
527
|
+
* Every failure states the live address is unchanged, because a creator whose
|
|
528
|
+
* publish just failed is asking exactly one question first.
|
|
529
|
+
*/
|
|
530
|
+
function unchangedLines(address, liveVersion) {
|
|
531
|
+
if (liveVersion === undefined) {
|
|
532
|
+
return [` Your live address is unchanged — ${address}.`];
|
|
533
|
+
}
|
|
534
|
+
return [
|
|
535
|
+
` Your live address is unchanged — ${address} is still`,
|
|
536
|
+
` serving v${liveVersion}.`,
|
|
537
|
+
];
|
|
538
|
+
}
|
|
539
|
+
export function tooLargeFromServer(observed, limit) {
|
|
540
|
+
return [
|
|
541
|
+
"",
|
|
542
|
+
"✗ This build is too large to publish.",
|
|
543
|
+
"",
|
|
544
|
+
...alignedPair(["your dist/", formatBytes(observed)], ["the limit", formatLimit(limit)]),
|
|
545
|
+
"",
|
|
546
|
+
" Compress or externalise the big ones, rebuild, then publish again.",
|
|
547
|
+
"",
|
|
548
|
+
" Nothing was uploaded.",
|
|
549
|
+
"",
|
|
550
|
+
];
|
|
551
|
+
}
|
|
552
|
+
export function rateLimited(retryAfterSec) {
|
|
553
|
+
return [
|
|
554
|
+
"",
|
|
555
|
+
`✗ Too many publishes recently. Try again in ${approxWait(retryAfterSec)}.`,
|
|
556
|
+
"",
|
|
557
|
+
" Publish again then: vincentt publish",
|
|
558
|
+
"",
|
|
559
|
+
];
|
|
560
|
+
}
|
|
561
|
+
function approxWait(seconds) {
|
|
562
|
+
if (seconds < 90)
|
|
563
|
+
return "about a minute";
|
|
564
|
+
const minutes = Math.round(seconds / 60);
|
|
565
|
+
if (minutes < 60)
|
|
566
|
+
return `about ${minutes} minutes`;
|
|
567
|
+
const hours = Math.round(minutes / 60);
|
|
568
|
+
return `about ${hours} hour${hours === 1 ? "" : "s"}`;
|
|
569
|
+
}
|
|
570
|
+
// ---- Flow L30, the console-only verbs ----------------------------------------
|
|
571
|
+
/**
|
|
572
|
+
* The same block serves rollback, unpublish, republish, versions, revert and
|
|
573
|
+
* take-offline. These are the words a creator will actually type, and every one
|
|
574
|
+
* of them lands on one screen that names the real home. A bare "unknown command"
|
|
575
|
+
* here would be the product failing to answer a question it knows the answer to.
|
|
576
|
+
*
|
|
577
|
+
* The reason is given in one clause and it is the honest one. Not "not
|
|
578
|
+
* supported", not "coming soon" — neither is true.
|
|
579
|
+
*
|
|
580
|
+
* IF THE FOLDER IS NOT LINKED THE LAST TWO LINES ARE DROPPED. The CLI does not
|
|
581
|
+
* know which project they meant, and guessing a console URL would be worse than
|
|
582
|
+
* none.
|
|
583
|
+
*/
|
|
584
|
+
export function consoleVerb(verb, versionsUrl) {
|
|
585
|
+
const out = [
|
|
586
|
+
`✗ There's no \`${verb}\` command.`,
|
|
587
|
+
"",
|
|
588
|
+
" Rolling back, taking an address offline, and putting it back online are",
|
|
589
|
+
" console actions — they change what a permanent address serves, and that",
|
|
590
|
+
" belongs on a screen where you can see every version first.",
|
|
591
|
+
"",
|
|
592
|
+
];
|
|
593
|
+
if (versionsUrl) {
|
|
594
|
+
out.push(" Open the project's Versions tab:", ` ${versionsUrl}`, "");
|
|
595
|
+
}
|
|
596
|
+
return out;
|
|
597
|
+
}
|
|
598
|
+
/** The six verbs that land on that block. */
|
|
599
|
+
export const CONSOLE_ONLY_VERBS = [
|
|
600
|
+
"rollback",
|
|
601
|
+
"unpublish",
|
|
602
|
+
"republish",
|
|
603
|
+
"versions",
|
|
604
|
+
"revert",
|
|
605
|
+
"take-offline",
|
|
606
|
+
];
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** One entry per known v2-template default, across template versions. */
|
|
2
|
+
export declare const TEMPLATE_DEFAULT_TITLES: readonly string[];
|
|
3
|
+
export declare const TEMPLATE_DEFAULT_DESCRIPTIONS: readonly string[];
|
|
4
|
+
/**
|
|
5
|
+
* Only paths that are distinctively the TEMPLATE'S. `/og.png` is deliberately
|
|
6
|
+
* absent: it is the most common filename a creator picks for their own social
|
|
7
|
+
* image, so listing it would tell a creator who did the work that they did not.
|
|
8
|
+
* A placeholder still sitting at `/og.png` is indistinguishable from a real one
|
|
9
|
+
* without reading the bytes, which the CLI does not do — the dead-image check
|
|
10
|
+
* carries the real failure instead.
|
|
11
|
+
*/
|
|
12
|
+
export declare const TEMPLATE_DEFAULT_IMAGES: readonly string[];
|
|
13
|
+
/** The framework names a `<title>` should never be. Feeds the generic-shape heuristic. */
|
|
14
|
+
export declare const FRAMEWORK_NAMES: readonly string[];
|
|
15
|
+
/** Corpus membership is exact after trimming and case-folding. */
|
|
16
|
+
export declare function inCorpus(value: string, corpus: readonly string[]): boolean;
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
// The CLI-bundled corpus of known v2-template default strings — the
|
|
2
|
+
// high-confidence half of D-Template-default-detection-is-both.
|
|
3
|
+
//
|
|
4
|
+
// It ships WITH the CLI, versioned with it, and extending it is a two-line diff.
|
|
5
|
+
// It rots as the template changes; the structural heuristics in classify.ts are
|
|
6
|
+
// what carry the design when it does.
|
|
7
|
+
/** One entry per known v2-template default, across template versions. */
|
|
8
|
+
export const TEMPLATE_DEFAULT_TITLES = [
|
|
9
|
+
"Vite + React",
|
|
10
|
+
"Vite + React + TS",
|
|
11
|
+
"Vite App",
|
|
12
|
+
"React App",
|
|
13
|
+
"Vincentt XR App",
|
|
14
|
+
"Vincentt AR Experience",
|
|
15
|
+
"My AR Experience",
|
|
16
|
+
];
|
|
17
|
+
export const TEMPLATE_DEFAULT_DESCRIPTIONS = [
|
|
18
|
+
"An AR experience built with Vincentt",
|
|
19
|
+
"An AR experience built with Vincentt.",
|
|
20
|
+
"A Vincentt XR experience",
|
|
21
|
+
"Web site created using create-react-app",
|
|
22
|
+
];
|
|
23
|
+
/**
|
|
24
|
+
* Only paths that are distinctively the TEMPLATE'S. `/og.png` is deliberately
|
|
25
|
+
* absent: it is the most common filename a creator picks for their own social
|
|
26
|
+
* image, so listing it would tell a creator who did the work that they did not.
|
|
27
|
+
* A placeholder still sitting at `/og.png` is indistinguishable from a real one
|
|
28
|
+
* without reading the bytes, which the CLI does not do — the dead-image check
|
|
29
|
+
* carries the real failure instead.
|
|
30
|
+
*/
|
|
31
|
+
export const TEMPLATE_DEFAULT_IMAGES = [
|
|
32
|
+
"/og-placeholder.png",
|
|
33
|
+
"/vite.svg",
|
|
34
|
+
"/placeholder-social.png",
|
|
35
|
+
];
|
|
36
|
+
/** The framework names a `<title>` should never be. Feeds the generic-shape heuristic. */
|
|
37
|
+
export const FRAMEWORK_NAMES = [
|
|
38
|
+
"vite",
|
|
39
|
+
"react",
|
|
40
|
+
"vite + react",
|
|
41
|
+
"next.js",
|
|
42
|
+
"nextjs",
|
|
43
|
+
"app",
|
|
44
|
+
"index",
|
|
45
|
+
"document",
|
|
46
|
+
"untitled",
|
|
47
|
+
];
|
|
48
|
+
/** Corpus membership is exact after trimming and case-folding. */
|
|
49
|
+
export function inCorpus(value, corpus) {
|
|
50
|
+
const v = value.trim().toLowerCase();
|
|
51
|
+
return corpus.some((entry) => entry.trim().toLowerCase() === v);
|
|
52
|
+
}
|