frontpage-host 0.1.7 → 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
@@ -122,7 +122,7 @@ frontpage prompt "make the hero shorter"
122
122
  frontpage prompt "Use this logo in the header" --attach ./logo.png
123
123
  frontpage prompt "Match this brand PDF" --attach ./guide.pdf --path /
124
124
  frontpage prompt "Reuse uploaded art" --attach /__media/uploads/<slug>/<file>
125
- frontpage runs wait # wait for the agent run to finish
125
+ frontpage runs wait # wait for the agent run to finish (failed/canceled → exit 2)
126
126
  # Review https://<subdomain>.frontpage.host — do not publish unless asked.
127
127
  ```
128
128
 
@@ -130,6 +130,9 @@ frontpage runs wait # wait for the agent run to finish
130
130
  - `--asset-url <https://…>` adds a legacy image URL. Repeatable.
131
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
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).
133
136
 
134
137
  Mid-task continuation is automatic. There is **no** `runs continue` in v1.
135
138
 
@@ -173,10 +176,10 @@ Global machine envelope (`--json`):
173
176
 
174
177
  ```json
175
178
  { "ok": true, "command": "status", "data": { } }
176
- { "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": "…" } }
177
180
  ```
178
181
 
179
- 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.
180
183
 
181
184
  ## Global flags
182
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);
@@ -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
+ }
@@ -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)",
@@ -215,7 +222,10 @@ export const COMMANDS = [
215
222
  "frontpage runs retry <runId>",
216
223
  "frontpage runs get <runId>",
217
224
  ],
218
- 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
+ ],
219
229
  seeAlso: ["prompt", "deploy"],
220
230
  },
221
231
  {
@@ -248,6 +258,7 @@ export const COMMANDS = [
248
258
  flags: [
249
259
  "Optional build wait only — staging is https://<subdomain>.frontpage.host",
250
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",
251
262
  ],
252
263
  },
253
264
  {
@@ -422,7 +433,7 @@ export const TOPICS = {
422
433
  frontpage status # tipDrift when remote moved since last pull
423
434
  frontpage pull # downloads src/, public/, content/ into cwd
424
435
  frontpage diff # local vs remote via git blobSha (--json → dirty + tipDrift)
425
- frontpage push [--message …] # uploads allowlisted files; CAS + active-run guard
436
+ frontpage push [--message …] [--wait] # uploads allowlisted files; CAS + active-run guard
426
437
  frontpage ls [prefix]
427
438
  frontpage cat <path>
428
439
  frontpage search <pattern> [path] [--regex] [--case-sensitive] [--whole-word] [--files-only]
@@ -431,6 +442,7 @@ export const TOPICS = {
431
442
  Local state: .frontpage/state.json { slug, baseCommitSha }
432
443
  Pull BEFORE EDITING when tip may have moved (UI agent / another checkout) — check tipDrift.
433
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).
434
446
  The UI/canvas agent does not use this CLI; only CLI checkouts need pull.
435
447
  Pushing module markup may auto-activate modules (same as the agent).
436
448
  tokens.ts may be auto-repaired at commit if it would break the build.
@@ -482,10 +494,10 @@ export const TOPICS = {
482
494
  frontpage prompt "Use this logo" --attach ./logo.png
483
495
  frontpage prompt "…" --attach /__media/uploads/<slug>/… --asset-url https://…
484
496
  frontpage runs # latest + active
485
- frontpage runs wait [runId]
497
+ frontpage runs wait [runId] # failed/canceled → exit 2; JSON ok:false + run.error
486
498
  frontpage runs cancel <runId>
487
499
  frontpage runs retry <runId>
488
- frontpage deploy wait # optional: wait for build; NOT publish
500
+ frontpage deploy wait # optional build wait; fail-closed on failed/canceled; NOT publish
489
501
  frontpage publish # ONLY if the user asked — production promote
490
502
  frontpage unpublish --yes # admin only; never unless asked; --yes when non-TTY
491
503
  frontpage usage
@@ -497,8 +509,9 @@ export const TOPICS = {
497
509
  frontpage extensions
498
510
 
499
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).
500
513
  Mid-task continuation is automatic — there is no runs continue.
501
- 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).
502
515
  Default after work: review https://<subdomain>.frontpage.host; do not publish.`,
503
516
  content: `Content
504
517
  frontpage media list|upload <file>
@@ -547,8 +560,9 @@ export const TOPICS = {
547
560
 
548
561
  Global --json envelope:
549
562
  { "ok": true, "command": "…", "data": {…} }
550
- { "ok": false, "command": "…", "error": { "message", "code?", "httpStatus?", "exitCode" } }
551
- 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.`,
552
566
  };
553
567
  export function findCommand(name) {
554
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
@@ -11,11 +11,12 @@
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
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
14
15
  - Auth: mint fp_live_ key in Frontpage UI (Developers); frontpage login KEY or FRONTPAGE_API_KEY
15
16
  - Site id: subdomain only (frontpage use <subdomain>). Not custom domains.
16
17
  - Staging: https://<subdomain>.frontpage.host — push/prompt/edit update staging; no separate staging deploy step for agents
17
18
  - Production: custom domain after frontpage publish — NEVER publish/unpublish unless the user explicitly asks
18
- - 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
19
20
  - Machine: global --json / --quiet; exit 0/2/3/4/5 (see help exit-codes)
20
- - 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
21
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.7",
3
+ "version": "0.1.8",
4
4
  "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {