frontpage-host 0.1.17 → 0.1.18

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
@@ -93,10 +93,11 @@ Rules:
93
93
  2. **Pull before push** so `baseCommitSha` is set (CAS). Still required even if you already pulled to edit.
94
94
  3. **Do not push** while an agent run is active (`status` / `runs`).
95
95
  4. Push updates **staging** (subdomain). Production is unchanged until someone runs `publish`.
96
- 5. Pushing module markup may auto-activate modules (same as the editor agent).
97
- 6. Binaries use base64; do not UTF-8 round-trip them.
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.
99
- 8. The **UI / canvas agent does not use this CLI** — it edits the live tip in-process. Only CLI checkouts need `pull`.
96
+ 5. A local delete of an allowlisted path is part of the next push (remote-only files are sent as deletes). `frontpage diff` shows them as `D`.
97
+ 6. Pushing module markup may auto-activate modules (same as the editor agent).
98
+ 7. Binaries use base64; do not UTF-8 round-trip them.
99
+ 8. 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.
100
+ 9. The **UI / canvas agent does not use this CLI** — it edits the live tip in-process. Only CLI checkouts need `pull`.
100
101
 
101
102
  ## Site contract (Astro)
102
103
 
@@ -106,10 +107,12 @@ Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or
106
107
  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.
107
108
  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.
108
109
  4. **No backends / databases / fake forms or checkout.** Real forms use `<form data-fp-form="slug">` + `frontpage modules`. Refuse or use modules for payments.
109
- 5. **Tokens** in `src/config/tokens.ts` — keep required color/font/spacing/radii keys; style via CSS variables / token Tailwind. **Fonts load rule:** the build bundles catalog faces when the family string appears in `tokens.ts` and/or in a real CSS `font-family` / `fontFamily` / `font-['…']` context under `src/pages|components|styles` — not from prose alone. Preferred path: `frontpage fonts` → exact `value` → `frontpage tokens set --group fonts --key <role> --value "…"` → **APPLY** `font-sans` / `font-serif` / `font-mono` / `font-display` (or `var(--font-*)`) on elements. Inline catalog `font-family` accents can bundle after rebuild, but brand roles still prefer tokens + utilities. Off-catalog families silently fall back to system fonts (push may warn). Module how-tos: `frontpage modules docs <key>` / `frontpage extensions docs <key>`.
110
- 6. **Shared chrome** — prefer `src/components/Header.astro` + `Footer.astro` (or `chrome: { managed: true }` in `site.ts`).
111
- 7. **404** — customize `src/pages/404.astro` if needed; keep `frontpage:404` meta; never register `/404` in `nav`/`pages`.
112
- 8. **Platform-owned paths** stay off-limits (BaseLayout overlay, module/extension shells, `package.json`, …). Customer layouts like `DocsLayout` / `GuideLayout` are OK.
110
+ 5. **Quoted attributes vs frontmatter.** In frontmatter JavaScript, a backslash-quote is an escape. In an Astro attribute it is not — the quote after `href=` closes the string and the next token fails the compile. Put HTML in a frontmatter const and pass it as an expression (`auditBody={auditBody}`), or use an expression attribute. Never put an HTML tag or a backslash-quote inside a quoted JSX attribute.
111
+ 6. **Tokens** in `src/config/tokens.ts` — keep required color/font/spacing/radii keys; style via CSS variables / token Tailwind. **Fonts load rule:** the build bundles catalog faces when the family string appears in `tokens.ts` and/or in a real CSS `font-family` / `fontFamily` / `font-['…']` context under `src/pages|components|styles` — not from prose alone. Preferred path: `frontpage fonts` → exact `value` → `frontpage tokens set --group fonts --key <role> --value "…"` → **APPLY** `font-sans` / `font-serif` / `font-mono` / `font-display` (or `var(--font-*)`) on elements. Inline catalog `font-family` accents can bundle after rebuild, but brand roles still prefer tokens + utilities. Off-catalog families silently fall back to system fonts (push may warn). Module how-tos: `frontpage modules docs <key>` / `frontpage extensions docs <key>`.
112
+ 7. **Shared chrome** — prefer `src/components/Header.astro` + `Footer.astro` (or `chrome: { managed: true }` in `site.ts`).
113
+ 8. **404** — customize `src/pages/404.astro` if needed; keep `frontpage:404` meta; never register `/404` in `nav`/`pages`.
114
+ 9. **Redirects** — real 301/308s are `frontpage redirects list|set|rm`, not site files. `set --from /old --to /new` is instant (no push/build). Do not ship `Astro.redirect`, `location.replace`, or meta refresh for a permanent move. `frontpage revert` does not undo redirects; `redirects rm` does.
115
+ 10. **Platform-owned paths** stay off-limits (BaseLayout overlay, module/extension shells, `package.json`, …). Customer layouts like `DocsLayout` / `GuideLayout` are OK.
113
116
 
114
117
  ## Modules & articles
115
118
 
@@ -122,6 +125,7 @@ Sites are Astro 6 + Tailwind v4. More detail: `frontpage help site-building` or
122
125
  - 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
126
  - 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.
124
127
  - Analytics: bare `frontpage analytics` = dashboard; also `summary|query|funnel|timeseries|compare|sql` for structured reads (SQL is guard + RLS site-scoped).
128
+ - People: `frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide`. Site-scoped person graph (email or phone). Inbox stays the raw ledger. Hide is not forget.
125
129
 
126
130
  ## Agent prompt loop
127
131
 
@@ -0,0 +1,21 @@
1
+ export type PathFrontpageVerdict = {
2
+ stale: boolean;
3
+ path: string | null;
4
+ pathVersion: string | null;
5
+ reason: "older-than-compile-wait" | "older-than-this-package" | "unreadable" | null;
6
+ };
7
+ export declare function resolveRealPath(path: string): string;
8
+ export declare function interpretPathFrontpage(args: {
9
+ thisVersion: string;
10
+ thisBin: string | null;
11
+ pathBin: string | null;
12
+ pathVersionLine: string | null;
13
+ minVersion?: string;
14
+ }): PathFrontpageVerdict;
15
+ export declare function whichFrontpage(): string | null;
16
+ export declare function frontpageVersionAt(bin: string): string | null;
17
+ export declare function inspectPathFrontpage(opts?: {
18
+ thisBin?: string | null;
19
+ thisVersion?: string;
20
+ }): PathFrontpageVerdict;
21
+ export declare function stalePathUpgradeMessage(v: PathFrontpageVerdict): string;
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Detect a stale `frontpage` on PATH (global 0.1.9 vs this package).
3
+ * Does not uninstall anything — doctor prints the uninstall command.
4
+ */
5
+ import { spawnSync } from "node:child_process";
6
+ import { realpathSync } from "node:fs";
7
+ import { COMPILE_WAIT_MIN_VERSION, isOlderThan, packageIdentity, parseVersionLine, } from "./version.js";
8
+ export function resolveRealPath(path) {
9
+ try {
10
+ return realpathSync(path);
11
+ }
12
+ catch {
13
+ return path;
14
+ }
15
+ }
16
+ export function interpretPathFrontpage(args) {
17
+ if (!args.pathBin) {
18
+ return { stale: false, path: null, pathVersion: null, reason: null };
19
+ }
20
+ if (args.thisBin && resolveRealPath(args.pathBin) === resolveRealPath(args.thisBin)) {
21
+ return { stale: false, path: args.pathBin, pathVersion: args.thisVersion, reason: null };
22
+ }
23
+ const pathVersion = parseVersionLine(args.pathVersionLine ?? "");
24
+ if (!pathVersion) {
25
+ return { stale: true, path: args.pathBin, pathVersion: null, reason: "unreadable" };
26
+ }
27
+ const floor = args.minVersion ?? COMPILE_WAIT_MIN_VERSION;
28
+ if (isOlderThan(pathVersion, floor)) {
29
+ return { stale: true, path: args.pathBin, pathVersion, reason: "older-than-compile-wait" };
30
+ }
31
+ if (isOlderThan(pathVersion, args.thisVersion)) {
32
+ return { stale: true, path: args.pathBin, pathVersion, reason: "older-than-this-package" };
33
+ }
34
+ return { stale: false, path: args.pathBin, pathVersion, reason: null };
35
+ }
36
+ export function whichFrontpage() {
37
+ const r = spawnSync("sh", ["-c", "command -v frontpage"], { encoding: "utf8" });
38
+ const line = (r.stdout ?? "").trim().split("\n")[0]?.trim();
39
+ return line || null;
40
+ }
41
+ export function frontpageVersionAt(bin) {
42
+ const r = spawnSync(bin, ["--version"], { encoding: "utf8" });
43
+ const text = `${r.stdout ?? ""}\n${r.stderr ?? ""}`;
44
+ return parseVersionLine(text);
45
+ }
46
+ export function inspectPathFrontpage(opts) {
47
+ const thisVersion = opts?.thisVersion ?? packageIdentity().version;
48
+ const thisBin = opts?.thisBin ?? process.argv[1] ?? null;
49
+ const pathBin = whichFrontpage();
50
+ const pathVersionLine = pathBin ? frontpageVersionAt(pathBin) : null;
51
+ return interpretPathFrontpage({
52
+ thisVersion,
53
+ thisBin,
54
+ pathBin,
55
+ pathVersionLine,
56
+ });
57
+ }
58
+ export function stalePathUpgradeMessage(v) {
59
+ const found = v.pathVersion ? `frontpage-host ${v.pathVersion}` : "an unknown frontpage-host";
60
+ const where = v.path ? ` at ${v.path}` : "";
61
+ return (`PATH frontpage is ${found}${where}. Compile-wait needs frontpage-host ${COMPILE_WAIT_MIN_VERSION} or newer. ` +
62
+ `Uninstall the stale global (npm uninstall -g frontpage-host) and install ` +
63
+ `npm i -g frontpage-host@${COMPILE_WAIT_MIN_VERSION} (or npx frontpage-host@${COMPILE_WAIT_MIN_VERSION}). ` +
64
+ `In the Frontpage repo, run node <repo>/cli/dist/bin.js instead of a global binary.`);
65
+ }
package/dist/bin.js CHANGED
@@ -27,6 +27,8 @@ import { runMedia } from "./commands/media.js";
27
27
  import { runImages } from "./commands/images.js";
28
28
  import { runInbox } from "./commands/inbox.js";
29
29
  import { runSeo } from "./commands/seo.js";
30
+ import { runRedirects } from "./commands/redirects.js";
31
+ import { runPeople } from "./commands/people.js";
30
32
  import { runArticles } from "./commands/articles.js";
31
33
  import { runSettings } from "./commands/settings.js";
32
34
  import { runEdit } from "./commands/edit.js";
@@ -112,8 +114,12 @@ async function main() {
112
114
  return runImages(args);
113
115
  case "inbox":
114
116
  return runInbox(args);
117
+ case "people":
118
+ return runPeople(args);
115
119
  case "seo":
116
120
  return runSeo(args);
121
+ case "redirects":
122
+ return runRedirects(args);
117
123
  case "articles":
118
124
  return runArticles(args);
119
125
  case "settings":
@@ -0,0 +1 @@
1
+ export declare function runPeople(args: string[]): Promise<number>;
@@ -0,0 +1,239 @@
1
+ import { resolveSlug } from "../site.js";
2
+ import { CliHttpError, EXIT_OK, EXIT_USAGE, exitCodeForError, v1Delete, v1Get, v1Patch, v1Post, } from "../http/client.js";
3
+ import { assertNoUnknownFlags, hasFlag, takeFlag } from "../argv.js";
4
+ import { emitErr, emitOk, isJson } from "../runtime.js";
5
+ const USAGE = "Usage: frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide [--site <slug>] [--json]";
6
+ function requireId(work) {
7
+ const fromFlag = takeFlag(work, "--id");
8
+ if (fromFlag)
9
+ return fromFlag;
10
+ const positional = work.find((a) => !a.startsWith("-"));
11
+ if (positional) {
12
+ work.splice(work.indexOf(positional), 1);
13
+ return positional;
14
+ }
15
+ throw new CliHttpError(`Person id is required. Run: frontpage help people`, EXIT_USAGE, 0, "bad_request");
16
+ }
17
+ function displayName(row) {
18
+ return row.name || row.email || row.phone || row.id;
19
+ }
20
+ function printPerson(row) {
21
+ const bits = [row.status ?? "—", displayName(row)];
22
+ if (row.email && row.email !== row.name)
23
+ bits.push(row.email);
24
+ if (row.phone && row.phone !== row.name)
25
+ bits.push(row.phone);
26
+ if (row.followUpAt)
27
+ bits.push(`follow-up ${row.followUpAt}`);
28
+ if (row.hiddenAt)
29
+ bits.push("hidden");
30
+ if (row.lastEvent?.summary)
31
+ bits.push(row.lastEvent.summary);
32
+ console.log(`${row.id} ${bits.join(" · ")}`);
33
+ }
34
+ export async function runPeople(args) {
35
+ try {
36
+ const { slug, rest } = await resolveSlug(args);
37
+ const sub = rest[0];
38
+ const base = `/api/v1/sites/${encodeURIComponent(slug)}/people`;
39
+ if (!sub || sub === "list") {
40
+ const work = sub === "list" ? rest.slice(1) : [...rest];
41
+ const q = takeFlag(work, "--q") ?? takeFlag(work, "--query");
42
+ const status = takeFlag(work, "--status");
43
+ const source = takeFlag(work, "--source");
44
+ const formSlug = takeFlag(work, "--form-slug");
45
+ const followUp = takeFlag(work, "--follow-up");
46
+ const sort = takeFlag(work, "--sort");
47
+ const lastSeenDays = takeFlag(work, "--last-seen-days");
48
+ const limit = takeFlag(work, "--limit");
49
+ const cursor = takeFlag(work, "--cursor");
50
+ const hidden = hasFlag(work, "--hidden");
51
+ assertNoUnknownFlags(work, "people");
52
+ const params = new URLSearchParams();
53
+ if (q)
54
+ params.set("q", q);
55
+ if (status)
56
+ params.set("status", status);
57
+ if (source)
58
+ params.set("source", source);
59
+ if (formSlug)
60
+ params.set("formSlug", formSlug);
61
+ if (followUp)
62
+ params.set("followUp", followUp);
63
+ if (sort)
64
+ params.set("sort", sort);
65
+ if (lastSeenDays)
66
+ params.set("lastSeenDays", lastSeenDays);
67
+ if (limit)
68
+ params.set("limit", limit);
69
+ if (cursor)
70
+ params.set("cursor", cursor);
71
+ if (hidden)
72
+ params.set("hidden", "1");
73
+ const qs = params.toString();
74
+ const data = await v1Get(qs ? `${base}?${qs}` : base);
75
+ if (isJson()) {
76
+ emitOk("people", data);
77
+ return EXIT_OK;
78
+ }
79
+ const rows = data.people ?? [];
80
+ if (rows.length === 0) {
81
+ console.log(hidden ? "No hidden people." : "No people.");
82
+ return EXIT_OK;
83
+ }
84
+ for (const row of rows)
85
+ printPerson(row);
86
+ if (data.nextCursor)
87
+ console.log(`next: ${data.nextCursor}`);
88
+ return EXIT_OK;
89
+ }
90
+ if (sub === "get") {
91
+ const work = rest.slice(1);
92
+ const id = requireId(work);
93
+ assertNoUnknownFlags(work, "people");
94
+ const data = await v1Get(`${base}/${encodeURIComponent(id)}`);
95
+ if (isJson()) {
96
+ emitOk("people", data);
97
+ return EXIT_OK;
98
+ }
99
+ printPerson(data.person);
100
+ const events = data.events ?? [];
101
+ for (const ev of events) {
102
+ const row = ev;
103
+ const extra = typeof row.payload?.body === "string" ? ` ${row.payload.body}` : "";
104
+ console.log(` ${row.occurredAt ?? ""} ${row.type ?? ""}${extra}`);
105
+ }
106
+ return EXIT_OK;
107
+ }
108
+ if (sub === "add") {
109
+ const work = rest.slice(1);
110
+ const email = takeFlag(work, "--email");
111
+ const phone = takeFlag(work, "--phone");
112
+ const name = takeFlag(work, "--name");
113
+ const note = takeFlag(work, "--note");
114
+ assertNoUnknownFlags(work, "people");
115
+ if (!email && !phone) {
116
+ console.error("Usage: frontpage people add --email you@example.com | --phone +15551234567 [--name …] [--note …]");
117
+ return EXIT_USAGE;
118
+ }
119
+ const data = await v1Post(base, {
120
+ email,
121
+ phone,
122
+ name,
123
+ note,
124
+ });
125
+ if (isJson()) {
126
+ emitOk("people", data);
127
+ return EXIT_OK;
128
+ }
129
+ printPerson(data.person);
130
+ return EXIT_OK;
131
+ }
132
+ if (sub === "set") {
133
+ const work = rest.slice(1);
134
+ const id = requireId(work);
135
+ const body = {};
136
+ for (const field of ["name", "email", "phone", "notes", "status"]) {
137
+ const v = takeFlag(work, `--${field}`);
138
+ if (v !== undefined)
139
+ body[field] = v === "" ? null : v;
140
+ }
141
+ assertNoUnknownFlags(work, "people");
142
+ if (Object.keys(body).length === 0) {
143
+ console.error("Usage: frontpage people set <id> [--name …] [--email …] [--phone …] [--notes …] [--status …]");
144
+ return EXIT_USAGE;
145
+ }
146
+ const data = await v1Patch(`${base}/${encodeURIComponent(id)}`, body);
147
+ if (isJson()) {
148
+ emitOk("people", data);
149
+ return EXIT_OK;
150
+ }
151
+ printPerson(data.person);
152
+ return EXIT_OK;
153
+ }
154
+ if (sub === "note") {
155
+ const work = rest.slice(1);
156
+ const id = requireId(work);
157
+ const body = takeFlag(work, "--body") ?? takeFlag(work, "--note");
158
+ assertNoUnknownFlags(work, "people");
159
+ if (!body) {
160
+ console.error("Usage: frontpage people note <id> --body \"…\"");
161
+ return EXIT_USAGE;
162
+ }
163
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body });
164
+ if (isJson()) {
165
+ emitOk("people", data);
166
+ return EXIT_OK;
167
+ }
168
+ console.log(`Noted ${id} (${data.event.id})`);
169
+ return EXIT_OK;
170
+ }
171
+ if (sub === "rm-note") {
172
+ const work = rest.slice(1);
173
+ const id = requireId(work);
174
+ const eventId = takeFlag(work, "--event") ?? takeFlag(work, "--event-id");
175
+ assertNoUnknownFlags(work, "people");
176
+ if (!eventId) {
177
+ console.error("Usage: frontpage people rm-note <id> --event <eventId>");
178
+ return EXIT_USAGE;
179
+ }
180
+ const data = await v1Delete(`${base}/${encodeURIComponent(id)}/events/${encodeURIComponent(eventId)}`);
181
+ if (isJson()) {
182
+ emitOk("people", data);
183
+ return EXIT_OK;
184
+ }
185
+ console.log(`Removed note ${data.removed.id}`);
186
+ return EXIT_OK;
187
+ }
188
+ if (sub === "follow-up") {
189
+ const work = rest.slice(1);
190
+ const id = requireId(work);
191
+ const clear = hasFlag(work, "--clear");
192
+ const at = takeFlag(work, "--at");
193
+ assertNoUnknownFlags(work, "people");
194
+ if (!clear && !at) {
195
+ console.error("Usage: frontpage people follow-up <id> --at <iso> | --clear");
196
+ return EXIT_USAGE;
197
+ }
198
+ const data = await v1Patch(`${base}/${encodeURIComponent(id)}/follow-up`, { at: clear ? null : at });
199
+ if (isJson()) {
200
+ emitOk("people", data);
201
+ return EXIT_OK;
202
+ }
203
+ printPerson(data.person);
204
+ return EXIT_OK;
205
+ }
206
+ if (sub === "hide") {
207
+ const work = rest.slice(1);
208
+ const id = requireId(work);
209
+ assertNoUnknownFlags(work, "people");
210
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/hide`, {});
211
+ if (isJson()) {
212
+ emitOk("people", data);
213
+ return EXIT_OK;
214
+ }
215
+ console.log(`Hidden ${displayName(data.person)}`);
216
+ return EXIT_OK;
217
+ }
218
+ if (sub === "unhide") {
219
+ const work = rest.slice(1);
220
+ const id = requireId(work);
221
+ assertNoUnknownFlags(work, "people");
222
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/unhide`, {});
223
+ if (isJson()) {
224
+ emitOk("people", data);
225
+ return EXIT_OK;
226
+ }
227
+ console.log(`Restored ${displayName(data.person)}`);
228
+ return EXIT_OK;
229
+ }
230
+ console.error(USAGE);
231
+ return EXIT_USAGE;
232
+ }
233
+ catch (err) {
234
+ if (isJson())
235
+ return emitErr("people", err);
236
+ console.error(err instanceof Error ? err.message : String(err));
237
+ return exitCodeForError(err);
238
+ }
239
+ }
@@ -48,11 +48,13 @@ export async function runPublish(args) {
48
48
  progress("Waiting for the site compile…\n");
49
49
  const compile = await waitForCompileReady({ slug });
50
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.";
51
+ const message = compile.apiMissing
52
+ ? "Compile status API is missing. Refusing to publish."
53
+ : compile.failed
54
+ ? "Site compile failed. Refusing to publish a previous build."
55
+ : compile.timedOut
56
+ ? "Timed out waiting for the site compile. Refusing to publish a previous build."
57
+ : "Site compile is not ready. Refusing to publish a previous build.";
56
58
  return emitFail("publish", {
57
59
  message,
58
60
  exitCode: compile.exitCode,
@@ -61,11 +63,13 @@ export async function runPublish(args) {
61
63
  compile: compile.status,
62
64
  tip: compile.status?.stagingCommit ?? null,
63
65
  live: compile.status?.liveCommit ?? null,
64
- status: compile.failed
65
- ? "compile_failed"
66
- : compile.timedOut
67
- ? "compile_timeout"
68
- : "compile_not_ready",
66
+ status: compile.apiMissing
67
+ ? "compile_api_missing"
68
+ : compile.failed
69
+ ? "compile_failed"
70
+ : compile.timedOut
71
+ ? "compile_timeout"
72
+ : "compile_not_ready",
69
73
  },
70
74
  });
71
75
  }
@@ -1 +1,6 @@
1
+ /** Remote allowlisted paths that are gone locally become explicit deletes. */
2
+ export declare function pushDeletesForMissingLocal(localPaths: Iterable<string>, remotePaths: Iterable<string>): Array<{
3
+ path: string;
4
+ delete: true;
5
+ }>;
1
6
  export declare function runPush(args: string[]): Promise<number>;
@@ -1,7 +1,7 @@
1
1
  import { readdir, readFile, stat } from "node:fs/promises";
2
2
  import { join, relative, sep } from "node:path";
3
3
  import { resolveSlug } from "../site.js";
4
- import { EXIT_OK, EXIT_USAGE, v1Put } from "../http/client.js";
4
+ import { EXIT_OK, EXIT_USAGE, v1Get, 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
7
  import { waitForAgentThenCompile } from "./wait-site-compile.js";
@@ -26,6 +26,14 @@ const TEXT_EXTS = new Set([
26
26
  ".toml",
27
27
  ]);
28
28
  const ROOTS = ["src", "public", "content"];
29
+ /** Remote allowlisted paths that are gone locally become explicit deletes. */
30
+ export function pushDeletesForMissingLocal(localPaths, remotePaths) {
31
+ const local = new Set(localPaths);
32
+ return [...remotePaths]
33
+ .filter((path) => path && !local.has(path))
34
+ .sort()
35
+ .map((path) => ({ path, delete: true }));
36
+ }
29
37
  async function walkAllowlisted(cwd) {
30
38
  const out = [];
31
39
  async function walk(abs) {
@@ -85,7 +93,10 @@ export async function runPush(args) {
85
93
  if (paths.length === 0) {
86
94
  return emitUsage("push", "No allowlisted files (src/, public/, content/) found in this folder.", EXIT_USAGE);
87
95
  }
88
- const files = [];
96
+ const listing = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/files`);
97
+ const files = [
98
+ ...pushDeletesForMissingLocal(paths, listing.files.map((f) => f.path)),
99
+ ];
89
100
  for (const path of paths) {
90
101
  const abs = join(cwd, path);
91
102
  const st = await stat(abs);
@@ -0,0 +1 @@
1
+ export declare function runRedirects(args: string[]): Promise<number>;
@@ -0,0 +1,83 @@
1
+ import { resolveSlug } from "../site.js";
2
+ import { CliHttpError, EXIT_OK, EXIT_USAGE, exitCodeForError, v1Delete, v1Get, v1Put } from "../http/client.js";
3
+ import { assertNoUnknownFlags, takeFlag } from "../argv.js";
4
+ import { emitErr, emitOk, isJson } from "../runtime.js";
5
+ const USAGE = "Usage: frontpage redirects list | set --from /old --to /new [--status 301|308] | rm --from /old [--site <slug>] [--json]";
6
+ function requirePathFlag(work, flag) {
7
+ const value = takeFlag(work, flag);
8
+ if (!value) {
9
+ throw new CliHttpError(`${flag} is required (e.g. ${flag} /old).`, EXIT_USAGE, 0, "bad_request");
10
+ }
11
+ if (!value.startsWith("/") || value.startsWith("//")) {
12
+ throw new CliHttpError(`${flag} must be a pathname starting with /.`, EXIT_USAGE, 0, "bad_request");
13
+ }
14
+ return value;
15
+ }
16
+ export async function runRedirects(args) {
17
+ try {
18
+ const { slug, rest } = await resolveSlug(args);
19
+ const sub = rest[0];
20
+ if (!sub || sub === "list") {
21
+ const work = sub === "list" ? rest.slice(1) : [...rest];
22
+ assertNoUnknownFlags(work, "redirects");
23
+ const data = await v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/redirects`);
24
+ if (isJson()) {
25
+ emitOk("redirects", data);
26
+ return EXIT_OK;
27
+ }
28
+ const rows = data.redirects ?? [];
29
+ if (rows.length === 0) {
30
+ console.log("No redirects.");
31
+ return EXIT_OK;
32
+ }
33
+ for (const row of rows) {
34
+ console.log(`${row.from} → ${row.to} (${row.status})`);
35
+ }
36
+ return EXIT_OK;
37
+ }
38
+ if (sub === "set") {
39
+ const work = rest.slice(1);
40
+ const from = requirePathFlag(work, "--from");
41
+ const to = requirePathFlag(work, "--to");
42
+ const statusRaw = takeFlag(work, "--status");
43
+ assertNoUnknownFlags(work, "redirects");
44
+ const body = { from, to };
45
+ if (statusRaw !== undefined) {
46
+ const status = Number(statusRaw);
47
+ if (status !== 301 && status !== 308) {
48
+ console.error("Status must be 301 or 308.");
49
+ return EXIT_USAGE;
50
+ }
51
+ body.status = status;
52
+ }
53
+ const data = await v1Put(`/api/v1/sites/${encodeURIComponent(slug)}/redirects`, body);
54
+ if (isJson()) {
55
+ emitOk("redirects", data);
56
+ return EXIT_OK;
57
+ }
58
+ const row = data.redirect;
59
+ console.log(`${row.from} → ${row.to} (${row.status})`);
60
+ return EXIT_OK;
61
+ }
62
+ if (sub === "rm" || sub === "remove") {
63
+ const work = rest.slice(1);
64
+ const from = requirePathFlag(work, "--from");
65
+ assertNoUnknownFlags(work, "redirects");
66
+ const data = await v1Delete(`/api/v1/sites/${encodeURIComponent(slug)}/redirects?from=${encodeURIComponent(from)}`);
67
+ if (isJson()) {
68
+ emitOk("redirects", data);
69
+ return EXIT_OK;
70
+ }
71
+ console.log(`Removed ${data.removed.from}`);
72
+ return EXIT_OK;
73
+ }
74
+ console.error(USAGE);
75
+ return EXIT_USAGE;
76
+ }
77
+ catch (err) {
78
+ if (isJson())
79
+ return emitErr("redirects", err);
80
+ console.error(err instanceof Error ? err.message : String(err));
81
+ return exitCodeForError(err);
82
+ }
83
+ }
@@ -15,6 +15,7 @@ export declare function emitCompileWaitOutcome(args: {
15
15
  exitCode: number;
16
16
  timedOut: boolean;
17
17
  failed?: boolean;
18
+ apiMissing?: boolean;
18
19
  status: DeployStatus | null;
19
20
  };
20
21
  extra?: Record<string, unknown>;
@@ -45,11 +45,13 @@ export function emitCompileWaitOutcome(args) {
45
45
  compileReady: Boolean(args.compile.status?.ready) && !args.compile.timedOut,
46
46
  };
47
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.";
48
+ const message = args.compile.apiMissing
49
+ ? "Compile status API is missing. Cannot confirm the tip is live."
50
+ : args.compile.failed
51
+ ? "Site compile failed. The git tip is not live."
52
+ : args.compile.timedOut
53
+ ? "Timed out waiting for the site compile. The git tip is not live yet."
54
+ : "Site compile is not ready.";
53
55
  if (isJson()) {
54
56
  return emitFail(args.command, {
55
57
  message,
@@ -58,11 +60,13 @@ export function emitCompileWaitOutcome(args) {
58
60
  slug: args.slug,
59
61
  waited: true,
60
62
  observedBusy: args.agent.observedBusy,
61
- status: args.compile.failed
62
- ? "compile_failed"
63
- : args.compile.timedOut
64
- ? "compile_timeout"
65
- : "compile_not_ready",
63
+ status: args.compile.apiMissing
64
+ ? "compile_api_missing"
65
+ : args.compile.failed
66
+ ? "compile_failed"
67
+ : args.compile.timedOut
68
+ ? "compile_timeout"
69
+ : "compile_not_ready",
66
70
  run: args.agent.run,
67
71
  ...compileData,
68
72
  ...args.extra,
package/dist/compile.d.ts CHANGED
@@ -20,8 +20,13 @@ export type CompileWaitResult = {
20
20
  exitCode: number;
21
21
  timedOut: boolean;
22
22
  failed: boolean;
23
+ apiMissing?: boolean;
23
24
  status: DeployStatus | null;
24
25
  };
26
+ /** Compile clock is gone (flag off / old API). A site-not-found 404 is not this. */
27
+ export declare function isMissingDeployApi(err: unknown): boolean;
28
+ /** A deploy payload we can poll. Missing `ready` means the compile clock is not there. */
29
+ export declare function deployStatusLooksComplete(status: DeployStatus | null | undefined): boolean;
25
30
  export declare function fetchDeployStatus(slug: string): Promise<DeployStatus>;
26
31
  export declare function waitForCompileReady(args: {
27
32
  slug: string;
package/dist/compile.js CHANGED
@@ -3,7 +3,7 @@
3
3
  *
4
4
  * GET /api/v1/sites/{slug}/deploy → ready when the live compile SHA equals the tip.
5
5
  */
6
- import { EXIT_OK, EXIT_USAGE, v1Get } from "./http/client.js";
6
+ import { CliHttpError, EXIT_OK, EXIT_USAGE, v1Get } from "./http/client.js";
7
7
  import { progress } from "./runtime.js";
8
8
  const SHA_RE = /^[0-9a-f]{40}$/i;
9
9
  export function normalizeSha(raw) {
@@ -32,6 +32,21 @@ export function compileIsReady(status, expectCommit) {
32
32
  return true;
33
33
  return live === expect;
34
34
  }
35
+ /** Compile clock is gone (flag off / old API). A site-not-found 404 is not this. */
36
+ export function isMissingDeployApi(err) {
37
+ if (!(err instanceof CliHttpError))
38
+ return false;
39
+ const message = err.message.toLowerCase();
40
+ if (message.includes("api not available"))
41
+ return true;
42
+ if (err.status === 404 && (message === "not found." || message === "not found"))
43
+ return true;
44
+ return false;
45
+ }
46
+ /** A deploy payload we can poll. Missing `ready` means the compile clock is not there. */
47
+ export function deployStatusLooksComplete(status) {
48
+ return Boolean(status && typeof status.ready === "boolean");
49
+ }
35
50
  export async function fetchDeployStatus(slug) {
36
51
  return v1Get(`/api/v1/sites/${encodeURIComponent(slug)}/deploy`);
37
52
  }
@@ -42,7 +57,20 @@ export async function waitForCompileReady(args) {
42
57
  const started = Date.now();
43
58
  let last = null;
44
59
  while (Date.now() - started < maxMs) {
45
- last = await fetchStatus(args.slug);
60
+ try {
61
+ last = await fetchStatus(args.slug);
62
+ }
63
+ catch (err) {
64
+ if (isMissingDeployApi(err)) {
65
+ progress("\n");
66
+ return { exitCode: EXIT_USAGE, timedOut: false, failed: true, apiMissing: true, status: null };
67
+ }
68
+ throw err;
69
+ }
70
+ if (!deployStatusLooksComplete(last)) {
71
+ progress("\n");
72
+ return { exitCode: EXIT_USAGE, timedOut: false, failed: true, apiMissing: true, status: last };
73
+ }
46
74
  if (last.compileState === "failed" || last.compileState === "canceled") {
47
75
  progress("\n");
48
76
  return { exitCode: EXIT_USAGE, timedOut: false, failed: true, status: last };
@@ -25,6 +25,7 @@ _frontpage_completions() {
25
25
  seo|settings) COMPREPLY=( $(compgen -W "get set" -- "\${cur}") ) ;;
26
26
  articles) COMPREPLY=( $(compgen -W "list content create-section create-article set-draft" -- "\${cur}") ) ;;
27
27
  analytics) COMPREPLY=( $(compgen -W "dashboard summary query funnel timeseries compare sql" -- "\${cur}") ) ;;
28
+ people) COMPREPLY=( $(compgen -W "list get add set note rm-note follow-up hide unhide" -- "\${cur}") ) ;;
28
29
  modules)
29
30
  if [[ \${COMP_CWORD} -ge 3 && \${COMP_WORDS[2]} == google_reviews ]]; then
30
31
  COMPREPLY=( $(compgen -W "docs request-setup enable disable archive config connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect" -- "\${cur}") )
@@ -69,6 +70,7 @@ _frontpage() {
69
70
  tokens) _values 'subcommand' set ;;
70
71
  seo|settings) _values 'subcommand' get set ;;
71
72
  analytics) _values 'subcommand' dashboard summary query funnel timeseries compare sql ;;
73
+ people) _values 'subcommand' list get add set note rm-note follow-up hide unhide ;;
72
74
  modules)
73
75
  if [[ $words[2] == google_reviews ]]; then
74
76
  _values 'subcommand' docs request-setup enable disable archive config connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect
@@ -106,6 +108,7 @@ export function generateFishCompletion() {
106
108
  `complete -c frontpage -n "__fish_seen_subcommand_from tokens" -a "set"`,
107
109
  `complete -c frontpage -n "__fish_seen_subcommand_from seo settings" -a "get set"`,
108
110
  `complete -c frontpage -n "__fish_seen_subcommand_from analytics" -a "dashboard summary query funnel timeseries compare sql"`,
111
+ `complete -c frontpage -n "__fish_seen_subcommand_from people" -a "list get add set note rm-note follow-up hide unhide"`,
109
112
  `complete -c frontpage -n "__fish_seen_subcommand_from modules" -a "list docs request-setup enable disable archive config callbar forms booking signups payments shop ordering articles google_reviews"`,
110
113
  `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from google_reviews" -a "connect resolve status list-reviews hide show set-stars set-sort set-weight reset-weights write-review-url refresh disconnect"`,
111
114
  `complete -c frontpage -n "__fish_seen_subcommand_from modules; and __fish_seen_subcommand_from forms" -a "get set destinations connect-destination remove-destination disconnect-destination attach-destination detach-destination request-destination-setup"`,
package/dist/registry.js CHANGED
@@ -413,6 +413,45 @@ export const COMMANDS = [
413
413
  topics: ["content"],
414
414
  dangers: ["--mark-read is side-effecting with no confirm"],
415
415
  },
416
+ {
417
+ name: "people",
418
+ summary: "List and manage site People (not inbox, not a module)",
419
+ usage: "frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide [--site <slug>] [--json]",
420
+ topics: ["content"],
421
+ examples: [
422
+ "frontpage people list",
423
+ "frontpage people add --email alex@example.com --name Alex",
424
+ "frontpage people set <id> --status working",
425
+ "frontpage people note <id> --body \"Called them\"",
426
+ "frontpage people follow-up <id> --at 2026-09-12T00:00:00Z",
427
+ "frontpage people hide <id>",
428
+ ],
429
+ flags: [
430
+ "list: --q --status --source --form-slug --follow-up due|upcoming|none --hidden --sort --last-seen-days --limit --cursor",
431
+ "add requires --email and/or --phone (never name alone)",
432
+ "Hide is not forget — unique email/phone stay; later ingest un-hides",
433
+ "Unknown leftover flags fail closed (exit 2)",
434
+ ],
435
+ seeAlso: ["inbox", "help content"],
436
+ },
437
+ {
438
+ name: "redirects",
439
+ summary: "List, set, or remove HTTP 301/308 path redirects",
440
+ usage: "frontpage redirects list | set --from /old --to /new [--status 301|308] | rm --from /old [--site <slug>] [--json]",
441
+ topics: ["content", "site-building"],
442
+ examples: [
443
+ "frontpage redirects list",
444
+ "frontpage redirects set --from /agents --to /agent",
445
+ "frontpage redirects set --from /old --to /new --status 308",
446
+ "frontpage redirects rm --from /old",
447
+ ],
448
+ flags: [
449
+ "Instant — no push or compile. Not a site file. frontpage revert does not undo a redirect.",
450
+ "Same-origin pathnames only. Status 301 (default) or 308. Cap 50 per site.",
451
+ "Unknown leftover flags fail closed (exit 2)",
452
+ ],
453
+ seeAlso: ["seo", "help site-building"],
454
+ },
416
455
  {
417
456
  name: "seo",
418
457
  summary: "Get or set page SEO",
@@ -502,7 +541,7 @@ export const TOPICS = {
502
541
  4. mkdir work && cd work && frontpage use <subdomain>
503
542
  5. frontpage pull
504
543
  6. Edit files under src/, public/, or content/
505
- 7. frontpage push --message "…"
544
+ 7. frontpage push --message "…" --wait
506
545
  8. frontpage deploy wait # tip compile ready (optional; push --wait does this)
507
546
  Review staging: https://<subdomain>.frontpage.host when that subdomain is publicly shareable.
508
547
  With a custom domain connected, the subdomain is often owner-private (login wall ≠ failed push);
@@ -528,7 +567,7 @@ export const TOPICS = {
528
567
  frontpage status # tipDrift when remote moved since last pull
529
568
  frontpage pull # downloads src/, public/, content/ into cwd
530
569
  frontpage diff # local vs remote via git blobSha (--json → dirty + tipDrift)
531
- frontpage push [--message …] [--wait] # uploads allowlisted files; CAS + active-run guard
570
+ frontpage push [--message …] [--wait] # uploads allowlisted files; deletes remote-only allowlisted paths; CAS + active-run guard
532
571
  frontpage ls [prefix]
533
572
  frontpage cat <path>
534
573
  frontpage search <pattern> [path|--path <path>] [--regex] [--case-sensitive] [--whole-word] [--files-only]
@@ -566,6 +605,10 @@ export const TOPICS = {
566
605
  Hard fences:
567
606
  - No API routes, serverless backends, or databases.
568
607
  - No fake lead forms / checkout / card fields — use data-fp-* modules + frontpage modules, or refuse.
608
+ - Quoted attributes: in frontmatter JS, a backslash-quote is an escape. In an Astro attribute it is
609
+ not. The quote after href= closes the string and the next token fails the compile. Put HTML in a
610
+ frontmatter const and pass it as an expression (auditBody={auditBody}), or use an expression
611
+ attribute. Never put an HTML tag or a backslash-quote inside a quoted JSX attribute.
569
612
  - Platform-owned paths are not syncable (BaseLayout overlay, module/extension shells, package.json…).
570
613
  - Customer layouts (DocsLayout, GuideLayout, …) are fine when site-owned. A wrapper MUST render
571
614
  <BaseLayout> and forward SEO: description={description}, ogImage={ogImage}, and
@@ -584,6 +627,8 @@ export const TOPICS = {
584
627
  - Prefer src/components/Header.astro + Footer.astro (or chrome: { managed: true } in site.ts).
585
628
  - seo block in site.ts + frontpage seo get|set; customize 404.astro but keep frontpage:404 meta;
586
629
  never put /404 in nav or pages. robots/sitemap are platform-managed.
630
+ - Real HTTP 301/308s: frontpage redirects list|set|rm. Instant (no push). Do not ship Astro.redirect,
631
+ location.replace, or a meta refresh for a permanent move. frontpage revert does not undo redirects.
587
632
 
588
633
  Modules & articles (short):
589
634
  - Forms: <form data-fp-form="slug"> with named inputs; no action/onsubmit/honeypot.
@@ -623,7 +668,9 @@ export const TOPICS = {
623
668
  frontpage media list|upload <file> # images/PDF/video; text/CSV = inline not library; sitePath first when stored
624
669
  frontpage images generate --prompt "…" [--count N] # 1–4 AI variations of one prompt; sitePath first; AI $ + storage
625
670
  frontpage inbox [leads|subscribers|orders|google_reviews|…] [--mark-read] [--csv]
671
+ frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide
626
672
  frontpage seo get [/path|--path /path] | seo set --path /
673
+ frontpage redirects list|set|rm --from /old --to /new
627
674
  frontpage articles list|content|create-section|create-article|set-draft|…
628
675
  frontpage fonts [list|search] [--slot …] [--query …]
629
676
  frontpage tokens set --group … --key … --value …
@@ -652,7 +699,8 @@ export const TOPICS = {
652
699
  revert creates a NEW commit whose tree matches the target (linear restore).
653
700
  Target SHA must be on this site's history (not another site in the monorepo).
654
701
  Refused while an agent run is active. Goes through commitSiteBranch (build gate).
655
- --yes required when stdin is not a TTY.`,
702
+ --yes required when stdin is not a TTY.
703
+ revert does not undo HTTP redirects — use frontpage redirects rm.`,
656
704
  isolation: `Isolation
657
705
  Every site-scoped call targets subdomain → branch site/<slug> only.
658
706
  No client-supplied branch, ref, repo, or clone URL is accepted.
package/llms.txt CHANGED
@@ -1,6 +1,7 @@
1
1
  # Frontpage CLI (frontpage-host)
2
2
 
3
3
  - Package: frontpage-host (npm). Binary: frontpage. Do NOT install npm package "frontpage".
4
+ - Astro quoted attributes: in frontmatter JS a backslash-quote is an escape; in an attribute it is not. Pass HTML as {auditBody}. Never HTML or a backslash-quote inside a quoted JSX attribute.
4
5
  - Docs in this package: README.md, AGENTS.md (agent operating manual).
5
6
  - Introspect: frontpage help | frontpage help --json | frontpage help <topic|command>
6
7
  - Topics: quickstart, auth, sync, site-building, agent, content, history, isolation, exit-codes
@@ -14,6 +15,8 @@
14
15
  - Prompt attachments: frontpage prompt "…" --attach <file|/__media/…|https://…> [--asset-url https://…] (same media caps as media upload)
15
16
  - Version: frontpage --version | -V | version
16
17
  - SEO get: frontpage seo get [/path|--path /path]; search path filter: [path|--path <path>]; open [/path|--path /path]
18
+ - Redirects: frontpage redirects list|set|rm — HTTP 301/308, instant, not a file; revert does not undo; do not fake with Astro.redirect / meta refresh
19
+ - People: frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide — site-scoped person graph (email or phone). Inbox stays the raw ledger. Hide is not forget. Unknown leftover flags fail closed.
17
20
  - Analytics funnel: --steps JSON array or comma paths (/, /pricing); bad JSON → Invalid JSON for --steps
18
21
  - Modules: unknown key errors list Known: …; mailing list module key is signups (not subscribe)
19
22
  - Errors: --json API envelopes include requestId when the server returned one; local validation omits requestId/httpStatus; runs wait failed/canceled → ok:false + run.error (exit 2); deploy wait = agent idle + tip compile ready (fail-closed / compile timeout → exit 2); push --wait and publish --wait wait for GET /deploy ready
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "frontpage-host",
3
- "version": "0.1.17",
3
+ "version": "0.1.18",
4
4
  "description": "Frontpage CLI — manage your site from the terminal",
5
5
  "type": "module",
6
6
  "bin": {