@vincentt-xr/cli 0.1.5 → 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/README.md +94 -0
- package/dist/cli/args.d.ts +14 -0
- package/dist/cli/args.js +51 -0
- package/dist/cli/index.js +416 -100
- package/dist/lib/backend.d.ts +44 -20
- package/dist/lib/backend.js +98 -51
- package/dist/lib/diagnostics.d.ts +27 -2
- package/dist/lib/diagnostics.js +73 -8
- package/dist/lib/initCommand.d.ts +14 -0
- package/dist/lib/initCommand.js +75 -0
- package/dist/lib/lifecycle.d.ts +85 -19
- package/dist/lib/lifecycle.js +133 -130
- package/dist/lib/org.d.ts +29 -0
- package/dist/lib/org.js +45 -0
- package/dist/lib/projects.d.ts +82 -0
- package/dist/lib/projects.js +217 -0
- package/dist/lib/relayInfo.d.ts +41 -3
- package/dist/lib/relayInfo.js +94 -0
- package/dist/lib/whoami.d.ts +11 -0
- package/dist/lib/whoami.js +38 -0
- package/dist/login/authBackend.d.ts +30 -0
- package/dist/login/authBackend.js +80 -0
- package/dist/login/guard.d.ts +13 -0
- package/dist/login/guard.js +17 -0
- package/dist/login/loginCommand.d.ts +39 -0
- package/dist/login/loginCommand.js +72 -0
- package/dist/login/pkce.d.ts +47 -0
- package/dist/login/pkce.js +106 -0
- package/dist/login/pkceLogin.d.ts +53 -0
- package/dist/login/pkceLogin.js +152 -0
- package/dist/mcp/server.js +52 -25
- package/dist/preview/api.d.ts +84 -0
- package/dist/preview/api.js +174 -0
- package/dist/preview/command.d.ts +96 -0
- package/dist/preview/command.js +276 -0
- package/dist/preview/copy.d.ts +209 -0
- package/dist/preview/copy.js +444 -0
- package/dist/preview/index.d.ts +2 -3
- package/dist/preview/index.js +5 -6
- package/dist/preview/proxy.d.ts +12 -1
- package/dist/preview/proxy.js +17 -7
- package/dist/preview/qr.d.ts +30 -0
- package/dist/preview/qr.js +89 -0
- package/dist/preview/runner.d.ts +155 -30
- package/dist/preview/runner.js +219 -68
- package/dist/preview/stream.d.ts +52 -0
- package/dist/preview/stream.js +171 -0
- package/dist/preview/viewers.d.ts +113 -0
- package/dist/preview/viewers.js +242 -0
- 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/dist/relay/server.d.ts +32 -0
- package/dist/relay/server.js +80 -12
- package/dist/relay/store.d.ts +32 -3
- package/dist/relay/store.js +76 -22
- package/dist/relay/testers.d.ts +90 -0
- package/dist/relay/testers.js +144 -0
- package/dist/shared/authError.d.ts +1 -0
- package/dist/shared/authError.js +11 -0
- package/dist/shared/config.d.ts +73 -5
- package/dist/shared/config.js +133 -20
- package/package.json +2 -3
- package/dist/login/login.d.ts +0 -34
- package/dist/login/login.js +0 -148
- package/dist/preview/cloudflared.d.ts +0 -13
- package/dist/preview/cloudflared.js +0 -46
- package/dist/preview/tunnel.d.ts +0 -14
- package/dist/preview/tunnel.js +0 -28
|
@@ -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(/</g, "<")
|
|
27
|
+
.replace(/>/g, ">")
|
|
28
|
+
.replace(/"/g, '"')
|
|
29
|
+
.replace(/�?39;/g, "'")
|
|
30
|
+
.replace(/'/g, "'")
|
|
31
|
+
.replace(/&/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
|
+
}
|