@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.
@@ -0,0 +1,138 @@
1
+ // The link-metadata read: parse every .html in the dist, classify what it finds,
2
+ // and report. Four tiers, three registers of copy (Flow L25).
3
+ //
4
+ // THE RESULT IS NEVER SENT. There is no wire field for a title, a description or
5
+ // an image URL, and that absence is B24's mechanism rather than a promise
6
+ // (D-Metadata-never-crosses-the-wire). This module returns findings to the
7
+ // terminal and to nothing else.
8
+ //
9
+ // The parse is regex-based on purpose: adding an HTML parser to the CLI would be
10
+ // a dependency on hostile-ish input for a report that never blocks, and the tags
11
+ // this reads are emitted by build tools in a shape a regex reads correctly.
12
+ import { FRAMEWORK_NAMES, TEMPLATE_DEFAULT_DESCRIPTIONS, TEMPLATE_DEFAULT_IMAGES, TEMPLATE_DEFAULT_TITLES, inCorpus, } from "./defaults.js";
13
+ const TITLE_RE = /<title[^>]*>([\s\S]*?)<\/title>/i;
14
+ /** A `<meta>` whose name/property matches `key`, in either attribute order. */
15
+ function metaContent(html, key) {
16
+ const escaped = key.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
17
+ const forward = new RegExp(`<meta[^>]*\\b(?:name|property)\\s*=\\s*["']${escaped}["'][^>]*>`, "i");
18
+ const tag = forward.exec(html)?.[0];
19
+ if (!tag)
20
+ return undefined;
21
+ const content = /\bcontent\s*=\s*["']([\s\S]*?)["']/i.exec(tag)?.[1];
22
+ return content === undefined ? undefined : decodeEntities(content.trim());
23
+ }
24
+ function decodeEntities(s) {
25
+ return s
26
+ .replace(/&lt;/g, "<")
27
+ .replace(/&gt;/g, ">")
28
+ .replace(/&quot;/g, '"')
29
+ .replace(/&#0?39;/g, "'")
30
+ .replace(/&apos;/g, "'")
31
+ .replace(/&amp;/g, "&");
32
+ }
33
+ export function parseMeta(html) {
34
+ const rawTitle = TITLE_RE.exec(html)?.[1];
35
+ return {
36
+ title: rawTitle === undefined ? undefined : decodeEntities(rawTitle.trim()),
37
+ ogTitle: metaContent(html, "og:title"),
38
+ description: metaContent(html, "description"),
39
+ ogDescription: metaContent(html, "og:description"),
40
+ ogImage: metaContent(html, "og:image"),
41
+ ogUrl: metaContent(html, "og:url"),
42
+ twitterCard: metaContent(html, "twitter:card"),
43
+ };
44
+ }
45
+ /**
46
+ * CLASSIFY, per 2A:
47
+ * absent -> MISSING
48
+ * value in the bundled corpus -> TEMPLATE-DEFAULT (high confidence)
49
+ * value matches a generic shape -> LIKELY-DEFAULT (low confidence)
50
+ * else -> PRESENT
51
+ *
52
+ * Plus the one structural certainty: an og:image naming a path that is not in the
53
+ * manifest is a dead social image, and the manifest either contains it or it does
54
+ * not.
55
+ */
56
+ export function classify(meta, ctx) {
57
+ return [classifyTitle(meta), classifyDescription(meta), classifyImage(meta, ctx)];
58
+ }
59
+ function classifyTitle(meta) {
60
+ const value = meta.ogTitle ?? meta.title;
61
+ const tag = meta.ogTitle !== undefined ? `<meta property="og:title">` : "<title>";
62
+ if (value === undefined || value === "") {
63
+ return { field: "title", tier: "missing", tag: "<title>" };
64
+ }
65
+ if (inCorpus(value, TEMPLATE_DEFAULT_TITLES)) {
66
+ return { field: "title", tier: "template-default", value, tag };
67
+ }
68
+ // A title under 4 characters, or one that is exactly a framework name.
69
+ if (value.trim().length < 4 || FRAMEWORK_NAMES.includes(value.trim().toLowerCase())) {
70
+ return { field: "title", tier: "likely-default", value, tag };
71
+ }
72
+ return { field: "title", tier: "present", value, tag };
73
+ }
74
+ function classifyDescription(meta) {
75
+ const value = meta.description ?? meta.ogDescription;
76
+ const tag = meta.description !== undefined
77
+ ? `<meta name="description">`
78
+ : `<meta property="og:description">`;
79
+ if (value === undefined || value === "") {
80
+ return { field: "description", tier: "missing", tag: `<meta name="description">` };
81
+ }
82
+ if (inCorpus(value, TEMPLATE_DEFAULT_DESCRIPTIONS)) {
83
+ return { field: "description", tier: "template-default", value, tag };
84
+ }
85
+ if (value.trim().length < 16) {
86
+ return { field: "description", tier: "likely-default", value, tag };
87
+ }
88
+ return { field: "description", tier: "present", value, tag };
89
+ }
90
+ function classifyImage(meta, ctx) {
91
+ const tag = `<meta property="og:image">`;
92
+ const value = meta.ogImage;
93
+ if (value === undefined || value === "") {
94
+ return { field: "image", tier: "missing", tag };
95
+ }
96
+ // The strongest single finding in the checklist, and purely structural: a
97
+ // social image that 404s, on a project that never touched the template.
98
+ const local = localManifestPath(value);
99
+ if (local !== undefined && !ctx.manifestPaths.has(local)) {
100
+ return { field: "image", tier: "dead-image", value, tag };
101
+ }
102
+ if (inCorpus(value, TEMPLATE_DEFAULT_IMAGES) && local !== undefined) {
103
+ return { field: "image", tier: "template-default", value, tag };
104
+ }
105
+ return { field: "image", tier: "present", value, tag };
106
+ }
107
+ /**
108
+ * The manifest path an og:image refers to, or undefined when it is an absolute
109
+ * URL on some other host — which the CLI cannot check and must not guess about.
110
+ */
111
+ export function localManifestPath(value) {
112
+ if (/^[a-z][a-z0-9+.-]*:\/\//i.test(value) || value.startsWith("//"))
113
+ return undefined;
114
+ const withoutQuery = value.split(/[?#]/)[0];
115
+ const trimmed = withoutQuery.replace(/^\.?\//, "");
116
+ return trimmed === "" ? undefined : trimmed;
117
+ }
118
+ /** Every `.html` in the manifest, classified. Multiple files group under their filename. */
119
+ export async function classifyDist(distDir, manifestPaths, readFile, join, ctx = {}) {
120
+ const htmlPaths = [...manifestPaths].filter((p) => /\.html?$/i.test(p)).sort();
121
+ const out = [];
122
+ for (const rel of htmlPaths) {
123
+ let html;
124
+ try {
125
+ html = await readFile(join(distDir, rel));
126
+ }
127
+ catch {
128
+ // An unreadable html file is not a finding about the creator's metadata;
129
+ // the checklist reports what it could read and blocks nothing.
130
+ continue;
131
+ }
132
+ out.push({
133
+ file: rel,
134
+ findings: classify(parseMeta(html), { ...ctx, manifestPaths }),
135
+ });
136
+ }
137
+ return out;
138
+ }
@@ -0,0 +1,27 @@
1
+ import type { ResolvedConfig } from "../shared/config.js";
2
+ export interface PublishDeps {
3
+ print: (line: string) => void;
4
+ /** Raw write, for the redrawn progress line only. */
5
+ write: (s: string) => void;
6
+ /** Resolve the confirmation. `defaultYes` decides what a bare Enter means. */
7
+ confirm: (prompt: string, defaultYes: boolean) => Promise<boolean>;
8
+ cwd: string;
9
+ config: () => Promise<ResolvedConfig>;
10
+ binding: () => Promise<{
11
+ projectId: string;
12
+ slug: string;
13
+ } | undefined>;
14
+ /** Absent means not signed in — the gate is LOCAL and nothing is attempted. */
15
+ token: () => Promise<string | undefined>;
16
+ isTTY: boolean;
17
+ now: () => number;
18
+ sleep: (ms: number) => Promise<void>;
19
+ fetchImpl?: typeof fetch;
20
+ orgName?: () => Promise<string>;
21
+ }
22
+ export interface PublishOptions {
23
+ distDir?: string;
24
+ note?: string;
25
+ yes?: boolean;
26
+ }
27
+ export declare function publishCommand(deps: PublishDeps, opts?: PublishOptions): Promise<number>;
@@ -0,0 +1,328 @@
1
+ // `vincentt publish` — Flows L22-L31.
2
+ //
3
+ // The shape: build the manifest LOCALLY, check the bounds LOCALLY, read the
4
+ // project ONCE, show the checklist, ask, then open → PUT direct to S3 → commit.
5
+ // Nothing is sent before the creator answers. `--yes` skips the prompt on every
6
+ // publish including the first; it never skips the checklist, so the permanence
7
+ // disclosure still reaches an unattended operator's transcript.
8
+ //
9
+ // The IO boundary (print, prompt, fetch, disk, clock, TTY) is injected so every
10
+ // branch is assertable without a real API, a real terminal or a real S3.
11
+ import { promises as fs } from "node:fs";
12
+ import path from "node:path";
13
+ import { PublishApiError, abandonUpload, commitUpload, openUpload, readProject, } from "./api.js";
14
+ import { buildManifest, ManifestError } from "./manifest.js";
15
+ import { checkBounds } from "./bounds.js";
16
+ import { classifyDist } from "./classify.js";
17
+ import { createProgress } from "./progress.js";
18
+ import { UploadFailed, uploadAll } from "./upload.js";
19
+ import * as copy from "./copy.js";
20
+ const COMMIT_RETRIES = 3;
21
+ const COMMIT_RETRY_DELAY_MS = 2000;
22
+ export async function publishCommand(deps, opts = {}) {
23
+ // The gate is LOCAL and NOTHING IS ATTEMPTED — no request, no partial upload.
24
+ if (!(await deps.token())) {
25
+ for (const l of copy.notSignedIn())
26
+ deps.print(l);
27
+ return 1;
28
+ }
29
+ const binding = await deps.binding();
30
+ if (!binding) {
31
+ deps.print("✗ This folder isn't linked to a project.");
32
+ deps.print("");
33
+ deps.print(" vincentt link <project-id> (copy it from the console)");
34
+ deps.print(" vincentt create Register a new project for this folder");
35
+ return 1;
36
+ }
37
+ const distDir = path.resolve(deps.cwd, opts.distDir ?? "dist");
38
+ // Everything below the manifest build happens before a single request. The
39
+ // creator learns about a too-large build in the same second they would
40
+ // otherwise have started a ten-minute upload.
41
+ let manifest;
42
+ try {
43
+ manifest = await buildManifest(distDir);
44
+ }
45
+ catch (err) {
46
+ if (err instanceof ManifestError) {
47
+ for (const l of manifestErrorLines(err, distDir))
48
+ deps.print(l);
49
+ return 1;
50
+ }
51
+ throw err;
52
+ }
53
+ const violation = checkBounds(manifest);
54
+ if (violation) {
55
+ deps.print(copy.headerLine(await projectLabel(deps, binding.slug), await orgOf(deps)));
56
+ deps.print(copy.readingLine(manifest.files.length, manifest.totalBytes));
57
+ for (const l of copy.tooLarge(violation))
58
+ deps.print(l);
59
+ return 1;
60
+ }
61
+ const cfg = await deps.config();
62
+ const fetchImpl = deps.fetchImpl ?? fetch;
63
+ // ONE call before the checklist. liveVersion and livePublishedAt ride it, so
64
+ // the republish line costs no second round trip.
65
+ let project;
66
+ try {
67
+ project = await readProject(cfg, binding.projectId, fetchImpl);
68
+ }
69
+ catch (err) {
70
+ if (err instanceof PublishApiError && err.status === 404) {
71
+ deps.print(`✗ ${err.message}`);
72
+ deps.print("");
73
+ deps.print(" This folder is linked to a project that can't be reached with your current");
74
+ deps.print(" account. Check who you are: vincentt whoami");
75
+ return 1;
76
+ }
77
+ throw err;
78
+ }
79
+ const address = addressOf(project);
80
+ const isFirstPublish = project.firstPublishedAt === null;
81
+ const nextVersion = (project.highestVersion ?? 0) + 1;
82
+ const orgName = await orgOf(deps);
83
+ // A non-interactive stdin with no --yes must REFUSE rather than assume yes.
84
+ if (!opts.yes && !deps.isTTY) {
85
+ deps.print("");
86
+ deps.print("✗ Publishing needs a confirmation, and this isn't an interactive terminal.");
87
+ deps.print("");
88
+ deps.print(" Re-run it interactively, or pass --yes: vincentt publish --yes");
89
+ deps.print("");
90
+ deps.print(" Nothing was uploaded.");
91
+ deps.print("");
92
+ return 1;
93
+ }
94
+ const findings = await classifyDist(distDir, new Set(manifest.files.map((f) => f.path)), (abs) => fs.readFile(abs, "utf8"), (dir, rel) => path.join(dir, ...rel.split("/")), { expectedHost: address });
95
+ const checklistInput = {
96
+ projectName: project.name,
97
+ orgName,
98
+ address,
99
+ fileCount: manifest.files.length,
100
+ totalBytes: manifest.totalBytes,
101
+ nextVersion,
102
+ findings,
103
+ };
104
+ let promptLine;
105
+ let defaultYes;
106
+ if (isFirstPublish) {
107
+ for (const l of copy.firstPublishChecklist(checklistInput))
108
+ deps.print(l);
109
+ promptLine = copy.firstPublishPrompt(address, nextVersion);
110
+ defaultYes = false;
111
+ }
112
+ else if (project.publishState === "unpublished") {
113
+ for (const l of copy.offlineChecklist(checklistInput))
114
+ deps.print(l);
115
+ promptLine = copy.offlinePrompt(nextVersion);
116
+ // This publish has a second consequence the creator did not ask for, so an
117
+ // accidental Enter must do nothing.
118
+ defaultYes = false;
119
+ }
120
+ else {
121
+ for (const l of copy.republishChecklist({
122
+ ...checklistInput,
123
+ liveVersion: project.liveVersion ?? nextVersion - 1,
124
+ livePublishedAt: project.livePublishedAt ?? undefined,
125
+ now: deps.now(),
126
+ })) {
127
+ deps.print(l);
128
+ }
129
+ promptLine = copy.republishPrompt(nextVersion);
130
+ defaultYes = true;
131
+ }
132
+ // --yes suppresses the PROMPT, not the REPORT. The findings block above is
133
+ // byte-identical on both paths, because stdout is the only channel that
134
+ // reaches an agent.
135
+ if (!opts.yes) {
136
+ const confirmed = await deps.confirm(promptLine, defaultYes);
137
+ if (!confirmed) {
138
+ for (const l of copy.declined())
139
+ deps.print(l);
140
+ return 0;
141
+ }
142
+ }
143
+ return runUpload(deps, cfg, {
144
+ projectId: binding.projectId,
145
+ distDir,
146
+ manifest,
147
+ note: opts.note,
148
+ address,
149
+ liveVersion: project.liveVersion ?? undefined,
150
+ fetchImpl,
151
+ });
152
+ }
153
+ async function runUpload(deps, cfg, i) {
154
+ let opened;
155
+ try {
156
+ opened = await openUpload(cfg, i.projectId, i.manifest.files, i.note, i.fetchImpl);
157
+ }
158
+ catch (err) {
159
+ for (const l of openErrorLines(err, i.address, i.liveVersion))
160
+ deps.print(l);
161
+ return 1;
162
+ }
163
+ deps.print(copy.uploadStart(i.manifest.files.length));
164
+ const progress = createProgress({
165
+ isTTY: deps.isTTY,
166
+ write: deps.write,
167
+ print: deps.print,
168
+ now: deps.now,
169
+ });
170
+ const startedAt = deps.now();
171
+ try {
172
+ await uploadAll({
173
+ distDir: i.distDir,
174
+ targets: opened.uploads,
175
+ manifest: i.manifest.files,
176
+ onProgress: (p) => progress.update(p),
177
+ fetchImpl: i.fetchImpl,
178
+ });
179
+ }
180
+ catch (err) {
181
+ progress.finish();
182
+ await abandonUpload(cfg, i.projectId, opened.uploadId, i.fetchImpl);
183
+ if (err instanceof UploadFailed && err.badDigest) {
184
+ for (const l of copy.badDigest(err.path, i.address, i.liveVersion))
185
+ deps.print(l);
186
+ return 1;
187
+ }
188
+ const failedPath = err instanceof UploadFailed ? err.path : "a file";
189
+ deps.print("");
190
+ deps.print(`✗ Uploading ${failedPath} failed.`);
191
+ deps.print("");
192
+ deps.print(i.liveVersion === undefined
193
+ ? ` Your live address is unchanged — ${i.address}.`
194
+ : ` Your live address is unchanged — ${i.address} is still serving v${i.liveVersion}.`);
195
+ deps.print("");
196
+ deps.print(" Publish again: vincentt publish");
197
+ deps.print("");
198
+ return 1;
199
+ }
200
+ progress.finish();
201
+ deps.print(copy.uploadDone(i.manifest.files.length, i.manifest.totalBytes, deps.now() - startedAt));
202
+ deps.print(copy.finishing());
203
+ return commitWithHealing(deps, cfg, i, opened.uploadId);
204
+ }
205
+ /**
206
+ * The commit, with the healing retry. A 502 publish_flip_failed is retried
207
+ * automatically — up to 3 attempts, 2s apart — because commit is idempotent on
208
+ * uploadId and the retry is exactly what the healing path was built for.
209
+ * Surfacing a 502 to a creator whose bytes are all safely stored and whose
210
+ * publish will succeed on the next call would be manufacturing an incident.
211
+ */
212
+ async function commitWithHealing(deps, cfg, i, uploadId) {
213
+ let announcedSlow = false;
214
+ for (let attempt = 1; attempt <= COMMIT_RETRIES; attempt++) {
215
+ try {
216
+ const result = await commitUpload(cfg, i.projectId, uploadId, i.fetchImpl);
217
+ for (const l of copy.liveAt(result.liveUrl, result.version))
218
+ deps.print(l);
219
+ return 0;
220
+ }
221
+ catch (err) {
222
+ if (!(err instanceof PublishApiError))
223
+ throw err;
224
+ if (err.code === "publish_flip_failed" || err.status === 502) {
225
+ if (attempt === COMMIT_RETRIES) {
226
+ for (const l of copy.flipFailed(i.address, i.liveVersion))
227
+ deps.print(l);
228
+ return 1;
229
+ }
230
+ // The `!` line prints only if a retry happens, and it says nothing about
231
+ // a failure — from the creator's position nothing has failed yet.
232
+ if (!announcedSlow) {
233
+ deps.print(copy.takingLonger());
234
+ announcedSlow = true;
235
+ }
236
+ await deps.sleep(COMMIT_RETRY_DELAY_MS);
237
+ continue;
238
+ }
239
+ for (const l of commitErrorLines(err, i.address, i.liveVersion))
240
+ deps.print(l);
241
+ return 1;
242
+ }
243
+ }
244
+ return 1;
245
+ }
246
+ function commitErrorLines(err, address, liveVersion) {
247
+ switch (err.code) {
248
+ case "publish_expired":
249
+ return copy.publishExpired(address, liveVersion);
250
+ case "publish_contended":
251
+ return copy.publishContended();
252
+ case "publish_incomplete":
253
+ // The response carries COUNTS, never paths, so these are the CLI's own
254
+ // strings about its own manifest.
255
+ return copy.publishIncomplete(address, { missing: err.detail.missing, mismatched: err.detail.mismatched }, liveVersion);
256
+ default:
257
+ return [
258
+ "",
259
+ `✗ ${err.message}`,
260
+ "",
261
+ liveVersion === undefined
262
+ ? ` Your live address is unchanged — ${address}.`
263
+ : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
264
+ "",
265
+ " Publish again: vincentt publish",
266
+ "",
267
+ ];
268
+ }
269
+ }
270
+ function openErrorLines(err, address, liveVersion) {
271
+ if (!(err instanceof PublishApiError)) {
272
+ return [
273
+ "",
274
+ `✗ ${err instanceof Error ? err.message : String(err)}`,
275
+ "",
276
+ " Nothing was uploaded.",
277
+ "",
278
+ ];
279
+ }
280
+ if (err.code === "publish_too_large") {
281
+ return copy.tooLargeFromServer(err.detail.observed ?? 0, err.detail.limit ?? 0);
282
+ }
283
+ if (err.code === "rate_limited") {
284
+ return copy.rateLimited(err.retryAfterSec ?? 60);
285
+ }
286
+ if (err.code === "manifest_invalid") {
287
+ return copy.badPath(err.detail.reason ?? "", err.detail.path ?? "");
288
+ }
289
+ return [
290
+ "",
291
+ `✗ ${err.message}`,
292
+ "",
293
+ liveVersion === undefined
294
+ ? ` Your live address is unchanged — ${address}.`
295
+ : ` Your live address is unchanged — ${address} is still serving v${liveVersion}.`,
296
+ "",
297
+ " Nothing was uploaded.",
298
+ "",
299
+ ];
300
+ }
301
+ function manifestErrorLines(err, distDir) {
302
+ if (err.reason === "no_dist")
303
+ return copy.noDist(distDir);
304
+ if (err.reason === "no_index_html")
305
+ return copy.noIndexHtml(distDir);
306
+ return copy.badPath(err.reason, err.offendingPath);
307
+ }
308
+ /**
309
+ * The address as a creator reads it: the host, never the scheme. `liveUrl` is
310
+ * server-computed (the apex differs per environment), so it is the authority and
311
+ * the slug is only a fallback.
312
+ */
313
+ function addressOf(project) {
314
+ if (!project.liveUrl)
315
+ return project.slug;
316
+ try {
317
+ return new URL(project.liveUrl).host;
318
+ }
319
+ catch {
320
+ return project.liveUrl.replace(/^https?:\/\//, "").replace(/\/$/, "");
321
+ }
322
+ }
323
+ async function orgOf(deps) {
324
+ return (await deps.orgName?.()) ?? "";
325
+ }
326
+ async function projectLabel(deps, slug) {
327
+ return slug || path.basename(deps.cwd);
328
+ }
@@ -0,0 +1,17 @@
1
+ export declare function isConsoleOnlyVerb(verb: string): boolean;
2
+ export interface ConsoleVerbDeps {
3
+ print: (line: string) => void;
4
+ /** The bound project id, or undefined for an unlinked folder. */
5
+ projectId: () => Promise<string | undefined>;
6
+ consoleUrl?: string;
7
+ }
8
+ /**
9
+ * The deep link goes to the exact tab, built from the binding the CLI already
10
+ * holds — a creator who typed `vincentt rollback` wants to be one click from
11
+ * rolling back.
12
+ *
13
+ * If the folder is NOT linked the last two lines are dropped and the block ends
14
+ * at the reason: the CLI does not know which project they meant, and guessing a
15
+ * console URL would be worse than none.
16
+ */
17
+ export declare function consoleVerbCommand(deps: ConsoleVerbDeps, verb: string): Promise<number>;
@@ -0,0 +1,26 @@
1
+ // Flow L30 — rollback, unpublish, republish, versions, revert and take-offline
2
+ // are NOT CLI verbs, and the CLI says where they are.
3
+ //
4
+ // This is ARGUMENT PARSING. Exit 1, no network call, nothing looked up.
5
+ import { CONSOLE_ONLY_VERBS, consoleVerb } from "./copy.js";
6
+ const DEFAULT_CONSOLE_URL = "https://console.vincentt.app";
7
+ export function isConsoleOnlyVerb(verb) {
8
+ return CONSOLE_ONLY_VERBS.includes(verb);
9
+ }
10
+ /**
11
+ * The deep link goes to the exact tab, built from the binding the CLI already
12
+ * holds — a creator who typed `vincentt rollback` wants to be one click from
13
+ * rolling back.
14
+ *
15
+ * If the folder is NOT linked the last two lines are dropped and the block ends
16
+ * at the reason: the CLI does not know which project they meant, and guessing a
17
+ * console URL would be worse than none.
18
+ */
19
+ export async function consoleVerbCommand(deps, verb) {
20
+ const projectId = await deps.projectId();
21
+ const base = (deps.consoleUrl ?? DEFAULT_CONSOLE_URL).replace(/\/$/, "");
22
+ const url = projectId ? `${base}/projects/${projectId}/versions` : undefined;
23
+ for (const line of consoleVerb(verb, url))
24
+ deps.print(line);
25
+ return 1;
26
+ }