frontpage-host 0.1.15 → 0.1.17

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
@@ -26,17 +26,22 @@ Frontpage has two surfaces:
26
26
  | **Staging** | `https://<subdomain>.frontpage.host` | Working copy for edits, previews, and review |
27
27
  | **Production** | Custom domain (when connected), after **publish** | What end customers see on the live domain |
28
28
 
29
- **How changes become visible on staging**
29
+ **Three clocks (do not collapse these)**
30
30
 
31
- - `frontpage push`, `frontpage prompt`, and `frontpage edit` update the site branch and the **staging** surface.
32
- - After a push or agent run finishes building, changes are on the site branch behind `https://<subdomain>.frontpage.host`.
33
- - There is **no separate “deploy to staging” step** agents must run. Do not treat `frontpage deploy wait` as “going live.” At most it waits for an in-flight build to finish before you open the subdomain to review.
31
+ 1. **Git tip** — `frontpage push` / `prompt` / `edit` land a new SHA. Fast.
32
+ 2. **Site compile** — Fly (or Vercel on legacy hosts) builds that SHA. This is what visitors get. Poll `GET /api/v1/sites/<slug>/deploy` until `ready` is true (`liveCommit` equals the tip).
33
+ 3. **Editor agent run** — a chat job (`queued` / `running` / `deploying`). `frontpage runs wait` watches this clock only.
34
+
35
+ - After the compile is ready, staging is `https://<subdomain>.frontpage.host`.
36
+ - There is **no separate “deploy to staging” step**. `frontpage deploy wait` waits for agent-run idle (if a run is in flight) **and** for the tip compile. It is not go-live.
37
+ - A file-only push has **no** agent run. Do not treat a historical run that says “Your changes are live” as this push.
34
38
  - `frontpage open` opens the subdomain staging URL.
35
39
  - **Custom-domain caveat:** `<subdomain>.frontpage.host` is publicly viewable (anonymous) only when the site has **no** custom domain and subdomain sharing is public. If a custom domain is connected (e.g. `arbiter.wiki`), the subdomain is often **owner-private** — an unauthenticated browser/curl may see the app login page. That login wall is **not** a failed push. Anonymous public review is then on the **published custom domain**; editing the subdomain still needs an owner browser session (CLI API key ≠ browser cookie).
36
40
 
37
41
  **Production publish is a deliberate promote**
38
42
 
39
- - `frontpage publish` copies the current **staging tip** to **production** (custom domain / published site).
43
+ - `frontpage publish` copies the last **ready staging compile** to **production** (custom domain / published site). It 409s if the tip is not compiled yet. Use `frontpage publish --wait` (or `deploy wait` then `publish`) so you do not promote the previous build.
44
+ - After publish, compare `<meta name="frontpage:commit">` on the custom domain to the push SHA. History’s `P` marker is the stamped tip; it is only trustworthy when it matches the SHA in `productionUrl`.
40
45
  - **Never run `publish` or `unpublish` unless the user explicitly asked**, or the user’s own automation/workflow clearly requires autonomous publishing.
41
46
  - Default: finish work on staging, tell the user to review the staging subdomain when it is publicly shareable (see custom-domain caveat above), and let **them** publish (or ask you to publish after they approve).
42
47
 
@@ -90,14 +95,14 @@ Rules:
90
95
  4. Push updates **staging** (subdomain). Production is unchanged until someone runs `publish`.
91
96
  5. Pushing module markup may auto-activate modules (same as the editor agent).
92
97
  6. Binaries use base64; do not UTF-8 round-trip them.
93
- 7. Prefer reviewing the subdomain after push; only use `deploy wait` if you need to block until a build finishes.
98
+ 7. After push, wait for the site compile (`frontpage push --wait` or `frontpage deploy wait`) before reviewing or publishing. Verify `<meta name="frontpage:commit">` when checking production.
94
99
  8. The **UI / canvas agent does not use this CLI** — it edits the live tip in-process. Only CLI checkouts need `pull`.
95
100
 
96
101
  ## Site contract (Astro)
97
102
 
98
103
  Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or https://frontpage.host/developers/site-building
99
104
 
100
- 1. **Every page** wraps content in `<BaseLayout title="…" description="…">` (import from `src/layouts/BaseLayout.astro`).
105
+ 1. **Every page** wraps content in `<BaseLayout title="…" description="…">` (import from `src/layouts/BaseLayout.astro`), or in a site layout that itself wraps `BaseLayout`. A custom wrapper MUST identity-forward `description={description}` and `ogImage={ogImage}` from a 1:1 `Astro.props` destructure (no default, no rename, no reassignment), and `title={seoTitle ?? <derived fallback>}` so the H1 can stay on `title` while Search & Social edits `seoTitle`. NEVER pass a derived local as `title={pageTitle}` with no `seoTitle` alias: the editor will lock Title and Share image on every page that uses that layout. Pages set literal `title` / `description` / optional `seoTitle` / `ogImage` on that tag.
101
106
  2. **`src/config/site.ts`** — keep `pages` (path registry) and `nav` in sync when adding/renaming/deleting page files. Tab title comes from BaseLayout, not the `pages` map label.
102
107
  3. **One file per URL** for similar page sets (services, guides, …). Do not default to `getStaticPaths()` over `src/data/*.ts`. Blog/news → `frontpage articles`, not a hand-rolled catalog.
103
108
  4. **No backends / databases / fake forms or checkout.** Real forms use `<form data-fp-form="slug">` + `frontpage modules`. Refuse or use modules for payments.
@@ -108,13 +113,14 @@ Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or
108
113
 
109
114
  ## Modules & articles
110
115
 
111
- - Forms: `data-fp-form` markup in pages + `frontpage modules` for enable/config. No submit JS / honeypot / `action`.
112
- - Wizard / connect-once modules (payments/shop/ordering): `frontpage modules <key> connect` opens the browser (Stripe or Square). Finish there, then `status`. `--no-open` for CI. `request-setup` remains valid when you want the **editor amber bar** instead. payments and shop share one Stripe account. Auto-active modules (forms/callbar/…) need markup, not request-setup. Typed settings: `frontpage modules callbar|forms|booking|signups get|set` (forms set needs `--instance`; do not target `default`). `frontpage modules <key>` prints that key's CLI usage (exit 2). Index: https://frontpage.host/developers/modules · payments/shop/ordering pages under /developers/modules/<key>
116
+ - Forms: `data-fp-form` markup in pages + `frontpage modules` for enable/config. No submit JS / honeypot / `action`. Do not put a CRM webhook or token in HTML.
117
+ - Wizard / connect-once modules (payments/shop/ordering): `frontpage modules <key> connect` opens Frontpage (you must be logged in there), then Stripe or Square. Already-connected sites stay live — do not retry connect. payments/shop connect needs an **admin** key; ordering connect needs **editor**. `--no-open` for CI. `request-setup` remains valid when you want the **editor amber bar** instead. payments and shop share one Stripe account — payments disconnect fails if the store still uses it. Auto-active modules (forms/callbar/…) need markup, not request-setup. Typed settings: `frontpage modules callbar|forms|booking|signups get|set` (forms set needs `--instance`; do not target `default`). Form destinations (HubSpot / Pipedrive / Privyr): connect once, then attach each form. `frontpage modules forms destinations|connect-destination|attach-destination|detach-destination|remove-destination --yes|request-destination-setup`. Token write is **admin**; `request-destination-setup` lights the editor amber bar (**editor** key). Do not use `request-setup forms` for a CRM (no-op). Connecting does not attach every form. Privyr: paste the Incoming Webhook URL from Automations → Lead Sources → Webhooks (`https://www.privyr.com/api/v1/incoming-leads/…`), not a token and not Outgoing Webhooks. HubSpot: Development → Keys → Service keys or Development → Legacy apps → Auth → Show token. Pipedrive: profile → Company settings → Personal preferences → API. Full docs: https://frontpage.host/developers/modules#forms-destinations. `frontpage modules <key>` prints that key's CLI usage (exit 2). Index: https://frontpage.host/developers/modules · payments/shop/ordering pages under /developers/modules/<key>
113
118
  - Google reviews: platform nodes only (`data-fp-google-reviews` / `data-fp-google-ask` + required `data-fp-requires="google_reviews"`). `data-layout` is `feed` (default), `badge`, or `link`. Never invent stars, quotes, a 1–10, or a Maps URL. Do not pick a Place ID via Autocomplete and do not write `placeId` through `modules config`. Owner-supplied Place ID / Maps URL: `frontpage modules google_reviews connect --place-id …` or `--maps-url …` (server resolves + seeds). Curation: `list-reviews`, `hide --id`, `show --id`, `set-stars`, `set-sort`, `set-weight`, `reset-weights`, `refresh`, `disconnect`. Usage: `frontpage modules google_reviews`. Docs: https://frontpage.host/developers/modules/google-reviews. Ask-widget scores: `frontpage inbox google_reviews`.
114
119
  - Articles: `frontpage articles …` for lifecycle; bodies via sync under `content/` or `articles content get|set`. Section landing is an owned `src/pages/<section>/index.astro`.
115
120
  - Migrate a folder into Articles: prefer `frontpage prompt "migrate … into articles"` — do not invent a mass delete+write migration.
116
121
  - Extensions: `frontpage extensions`; if LOCKED on plan, do not fake charts/etc.
117
122
  - Media: `frontpage media upload <file>` — images/PDF ≤25 MB, video (MP4/WebM/MOV) ≤50 MB, SVG/text ≤4 MB (sanitizing proxy). Human output prints `sitePath:` first (use that in markup — never raw blob hosts), then optional `url:`. Optional `?w=` on rasters only. **Text/CSV** uploads are inline only — **not** added to the media library and **cannot** be linked via `/__media` (`media upload --json` omits full `content`; use `inbox … --csv` for submission exports).
123
+ - Generated images: `frontpage images generate --prompt "…"` `[--count N]` — one prompt, 1–4 variations of that same picture. Prints `sitePath:` first. Counts against AI $ and storage. Flag-off environments return 404 (`Image generation is not enabled.`). Do not call xAI / Imagine yourself.
118
124
  - Analytics: bare `frontpage analytics` = dashboard; also `summary|query|funnel|timeseries|compare|sql` for structured reads (SQL is guard + RLS site-scoped).
119
125
 
120
126
  ## Agent prompt loop
@@ -134,8 +140,9 @@ frontpage runs wait # wait for the agent run to finish (failed/canceled
134
140
  - Caps: images/PDF ≤25 MB, video ≤50 MB, SVG/text via proxy ≤4 MB (large text/data Blob upload is editor-only).
135
141
  - Prompt text is still required (API). Attachments ride on the same enqueue as the editor.
136
142
  - `runs wait` with `--json`: failures use `ok: false` and include `data.run` (with `run.error` when present).
137
- - `deploy wait` is fail-closed: exit 2 if the waited run (or latest, when already idle) is `failed`/`canceled`.
138
- - `frontpage push --wait` waits for agent-run idle after push (same busy statuses; not a separate Vercel oracle).
143
+ - `runs wait` watches an editor agent run only.
144
+ - `deploy wait` is fail-closed on a failed/canceled agent run it actually watched (or a latest failed/canceled when idle). It then waits until `GET /deploy` `ready` (live compile SHA equals the tip). Exit 2 on compile failure or timeout.
145
+ - `frontpage push --wait` does the same compile wait for the SHA just pushed. A file-only push still waits for the compile.
139
146
 
140
147
  Mid-task continuation is automatic. There is **no** `runs continue` in v1.
141
148
 
@@ -217,7 +224,7 @@ Safe default after finishing work: summarize what changed and give the staging U
217
224
  6. **Active-run guard.** Push/revert refuse while an editor agent run is active (exit 3).
218
225
  7. **CAS.** Tip moved since pull → exit 3; pull and retry.
219
226
  8. **No invented v1 commands:** `domains*`, `billing*`, `members*`, `suggestions*`, `sites delete|restore`, `runs continue`, MCP, git remotes / clone URLs.
220
- 9. **`deploy wait` ≠ publish.** Optional build wait only; staging visibility is the subdomain.
227
+ 9. **`deploy wait` ≠ publish.** It waits for the tip compile. Production still requires `publish`. Verify live HTML with `<meta name="frontpage:commit">`.
221
228
  10. **Honesty on stubs.** `open --prod` is currently a no-op stub (still opens the subdomain).
222
229
 
223
230
  ## Isolation (hard fence)
package/README.md CHANGED
@@ -23,9 +23,9 @@ Requires Node.js ≥ 20.
23
23
  3. `frontpage sites` — note the **subdomain** (not a custom domain).
24
24
  4. `mkdir my-site && cd my-site && frontpage use <subdomain>`
25
25
  5. `frontpage pull` → edit files under `src/`, `public/`, or `content/`
26
- 6. `frontpage push --message "describe the change"`
27
- 7. Review staging at `https://<subdomain>.frontpage.host` (no separate staging-deploy step).
28
- 8. `frontpage publish` only when you intentionally promote to production.
26
+ 6. `frontpage push --message "describe the change"` (add `--wait` to block until the site compile is live)
27
+ 7. Review staging at `https://<subdomain>.frontpage.host` after the compile is ready.
28
+ 8. `frontpage publish --wait` only when you intentionally promote the ready compile to production.
29
29
 
30
30
  ## Environment
31
31
 
package/dist/bin.js CHANGED
@@ -24,6 +24,7 @@ import { runPages } from "./commands/pages.js";
24
24
  import { runModules } from "./commands/modules.js";
25
25
  import { runExtensions } from "./commands/extensions.js";
26
26
  import { runMedia } from "./commands/media.js";
27
+ import { runImages } from "./commands/images.js";
27
28
  import { runInbox } from "./commands/inbox.js";
28
29
  import { runSeo } from "./commands/seo.js";
29
30
  import { runArticles } from "./commands/articles.js";
@@ -107,6 +108,8 @@ async function main() {
107
108
  return runExtensions(args);
108
109
  case "media":
109
110
  return runMedia(args);
111
+ case "images":
112
+ return runImages(args);
110
113
  case "inbox":
111
114
  return runInbox(args);
112
115
  case "seo":
@@ -1,2 +1,2 @@
1
- /** `frontpage deploy wait` — wait until no run is in deploying/queued/running. */
1
+ /** `frontpage deploy wait` — agent-run idle, then the tip's site compile. */
2
2
  export declare function runDeploy(args: string[]): Promise<number>;
@@ -1,8 +1,8 @@
1
1
  import { resolveSlug } from "../site.js";
2
2
  import { EXIT_USAGE } from "../http/client.js";
3
3
  import { emitErr, emitUsage } from "../runtime.js";
4
- import { emitWaitOutcome, waitForAgentIdle } from "./wait-agent-idle.js";
5
- /** `frontpage deploy wait` — wait until no run is in deploying/queued/running. */
4
+ import { waitForAgentThenCompile } from "./wait-site-compile.js";
5
+ /** `frontpage deploy wait` — agent-run idle, then the tip's site compile. */
6
6
  export async function runDeploy(args) {
7
7
  try {
8
8
  const sub = args[0];
@@ -10,12 +10,11 @@ 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 result = await waitForAgentIdle({
13
+ return waitForAgentThenCompile({
14
+ command: "deploy",
14
15
  slug,
15
- // Fail-closed: idle with latest failed/canceled is not success.
16
16
  failClosedOnIdleTerminalFailure: true,
17
17
  });
18
- return emitWaitOutcome({ command: "deploy", slug, result });
19
18
  }
20
19
  catch (err) {
21
20
  return emitErr("deploy", err);
@@ -0,0 +1 @@
1
+ export declare function runImages(args: string[]): Promise<number>;
@@ -0,0 +1,57 @@
1
+ import { resolveSlug } from "../site.js";
2
+ import { EXIT_OK, EXIT_USAGE, exitCodeForError, v1Post } from "../http/client.js";
3
+ import { assertNoUnknownFlags, takeFlag } from "../argv.js";
4
+ import { emitErr, emitOk, isJson } from "../runtime.js";
5
+ const USAGE = 'Usage: frontpage images generate --prompt "…" [--count N] [--site <slug>] [--json]';
6
+ const GENERATE_TIMEOUT_MS = 180_000;
7
+ export async function runImages(args) {
8
+ try {
9
+ const { slug, rest } = await resolveSlug(args);
10
+ const sub = rest[0];
11
+ if (sub !== "generate") {
12
+ console.error(USAGE);
13
+ return EXIT_USAGE;
14
+ }
15
+ const work = rest.slice(1);
16
+ const prompt = takeFlag(work, "--prompt");
17
+ const countRaw = takeFlag(work, "--count");
18
+ assertNoUnknownFlags(work, "images");
19
+ if (work.length > 0) {
20
+ console.error(`Unexpected argument: ${work[0]}\n${USAGE}`);
21
+ return EXIT_USAGE;
22
+ }
23
+ if (!prompt) {
24
+ console.error(USAGE);
25
+ return EXIT_USAGE;
26
+ }
27
+ let count;
28
+ if (countRaw !== undefined) {
29
+ const n = Number(countRaw);
30
+ if (!Number.isInteger(n) || n < 1 || n > 4) {
31
+ console.error("Ask for 1 to 4 variations.");
32
+ return EXIT_USAGE;
33
+ }
34
+ count = n;
35
+ }
36
+ const body = { prompt };
37
+ if (count !== undefined)
38
+ body.count = count;
39
+ const data = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/images`, body, { timeoutMs: GENERATE_TIMEOUT_MS });
40
+ if (isJson()) {
41
+ emitOk("images", { slug, images: data.images });
42
+ return EXIT_OK;
43
+ }
44
+ for (const img of data.images ?? []) {
45
+ console.log(`sitePath: ${img.sitePath}`);
46
+ if (img.filename)
47
+ console.log(`filename: ${img.filename}`);
48
+ }
49
+ return EXIT_OK;
50
+ }
51
+ catch (err) {
52
+ if (isJson())
53
+ return emitErr("images", err);
54
+ console.error(err instanceof Error ? err.message : String(err));
55
+ return exitCodeForError(err);
56
+ }
57
+ }
@@ -5,6 +5,7 @@ import { emitUsage } from "../runtime.js";
5
5
  import { formatModuleUsage, formatModulesIndex, isCliModuleAction, isDocumentedModuleKey, isGenericModuleVerb, } from "../modules/commands.js";
6
6
  import { runGoogleReviews } from "../modules/google_reviews.js";
7
7
  import { isTypedConfigCliKey, runTypedConfig } from "../modules/typed_config.js";
8
+ import { isFormsDestinationCliAction, runFormsDestinations } from "../modules/forms_destinations.js";
8
9
  import { isOauthCliKey, runOauthHandoff } from "../modules/oauth.js";
9
10
  /** Drop --site so we can print local usage without resolving a checkout. */
10
11
  function positionalArgs(args) {
@@ -44,6 +45,9 @@ export async function runModules(args) {
44
45
  if (peekedKey === "google_reviews") {
45
46
  return await runGoogleReviews(slug, peekedAction, extraFlags);
46
47
  }
48
+ if (peekedKey === "forms" && isFormsDestinationCliAction(peekedAction)) {
49
+ return await runFormsDestinations(slug, peekedAction, extraFlags);
50
+ }
47
51
  if (isTypedConfigCliKey(peekedKey)) {
48
52
  return await runTypedConfig(peekedKey, slug, peekedAction, extraFlags);
49
53
  }
@@ -1,19 +1,77 @@
1
1
  import { resolveSlug } from "../site.js";
2
- import { EXIT_OK, EXIT_USAGE, v1Delete, v1Post } from "../http/client.js";
3
- import { emitErr, emitOk, emitUsage, isJson, requireYesOrTty } from "../runtime.js";
2
+ import { EXIT_OK, EXIT_USAGE, v1Post, v1Delete } from "../http/client.js";
3
+ import { emitErr, emitFail, emitOk, emitUsage, isJson, progress, requireYesOrTty } from "../runtime.js";
4
+ import { describePublishResult, waitForCompileReady } from "../compile.js";
5
+ function takeWaitFlag(rest) {
6
+ const i = rest.indexOf("--wait");
7
+ if (i === -1)
8
+ return false;
9
+ rest.splice(i, 1);
10
+ return true;
11
+ }
12
+ function emitPublished(slug, data) {
13
+ const described = describePublishResult({
14
+ productionCommit: data.productionCommit ?? null,
15
+ productionUrl: data.productionUrl ?? null,
16
+ });
17
+ if (isJson()) {
18
+ emitOk("publish", {
19
+ slug,
20
+ productionUrl: data.productionUrl ?? null,
21
+ productionCommit: data.productionCommit ?? null,
22
+ servingCommit: described.servingCommit,
23
+ matchesTip: described.matchesTip,
24
+ });
25
+ }
26
+ else {
27
+ console.log(`Published ${slug}`);
28
+ for (const line of described.lines)
29
+ console.log(line);
30
+ if (data.productionUrl)
31
+ console.log(data.productionUrl);
32
+ if (!described.matchesTip) {
33
+ console.error("Publish promoted a build that is not the current tip. Wait for the site compile, then publish again.");
34
+ }
35
+ }
36
+ return EXIT_OK;
37
+ }
4
38
  export async function runPublish(args) {
5
39
  try {
6
- const { slug } = await resolveSlug(args);
7
- const data = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/publish`);
8
- if (isJson()) {
9
- emitOk("publish", { slug, productionUrl: data.productionUrl ?? null });
40
+ const { slug, rest } = await resolveSlug(args);
41
+ const work = [...rest];
42
+ const wait = takeWaitFlag(work);
43
+ if (work.length > 0) {
44
+ return emitUsage("publish", "Usage: frontpage publish [--site <slug>] [--wait] [--json]", EXIT_USAGE);
10
45
  }
11
- else {
12
- console.log(`Published ${slug}`);
13
- if (data.productionUrl)
14
- console.log(data.productionUrl);
46
+ if (wait) {
47
+ if (!isJson())
48
+ progress("Waiting for the site compile…\n");
49
+ const compile = await waitForCompileReady({ slug });
50
+ if (compile.exitCode !== EXIT_OK) {
51
+ const message = compile.failed
52
+ ? "Site compile failed. Refusing to publish a previous build."
53
+ : compile.timedOut
54
+ ? "Timed out waiting for the site compile. Refusing to publish a previous build."
55
+ : "Site compile is not ready. Refusing to publish a previous build.";
56
+ return emitFail("publish", {
57
+ message,
58
+ exitCode: compile.exitCode,
59
+ data: {
60
+ slug,
61
+ compile: compile.status,
62
+ tip: compile.status?.stagingCommit ?? null,
63
+ live: compile.status?.liveCommit ?? null,
64
+ status: compile.failed
65
+ ? "compile_failed"
66
+ : compile.timedOut
67
+ ? "compile_timeout"
68
+ : "compile_not_ready",
69
+ },
70
+ });
71
+ }
15
72
  }
16
- return EXIT_OK;
73
+ const data = await v1Post(`/api/v1/sites/${encodeURIComponent(slug)}/publish`);
74
+ return emitPublished(slug, data);
17
75
  }
18
76
  catch (err) {
19
77
  return emitErr("publish", err);
@@ -4,7 +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
+ import { waitForAgentThenCompile } from "./wait-site-compile.js";
8
8
  const TEXT_EXTS = new Set([
9
9
  ".astro",
10
10
  ".ts",
@@ -136,8 +136,7 @@ export async function runPush(args) {
136
136
  console.error(`Warning: ${w}`);
137
137
  return EXIT_OK;
138
138
  }
139
- // --wait: after push, wait for agent-run busy to clear (same statuses as deploy wait).
140
- // Does not invent a Vercel deploy oracle; file-only pushes with no active run return promptly.
139
+ // --wait: agent-run idle (if any), then the new commit's site compile.
141
140
  if (!isJson()) {
142
141
  if (!data.changed) {
143
142
  console.log("Nothing to commit (remote already matches).");
@@ -148,17 +147,12 @@ export async function runPush(args) {
148
147
  }
149
148
  for (const w of data.warnings ?? [])
150
149
  console.error(`Warning: ${w}`);
151
- console.error("Waiting for agent run / deploy idle…");
152
150
  }
153
- const result = await waitForAgentIdle({
154
- slug,
155
- // Do not fail on an ancient canceled run when this push never saw busy work.
156
- failClosedOnIdleTerminalFailure: false,
157
- });
158
- return emitWaitOutcome({
151
+ return waitForAgentThenCompile({
159
152
  command: "push",
160
153
  slug,
161
- result,
154
+ expectCommit: data.commitSha ?? null,
155
+ failClosedOnIdleTerminalFailure: false,
162
156
  extra: { ...pushPayload, waited: true },
163
157
  });
164
158
  }
@@ -16,6 +16,18 @@ export type WaitIdleResult = {
16
16
  };
17
17
  export declare function isBusyStatus(status: string): boolean;
18
18
  export declare function isFailTerminal(status: string): boolean;
19
+ /**
20
+ * A historical succeeded run is unrelated when this wait never saw busy work.
21
+ * Keep failed/canceled latest so deploy wait stays fail-closed (Phase D).
22
+ */
23
+ export declare function sanitizeUnrelatedIdleRun(args: {
24
+ observedBusy: boolean;
25
+ run: WaitRunSnapshot | null;
26
+ status: string;
27
+ }): {
28
+ run: WaitRunSnapshot | null;
29
+ status: string;
30
+ };
19
31
  /**
20
32
  * Decide exit after idle. Pure — unit-tested.
21
33
  * - If we watched a busy run end: succeeded → 0; failed/canceled → 2.
@@ -1,6 +1,7 @@
1
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.
2
+ * Shared wait for editor agent-run busy states (queued/running/deploying).
3
+ * This is the agent-run clock, not the site compile. File-only pushes have
4
+ * no agent run — success here does not mean the latest commit is built.
4
5
  */
5
6
  import { EXIT_OK, EXIT_USAGE, v1Get } from "../http/client.js";
6
7
  import { emitFail, emitOk, isJson, progress } from "../runtime.js";
@@ -12,6 +13,18 @@ export function isBusyStatus(status) {
12
13
  export function isFailTerminal(status) {
13
14
  return FAIL_TERMINAL.has(status);
14
15
  }
16
+ /**
17
+ * A historical succeeded run is unrelated when this wait never saw busy work.
18
+ * Keep failed/canceled latest so deploy wait stays fail-closed (Phase D).
19
+ */
20
+ export function sanitizeUnrelatedIdleRun(args) {
21
+ if (args.observedBusy)
22
+ return { run: args.run, status: args.status };
23
+ if (args.run && isFailTerminal(args.run.status)) {
24
+ return { run: args.run, status: args.status };
25
+ }
26
+ return { run: null, status: "idle" };
27
+ }
15
28
  /**
16
29
  * Decide exit after idle. Pure — unit-tested.
17
30
  * - If we watched a busy run end: succeeded → 0; failed/canceled → 2.
@@ -111,11 +124,16 @@ export async function waitForAgentIdle(args) {
111
124
  observedBusy,
112
125
  failClosedOnIdleTerminalFailure: args.failClosedOnIdleTerminalFailure,
113
126
  });
127
+ const sanitized = sanitizeUnrelatedIdleRun({
128
+ observedBusy,
129
+ run: decided.run,
130
+ status: decided.status,
131
+ });
114
132
  return {
115
133
  exitCode: decided.exitCode,
116
- run: decided.run,
134
+ run: sanitized.run,
117
135
  observedBusy,
118
- status: decided.status,
136
+ status: sanitized.status,
119
137
  };
120
138
  }
121
139
  progress("\n");
@@ -175,7 +193,7 @@ export function emitWaitOutcome(args) {
175
193
  console.log(`${result.run.id} ${result.run.status}${result.run.statusMessage ? ` ${result.run.statusMessage}` : ""}`);
176
194
  }
177
195
  else {
178
- console.log("No active deploy.");
196
+ console.log("No agent run was in flight. This does not mean the latest commit is compiled.");
179
197
  }
180
198
  return EXIT_OK;
181
199
  }
@@ -0,0 +1,21 @@
1
+ import { type DeployStatus } from "../compile.js";
2
+ import { type WaitIdleResult } from "./wait-agent-idle.js";
3
+ export declare function waitForAgentThenCompile(args: {
4
+ command: "deploy" | "push";
5
+ slug: string;
6
+ expectCommit?: string | null;
7
+ failClosedOnIdleTerminalFailure: boolean;
8
+ extra?: Record<string, unknown>;
9
+ }): Promise<number>;
10
+ export declare function emitCompileWaitOutcome(args: {
11
+ command: "deploy" | "push";
12
+ slug: string;
13
+ agent: WaitIdleResult;
14
+ compile: {
15
+ exitCode: number;
16
+ timedOut: boolean;
17
+ failed?: boolean;
18
+ status: DeployStatus | null;
19
+ };
20
+ extra?: Record<string, unknown>;
21
+ }): number;
@@ -0,0 +1,94 @@
1
+ /**
2
+ * After the agent-run clock is idle, wait for the site compile of the tip.
3
+ */
4
+ import { EXIT_OK } from "../http/client.js";
5
+ import { emitFail, emitOk, isJson, progress } from "../runtime.js";
6
+ import { waitForCompileReady } from "../compile.js";
7
+ import { emitWaitOutcome, waitForAgentIdle, } from "./wait-agent-idle.js";
8
+ export async function waitForAgentThenCompile(args) {
9
+ const agent = await waitForAgentIdle({
10
+ slug: args.slug,
11
+ failClosedOnIdleTerminalFailure: args.failClosedOnIdleTerminalFailure,
12
+ });
13
+ if (agent.exitCode !== EXIT_OK) {
14
+ return emitWaitOutcome({
15
+ command: args.command,
16
+ slug: args.slug,
17
+ result: agent,
18
+ extra: args.extra,
19
+ });
20
+ }
21
+ if (!isJson()) {
22
+ if (agent.observedBusy && agent.run) {
23
+ console.log(`${agent.run.id} ${agent.run.status}${agent.run.statusMessage ? ` ${agent.run.statusMessage}` : ""}`);
24
+ }
25
+ else {
26
+ console.log("No agent run was in flight.");
27
+ }
28
+ progress("Waiting for the site compile…\n");
29
+ }
30
+ const compile = await waitForCompileReady({
31
+ slug: args.slug,
32
+ expectCommit: args.expectCommit,
33
+ });
34
+ return emitCompileWaitOutcome({
35
+ command: args.command,
36
+ slug: args.slug,
37
+ agent,
38
+ compile,
39
+ extra: args.extra,
40
+ });
41
+ }
42
+ export function emitCompileWaitOutcome(args) {
43
+ const compileData = {
44
+ compile: args.compile.status,
45
+ compileReady: Boolean(args.compile.status?.ready) && !args.compile.timedOut,
46
+ };
47
+ if (args.compile.exitCode !== EXIT_OK) {
48
+ const message = args.compile.failed
49
+ ? "Site compile failed. The git tip is not live."
50
+ : args.compile.timedOut
51
+ ? "Timed out waiting for the site compile. The git tip is not live yet."
52
+ : "Site compile is not ready.";
53
+ if (isJson()) {
54
+ return emitFail(args.command, {
55
+ message,
56
+ exitCode: args.compile.exitCode,
57
+ data: {
58
+ slug: args.slug,
59
+ waited: true,
60
+ observedBusy: args.agent.observedBusy,
61
+ status: args.compile.failed
62
+ ? "compile_failed"
63
+ : args.compile.timedOut
64
+ ? "compile_timeout"
65
+ : "compile_not_ready",
66
+ run: args.agent.run,
67
+ ...compileData,
68
+ ...args.extra,
69
+ },
70
+ });
71
+ }
72
+ console.error(message);
73
+ if (args.compile.status) {
74
+ console.error(`tip ${args.compile.status.stagingCommit ?? "(none)"} live ${args.compile.status.liveCommit ?? "(none)"}`);
75
+ }
76
+ return args.compile.exitCode;
77
+ }
78
+ if (isJson()) {
79
+ emitOk(args.command, {
80
+ slug: args.slug,
81
+ waited: true,
82
+ observedBusy: args.agent.observedBusy,
83
+ status: "ready",
84
+ run: args.agent.run,
85
+ ...compileData,
86
+ ...args.extra,
87
+ });
88
+ }
89
+ else {
90
+ const live = args.compile.status?.liveCommit ?? args.compile.status?.stagingCommit;
91
+ console.log(`Site compile ready ${live ?? ""}`.trim());
92
+ }
93
+ return EXIT_OK;
94
+ }
@@ -0,0 +1,41 @@
1
+ export type DeployStatus = {
2
+ slug?: string;
3
+ stagingCommit: string | null;
4
+ servingCommit: string | null;
5
+ liveCommit: string | null;
6
+ ready: boolean;
7
+ splitBrain: boolean;
8
+ compileState?: "ready" | "building" | "failed" | "canceled" | "unknown";
9
+ stagingUrl: string | null;
10
+ productionCommit: string | null;
11
+ productionServingCommit: string | null;
12
+ productionUrl: string | null;
13
+ };
14
+ export declare function normalizeSha(raw: string | null | undefined): string | null;
15
+ export declare function shortSha(raw: string | null | undefined): string;
16
+ /** SHA encoded in a Blob artifact prefix URL. */
17
+ export declare function commitFromArtifactUrl(url: string | null | undefined): string | null;
18
+ export declare function compileIsReady(status: Pick<DeployStatus, "ready" | "liveCommit">, expectCommit?: string | null): boolean;
19
+ export type CompileWaitResult = {
20
+ exitCode: number;
21
+ timedOut: boolean;
22
+ failed: boolean;
23
+ status: DeployStatus | null;
24
+ };
25
+ export declare function fetchDeployStatus(slug: string): Promise<DeployStatus>;
26
+ export declare function waitForCompileReady(args: {
27
+ slug: string;
28
+ expectCommit?: string | null;
29
+ maxMs?: number;
30
+ pollMs?: number;
31
+ fetchStatus?: (slug: string) => Promise<DeployStatus>;
32
+ }): Promise<CompileWaitResult>;
33
+ export declare function describePublishResult(args: {
34
+ productionCommit: string | null;
35
+ productionUrl: string | null;
36
+ }): {
37
+ tipCommit: string | null;
38
+ servingCommit: string | null;
39
+ matchesTip: boolean;
40
+ lines: string[];
41
+ };