@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.
@@ -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, projectPublish, } from "../lib/lifecycle.js";
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
- await requireLogin();
191
- const result = await projectPublish(projectDir(), {
192
- distDir: flag("--dist"),
193
- note: flag("--note"),
194
- allowCustomApi: has("--allow-custom-api"),
195
- warn: (l) => console.log(l),
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;
@@ -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;
@@ -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) {
@@ -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
- notBound: boolean;
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 {
@@ -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 path from "node:path";
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 cfg = await resolveConfig(projectCwd, {
73
- allowCustomApi: opts.allowCustomApi,
74
- warn: opts.warn,
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;
@@ -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
- const result = await projectPublish(resolveProjectDir(baseProjectDir, projectDir), {
158
- distDir,
159
- note,
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[]>;