@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
package/dist/cli/args.d.ts
CHANGED
|
@@ -5,6 +5,7 @@
|
|
|
5
5
|
* usage instead of running the (side-effectful) verb.
|
|
6
6
|
*/
|
|
7
7
|
export declare function isHelpInvocation(args: string[]): boolean;
|
|
8
|
+
export declare function refusedFlag(args: string[]): string | undefined;
|
|
8
9
|
/**
|
|
9
10
|
* The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
|
|
10
11
|
* /tmp 66f1` reads `/tmp` as the project id — a wrong-but-plausible value, which is
|
package/dist/cli/args.js
CHANGED
|
@@ -18,9 +18,19 @@ const BOOLEAN_FLAGS = new Set([
|
|
|
18
18
|
"--stop",
|
|
19
19
|
"--no-install",
|
|
20
20
|
"--allow-custom-api",
|
|
21
|
+
"--yes",
|
|
21
22
|
"--help",
|
|
22
23
|
"-h",
|
|
23
24
|
]);
|
|
25
|
+
/**
|
|
26
|
+
* Flags the CLI deliberately does NOT have. The checklist reports and never
|
|
27
|
+
* writes: a suggested value is one keystroke from a tool that writes it, and
|
|
28
|
+
* writing to the creator's source is a named boundary.
|
|
29
|
+
*/
|
|
30
|
+
const REFUSED_FLAGS = new Set(["--fix", "--write-meta"]);
|
|
31
|
+
export function refusedFlag(args) {
|
|
32
|
+
return args.find((a) => REFUSED_FLAGS.has(a.split("=")[0]));
|
|
33
|
+
}
|
|
24
34
|
/**
|
|
25
35
|
* The bare arguments, with flag VALUES removed. Without this, `vincentt link --dir
|
|
26
36
|
* /tmp 66f1` reads `/tmp` as the project id — a wrong-but-plausible value, which is
|
package/dist/cli/index.js
CHANGED
|
@@ -21,7 +21,10 @@ import { getMe, ProjectUnreachableError } from "../lib/backend.js";
|
|
|
21
21
|
import { initCommand } from "../lib/initCommand.js";
|
|
22
22
|
import { linkCommand, printUnreachable, unlinkCommand } from "../lib/projects.js";
|
|
23
23
|
import { whoamiCommand } from "../lib/whoami.js";
|
|
24
|
-
import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate,
|
|
24
|
+
import { diagQuery, diagRead, previewStart, previewStop, previewStopSignal, projectCreate, } from "../lib/lifecycle.js";
|
|
25
|
+
import { publishCommand } from "../publish/command.js";
|
|
26
|
+
import { stdinConfirm } from "../publish/prompt.js";
|
|
27
|
+
import { consoleVerbCommand, isConsoleOnlyVerb } from "../publish/consoleVerbs.js";
|
|
25
28
|
import { PreviewUi } from "../preview/command.js";
|
|
26
29
|
import { httpRelayClient } from "../lib/diagnostics.js";
|
|
27
30
|
import { readLivePreview } from "../lib/relayInfo.js";
|
|
@@ -29,7 +32,7 @@ import { mintRateLimited, notSignedIn, previewConflict, startBlock, } from "../p
|
|
|
29
32
|
import { renderTerminalQr, shouldRenderQr } from "../preview/qr.js";
|
|
30
33
|
import { PreviewConflictError, PreviewRateLimitedError } from "../preview/api.js";
|
|
31
34
|
import { fetchNext, readCursor, writeCursor } from "../lib/feedback.js";
|
|
32
|
-
import { isHelpInvocation, positionalArgs } from "./args.js";
|
|
35
|
+
import { isHelpInvocation, positionalArgs, refusedFlag } from "./args.js";
|
|
33
36
|
// The console origin serves /cli-auth (the browser target for `vincentt login`). VINCENTT_
|
|
34
37
|
// CONSOLE_URL overrides for dev/dogfood; VINCENTT_EDITOR_URL is honored as a legacy alias.
|
|
35
38
|
const DEFAULT_CONSOLE_URL = "https://vincentt.app";
|
|
@@ -186,17 +189,54 @@ async function unlink() {
|
|
|
186
189
|
config: () => resolveConfig(cwd),
|
|
187
190
|
});
|
|
188
191
|
}
|
|
192
|
+
/**
|
|
193
|
+
* `vincentt publish` — Flows L22-L31.
|
|
194
|
+
*
|
|
195
|
+
* The whole gate lives here: the checklist, the permanence disclosure, the
|
|
196
|
+
* confirmation, the upload and the live URL. There is no browser step in
|
|
197
|
+
* publishing, not on the first publish, not ever.
|
|
198
|
+
*/
|
|
189
199
|
async function publish() {
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
});
|
|
197
|
-
console.log(result.message);
|
|
198
|
-
if (result.notBound)
|
|
200
|
+
const refused = refusedFlag(argv);
|
|
201
|
+
if (refused) {
|
|
202
|
+
console.log(`✗ There's no \`${refused}\` option.`);
|
|
203
|
+
console.log("");
|
|
204
|
+
console.log(" The link-preview check reports what it found; it never edits your");
|
|
205
|
+
console.log(" source. Fix the tags in your own files, rebuild, then publish again.");
|
|
199
206
|
process.exitCode = 1;
|
|
207
|
+
return;
|
|
208
|
+
}
|
|
209
|
+
const cwd = projectDir();
|
|
210
|
+
process.exitCode = await publishCommand({
|
|
211
|
+
print: (l) => console.log(l),
|
|
212
|
+
write: (s) => process.stdout.write(s),
|
|
213
|
+
confirm: stdinConfirm(),
|
|
214
|
+
cwd,
|
|
215
|
+
config: () => resolveConfig(cwd, {
|
|
216
|
+
allowCustomApi: has("--allow-custom-api"),
|
|
217
|
+
warn: (l) => console.log(l),
|
|
218
|
+
}),
|
|
219
|
+
binding: async () => {
|
|
220
|
+
const located = await locateProjectBinding(cwd);
|
|
221
|
+
return located
|
|
222
|
+
? { projectId: located.binding.projectId, slug: located.binding.slug }
|
|
223
|
+
: undefined;
|
|
224
|
+
},
|
|
225
|
+
token: currentToken,
|
|
226
|
+
isTTY: Boolean(process.stdout.isTTY) && Boolean(process.stdin.isTTY),
|
|
227
|
+
now: () => Date.now(),
|
|
228
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
229
|
+
orgName: activeOrgName,
|
|
230
|
+
}, { distDir: flag("--dist"), note: flag("--note"), yes: has("--yes") });
|
|
231
|
+
}
|
|
232
|
+
/** Flow L30 — the six verbs that are console actions, answered rather than rejected. */
|
|
233
|
+
async function consoleOnly(verb) {
|
|
234
|
+
const cwd = projectDir();
|
|
235
|
+
process.exitCode = await consoleVerbCommand({
|
|
236
|
+
print: (l) => console.log(l),
|
|
237
|
+
projectId: async () => (await locateProjectBinding(cwd))?.binding.projectId,
|
|
238
|
+
consoleUrl: flag("--console") ?? process.env.VINCENTT_CONSOLE_URL,
|
|
239
|
+
}, verb);
|
|
200
240
|
}
|
|
201
241
|
/**
|
|
202
242
|
* `vincentt preview` — Flows L13-L21.
|
|
@@ -497,6 +537,7 @@ function usage() {
|
|
|
497
537
|
` unlink Disconnect this folder from its project\n\n` +
|
|
498
538
|
`Dev loop\n` +
|
|
499
539
|
` publish Upload the built dist; go live at <slug>.vincentt.app\n` +
|
|
540
|
+
` [--dist <path>] [--note <s>] [--yes]\n` +
|
|
500
541
|
` preview Serve the app at an https link a phone can open\n` +
|
|
501
542
|
` [--port <n>] [--tester <id>] [--no-qr] [--stop]\n\n` +
|
|
502
543
|
`Diagnostics (need a running preview)\n` +
|
|
@@ -555,6 +596,11 @@ async function main() {
|
|
|
555
596
|
case "relay":
|
|
556
597
|
return relay();
|
|
557
598
|
default:
|
|
599
|
+
// Flow L30: rolling back and taking an address offline are console
|
|
600
|
+
// actions, and a bare "unknown command" here would be the product failing
|
|
601
|
+
// to answer a question it knows the answer to.
|
|
602
|
+
if (cmd !== undefined && isConsoleOnlyVerb(cmd))
|
|
603
|
+
return consoleOnly(cmd);
|
|
558
604
|
console.error(`Unknown command: ${cmd}\n`);
|
|
559
605
|
usage();
|
|
560
606
|
process.exitCode = 1;
|
package/dist/lib/backend.d.ts
CHANGED
|
@@ -19,13 +19,6 @@ export interface CreatedProject {
|
|
|
19
19
|
orgId: string;
|
|
20
20
|
liveUrl: string;
|
|
21
21
|
}
|
|
22
|
-
export interface PublishResult {
|
|
23
|
-
version: number;
|
|
24
|
-
/** Immutable versioned URL (<slug>.<apex>/v<n>/). */
|
|
25
|
-
url: string;
|
|
26
|
-
/** Stable live alias (<slug>.<apex>/). */
|
|
27
|
-
liveUrl: string;
|
|
28
|
-
}
|
|
29
22
|
export declare class BackendError extends Error {
|
|
30
23
|
readonly status?: number | undefined;
|
|
31
24
|
constructor(message: string, status?: number | undefined);
|
|
@@ -70,15 +63,6 @@ export declare function reapTunnel(cfg: ResolvedConfig, projectId: string, ref:
|
|
|
70
63
|
}): Promise<void>;
|
|
71
64
|
/** Recursively list files under `dir`, returning dir-relative POSIX paths. */
|
|
72
65
|
export declare function walkDir(dir: string, base?: string): Promise<string[]>;
|
|
73
|
-
/**
|
|
74
|
-
* Upload a locally-built dist and make it live. Each file rides as a multipart
|
|
75
|
-
* part whose filename is its dist-relative path (the backend reconstructs the
|
|
76
|
-
* tree). Requires an index.html at the dist root.
|
|
77
|
-
*/
|
|
78
|
-
export declare function publishUpload(cfg: ResolvedConfig, projectId: string, distDir: string, opts?: {
|
|
79
|
-
note?: string;
|
|
80
|
-
commitSha?: string;
|
|
81
|
-
}): Promise<PublishResult>;
|
|
82
66
|
export interface MeMembership {
|
|
83
67
|
orgId: string;
|
|
84
68
|
orgSlug: string;
|
package/dist/lib/backend.js
CHANGED
|
@@ -178,40 +178,6 @@ export async function walkDir(dir, base = dir) {
|
|
|
178
178
|
}
|
|
179
179
|
return out;
|
|
180
180
|
}
|
|
181
|
-
/**
|
|
182
|
-
* Upload a locally-built dist and make it live. Each file rides as a multipart
|
|
183
|
-
* part whose filename is its dist-relative path (the backend reconstructs the
|
|
184
|
-
* tree). Requires an index.html at the dist root.
|
|
185
|
-
*/
|
|
186
|
-
export async function publishUpload(cfg, projectId, distDir, opts = {}) {
|
|
187
|
-
const files = await walkDir(distDir);
|
|
188
|
-
if (!files.includes("index.html")) {
|
|
189
|
-
throw new BackendError(`dist at ${distDir} has no index.html at its root — did the build run?`);
|
|
190
|
-
}
|
|
191
|
-
const form = new FormData();
|
|
192
|
-
// The real dist-relative paths ride as an ordered manifest — a multipart filename
|
|
193
|
-
// can't carry a directory (FormData strips it to a basename), so the backend maps
|
|
194
|
-
// paths[i] → files[i] to rebuild the tree.
|
|
195
|
-
form.append("paths", JSON.stringify(files));
|
|
196
|
-
for (const rel of files) {
|
|
197
|
-
const bytes = await fs.readFile(path.join(distDir, rel));
|
|
198
|
-
// Uint8Array (Buffer) is a valid BlobPart; the filename is cosmetic (the backend
|
|
199
|
-
// uses the manifest), but pass the basename so server logs read sensibly.
|
|
200
|
-
form.append("files", new Blob([bytes]), path.basename(rel));
|
|
201
|
-
}
|
|
202
|
-
if (opts.note)
|
|
203
|
-
form.append("note", opts.note);
|
|
204
|
-
if (opts.commitSha)
|
|
205
|
-
form.append("commitSha", opts.commitSha);
|
|
206
|
-
const res = await authFetch(cfg, `/projects/${projectId}/publish/upload`, {
|
|
207
|
-
method: "POST",
|
|
208
|
-
body: form,
|
|
209
|
-
});
|
|
210
|
-
if (!res.ok) {
|
|
211
|
-
throw new BackendError(authFailureMessage("publish failed", res.status, await safeText(res)), res.status);
|
|
212
|
-
}
|
|
213
|
-
return (await res.json());
|
|
214
|
-
}
|
|
215
181
|
/** Resolve the signed-in identity for `vincentt whoami`. Throws on any non-2xx so
|
|
216
182
|
* the caller can degrade to "token present, unverified". */
|
|
217
183
|
export async function getMe(cfg) {
|
package/dist/lib/lifecycle.d.ts
CHANGED
|
@@ -59,14 +59,22 @@ export interface PublishOptions {
|
|
|
59
59
|
* has already happened (or thrown).
|
|
60
60
|
*/
|
|
61
61
|
warn?: (line: string) => void;
|
|
62
|
+
/** Skip the confirmation. REFUSED on a first publish; see publishCommand. */
|
|
63
|
+
yes?: boolean;
|
|
62
64
|
}
|
|
63
65
|
export interface PublishOutcome {
|
|
66
|
+
/** The full transcript, exactly as the CLI prints it. */
|
|
64
67
|
message: string;
|
|
65
|
-
|
|
66
|
-
version?: number;
|
|
67
|
-
liveUrl?: string;
|
|
68
|
-
url?: string;
|
|
68
|
+
exitCode: number;
|
|
69
69
|
}
|
|
70
|
+
/**
|
|
71
|
+
* The shared `publish` orchestration behind both `vincentt publish` and the MCP
|
|
72
|
+
* `project_publish` tool, so the two surfaces can never diverge.
|
|
73
|
+
*
|
|
74
|
+
* MCP has no terminal, so it is a non-TTY caller: the command refuses without
|
|
75
|
+
* `--yes` rather than assuming consent, and refuses `--yes` outright on a first
|
|
76
|
+
* publish. An agent cannot make an address permanent.
|
|
77
|
+
*/
|
|
70
78
|
export declare function projectPublish(projectCwd: string, opts?: PublishOptions): Promise<PublishOutcome>;
|
|
71
79
|
export declare function getActivePreview(): RunningPreview | null;
|
|
72
80
|
export interface PreviewStartResult {
|
package/dist/lib/lifecycle.js
CHANGED
|
@@ -3,9 +3,9 @@
|
|
|
3
3
|
// lower-level pieces (backend calls, scaffold, preview, diagnostics); no CLI or
|
|
4
4
|
// MCP SDK concerns leak in here. Both surfaces render the returned data their
|
|
5
5
|
// own way.
|
|
6
|
-
import
|
|
7
|
-
import { getMe, gitHeadSha, publishUpload } from "./backend.js";
|
|
6
|
+
import { getMe } from "./backend.js";
|
|
8
7
|
import { createCommand } from "./projects.js";
|
|
8
|
+
import { publishCommand } from "../publish/command.js";
|
|
9
9
|
import { activeOrgSlug, resolveActiveOrg } from "./org.js";
|
|
10
10
|
import { filterErrors, httpRelayClient, renderResult, } from "./diagnostics.js";
|
|
11
11
|
import { clearRelayInfo, readLivePreview, readRelayInfo, writeRelayInfo, } from "./relayInfo.js";
|
|
@@ -68,36 +68,42 @@ export async function resolveOrgForCommand(cfg, input) {
|
|
|
68
68
|
: "✗ No active organization.\n\n Run `vincentt whoami` to see which organizations you belong to,\n then re-run with `--org <slug>`.",
|
|
69
69
|
};
|
|
70
70
|
}
|
|
71
|
+
/**
|
|
72
|
+
* The shared `publish` orchestration behind both `vincentt publish` and the MCP
|
|
73
|
+
* `project_publish` tool, so the two surfaces can never diverge.
|
|
74
|
+
*
|
|
75
|
+
* MCP has no terminal, so it is a non-TTY caller: the command refuses without
|
|
76
|
+
* `--yes` rather than assuming consent, and refuses `--yes` outright on a first
|
|
77
|
+
* publish. An agent cannot make an address permanent.
|
|
78
|
+
*/
|
|
71
79
|
export async function projectPublish(projectCwd, opts = {}) {
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
});
|
|
76
|
-
const located = await locateProjectBinding(projectCwd);
|
|
77
|
-
if (!located) {
|
|
78
|
-
return {
|
|
79
|
-
message: [
|
|
80
|
-
"✗ This folder isn't linked to a project.",
|
|
81
|
-
"",
|
|
82
|
-
" vincentt link <project-id> (copy it from the console)",
|
|
83
|
-
" vincentt create Register a new project for this folder",
|
|
84
|
-
].join("\n"),
|
|
85
|
-
notBound: true,
|
|
86
|
-
};
|
|
87
|
-
}
|
|
88
|
-
const dist = path.resolve(projectCwd, opts.distDir ?? "dist");
|
|
89
|
-
const commitSha = await gitHeadSha(projectCwd);
|
|
90
|
-
const result = await publishUpload(cfg, located.binding.projectId, dist, {
|
|
91
|
-
note: opts.note,
|
|
92
|
-
commitSha,
|
|
93
|
-
});
|
|
94
|
-
return {
|
|
95
|
-
message: `Published v${result.version}. Live at ${result.liveUrl}`,
|
|
96
|
-
notBound: false,
|
|
97
|
-
version: result.version,
|
|
98
|
-
liveUrl: result.liveUrl,
|
|
99
|
-
url: result.url,
|
|
80
|
+
const lines = [];
|
|
81
|
+
const print = (line) => {
|
|
82
|
+
lines.push(line);
|
|
100
83
|
};
|
|
84
|
+
const exitCode = await publishCommand({
|
|
85
|
+
print,
|
|
86
|
+
write: () => {
|
|
87
|
+
// No redraw off a terminal; the degraded path emits whole lines.
|
|
88
|
+
},
|
|
89
|
+
confirm: async () => false,
|
|
90
|
+
cwd: projectCwd,
|
|
91
|
+
config: () => resolveConfig(projectCwd, {
|
|
92
|
+
allowCustomApi: opts.allowCustomApi,
|
|
93
|
+
warn: opts.warn ?? print,
|
|
94
|
+
}),
|
|
95
|
+
binding: async () => {
|
|
96
|
+
const located = await locateProjectBinding(projectCwd);
|
|
97
|
+
return located
|
|
98
|
+
? { projectId: located.binding.projectId, slug: located.binding.slug }
|
|
99
|
+
: undefined;
|
|
100
|
+
},
|
|
101
|
+
token: async () => process.env.VINCENTT_PAT ?? (await loadMachineConfig()).pat,
|
|
102
|
+
isTTY: false,
|
|
103
|
+
now: () => Date.now(),
|
|
104
|
+
sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
|
|
105
|
+
}, { distDir: opts.distDir, note: opts.note, yes: opts.yes });
|
|
106
|
+
return { message: lines.join("\n"), exitCode };
|
|
101
107
|
}
|
|
102
108
|
// ---- preview (one per process) ----------------------------------------------
|
|
103
109
|
let activePreview = null;
|
package/dist/mcp/server.js
CHANGED
|
@@ -141,7 +141,7 @@ export function createHarnessMcp(opts = {}) {
|
|
|
141
141
|
return result.message;
|
|
142
142
|
}));
|
|
143
143
|
server.registerTool("project_publish", {
|
|
144
|
-
description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding.",
|
|
144
|
+
description: "Publish the locally-built dist so this project goes live at <slug>.vincentt.app. Build first (e.g. npm run build), then call this — the dist is uploaded as-is and the server never rebuilds. Requires a prior project_create binding. A project's FIRST publish cannot be automated: it makes the address permanent, so the user must run `vincentt publish` in their own terminal once. Every publish after that works from here.",
|
|
145
145
|
inputSchema: {
|
|
146
146
|
distDir: z
|
|
147
147
|
.string()
|
|
@@ -154,10 +154,10 @@ export function createHarnessMcp(opts = {}) {
|
|
|
154
154
|
...projectDirInput,
|
|
155
155
|
},
|
|
156
156
|
}, async ({ distDir, note, projectDir }) => guard(async () => {
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
});
|
|
157
|
+
// `yes` is the only value an MCP caller can pass: there is no terminal to
|
|
158
|
+
// prompt at. On a FIRST publish the command refuses it outright, which is
|
|
159
|
+
// what keeps an irreversible act off an agent's tool surface.
|
|
160
|
+
const result = await projectPublish(resolveProjectDir(baseProjectDir, projectDir), { distDir, note, yes: true });
|
|
161
161
|
return result.message;
|
|
162
162
|
}));
|
|
163
163
|
server.registerTool("preview_start", {
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import type { ResolvedConfig } from "../shared/config.js";
|
|
2
|
+
import type { ManifestEntry } from "./manifest.js";
|
|
3
|
+
export interface PublishProject {
|
|
4
|
+
id: string;
|
|
5
|
+
slug: string;
|
|
6
|
+
name: string;
|
|
7
|
+
liveUrl: string;
|
|
8
|
+
publishState: "never" | "live" | "unpublished" | "not_serving";
|
|
9
|
+
firstPublishedAt: string | null;
|
|
10
|
+
liveVersion: number | null;
|
|
11
|
+
highestVersion: number | null;
|
|
12
|
+
/** The LIVE version's publishedAt. Ledger-derived; it is why the checklist needs one call. */
|
|
13
|
+
livePublishedAt: string | null;
|
|
14
|
+
rolledBackFrom: number | null;
|
|
15
|
+
}
|
|
16
|
+
export interface UploadTarget {
|
|
17
|
+
path: string;
|
|
18
|
+
/** The presigned S3 PUT. THE CLI NEVER PARSES IT. */
|
|
19
|
+
url: string;
|
|
20
|
+
/**
|
|
21
|
+
* The map the CLI MUST send verbatim. Content-Type and x-amz-checksum-sha256
|
|
22
|
+
* are both covered by the signature: dropping either yields
|
|
23
|
+
* SignatureDoesNotMatch, which reads as an auth bug and gets "fixed" by
|
|
24
|
+
* deleting the header — §2.7's contract traps 3 and 4.
|
|
25
|
+
*/
|
|
26
|
+
headers: Record<string, string>;
|
|
27
|
+
}
|
|
28
|
+
export interface OpenedUpload {
|
|
29
|
+
uploadId: string;
|
|
30
|
+
uploads: UploadTarget[];
|
|
31
|
+
expiresAt: string;
|
|
32
|
+
}
|
|
33
|
+
export interface CommitResult {
|
|
34
|
+
version: number;
|
|
35
|
+
liveVersion: number;
|
|
36
|
+
highestVersion: number;
|
|
37
|
+
liveUrl: string;
|
|
38
|
+
publishedAt: string;
|
|
39
|
+
/** Response-side name of the request's `note`. The asymmetry is deliberate. */
|
|
40
|
+
releaseNote?: string;
|
|
41
|
+
}
|
|
42
|
+
export interface ApiErrorDetail {
|
|
43
|
+
limit?: number;
|
|
44
|
+
observed?: number;
|
|
45
|
+
missing?: number;
|
|
46
|
+
mismatched?: number;
|
|
47
|
+
reason?: string;
|
|
48
|
+
path?: string;
|
|
49
|
+
}
|
|
50
|
+
export declare class PublishApiError extends Error {
|
|
51
|
+
readonly status: number;
|
|
52
|
+
readonly code: string;
|
|
53
|
+
readonly detail: ApiErrorDetail;
|
|
54
|
+
readonly retryAfterSec?: number | undefined;
|
|
55
|
+
constructor(status: number, code: string, message: string, detail?: ApiErrorDetail, retryAfterSec?: number | undefined);
|
|
56
|
+
}
|
|
57
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
58
|
+
export declare function readProject(cfg: ResolvedConfig, projectId: string, fetchImpl?: typeof fetch): Promise<PublishProject>;
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export declare function openUpload(cfg: ResolvedConfig, projectId: string, files: ManifestEntry[], note: string | undefined, fetchImpl?: typeof fetch): Promise<OpenedUpload>;
|
|
65
|
+
export declare function commitUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<CommitResult>;
|
|
66
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
67
|
+
export declare function abandonUpload(cfg: ResolvedConfig, projectId: string, uploadId: string, fetchImpl?: typeof fetch): Promise<void>;
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
// The three-call publish transaction against the Go API, built against
|
|
2
|
+
// implementation.md §2.7's contract table — every wire field named canonically
|
|
3
|
+
// once, and the deliberate asymmetry (request `note`, response `releaseNote`)
|
|
4
|
+
// carried as written.
|
|
5
|
+
//
|
|
6
|
+
// The bytes do NOT transit the API. Open returns one presigned PUT per file,
|
|
7
|
+
// each signed for one exact key; the CLI PUTs direct to S3 and then commits.
|
|
8
|
+
export class PublishApiError extends Error {
|
|
9
|
+
status;
|
|
10
|
+
code;
|
|
11
|
+
detail;
|
|
12
|
+
retryAfterSec;
|
|
13
|
+
constructor(status, code, message, detail = {}, retryAfterSec) {
|
|
14
|
+
super(message);
|
|
15
|
+
this.status = status;
|
|
16
|
+
this.code = code;
|
|
17
|
+
this.detail = detail;
|
|
18
|
+
this.retryAfterSec = retryAfterSec;
|
|
19
|
+
this.name = "PublishApiError";
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
async function readError(res) {
|
|
23
|
+
let body = {};
|
|
24
|
+
try {
|
|
25
|
+
body = (await res.json());
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// A non-JSON error body is still an error; the status carries the meaning.
|
|
29
|
+
}
|
|
30
|
+
const retryAfter = res.headers.get("retry-after");
|
|
31
|
+
return new PublishApiError(res.status, body.code ?? `http_${res.status}`, body.message ?? `request failed (${res.status})`, body.detail ?? {}, retryAfter ? Number(retryAfter) : undefined);
|
|
32
|
+
}
|
|
33
|
+
function authHeaders(cfg) {
|
|
34
|
+
return { authorization: `Bearer ${cfg.pat}` };
|
|
35
|
+
}
|
|
36
|
+
/** The ONE call before the checklist. liveVersion + livePublishedAt ride it, by design. */
|
|
37
|
+
export async function readProject(cfg, projectId, fetchImpl = fetch) {
|
|
38
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}`, {
|
|
39
|
+
headers: authHeaders(cfg),
|
|
40
|
+
});
|
|
41
|
+
if (!res.ok)
|
|
42
|
+
throw await readError(res);
|
|
43
|
+
const raw = (await res.json());
|
|
44
|
+
const num = (k) => typeof raw[k] === "number" ? raw[k] : null;
|
|
45
|
+
const str = (k) => typeof raw[k] === "string" ? raw[k] : null;
|
|
46
|
+
return {
|
|
47
|
+
id: String(raw.id ?? projectId),
|
|
48
|
+
slug: String(raw.slug ?? ""),
|
|
49
|
+
name: typeof raw.name === "string" ? raw.name : String(raw.slug ?? ""),
|
|
50
|
+
liveUrl: str("liveUrl") ?? "",
|
|
51
|
+
publishState: (str("publishState") ?? "never"),
|
|
52
|
+
firstPublishedAt: str("firstPublishedAt"),
|
|
53
|
+
liveVersion: num("liveVersion"),
|
|
54
|
+
highestVersion: num("highestVersion"),
|
|
55
|
+
livePublishedAt: str("livePublishedAt"),
|
|
56
|
+
rolledBackFrom: num("rolledBackFrom"),
|
|
57
|
+
};
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* Open an upload. The request body carries `files` (path/size/sha256) and an
|
|
61
|
+
* optional `note`, AND NOTHING ELSE — there is no field for a MIME type and no
|
|
62
|
+
* field a parsed title, description or image URL could land in.
|
|
63
|
+
*/
|
|
64
|
+
export async function openUpload(cfg, projectId, files, note, fetchImpl = fetch) {
|
|
65
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes`, {
|
|
66
|
+
method: "POST",
|
|
67
|
+
headers: { ...authHeaders(cfg), "content-type": "application/json" },
|
|
68
|
+
body: JSON.stringify({ files, ...(note ? { note } : {}) }),
|
|
69
|
+
});
|
|
70
|
+
if (!res.ok)
|
|
71
|
+
throw await readError(res);
|
|
72
|
+
const body = (await res.json());
|
|
73
|
+
if (!body.uploadId || !Array.isArray(body.uploads)) {
|
|
74
|
+
throw new PublishApiError(res.status, "malformed_response", "open upload: response missing uploadId/uploads");
|
|
75
|
+
}
|
|
76
|
+
return {
|
|
77
|
+
uploadId: body.uploadId,
|
|
78
|
+
uploads: body.uploads,
|
|
79
|
+
expiresAt: body.expiresAt ?? "",
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
export async function commitUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
83
|
+
const res = await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}/commit`, { method: "POST", headers: authHeaders(cfg) });
|
|
84
|
+
if (!res.ok)
|
|
85
|
+
throw await readError(res);
|
|
86
|
+
return (await res.json());
|
|
87
|
+
}
|
|
88
|
+
/** Abandon an open upload. Idempotent server-side; a failure here must never mask the real error. */
|
|
89
|
+
export async function abandonUpload(cfg, projectId, uploadId, fetchImpl = fetch) {
|
|
90
|
+
try {
|
|
91
|
+
await fetchImpl(`${cfg.apiUrl}/projects/${encodeURIComponent(projectId)}/publishes/${encodeURIComponent(uploadId)}`, { method: "DELETE", headers: authHeaders(cfg) });
|
|
92
|
+
}
|
|
93
|
+
catch {
|
|
94
|
+
// Best-effort cleanup: the TTL reaps the row either way.
|
|
95
|
+
}
|
|
96
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import type { Manifest, ManifestEntry } from "./manifest.js";
|
|
2
|
+
export declare const MAX_TOTAL_BYTES = 524288000;
|
|
3
|
+
export declare const MAX_FILES = 5000;
|
|
4
|
+
export declare const MAX_FILE_BYTES = 104857600;
|
|
5
|
+
export type BoundKind = "total" | "count" | "file";
|
|
6
|
+
export interface BoundViolation {
|
|
7
|
+
kind: BoundKind;
|
|
8
|
+
observed: number;
|
|
9
|
+
limit: number;
|
|
10
|
+
/** The one offending path, for the per-file case. */
|
|
11
|
+
path?: string;
|
|
12
|
+
/** The three largest files by size — the actual diagnosis for the total case. */
|
|
13
|
+
largest: ManifestEntry[];
|
|
14
|
+
}
|
|
15
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
16
|
+
export declare function largestFiles(files: ManifestEntry[], n?: number): ManifestEntry[];
|
|
17
|
+
/**
|
|
18
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
19
|
+
* files and exactly 100 MiB all pass.
|
|
20
|
+
*/
|
|
21
|
+
export declare function checkBounds(manifest: Manifest, limits?: {
|
|
22
|
+
total?: number;
|
|
23
|
+
count?: number;
|
|
24
|
+
file?: number;
|
|
25
|
+
}): BoundViolation | undefined;
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The three bounds, checked LOCALLY before any request (implementation.md §2.5,
|
|
2
|
+
// Flow L28). The manifest is built locally and the limits are known locally, so
|
|
3
|
+
// the creator learns in the same second they would otherwise have started a
|
|
4
|
+
// ten-minute upload.
|
|
5
|
+
//
|
|
6
|
+
// These are the compiled-in defaults the API also carries. A divergence would
|
|
7
|
+
// mean the CLI's printed limit is off by one from the API's, which is why QA
|
|
8
|
+
// drives a manifest exactly AT each bound.
|
|
9
|
+
export const MAX_TOTAL_BYTES = 524_288_000; // 500 MiB
|
|
10
|
+
export const MAX_FILES = 5_000;
|
|
11
|
+
export const MAX_FILE_BYTES = 104_857_600; // 100 MiB
|
|
12
|
+
/** The three largest files, descending. Local information about the creator's own disk. */
|
|
13
|
+
export function largestFiles(files, n = 3) {
|
|
14
|
+
return [...files].sort((a, b) => b.size - a.size).slice(0, n);
|
|
15
|
+
}
|
|
16
|
+
/**
|
|
17
|
+
* The bounds are INCLUSIVE at the stated number: exactly 500 MiB, exactly 5000
|
|
18
|
+
* files and exactly 100 MiB all pass.
|
|
19
|
+
*/
|
|
20
|
+
export function checkBounds(manifest, limits = {}) {
|
|
21
|
+
const maxTotal = limits.total ?? MAX_TOTAL_BYTES;
|
|
22
|
+
const maxCount = limits.count ?? MAX_FILES;
|
|
23
|
+
const maxFile = limits.file ?? MAX_FILE_BYTES;
|
|
24
|
+
// Per-file first: one pathological object is a more specific diagnosis than
|
|
25
|
+
// the total it also blows, and naming the file is the whole remedy.
|
|
26
|
+
const oversize = manifest.files.find((f) => f.size > maxFile);
|
|
27
|
+
if (oversize) {
|
|
28
|
+
return {
|
|
29
|
+
kind: "file",
|
|
30
|
+
observed: oversize.size,
|
|
31
|
+
limit: maxFile,
|
|
32
|
+
path: oversize.path,
|
|
33
|
+
largest: largestFiles(manifest.files),
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
if (manifest.files.length > maxCount) {
|
|
37
|
+
return {
|
|
38
|
+
kind: "count",
|
|
39
|
+
observed: manifest.files.length,
|
|
40
|
+
limit: maxCount,
|
|
41
|
+
largest: largestFiles(manifest.files),
|
|
42
|
+
};
|
|
43
|
+
}
|
|
44
|
+
if (manifest.totalBytes > maxTotal) {
|
|
45
|
+
return {
|
|
46
|
+
kind: "total",
|
|
47
|
+
observed: manifest.totalBytes,
|
|
48
|
+
limit: maxTotal,
|
|
49
|
+
largest: largestFiles(manifest.files),
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
return undefined;
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
export type Tier = "present" | "missing" | "template-default" | "likely-default" | "dead-image";
|
|
2
|
+
/** The five tags the checklist reads. `twitter:card` is read but reported only through `social`. */
|
|
3
|
+
export type FieldKey = "title" | "description" | "image";
|
|
4
|
+
export interface Finding {
|
|
5
|
+
field: FieldKey;
|
|
6
|
+
tier: Tier;
|
|
7
|
+
/** The value found, if any. Both default tiers PRINT it — evidence is what makes them safe. */
|
|
8
|
+
value?: string;
|
|
9
|
+
/** The tag as it appears in the source, e.g. `<meta property="og:image">`. */
|
|
10
|
+
tag: string;
|
|
11
|
+
}
|
|
12
|
+
export interface FileFindings {
|
|
13
|
+
/** The dist-relative html path, e.g. `index.html`. */
|
|
14
|
+
file: string;
|
|
15
|
+
findings: Finding[];
|
|
16
|
+
}
|
|
17
|
+
export interface ParsedMeta {
|
|
18
|
+
title?: string;
|
|
19
|
+
ogTitle?: string;
|
|
20
|
+
description?: string;
|
|
21
|
+
ogDescription?: string;
|
|
22
|
+
ogImage?: string;
|
|
23
|
+
ogUrl?: string;
|
|
24
|
+
twitterCard?: string;
|
|
25
|
+
}
|
|
26
|
+
export declare function parseMeta(html: string): ParsedMeta;
|
|
27
|
+
export interface ClassifyContext {
|
|
28
|
+
/** Every normalized path in the manifest — what makes the dead-og:image check structural. */
|
|
29
|
+
manifestPaths: ReadonlySet<string>;
|
|
30
|
+
/** `<slug>.<apex>`, for the og:url host heuristic. Absent skips that check. */
|
|
31
|
+
expectedHost?: string;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* CLASSIFY, per 2A:
|
|
35
|
+
* absent -> MISSING
|
|
36
|
+
* value in the bundled corpus -> TEMPLATE-DEFAULT (high confidence)
|
|
37
|
+
* value matches a generic shape -> LIKELY-DEFAULT (low confidence)
|
|
38
|
+
* else -> PRESENT
|
|
39
|
+
*
|
|
40
|
+
* Plus the one structural certainty: an og:image naming a path that is not in the
|
|
41
|
+
* manifest is a dead social image, and the manifest either contains it or it does
|
|
42
|
+
* not.
|
|
43
|
+
*/
|
|
44
|
+
export declare function classify(meta: ParsedMeta, ctx: ClassifyContext): Finding[];
|
|
45
|
+
/**
|
|
46
|
+
* The manifest path an og:image refers to, or undefined when it is an absolute
|
|
47
|
+
* URL on some other host — which the CLI cannot check and must not guess about.
|
|
48
|
+
*/
|
|
49
|
+
export declare function localManifestPath(value: string): string | undefined;
|
|
50
|
+
/** Every `.html` in the manifest, classified. Multiple files group under their filename. */
|
|
51
|
+
export declare function classifyDist(distDir: string, manifestPaths: ReadonlySet<string>, readFile: (abs: string) => Promise<string>, join: (dir: string, rel: string) => string, ctx?: Omit<ClassifyContext, "manifestPaths">): Promise<FileFindings[]>;
|