frontpage-host 0.1.6 → 0.1.8

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/AGENTS.md CHANGED
@@ -119,10 +119,21 @@ Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or
119
119
 
120
120
  ```bash
121
121
  frontpage prompt "make the hero shorter"
122
- frontpage runs wait # wait for the agent run to finish
122
+ frontpage prompt "Use this logo in the header" --attach ./logo.png
123
+ frontpage prompt "Match this brand PDF" --attach ./guide.pdf --path /
124
+ frontpage prompt "Reuse uploaded art" --attach /__media/uploads/<slug>/<file>
125
+ frontpage runs wait # wait for the agent run to finish (failed/canceled → exit 2)
123
126
  # Review https://<subdomain>.frontpage.host — do not publish unless asked.
124
127
  ```
125
128
 
129
+ - `--attach` accepts a local file (uploads via the same path as `frontpage media upload`), an existing `/__media/…` site path, or an `https://` Blob URL. Repeatable.
130
+ - `--asset-url <https://…>` adds a legacy image URL. Repeatable.
131
+ - Caps match the media proxy: images / PDF / text-code. CLI video and >4 MB binaries are not supported yet (editor attach / Phase E).
132
+ - Prompt text is still required (API). Attachments ride on the same enqueue as the editor.
133
+ - `runs wait` with `--json`: failures use `ok: false` and include `data.run` (with `run.error` when present).
134
+ - `deploy wait` is fail-closed: exit 2 if the waited run (or latest, when already idle) is `failed`/`canceled`.
135
+ - `frontpage push --wait` waits for agent-run idle after push (same busy statuses; not a separate Vercel oracle).
136
+
126
137
  Mid-task continuation is automatic. There is **no** `runs continue` in v1.
127
138
 
128
139
  ## Content / JSON-native commands
@@ -165,10 +176,10 @@ Global machine envelope (`--json`):
165
176
 
166
177
  ```json
167
178
  { "ok": true, "command": "status", "data": { } }
168
- { "ok": false, "command": "push", "error": { "message": "…", "code": "conflict", "httpStatus": 409, "exitCode": 3 } }
179
+ { "ok": false, "command": "push", "error": { "message": "…", "code": "conflict", "httpStatus": 409, "exitCode": 3, "requestId": "…" } }
169
180
  ```
170
181
 
171
- Failures with `--json` print the envelope on **stdout** (still use the process exit code).
182
+ Failures with `--json` print the envelope on **stdout** (still use the process exit code). Include `requestId` when filing support issues.
172
183
 
173
184
  ## Global flags
174
185
 
@@ -1,7 +1,7 @@
1
1
  import { resolveSlug } from "../site.js";
2
- import { EXIT_OK, EXIT_USAGE, v1Get } from "../http/client.js";
3
- import { emitErr, emitOk, emitUsage, progress } from "../runtime.js";
4
- import { isJson } from "../runtime.js";
2
+ import { EXIT_USAGE } from "../http/client.js";
3
+ import { emitErr, emitUsage } from "../runtime.js";
4
+ import { emitWaitOutcome, waitForAgentIdle } from "./wait-agent-idle.js";
5
5
  /** `frontpage deploy wait` — wait until no run is in deploying/queued/running. */
6
6
  export async function runDeploy(args) {
7
7
  try {
@@ -10,30 +10,12 @@ export async function runDeploy(args) {
10
10
  return emitUsage("deploy", "Usage: frontpage deploy wait [--site <slug>]", EXIT_USAGE);
11
11
  }
12
12
  const { slug } = await resolveSlug(args.slice(1));
13
- const started = Date.now();
14
- const maxMs = 30 * 60 * 1000;
15
- while (Date.now() - started < maxMs) {
16
- const data = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/agent-runs`);
17
- const busy = (data.active ?? []).filter((r) => ["queued", "running", "deploying"].includes(r.status));
18
- if (busy.length === 0) {
19
- if (isJson()) {
20
- emitOk("deploy", {
21
- slug,
22
- waited: true,
23
- run: data.run,
24
- status: data.run?.status ?? "idle",
25
- });
26
- }
27
- else {
28
- console.log(data.run ? `${data.run.id} ${data.run.status}` : "No active deploy.");
29
- }
30
- return EXIT_OK;
31
- }
32
- const head = busy.find((r) => r.status === "deploying") ?? busy[0];
33
- progress(`\r${head.status} ${head.statusMessage ?? ""} `);
34
- await new Promise((r) => setTimeout(r, 2000));
35
- }
36
- return emitUsage("deploy", "Timed out waiting for deploy.", EXIT_USAGE);
13
+ const result = await waitForAgentIdle({
14
+ slug,
15
+ // Fail-closed: idle with latest failed/canceled is not success.
16
+ failClosedOnIdleTerminalFailure: true,
17
+ });
18
+ return emitWaitOutcome({ command: "deploy", slug, result });
37
19
  }
38
20
  catch (err) {
39
21
  return emitErr("deploy", err);
@@ -0,0 +1,59 @@
1
+ /** Mirror ATTACH_LIMITS in src/lib/agent/attachments.ts (images/docs/text only via proxy media). */
2
+ export declare const CLI_ATTACH_LIMITS: {
3
+ readonly maxImages: 6;
4
+ readonly maxDocuments: 4;
5
+ readonly maxTextFiles: 6;
6
+ };
7
+ export type PromptAttachmentsPayload = {
8
+ images: Array<{
9
+ url: string;
10
+ name?: string;
11
+ }>;
12
+ documents: Array<{
13
+ url: string;
14
+ name?: string;
15
+ }>;
16
+ textFiles: Array<{
17
+ name: string;
18
+ content: string;
19
+ }>;
20
+ };
21
+ export type MediaUploadResult = {
22
+ kind: string;
23
+ url?: string;
24
+ key?: string;
25
+ filename?: string;
26
+ sizeBytes?: number;
27
+ content?: string;
28
+ };
29
+ export declare function emptyAttachments(): PromptAttachmentsPayload;
30
+ export declare function isHttpsUrl(s: string): boolean;
31
+ export declare function isMediaSitePath(s: string): boolean;
32
+ export declare function classifyRemoteByName(nameOrUrl: string): "image" | "document";
33
+ export declare function addHttpsRef(out: PromptAttachmentsPayload, url: string, opts?: {
34
+ name?: string;
35
+ asImageOnly?: boolean;
36
+ }): void;
37
+ export declare function addUploadResult(out: PromptAttachmentsPayload, data: MediaUploadResult): void;
38
+ export declare function uploadLocalForAttach(slug: string, filePath: string): Promise<MediaUploadResult>;
39
+ export declare function resolveSitePathUrl(slug: string, sitePath: string): Promise<{
40
+ url: string;
41
+ name: string;
42
+ type: string;
43
+ }>;
44
+ /**
45
+ * Resolve one --attach value (local path, /__media/…, or https URL) into the payload.
46
+ */
47
+ export declare function resolveAttachRef(slug: string, ref: string, out: PromptAttachmentsPayload): Promise<void>;
48
+ export declare function buildPromptAttachments(args: {
49
+ slug: string;
50
+ attachRefs: string[];
51
+ assetUrls: string[];
52
+ }): Promise<PromptAttachmentsPayload | undefined>;
53
+ /** Parse prompt argv: prompt words + --path / --attach / --asset-url flags. */
54
+ export declare function parsePromptArgs(rest: string[]): {
55
+ prompt: string;
56
+ currentPath: string;
57
+ attachRefs: string[];
58
+ assetUrls: string[];
59
+ };
@@ -0,0 +1,207 @@
1
+ /**
2
+ * §CLI Phase C — resolve --attach / --asset-url into the agent attachments payload.
3
+ * Local files upload via the same multipart media endpoint as `frontpage media upload`.
4
+ */
5
+ import { access, readFile } from "node:fs/promises";
6
+ import { basename } from "node:path";
7
+ import { constants as fsConstants } from "node:fs";
8
+ import { v1Get, v1PostMultipart, CliHttpError, EXIT_USAGE, EXIT_UNEXPECTED } from "../http/client.js";
9
+ /** Mirror ATTACH_LIMITS in src/lib/agent/attachments.ts (images/docs/text only via proxy media). */
10
+ export const CLI_ATTACH_LIMITS = {
11
+ maxImages: 6,
12
+ maxDocuments: 4,
13
+ maxTextFiles: 6,
14
+ };
15
+ export function emptyAttachments() {
16
+ return { images: [], documents: [], textFiles: [] };
17
+ }
18
+ export function isHttpsUrl(s) {
19
+ return /^https:\/\/[^\s]+$/i.test(s) && s.length <= 2048;
20
+ }
21
+ export function isMediaSitePath(s) {
22
+ return s.startsWith("/__media/");
23
+ }
24
+ export function classifyRemoteByName(nameOrUrl) {
25
+ const base = nameOrUrl.split(/[?#]/)[0] ?? nameOrUrl;
26
+ const ext = base.includes(".") ? (base.split(".").pop() ?? "").toLowerCase() : "";
27
+ if (ext === "pdf")
28
+ return "document";
29
+ return "image";
30
+ }
31
+ function pushUniqueUrl(list, max, url, name, label) {
32
+ if (list.some((x) => x.url === url))
33
+ return;
34
+ if (list.length >= max) {
35
+ throw new CliHttpError(`Too many ${label} attachments (max ${max}).`, EXIT_USAGE, 400, "bad_request");
36
+ }
37
+ list.push(name ? { url, name } : { url });
38
+ }
39
+ function pushText(list, name, content) {
40
+ if (list.length >= CLI_ATTACH_LIMITS.maxTextFiles) {
41
+ throw new CliHttpError(`Too many text attachments (max ${CLI_ATTACH_LIMITS.maxTextFiles}).`, EXIT_USAGE, 400, "bad_request");
42
+ }
43
+ list.push({ name, content });
44
+ }
45
+ export function addHttpsRef(out, url, opts) {
46
+ if (!isHttpsUrl(url)) {
47
+ throw new CliHttpError(`Not an https URL: ${url}`, EXIT_USAGE, 400, "bad_request");
48
+ }
49
+ const kind = opts?.asImageOnly ? "image" : classifyRemoteByName(opts?.name ?? url);
50
+ if (kind === "document") {
51
+ pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, url, opts?.name, "document");
52
+ }
53
+ else {
54
+ pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, url, opts?.name, "image");
55
+ }
56
+ }
57
+ export function addUploadResult(out, data) {
58
+ const name = data.filename?.trim() || undefined;
59
+ if (data.kind === "text") {
60
+ if (typeof data.content !== "string" || !data.content) {
61
+ throw new CliHttpError("Text upload returned no content.", EXIT_UNEXPECTED, 500, "internal");
62
+ }
63
+ pushText(out.textFiles, name ?? "snippet.txt", data.content);
64
+ return;
65
+ }
66
+ if (data.kind === "image" || data.kind === "document") {
67
+ if (!data.url || !isHttpsUrl(data.url)) {
68
+ throw new CliHttpError(`Upload of ${name ?? "file"} did not return a Blob URL.`, EXIT_UNEXPECTED, 500, "internal");
69
+ }
70
+ if (data.kind === "document") {
71
+ pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, data.url, name, "document");
72
+ }
73
+ else {
74
+ pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, data.url, name, "image");
75
+ }
76
+ return;
77
+ }
78
+ throw new CliHttpError(`Unsupported upload kind "${data.kind}" for prompt attach. Use images, PDFs, or text/code files (video/large files need the editor path until Phase E).`, EXIT_USAGE, 400, "bad_request");
79
+ }
80
+ export async function uploadLocalForAttach(slug, filePath) {
81
+ try {
82
+ await access(filePath, fsConstants.R_OK);
83
+ }
84
+ catch {
85
+ throw new CliHttpError(`Cannot read file for --attach: ${filePath}`, EXIT_USAGE, 400, "bad_request");
86
+ }
87
+ const bytes = await readFile(filePath);
88
+ if (bytes.length === 0) {
89
+ throw new CliHttpError(`File is empty: ${filePath}`, EXIT_USAGE, 400, "bad_request");
90
+ }
91
+ const form = new FormData();
92
+ form.append("file", new Blob([Uint8Array.from(bytes)]), basename(filePath));
93
+ return v1PostMultipart(`/api/v1/sites/${encodeURIComponent(slug)}/media`, form);
94
+ }
95
+ export async function resolveSitePathUrl(slug, sitePath) {
96
+ const data = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/media`);
97
+ const norm = (p) => p.replace(/\/+/g, "/").replace(/\/$/, "") || p;
98
+ const want = norm(sitePath);
99
+ const hit = data.assets.find((a) => a.sitePath === sitePath || norm(a.sitePath) === want);
100
+ if (!hit?.url) {
101
+ throw new CliHttpError(`No media asset found for ${sitePath}. Upload with frontpage media upload, or pass a Blob https URL.`, EXIT_USAGE, 404, "not_found");
102
+ }
103
+ return { url: hit.url, name: hit.name, type: hit.type };
104
+ }
105
+ /**
106
+ * Resolve one --attach value (local path, /__media/…, or https URL) into the payload.
107
+ */
108
+ export async function resolveAttachRef(slug, ref, out) {
109
+ const raw = ref.trim();
110
+ if (!raw) {
111
+ throw new CliHttpError("--attach requires a path or URL", EXIT_USAGE, 400, "bad_request");
112
+ }
113
+ if (/^https?:\/\//i.test(raw)) {
114
+ if (!isHttpsUrl(raw)) {
115
+ throw new CliHttpError(`--attach URLs must be https:// (got ${raw.split(":")[0]}://)`, EXIT_USAGE, 400, "bad_request");
116
+ }
117
+ addHttpsRef(out, raw);
118
+ return;
119
+ }
120
+ if (isMediaSitePath(raw)) {
121
+ const asset = await resolveSitePathUrl(slug, raw);
122
+ const kind = classifyRemoteByName(asset.name || asset.type || raw);
123
+ if (kind === "document") {
124
+ pushUniqueUrl(out.documents, CLI_ATTACH_LIMITS.maxDocuments, asset.url, asset.name, "document");
125
+ }
126
+ else {
127
+ pushUniqueUrl(out.images, CLI_ATTACH_LIMITS.maxImages, asset.url, asset.name, "image");
128
+ }
129
+ return;
130
+ }
131
+ const uploaded = await uploadLocalForAttach(slug, raw);
132
+ addUploadResult(out, uploaded);
133
+ }
134
+ export async function buildPromptAttachments(args) {
135
+ if (args.attachRefs.length === 0 && args.assetUrls.length === 0)
136
+ return undefined;
137
+ const out = emptyAttachments();
138
+ for (const url of args.assetUrls) {
139
+ addHttpsRef(out, url.trim(), { asImageOnly: true });
140
+ }
141
+ for (const ref of args.attachRefs) {
142
+ await resolveAttachRef(args.slug, ref, out);
143
+ }
144
+ if (out.images.length === 0 &&
145
+ out.documents.length === 0 &&
146
+ out.textFiles.length === 0) {
147
+ throw new CliHttpError("No attachments could be resolved from --attach / --asset-url.", EXIT_USAGE, 400, "bad_request");
148
+ }
149
+ return out;
150
+ }
151
+ /** Parse prompt argv: prompt words + --path / --attach / --asset-url flags. */
152
+ export function parsePromptArgs(rest) {
153
+ let currentPath = "/";
154
+ const promptParts = [];
155
+ const attachRefs = [];
156
+ const assetUrls = [];
157
+ for (let i = 0; i < rest.length; i++) {
158
+ const a = rest[i];
159
+ if (a === "--path") {
160
+ currentPath = rest[++i] ?? "/";
161
+ continue;
162
+ }
163
+ if (a.startsWith("--path=")) {
164
+ currentPath = a.slice("--path=".length) || "/";
165
+ continue;
166
+ }
167
+ if (a === "--attach") {
168
+ const v = rest[++i];
169
+ if (!v || v.startsWith("--")) {
170
+ throw new CliHttpError('Usage: frontpage prompt "…" --attach <file|/__media/…|https://…>', EXIT_USAGE, 400, "bad_request");
171
+ }
172
+ attachRefs.push(v);
173
+ continue;
174
+ }
175
+ if (a.startsWith("--attach=")) {
176
+ const v = a.slice("--attach=".length);
177
+ if (!v) {
178
+ throw new CliHttpError("--attach= requires a value", EXIT_USAGE, 400, "bad_request");
179
+ }
180
+ attachRefs.push(v);
181
+ continue;
182
+ }
183
+ if (a === "--asset-url") {
184
+ const v = rest[++i];
185
+ if (!v || v.startsWith("--")) {
186
+ throw new CliHttpError("Usage: frontpage prompt \"…\" --asset-url <https://…>", EXIT_USAGE, 400, "bad_request");
187
+ }
188
+ assetUrls.push(v);
189
+ continue;
190
+ }
191
+ if (a.startsWith("--asset-url=")) {
192
+ const v = a.slice("--asset-url=".length);
193
+ if (!v) {
194
+ throw new CliHttpError("--asset-url= requires a value", EXIT_USAGE, 400, "bad_request");
195
+ }
196
+ assetUrls.push(v);
197
+ continue;
198
+ }
199
+ promptParts.push(a);
200
+ }
201
+ return {
202
+ prompt: promptParts.join(" ").trim(),
203
+ currentPath,
204
+ attachRefs,
205
+ assetUrls,
206
+ };
207
+ }
@@ -1,38 +1,48 @@
1
1
  import { resolveSlug } from "../site.js";
2
2
  import { EXIT_OK, EXIT_USAGE, v1Post } from "../http/client.js";
3
3
  import { emitErr, emitOk, emitUsage, isJson } from "../runtime.js";
4
+ import { buildPromptAttachments, parsePromptArgs } from "./prompt-attach.js";
4
5
  export async function runPrompt(args) {
5
6
  try {
6
7
  const { slug, rest } = await resolveSlug(args);
7
- let currentPath = "/";
8
- const promptParts = [];
9
- for (let i = 0; i < rest.length; i++) {
10
- const a = rest[i];
11
- if (a === "--path") {
12
- currentPath = rest[++i] ?? "/";
13
- continue;
14
- }
15
- if (a.startsWith("--path=")) {
16
- currentPath = a.slice("--path=".length) || "/";
17
- continue;
18
- }
19
- promptParts.push(a);
20
- }
21
- const prompt = promptParts.join(" ").trim();
8
+ const { prompt, currentPath, attachRefs, assetUrls } = parsePromptArgs(rest);
22
9
  if (!prompt) {
23
- return emitUsage("prompt", 'Usage: frontpage prompt "..." [--site <slug>] [--path /page]', EXIT_USAGE);
10
+ return emitUsage("prompt", 'Usage: frontpage prompt "..." [--site <slug>] [--path /page] [--attach <file|/__media/…|https://…>]… [--asset-url <https://…>]…', EXIT_USAGE);
24
11
  }
25
12
  if (!currentPath.startsWith("/")) {
26
13
  return emitUsage("prompt", "currentPath must start with /", EXIT_USAGE);
27
14
  }
28
- const data = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/agent-runs`, { prompt, currentPath });
15
+ const attachments = await buildPromptAttachments({ slug, attachRefs, assetUrls });
16
+ const body = { prompt, currentPath };
17
+ if (attachments) {
18
+ body.attachments = attachments;
19
+ // Legacy image list for run display / back-compat readers.
20
+ body.assetUrls = attachments.images.map((i) => i.url);
21
+ }
22
+ const data = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/agent-runs`, body);
29
23
  if (isJson()) {
30
- emitOk("prompt", { slug, ...data });
24
+ emitOk("prompt", {
25
+ slug,
26
+ ...data,
27
+ attached: attachments
28
+ ? {
29
+ images: attachments.images.length,
30
+ documents: attachments.documents.length,
31
+ textFiles: attachments.textFiles.length,
32
+ }
33
+ : undefined,
34
+ });
31
35
  }
32
36
  else {
33
37
  console.log(data.runId);
34
38
  if (data.coalesced)
35
39
  console.error("(coalesced with an identical queued run)");
40
+ if (attachments) {
41
+ const n = attachments.images.length +
42
+ attachments.documents.length +
43
+ attachments.textFiles.length;
44
+ console.error(`(attached ${n} file${n === 1 ? "" : "s"})`);
45
+ }
36
46
  }
37
47
  return EXIT_OK;
38
48
  }
@@ -4,6 +4,7 @@ import { resolveSlug } from "../site.js";
4
4
  import { EXIT_OK, EXIT_USAGE, v1Put } from "../http/client.js";
5
5
  import { readState, writeState } from "../state/frontpage-state.js";
6
6
  import { emitErr, emitOk, emitUsage, isJson } from "../runtime.js";
7
+ import { emitWaitOutcome, waitForAgentIdle } from "./wait-agent-idle.js";
7
8
  const TEXT_EXTS = new Set([
8
9
  ".astro",
9
10
  ".ts",
@@ -59,13 +60,22 @@ function isTextPath(repoPath) {
59
60
  return true;
60
61
  return TEXT_EXTS.has(repoPath.slice(i).toLowerCase());
61
62
  }
63
+ function takeWaitFlag(rest) {
64
+ const i = rest.indexOf("--wait");
65
+ if (i === -1)
66
+ return false;
67
+ rest.splice(i, 1);
68
+ return true;
69
+ }
62
70
  export async function runPush(args) {
63
71
  try {
64
72
  const { slug, rest } = await resolveSlug(args);
65
- const messageIdx = rest.indexOf("--message");
73
+ const work = [...rest];
74
+ const wait = takeWaitFlag(work);
75
+ const messageIdx = work.indexOf("--message");
66
76
  const message = messageIdx >= 0
67
- ? rest[messageIdx + 1]
68
- : rest.find((a) => a.startsWith("--message="))?.slice("--message=".length);
77
+ ? work[messageIdx + 1]
78
+ : work.find((a) => a.startsWith("--message="))?.slice("--message=".length);
69
79
  const cwd = process.cwd();
70
80
  const state = await readState(cwd);
71
81
  if (!state.baseCommitSha) {
@@ -99,25 +109,51 @@ export async function runPush(args) {
99
109
  if (data.commitSha) {
100
110
  await writeState({ ...state, slug, baseCommitSha: data.commitSha }, cwd);
101
111
  }
102
- if (isJson()) {
103
- emitOk("push", {
104
- slug,
105
- changed: data.changed,
106
- commitSha: data.commitSha,
107
- baseCommitSha: data.baseCommitSha,
108
- filesChanged: data.filesChanged,
109
- });
112
+ const pushPayload = {
113
+ slug,
114
+ changed: data.changed,
115
+ commitSha: data.commitSha,
116
+ baseCommitSha: data.baseCommitSha,
117
+ filesChanged: data.filesChanged,
118
+ };
119
+ if (!wait) {
120
+ if (isJson()) {
121
+ emitOk("push", pushPayload);
122
+ return EXIT_OK;
123
+ }
124
+ if (!data.changed) {
125
+ console.log("Nothing to commit (remote already matches).");
126
+ return EXIT_OK;
127
+ }
128
+ console.log(`Pushed ${data.filesChanged.length} file(s) → ${data.commitSha}`);
129
+ console.log("Updated .frontpage/state.json baseCommitSha");
130
+ console.log("Note: module markup may auto-activate modules (same as the editor agent).");
131
+ console.log("tokens.ts may be auto-repaired at commit if it would break the build.");
110
132
  return EXIT_OK;
111
133
  }
112
- if (!data.changed) {
113
- console.log("Nothing to commit (remote already matches).");
114
- return EXIT_OK;
134
+ // --wait: after push, wait for agent-run busy to clear (same statuses as deploy wait).
135
+ // Does not invent a Vercel deploy oracle; file-only pushes with no active run return promptly.
136
+ if (!isJson()) {
137
+ if (!data.changed) {
138
+ console.log("Nothing to commit (remote already matches).");
139
+ }
140
+ else {
141
+ console.log(`Pushed ${data.filesChanged.length} file(s) → ${data.commitSha}`);
142
+ console.log("Updated .frontpage/state.json baseCommitSha");
143
+ }
144
+ console.error("Waiting for agent run / deploy idle…");
115
145
  }
116
- console.log(`Pushed ${data.filesChanged.length} file(s) → ${data.commitSha}`);
117
- console.log("Updated .frontpage/state.json baseCommitSha");
118
- console.log("Note: module markup may auto-activate modules (same as the editor agent).");
119
- console.log("tokens.ts may be auto-repaired at commit if it would break the build.");
120
- return EXIT_OK;
146
+ const result = await waitForAgentIdle({
147
+ slug,
148
+ // Do not fail on an ancient canceled run when this push never saw busy work.
149
+ failClosedOnIdleTerminalFailure: false,
150
+ });
151
+ return emitWaitOutcome({
152
+ command: "push",
153
+ slug,
154
+ result,
155
+ extra: { ...pushPayload, waited: true },
156
+ });
121
157
  }
122
158
  catch (err) {
123
159
  return emitErr("push", err);
@@ -1,13 +1,33 @@
1
1
  import { resolveSlug } from "../site.js";
2
2
  import { EXIT_OK, EXIT_USAGE, v1Get, v1Post } from "../http/client.js";
3
- import { emitErr, emitOk, emitUsage, isJson, progress } from "../runtime.js";
3
+ import { emitErr, emitFail, emitOk, emitUsage, isJson, progress } from "../runtime.js";
4
4
  const TERMINAL = new Set(["succeeded", "failed", "canceled"]);
5
+ const FAIL_TERMINAL = new Set(["failed", "canceled"]);
5
6
  function printRun(run) {
6
- const msg = run.statusMessage || run.summary || run.error || "";
7
+ const msg = run.statusMessage || run.summary || "";
7
8
  console.log(`${run.id} ${run.status}${msg ? ` ${msg}` : ""}`);
9
+ if (run.error)
10
+ console.log(` error: ${run.error}`);
8
11
  if (run.commitSha)
9
12
  console.log(` commit: ${run.commitSha}`);
10
13
  }
14
+ /** Finish runs wait — honest JSON (ok:false on failure) + always surface run.error. */
15
+ function finishWait(slug, run) {
16
+ const failed = FAIL_TERMINAL.has(run.status);
17
+ if (isJson()) {
18
+ if (failed) {
19
+ return emitFail("runs", {
20
+ message: run.error || run.statusMessage || `Run ${run.status}`,
21
+ exitCode: EXIT_USAGE,
22
+ data: { slug, action: "wait", run },
23
+ });
24
+ }
25
+ emitOk("runs", { slug, action: "wait", run });
26
+ return EXIT_OK;
27
+ }
28
+ printRun(run);
29
+ return failed ? EXIT_USAGE : EXIT_OK;
30
+ }
11
31
  export async function runRuns(args) {
12
32
  try {
13
33
  const sub = args[0];
@@ -84,17 +104,7 @@ async function waitRun(args) {
84
104
  progress(`\r${data.run.status} ${data.run.statusMessage ?? ""} `);
85
105
  if (TERMINAL.has(data.run.status)) {
86
106
  progress("\n");
87
- if (isJson()) {
88
- emitOk("runs", { slug, action: "wait", run: data.run });
89
- }
90
- else {
91
- printRun(data.run);
92
- }
93
- if (data.run.status === "failed")
94
- return EXIT_USAGE;
95
- if (data.run.status === "canceled")
96
- return EXIT_USAGE;
97
- return EXIT_OK;
107
+ return finishWait(slug, data.run);
98
108
  }
99
109
  }
100
110
  else {
@@ -107,13 +117,17 @@ async function waitRun(args) {
107
117
  progress(`\r${current.status} ${current.statusMessage ?? ""} `);
108
118
  if (TERMINAL.has(current.status) && !data.poll?.active) {
109
119
  progress("\n");
110
- if (isJson()) {
111
- emitOk("runs", { slug, action: "wait", run: current });
112
- }
113
- else {
114
- printRun(current);
120
+ // Refresh full run for error field when list view is thin.
121
+ if (!current.error) {
122
+ try {
123
+ const full = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/agent-runs/${encodeURIComponent(current.id)}`);
124
+ return finishWait(slug, full.run);
125
+ }
126
+ catch {
127
+ return finishWait(slug, current);
128
+ }
115
129
  }
116
- return current.status === "succeeded" ? EXIT_OK : EXIT_USAGE;
130
+ return finishWait(slug, current);
117
131
  }
118
132
  }
119
133
  else {
@@ -0,0 +1,49 @@
1
+ export type WaitRunSnapshot = {
2
+ id: string;
3
+ status: string;
4
+ statusMessage?: string | null;
5
+ summary?: string | null;
6
+ error?: string | null;
7
+ commitSha?: string | null;
8
+ };
9
+ export type WaitIdleResult = {
10
+ exitCode: number;
11
+ /** Run that determined the outcome (watched busy run, or latest when idle). */
12
+ run: WaitRunSnapshot | null;
13
+ /** True when we observed queued/running/deploying during this wait. */
14
+ observedBusy: boolean;
15
+ status: string;
16
+ };
17
+ export declare function isBusyStatus(status: string): boolean;
18
+ export declare function isFailTerminal(status: string): boolean;
19
+ /**
20
+ * Decide exit after idle. Pure — unit-tested.
21
+ * - If we watched a busy run end: succeeded → 0; failed/canceled → 2.
22
+ * - If never busy: optional fail-closed on latest terminal failure (deploy wait);
23
+ * otherwise idle/succeeded → 0 (push --wait after a file-only push).
24
+ */
25
+ export declare function decideIdleExit(args: {
26
+ watched: WaitRunSnapshot | null;
27
+ latest: WaitRunSnapshot | null;
28
+ observedBusy: boolean;
29
+ failClosedOnIdleTerminalFailure: boolean;
30
+ }): {
31
+ exitCode: number;
32
+ run: WaitRunSnapshot | null;
33
+ status: string;
34
+ };
35
+ export declare function waitForAgentIdle(args: {
36
+ slug: string;
37
+ /** When true, idle + latest failed/canceled → non-zero (deploy wait). */
38
+ failClosedOnIdleTerminalFailure: boolean;
39
+ maxMs?: number;
40
+ pollMs?: number;
41
+ }): Promise<WaitIdleResult>;
42
+ /** Emit deploy/push-wait JSON or text from a WaitIdleResult. */
43
+ export declare function emitWaitOutcome(args: {
44
+ command: "deploy" | "push";
45
+ slug: string;
46
+ result: WaitIdleResult;
47
+ /** Extra fields merged into JSON data (e.g. push commitSha). */
48
+ extra?: Record<string, unknown>;
49
+ }): number;
@@ -0,0 +1,181 @@
1
+ /**
2
+ * §CLI Phase D — shared wait for agent-run busy states (queued/running/deploying).
3
+ * Used by `deploy wait` and `push --wait`. Not a Vercel deploy oracle.
4
+ */
5
+ import { EXIT_OK, EXIT_USAGE, v1Get } from "../http/client.js";
6
+ import { emitFail, emitOk, isJson, progress } from "../runtime.js";
7
+ const BUSY = new Set(["queued", "running", "deploying"]);
8
+ const FAIL_TERMINAL = new Set(["failed", "canceled"]);
9
+ export function isBusyStatus(status) {
10
+ return BUSY.has(status);
11
+ }
12
+ export function isFailTerminal(status) {
13
+ return FAIL_TERMINAL.has(status);
14
+ }
15
+ /**
16
+ * Decide exit after idle. Pure — unit-tested.
17
+ * - If we watched a busy run end: succeeded → 0; failed/canceled → 2.
18
+ * - If never busy: optional fail-closed on latest terminal failure (deploy wait);
19
+ * otherwise idle/succeeded → 0 (push --wait after a file-only push).
20
+ */
21
+ export function decideIdleExit(args) {
22
+ if (args.observedBusy && args.watched) {
23
+ const status = args.watched.status;
24
+ // Stale snapshot still busy → not success (caller should keep polling).
25
+ if (isBusyStatus(status)) {
26
+ return { exitCode: EXIT_USAGE, run: args.watched, status: "still_busy" };
27
+ }
28
+ if (isFailTerminal(status)) {
29
+ return { exitCode: EXIT_USAGE, run: args.watched, status };
30
+ }
31
+ return { exitCode: EXIT_OK, run: args.watched, status: status || "succeeded" };
32
+ }
33
+ const latest = args.latest;
34
+ if (args.failClosedOnIdleTerminalFailure &&
35
+ latest &&
36
+ isFailTerminal(latest.status)) {
37
+ return { exitCode: EXIT_USAGE, run: latest, status: latest.status };
38
+ }
39
+ return {
40
+ exitCode: EXIT_OK,
41
+ run: latest,
42
+ status: latest?.status ?? "idle",
43
+ };
44
+ }
45
+ export async function waitForAgentIdle(args) {
46
+ const maxMs = args.maxMs ?? 30 * 60 * 1000;
47
+ const pollMs = args.pollMs ?? 2000;
48
+ const started = Date.now();
49
+ let observedBusy = false;
50
+ let watched = null;
51
+ while (Date.now() - started < maxMs) {
52
+ const data = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs`);
53
+ const busy = (data.active ?? []).filter((r) => isBusyStatus(r.status));
54
+ if (busy.length > 0) {
55
+ observedBusy = true;
56
+ // If we were watching a run that left the active set, resolve it before
57
+ // switching — otherwise a failed run can be skipped when the next starts.
58
+ if (watched && !busy.some((r) => r.id === watched.id)) {
59
+ try {
60
+ const detail = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs/${encodeURIComponent(watched.id)}`);
61
+ if (detail.run && isFailTerminal(detail.run.status)) {
62
+ progress("\n");
63
+ return {
64
+ exitCode: EXIT_USAGE,
65
+ run: detail.run,
66
+ observedBusy: true,
67
+ status: detail.run.status,
68
+ };
69
+ }
70
+ }
71
+ catch {
72
+ // Fall through and follow the new busy head.
73
+ }
74
+ }
75
+ const prevId = watched?.id;
76
+ const still = prevId
77
+ ? busy.find((r) => r.id === prevId)
78
+ : undefined;
79
+ const next = still ?? busy.find((r) => r.status === "deploying") ?? busy[0];
80
+ watched = next;
81
+ progress(`\r${next.status} ${next.statusMessage ?? ""} `);
82
+ await new Promise((r) => setTimeout(r, pollMs));
83
+ continue;
84
+ }
85
+ // Idle — if we were watching a busy run, refresh it for terminal fields (error).
86
+ if (observedBusy && watched) {
87
+ const watching = watched;
88
+ let refreshed = watching;
89
+ try {
90
+ const detail = await v1Get(`/api/v1/sites/${encodeURIComponent(args.slug)}/agent-runs/${encodeURIComponent(watching.id)}`);
91
+ if (detail.run)
92
+ refreshed = detail.run;
93
+ }
94
+ catch {
95
+ // Fall back to list `run` only when it is the same id.
96
+ if (data.run?.id === watching.id)
97
+ refreshed = data.run;
98
+ }
99
+ watched = refreshed;
100
+ // Active list cleared but our snapshot is still busy → keep polling.
101
+ if (isBusyStatus(refreshed.status)) {
102
+ progress(`\r${refreshed.status} ${refreshed.statusMessage ?? ""} `);
103
+ await new Promise((r) => setTimeout(r, pollMs));
104
+ continue;
105
+ }
106
+ }
107
+ progress("\n");
108
+ const decided = decideIdleExit({
109
+ watched,
110
+ latest: data.run,
111
+ observedBusy,
112
+ failClosedOnIdleTerminalFailure: args.failClosedOnIdleTerminalFailure,
113
+ });
114
+ return {
115
+ exitCode: decided.exitCode,
116
+ run: decided.run,
117
+ observedBusy,
118
+ status: decided.status,
119
+ };
120
+ }
121
+ progress("\n");
122
+ return {
123
+ exitCode: EXIT_USAGE,
124
+ run: watched,
125
+ observedBusy,
126
+ status: "timed_out",
127
+ };
128
+ }
129
+ /** Emit deploy/push-wait JSON or text from a WaitIdleResult. */
130
+ export function emitWaitOutcome(args) {
131
+ const { result } = args;
132
+ const message = result.status === "timed_out"
133
+ ? "Timed out waiting for deploy."
134
+ : result.status === "still_busy"
135
+ ? "Timed out waiting for deploy (run still busy)."
136
+ : result.run?.error ||
137
+ result.run?.statusMessage ||
138
+ (isFailTerminal(result.status)
139
+ ? `Latest run ${result.status}`
140
+ : result.status);
141
+ if (result.exitCode !== EXIT_OK) {
142
+ if (isJson()) {
143
+ return emitFail(args.command, {
144
+ message,
145
+ exitCode: result.exitCode,
146
+ data: {
147
+ slug: args.slug,
148
+ waited: true,
149
+ observedBusy: result.observedBusy,
150
+ status: result.status,
151
+ run: result.run,
152
+ ...args.extra,
153
+ },
154
+ });
155
+ }
156
+ if (result.run) {
157
+ console.error(`${result.run.id} ${result.run.status}${result.run.error ? ` ${result.run.error}` : result.run.statusMessage ? ` ${result.run.statusMessage}` : ""}`);
158
+ }
159
+ else {
160
+ console.error(message);
161
+ }
162
+ return result.exitCode;
163
+ }
164
+ if (isJson()) {
165
+ emitOk(args.command, {
166
+ slug: args.slug,
167
+ waited: true,
168
+ observedBusy: result.observedBusy,
169
+ status: result.status,
170
+ run: result.run,
171
+ ...args.extra,
172
+ });
173
+ }
174
+ else if (result.run) {
175
+ console.log(`${result.run.id} ${result.run.status}${result.run.statusMessage ? ` ${result.run.statusMessage}` : ""}`);
176
+ }
177
+ else {
178
+ console.log("No active deploy.");
179
+ }
180
+ return EXIT_OK;
181
+ }
@@ -26,6 +26,7 @@ _frontpage_completions() {
26
26
  analytics) COMPREPLY=( $(compgen -W "dashboard summary query funnel timeseries compare sql" -- "\${cur}") ) ;;
27
27
  modules) COMPREPLY=( $(compgen -W "list docs request-setup enable disable archive config" -- "\${cur}") ) ;;
28
28
  extensions) COMPREPLY=( $(compgen -W "list docs enable disable config" -- "\${cur}") ) ;;
29
+ prompt) COMPREPLY=( $(compgen -W "--path --attach --asset-url --site --json --help" -- "\${cur}") ) ;;
29
30
  *) COMPREPLY=( $(compgen -W "--site --help --json --quiet --yes" -- "\${cur}") ) ;;
30
31
  esac
31
32
  }
@@ -3,13 +3,6 @@ export declare const EXIT_USAGE = 2;
3
3
  export declare const EXIT_CONFLICT = 3;
4
4
  export declare const EXIT_AUTH = 4;
5
5
  export declare const EXIT_UNEXPECTED = 5;
6
- export declare class CliHttpError extends Error {
7
- readonly exitCode: number;
8
- readonly status: number;
9
- readonly code?: string;
10
- constructor(message: string, exitCode: number, status: number, code?: string);
11
- }
12
- export declare function apiBase(): string;
13
6
  export type V1Envelope = {
14
7
  error?: {
15
8
  code?: string;
@@ -17,6 +10,17 @@ export type V1Envelope = {
17
10
  requestId?: string;
18
11
  };
19
12
  };
13
+ export declare class CliHttpError extends Error {
14
+ readonly exitCode: number;
15
+ readonly status: number;
16
+ readonly code?: string;
17
+ /** v1 envelope requestId when the API returned one (§CLI Phase D). */
18
+ readonly requestId?: string;
19
+ constructor(message: string, exitCode: number, status: number, code?: string, requestId?: string);
20
+ }
21
+ /** Map a non-OK v1 response into CliHttpError (shared by JSON + multipart clients). */
22
+ export declare function httpErrorFromV1(status: number, parsed: V1Envelope, fallbackMessage?: string): CliHttpError;
23
+ export declare function apiBase(): string;
20
24
  export declare function v1Get<T>(path: string): Promise<T>;
21
25
  export declare function v1Post<T>(path: string, body?: unknown): Promise<T>;
22
26
  export declare function v1Put<T>(path: string, body?: unknown): Promise<T>;
@@ -12,14 +12,49 @@ export class CliHttpError extends Error {
12
12
  exitCode;
13
13
  status;
14
14
  code;
15
- constructor(message, exitCode, status, code) {
15
+ /** v1 envelope requestId when the API returned one (§CLI Phase D). */
16
+ requestId;
17
+ constructor(message, exitCode, status, code, requestId) {
16
18
  super(message);
17
19
  this.name = "CliHttpError";
18
20
  this.exitCode = exitCode;
19
21
  this.status = status;
20
22
  this.code = code;
23
+ this.requestId = requestId;
21
24
  }
22
25
  }
26
+ /** Map a non-OK v1 response into CliHttpError (shared by JSON + multipart clients). */
27
+ export function httpErrorFromV1(status, parsed, fallbackMessage) {
28
+ const message = parsed.error?.message ?? fallbackMessage ?? `Request failed (${status})`;
29
+ const code = parsed.error?.code;
30
+ const requestId = parsed.error?.requestId;
31
+ if (status === 401 || status === 403) {
32
+ return new CliHttpError(message, EXIT_AUTH, status, code, requestId);
33
+ }
34
+ if (status === 400 || code === "bad_request") {
35
+ return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
36
+ }
37
+ if (status === 402 || status === 429 || code === "rate_limited" || code === "payment_required") {
38
+ return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
39
+ }
40
+ if (status === 409 || code === "conflict") {
41
+ return new CliHttpError(message, EXIT_CONFLICT, status, code, requestId);
42
+ }
43
+ if (status === 413 || code === "payload_too_large") {
44
+ return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
45
+ }
46
+ if (status === 503 || code === "unavailable") {
47
+ return new CliHttpError(message, EXIT_USAGE, status, code, requestId);
48
+ }
49
+ if (status === 404) {
50
+ const lower = message.toLowerCase();
51
+ if (lower === "not found." || lower === "not found") {
52
+ return new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, status, code, requestId);
53
+ }
54
+ return new CliHttpError(message, EXIT_AUTH, status, code, requestId);
55
+ }
56
+ return new CliHttpError(message, EXIT_UNEXPECTED, status, code, requestId);
57
+ }
23
58
  export function apiBase() {
24
59
  const fromEnv = process.env.FRONTPAGE_API_BASE?.replace(/\/$/, "");
25
60
  if (fromEnv)
@@ -58,31 +93,7 @@ async function v1Request(method, path, body) {
58
93
  }
59
94
  if (res.ok)
60
95
  return parsed;
61
- const message = parsed.error?.message ?? `Request failed (${res.status})`;
62
- const code = parsed.error?.code;
63
- if (res.status === 401 || res.status === 403) {
64
- throw new CliHttpError(message, EXIT_AUTH, res.status, code);
65
- }
66
- if (res.status === 402 || res.status === 429 || code === "rate_limited" || code === "payment_required") {
67
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
68
- }
69
- if (res.status === 409 || code === "conflict") {
70
- throw new CliHttpError(message, EXIT_CONFLICT, res.status, code);
71
- }
72
- if (res.status === 413 || code === "payload_too_large") {
73
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
74
- }
75
- if (res.status === 503 || code === "unavailable") {
76
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
77
- }
78
- if (res.status === 404) {
79
- const lower = message.toLowerCase();
80
- if (lower === "not found." || lower === "not found") {
81
- throw new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, res.status, code);
82
- }
83
- throw new CliHttpError(message, EXIT_AUTH, res.status, code);
84
- }
85
- throw new CliHttpError(message, EXIT_UNEXPECTED, res.status, code);
96
+ throw httpErrorFromV1(res.status, parsed);
86
97
  }
87
98
  export async function v1Get(path) {
88
99
  return v1Request("GET", path);
@@ -130,31 +141,7 @@ export async function v1PostMultipart(path, form) {
130
141
  }
131
142
  if (res.ok)
132
143
  return parsed;
133
- const message = parsed.error?.message ?? `Request failed (${res.status})`;
134
- const code = parsed.error?.code;
135
- if (res.status === 401 || res.status === 403) {
136
- throw new CliHttpError(message, EXIT_AUTH, res.status, code);
137
- }
138
- if (res.status === 402 || res.status === 429 || code === "rate_limited" || code === "payment_required") {
139
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
140
- }
141
- if (res.status === 409 || code === "conflict") {
142
- throw new CliHttpError(message, EXIT_CONFLICT, res.status, code);
143
- }
144
- if (res.status === 413 || code === "payload_too_large") {
145
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
146
- }
147
- if (res.status === 503 || code === "unavailable") {
148
- throw new CliHttpError(message, EXIT_USAGE, res.status, code);
149
- }
150
- if (res.status === 404) {
151
- const lower = message.toLowerCase();
152
- if (lower === "not found." || lower === "not found") {
153
- throw new CliHttpError("API not available (is PUBLIC_CLI_ENABLED on for this environment?).", EXIT_UNEXPECTED, res.status, code);
154
- }
155
- throw new CliHttpError(message, EXIT_AUTH, res.status, code);
156
- }
157
- throw new CliHttpError(message, EXIT_UNEXPECTED, res.status, code);
144
+ throw httpErrorFromV1(res.status, parsed);
158
145
  }
159
146
  export function exitCodeForError(err) {
160
147
  if (err instanceof CliHttpError)
package/dist/registry.js CHANGED
@@ -88,10 +88,17 @@ export const COMMANDS = [
88
88
  {
89
89
  name: "push",
90
90
  summary: "Upload local allowlisted files (CAS + active-run guard)",
91
- usage: "frontpage push [--site <slug>] [--message …] [--json]",
91
+ usage: "frontpage push [--site <slug>] [--message …] [--wait] [--json]",
92
92
  topics: ["sync"],
93
- examples: ['frontpage push --message "Update hero copy"'],
94
- flags: ["--site <slug>", "--message <text>"],
93
+ examples: [
94
+ 'frontpage push --message "Update hero copy"',
95
+ 'frontpage push --message "…" --wait',
96
+ ],
97
+ flags: [
98
+ "--site <slug>",
99
+ "--message <text>",
100
+ "--wait: after push, wait until no agent run is queued/running/deploying (same as deploy wait; not a Vercel oracle)",
101
+ ],
95
102
  dangers: [
96
103
  "Requires prior pull (baseCommitSha)",
97
104
  "Refused while an agent run is active (exit 3)",
@@ -184,11 +191,23 @@ export const COMMANDS = [
184
191
  {
185
192
  name: "prompt",
186
193
  summary: "Enqueue an agent build",
187
- usage: 'frontpage prompt "..." [--site <slug>] [--path /] [--json]',
194
+ usage: 'frontpage prompt "..." [--site <slug>] [--path /] [--attach <file|/__media/…|https://…>]… [--asset-url <https://…>]… [--json]',
188
195
  topics: ["agent"],
189
- examples: ['frontpage prompt "make the hero shorter"'],
190
- flags: ["--path must start with /", "stdout is bare runId (or JSON with --json)"],
191
- seeAlso: ["runs", "deploy"],
196
+ examples: [
197
+ 'frontpage prompt "make the hero shorter"',
198
+ 'frontpage prompt "Use this logo in the header" --attach ./logo.png',
199
+ 'frontpage prompt "Match this PDF brand guide" --attach ./guide.pdf --path /',
200
+ 'frontpage prompt "Reuse uploaded art" --attach /__media/uploads/acme/uuid.png',
201
+ 'frontpage prompt "Use this image" --asset-url "https://….blob.vercel-storage.com/uploads/…"',
202
+ ],
203
+ flags: [
204
+ "--path must start with /",
205
+ "--attach: local file (uploads via media), /__media/… site path, or https Blob URL; repeatable",
206
+ "--asset-url: https image URL (legacy); repeatable",
207
+ "Local attach uses the same caps as frontpage media upload (images/PDF/text; no CLI video yet)",
208
+ "stdout is bare runId (or JSON with --json)",
209
+ ],
210
+ seeAlso: ["runs", "deploy", "media"],
192
211
  },
193
212
  {
194
213
  name: "runs",
@@ -203,7 +222,10 @@ export const COMMANDS = [
203
222
  "frontpage runs retry <runId>",
204
223
  "frontpage runs get <runId>",
205
224
  ],
206
- flags: ["There is no runs continue — mid-task continuation is automatic"],
225
+ flags: [
226
+ "There is no runs continue — mid-task continuation is automatic",
227
+ "runs wait: failed/canceled → exit 2; --json uses ok:false and includes run.error",
228
+ ],
207
229
  seeAlso: ["prompt", "deploy"],
208
230
  },
209
231
  {
@@ -236,6 +258,7 @@ export const COMMANDS = [
236
258
  flags: [
237
259
  "Optional build wait only — staging is https://<subdomain>.frontpage.host",
238
260
  "Does NOT promote to production; use publish only when the user asks",
261
+ "Fail-closed: exit 2 when the waited run (or latest, if already idle) is failed/canceled",
239
262
  ],
240
263
  },
241
264
  {
@@ -410,7 +433,7 @@ export const TOPICS = {
410
433
  frontpage status # tipDrift when remote moved since last pull
411
434
  frontpage pull # downloads src/, public/, content/ into cwd
412
435
  frontpage diff # local vs remote via git blobSha (--json → dirty + tipDrift)
413
- frontpage push [--message …] # uploads allowlisted files; CAS + active-run guard
436
+ frontpage push [--message …] [--wait] # uploads allowlisted files; CAS + active-run guard
414
437
  frontpage ls [prefix]
415
438
  frontpage cat <path>
416
439
  frontpage search <pattern> [path] [--regex] [--case-sensitive] [--whole-word] [--files-only]
@@ -419,6 +442,7 @@ export const TOPICS = {
419
442
  Local state: .frontpage/state.json { slug, baseCommitSha }
420
443
  Pull BEFORE EDITING when tip may have moved (UI agent / another checkout) — check tipDrift.
421
444
  Pull before push so CAS has baseCommitSha. Do not push while the editor agent is building.
445
+ --wait after push blocks until no agent run is queued/running/deploying (same as deploy wait).
422
446
  The UI/canvas agent does not use this CLI; only CLI checkouts need pull.
423
447
  Pushing module markup may auto-activate modules (same as the agent).
424
448
  tokens.ts may be auto-repaired at commit if it would break the build.
@@ -467,11 +491,13 @@ export const TOPICS = {
467
491
  See also: frontpage help sync | content | AGENTS.md`,
468
492
  agent: `Agent + publish
469
493
  frontpage prompt "make the hero shorter"
494
+ frontpage prompt "Use this logo" --attach ./logo.png
495
+ frontpage prompt "…" --attach /__media/uploads/<slug>/… --asset-url https://…
470
496
  frontpage runs # latest + active
471
- frontpage runs wait [runId]
497
+ frontpage runs wait [runId] # failed/canceled → exit 2; JSON ok:false + run.error
472
498
  frontpage runs cancel <runId>
473
499
  frontpage runs retry <runId>
474
- frontpage deploy wait # optional: wait for build; NOT publish
500
+ frontpage deploy wait # optional build wait; fail-closed on failed/canceled; NOT publish
475
501
  frontpage publish # ONLY if the user asked — production promote
476
502
  frontpage unpublish --yes # admin only; never unless asked; --yes when non-TTY
477
503
  frontpage usage
@@ -482,8 +508,10 @@ export const TOPICS = {
482
508
  frontpage modules request-setup <key> --reason "…"
483
509
  frontpage extensions
484
510
 
511
+ --attach uploads local files via media (images/PDF/text) or accepts /__media/… / https URLs.
512
+ API errors include requestId in --json error envelopes (and stderr when not --json).
485
513
  Mid-task continuation is automatic — there is no runs continue.
486
- Do not push while an agent run is active.
514
+ Do not file-sync while an agent run is active (see help sync for push --wait).
487
515
  Default after work: review https://<subdomain>.frontpage.host; do not publish.`,
488
516
  content: `Content
489
517
  frontpage media list|upload <file>
@@ -532,8 +560,9 @@ export const TOPICS = {
532
560
 
533
561
  Global --json envelope:
534
562
  { "ok": true, "command": "…", "data": {…} }
535
- { "ok": false, "command": "…", "error": { "message", "code?", "httpStatus?", "exitCode" } }
536
- Failures with --json still use the process exit code; envelope is on stdout.`,
563
+ { "ok": false, "command": "…", "error": { "message", "code?", "httpStatus?", "exitCode", "requestId?" }, "data"? }
564
+ Failures with --json still use the process exit code; envelope is on stdout.
565
+ Prefer requestId from error envelopes when filing support issues.`,
537
566
  };
538
567
  export function findCommand(name) {
539
568
  return COMMANDS.find((c) => c.name === name);
package/dist/runtime.d.ts CHANGED
@@ -13,8 +13,18 @@ export type JsonErrorBody = {
13
13
  code?: string;
14
14
  httpStatus?: number;
15
15
  exitCode: number;
16
+ requestId?: string;
16
17
  };
17
18
  export declare function emitOk(command: string, data: unknown): void;
19
+ /** Structured failure with optional data payload (e.g. failed run wait). */
20
+ export declare function emitFail(command: string, args: {
21
+ message: string;
22
+ exitCode: number;
23
+ code?: string;
24
+ requestId?: string;
25
+ httpStatus?: number;
26
+ data?: unknown;
27
+ }): number;
18
28
  export declare function emitErr(command: string, err: unknown): number;
19
29
  export declare function emitUsage(command: string, message: string, exitCode?: number): number;
20
30
  /** Progress / spinner lines — suppressed when --quiet or --json. */
package/dist/runtime.js CHANGED
@@ -31,23 +31,43 @@ export function requireYesOrTty(action) {
31
31
  export function emitOk(command, data) {
32
32
  console.log(JSON.stringify({ ok: true, command, data }));
33
33
  }
34
+ /** Structured failure with optional data payload (e.g. failed run wait). */
35
+ export function emitFail(command, args) {
36
+ const body = {
37
+ message: args.message,
38
+ exitCode: args.exitCode,
39
+ };
40
+ if (args.code)
41
+ body.code = args.code;
42
+ if (args.httpStatus)
43
+ body.httpStatus = args.httpStatus;
44
+ if (args.requestId)
45
+ body.requestId = args.requestId;
46
+ if (jsonMode) {
47
+ const out = { ok: false, command, error: body };
48
+ if (args.data !== undefined)
49
+ out.data = args.data;
50
+ console.log(JSON.stringify(out));
51
+ }
52
+ else {
53
+ const suffix = args.requestId ? ` (requestId: ${args.requestId})` : "";
54
+ console.error(`${args.message}${suffix}`);
55
+ }
56
+ return args.exitCode;
57
+ }
34
58
  export function emitErr(command, err) {
35
59
  const exitCode = exitCodeForError(err);
36
60
  const message = err instanceof Error ? err.message : String(err);
37
- const body = { message, exitCode };
38
61
  if (err instanceof CliHttpError) {
39
- if (err.code)
40
- body.code = err.code;
41
- if (err.status)
42
- body.httpStatus = err.status;
62
+ return emitFail(command, {
63
+ message,
64
+ exitCode,
65
+ code: err.code,
66
+ httpStatus: err.status || undefined,
67
+ requestId: err.requestId,
68
+ });
43
69
  }
44
- if (jsonMode) {
45
- console.log(JSON.stringify({ ok: false, command, error: body }));
46
- }
47
- else {
48
- console.error(message);
49
- }
50
- return exitCode;
70
+ return emitFail(command, { message, exitCode });
51
71
  }
52
72
  export function emitUsage(command, message, exitCode = 2) {
53
73
  if (jsonMode) {
package/llms.txt CHANGED
@@ -10,11 +10,13 @@
10
10
  - Search flags: --regex --case-sensitive --whole-word --files-only --context-lines --max-results
11
11
  - Modules setup: frontpage modules request-setup <key> --reason "…" (owner status bar; CLI cannot show it)
12
12
  - Analytics ops: frontpage analytics summary|query|funnel|timeseries|compare|sql (bare analytics = dashboard)
13
+ - Prompt attachments: frontpage prompt "…" --attach <file|/__media/…|https://…> [--asset-url https://…] (media upload for locals; images/PDF/text)
14
+ - Errors: --json error envelopes include requestId; runs wait failed/canceled → ok:false + run.error (exit 2); deploy wait fail-closed; push --wait
13
15
  - Auth: mint fp_live_ key in Frontpage UI (Developers); frontpage login KEY or FRONTPAGE_API_KEY
14
16
  - Site id: subdomain only (frontpage use <subdomain>). Not custom domains.
15
17
  - Staging: https://<subdomain>.frontpage.host — push/prompt/edit update staging; no separate staging deploy step for agents
16
18
  - Production: custom domain after frontpage publish — NEVER publish/unpublish unless the user explicitly asks
17
- - Sync: allowlist src/ public/ content/; platform overlay + secret-like paths rejected; pull before edit if tip may have moved (status/diff tipDrift); pull → edit → diff (blobSha) → push (CAS); no push during active agent run; UI agent does not use CLI pull; review on subdomain
19
+ - Sync: allowlist src/ public/ content/; platform overlay + secret-like paths rejected; pull before edit if tip may have moved (status/diff tipDrift); pull → edit → diff (blobSha) → push [--wait] (CAS); no push during active agent run; UI agent does not use CLI pull; review on subdomain
18
20
  - Machine: global --json / --quiet; exit 0/2/3/4/5 (see help exit-codes)
19
- - deploy wait = optional build wait only; it is NOT go-live / publish
21
+ - deploy wait = optional build wait only (fail-closed on failed/canceled); it is NOT go-live / publish
20
22
  - No runs continue, domains, billing, members, MCP, or git remotes in v1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.1.6",
3
+ "version": "0.1.8",
4
4
  "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {