frontpage-host 0.1.17 → 0.1.19

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.
@@ -0,0 +1,341 @@
1
+ import { writeFile } from "node:fs/promises";
2
+ import { resolveSlug } from "../site.js";
3
+ import { CliHttpError, EXIT_OK, EXIT_USAGE, apiBase, exitCodeForError, getBearerAuthHeader, httpErrorFromV1, v1Delete, v1Get, v1Patch, v1Post, } from "../http/client.js";
4
+ import { assertNoUnknownFlags, hasFlag, takeFlag } from "../argv.js";
5
+ import { emitErr, emitOk, isJson } from "../runtime.js";
6
+ const USAGE = "Usage: frontpage people list|get|add|set|note|rm-note|follow-up|hide|unhide|tag|export [--site <slug>] [--json]";
7
+ const PERSON_ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
8
+ function requireId(work) {
9
+ const fromFlag = takeFlag(work, "--id");
10
+ const positional = work.find((a) => !a.startsWith("-"));
11
+ const raw = fromFlag ?? (positional
12
+ ? work.splice(work.indexOf(positional), 1)[0]
13
+ : undefined);
14
+ if (!raw) {
15
+ throw new CliHttpError(`Person id is required. Run: frontpage help people`, EXIT_USAGE, 0, "bad_request");
16
+ }
17
+ if (!PERSON_ID_RE.test(raw)) {
18
+ throw new CliHttpError("Person not found.", EXIT_USAGE, 404, "not_found");
19
+ }
20
+ return raw;
21
+ }
22
+ function requireEventId(raw) {
23
+ if (!raw) {
24
+ throw new CliHttpError(`Usage: frontpage people rm-note <id> --event <eventId>`, EXIT_USAGE, 0, "bad_request");
25
+ }
26
+ if (!PERSON_ID_RE.test(raw)) {
27
+ throw new CliHttpError("Event not found.", EXIT_USAGE, 404, "not_found");
28
+ }
29
+ return raw;
30
+ }
31
+ function displayName(row) {
32
+ return row.name || row.email || row.phone || row.id;
33
+ }
34
+ function printPerson(row) {
35
+ const bits = [row.status ?? "—", displayName(row)];
36
+ if (row.email && row.email !== row.name)
37
+ bits.push(row.email);
38
+ if (row.phone && row.phone !== row.name)
39
+ bits.push(row.phone);
40
+ if (row.followUpAt)
41
+ bits.push(`follow-up ${row.followUpAt}`);
42
+ if (row.hiddenAt)
43
+ bits.push("hidden");
44
+ if (row.tags?.length)
45
+ bits.push(row.tags.join(","));
46
+ if (row.lastEvent?.summary)
47
+ bits.push(row.lastEvent.summary);
48
+ console.log(`${row.id} ${bits.join(" · ")}`);
49
+ }
50
+ async function fetchPeopleCsv(path) {
51
+ const headers = await getBearerAuthHeader();
52
+ const res = await fetch(`${apiBase()}${path}`, {
53
+ headers: { ...headers, Accept: "text/csv" },
54
+ });
55
+ const text = await res.text();
56
+ if (res.ok)
57
+ return text;
58
+ let parsed = {};
59
+ try {
60
+ parsed = text ? JSON.parse(text) : {};
61
+ }
62
+ catch {
63
+ parsed = {};
64
+ }
65
+ throw httpErrorFromV1(res.status, parsed, text || `Request failed (${res.status})`);
66
+ }
67
+ export async function runPeople(args) {
68
+ try {
69
+ const { slug, rest } = await resolveSlug(args);
70
+ const sub = rest[0];
71
+ const base = `/api/v1/sites/${encodeURIComponent(slug)}/people`;
72
+ if (!sub || sub === "list") {
73
+ const work = sub === "list" ? rest.slice(1) : [...rest];
74
+ const q = takeFlag(work, "--q") ?? takeFlag(work, "--query");
75
+ const status = takeFlag(work, "--status");
76
+ const source = takeFlag(work, "--source");
77
+ const formSlug = takeFlag(work, "--form-slug");
78
+ const followUp = takeFlag(work, "--follow-up");
79
+ const sort = takeFlag(work, "--sort");
80
+ const lastSeenDays = takeFlag(work, "--last-seen-days");
81
+ const limit = takeFlag(work, "--limit");
82
+ const cursor = takeFlag(work, "--cursor");
83
+ const hidden = hasFlag(work, "--hidden");
84
+ const tag = takeFlag(work, "--tag");
85
+ assertNoUnknownFlags(work, "people");
86
+ const params = new URLSearchParams();
87
+ if (q)
88
+ params.set("q", q);
89
+ if (status)
90
+ params.set("status", status);
91
+ if (source)
92
+ params.set("source", source);
93
+ if (formSlug)
94
+ params.set("formSlug", formSlug);
95
+ if (followUp)
96
+ params.set("followUp", followUp);
97
+ if (sort)
98
+ params.set("sort", sort);
99
+ if (lastSeenDays)
100
+ params.set("lastSeenDays", lastSeenDays);
101
+ if (limit)
102
+ params.set("limit", limit);
103
+ if (cursor)
104
+ params.set("cursor", cursor);
105
+ if (hidden)
106
+ params.set("hidden", "1");
107
+ if (tag)
108
+ params.set("tag", tag);
109
+ const qs = params.toString();
110
+ const data = await v1Get(qs ? `${base}?${qs}` : base);
111
+ if (isJson()) {
112
+ emitOk("people", data);
113
+ return EXIT_OK;
114
+ }
115
+ const rows = data.people ?? [];
116
+ if (rows.length === 0) {
117
+ console.log(hidden ? "No hidden people." : "No people.");
118
+ return EXIT_OK;
119
+ }
120
+ for (const row of rows)
121
+ printPerson(row);
122
+ if (data.nextCursor)
123
+ console.log(`next: ${data.nextCursor}`);
124
+ return EXIT_OK;
125
+ }
126
+ if (sub === "get") {
127
+ const work = rest.slice(1);
128
+ const id = requireId(work);
129
+ assertNoUnknownFlags(work, "people");
130
+ const data = await v1Get(`${base}/${encodeURIComponent(id)}`);
131
+ if (isJson()) {
132
+ emitOk("people", data);
133
+ return EXIT_OK;
134
+ }
135
+ printPerson(data.person);
136
+ const events = data.events ?? [];
137
+ for (const ev of events) {
138
+ const row = ev;
139
+ const extra = typeof row.payload?.body === "string" ? ` ${row.payload.body}` : "";
140
+ console.log(` ${row.occurredAt ?? ""} ${row.type ?? ""}${extra}`);
141
+ }
142
+ return EXIT_OK;
143
+ }
144
+ if (sub === "add") {
145
+ const work = rest.slice(1);
146
+ const email = takeFlag(work, "--email");
147
+ const phone = takeFlag(work, "--phone");
148
+ const name = takeFlag(work, "--name");
149
+ const note = takeFlag(work, "--note");
150
+ assertNoUnknownFlags(work, "people");
151
+ if (!email && !phone) {
152
+ console.error("Usage: frontpage people add --email you@example.com | --phone +15551234567 [--name …] [--note …]");
153
+ return EXIT_USAGE;
154
+ }
155
+ const data = await v1Post(base, {
156
+ email,
157
+ phone,
158
+ name,
159
+ note,
160
+ });
161
+ if (isJson()) {
162
+ emitOk("people", data);
163
+ return EXIT_OK;
164
+ }
165
+ printPerson(data.person);
166
+ return EXIT_OK;
167
+ }
168
+ if (sub === "set") {
169
+ const work = rest.slice(1);
170
+ const id = requireId(work);
171
+ const body = {};
172
+ for (const field of ["name", "email", "phone", "notes", "status"]) {
173
+ const v = takeFlag(work, `--${field}`);
174
+ if (v !== undefined)
175
+ body[field] = v === "" ? null : v;
176
+ }
177
+ assertNoUnknownFlags(work, "people");
178
+ if (Object.keys(body).length === 0) {
179
+ console.error("Usage: frontpage people set <id> [--name …] [--email …] [--phone …] [--notes …] [--status …]");
180
+ return EXIT_USAGE;
181
+ }
182
+ const data = await v1Patch(`${base}/${encodeURIComponent(id)}`, body);
183
+ if (isJson()) {
184
+ emitOk("people", data);
185
+ return EXIT_OK;
186
+ }
187
+ printPerson(data.person);
188
+ return EXIT_OK;
189
+ }
190
+ if (sub === "note") {
191
+ const work = rest.slice(1);
192
+ const id = requireId(work);
193
+ const body = takeFlag(work, "--body") ?? takeFlag(work, "--note");
194
+ assertNoUnknownFlags(work, "people");
195
+ if (!body) {
196
+ console.error("Usage: frontpage people note <id> --body \"…\"");
197
+ return EXIT_USAGE;
198
+ }
199
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/events`, { body });
200
+ if (isJson()) {
201
+ emitOk("people", data);
202
+ return EXIT_OK;
203
+ }
204
+ console.log(`Noted ${id} (${data.event.id})`);
205
+ return EXIT_OK;
206
+ }
207
+ if (sub === "rm-note") {
208
+ const work = rest.slice(1);
209
+ const id = requireId(work);
210
+ const eventId = requireEventId(takeFlag(work, "--event") ?? takeFlag(work, "--event-id"));
211
+ assertNoUnknownFlags(work, "people");
212
+ const data = await v1Delete(`${base}/${encodeURIComponent(id)}/events/${encodeURIComponent(eventId)}`);
213
+ if (isJson()) {
214
+ emitOk("people", data);
215
+ return EXIT_OK;
216
+ }
217
+ console.log(`Removed note ${data.removed.id}`);
218
+ return EXIT_OK;
219
+ }
220
+ if (sub === "follow-up") {
221
+ const work = rest.slice(1);
222
+ const id = requireId(work);
223
+ const clear = hasFlag(work, "--clear");
224
+ const at = takeFlag(work, "--at");
225
+ assertNoUnknownFlags(work, "people");
226
+ if (!clear && !at) {
227
+ console.error("Usage: frontpage people follow-up <id> --at <iso> | --clear");
228
+ return EXIT_USAGE;
229
+ }
230
+ const data = await v1Patch(`${base}/${encodeURIComponent(id)}/follow-up`, { at: clear ? null : at });
231
+ if (isJson()) {
232
+ emitOk("people", data);
233
+ return EXIT_OK;
234
+ }
235
+ printPerson(data.person);
236
+ return EXIT_OK;
237
+ }
238
+ if (sub === "hide") {
239
+ const work = rest.slice(1);
240
+ const id = requireId(work);
241
+ assertNoUnknownFlags(work, "people");
242
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/hide`, {});
243
+ if (isJson()) {
244
+ emitOk("people", data);
245
+ return EXIT_OK;
246
+ }
247
+ console.log(`Hidden ${displayName(data.person)}`);
248
+ return EXIT_OK;
249
+ }
250
+ if (sub === "unhide") {
251
+ const work = rest.slice(1);
252
+ const id = requireId(work);
253
+ assertNoUnknownFlags(work, "people");
254
+ const data = await v1Post(`${base}/${encodeURIComponent(id)}/unhide`, {});
255
+ if (isJson()) {
256
+ emitOk("people", data);
257
+ return EXIT_OK;
258
+ }
259
+ console.log(`Restored ${displayName(data.person)}`);
260
+ return EXIT_OK;
261
+ }
262
+ if (sub === "tag") {
263
+ const work = rest.slice(1);
264
+ const id = requireId(work);
265
+ const add = takeFlag(work, "--add");
266
+ const remove = takeFlag(work, "--remove") ?? takeFlag(work, "--rm");
267
+ assertNoUnknownFlags(work, "people");
268
+ if ((!add && !remove) || (add && remove)) {
269
+ console.error("Usage: frontpage people tag <id> --add <tag> | --remove <tag>");
270
+ return EXIT_USAGE;
271
+ }
272
+ const data = add
273
+ ? await v1Post(`${base}/${encodeURIComponent(id)}/tags`, {
274
+ tag: add,
275
+ })
276
+ : await v1Delete(`${base}/${encodeURIComponent(id)}/tags/${encodeURIComponent(remove)}`);
277
+ if (isJson()) {
278
+ emitOk("people", data);
279
+ return EXIT_OK;
280
+ }
281
+ printPerson(data.person);
282
+ return EXIT_OK;
283
+ }
284
+ if (sub === "export") {
285
+ const work = rest.slice(1);
286
+ const q = takeFlag(work, "--q") ?? takeFlag(work, "--query");
287
+ const status = takeFlag(work, "--status");
288
+ const source = takeFlag(work, "--source");
289
+ const formSlug = takeFlag(work, "--form-slug");
290
+ const followUp = takeFlag(work, "--follow-up");
291
+ const sort = takeFlag(work, "--sort");
292
+ const lastSeenDays = takeFlag(work, "--last-seen-days");
293
+ const tag = takeFlag(work, "--tag");
294
+ const out = takeFlag(work, "--out");
295
+ const hidden = hasFlag(work, "--hidden");
296
+ assertNoUnknownFlags(work, "people");
297
+ const params = new URLSearchParams();
298
+ if (q)
299
+ params.set("q", q);
300
+ if (status)
301
+ params.set("status", status);
302
+ if (source)
303
+ params.set("source", source);
304
+ if (formSlug)
305
+ params.set("formSlug", formSlug);
306
+ if (followUp)
307
+ params.set("followUp", followUp);
308
+ if (sort)
309
+ params.set("sort", sort);
310
+ if (lastSeenDays)
311
+ params.set("lastSeenDays", lastSeenDays);
312
+ if (hidden)
313
+ params.set("hidden", "1");
314
+ if (tag)
315
+ params.set("tag", tag);
316
+ const qs = params.toString();
317
+ const csv = await fetchPeopleCsv(qs ? `${base}/export?${qs}` : `${base}/export`);
318
+ if (out) {
319
+ await writeFile(out, csv, "utf8");
320
+ if (isJson()) {
321
+ emitOk("people", { path: out, bytes: Buffer.byteLength(csv, "utf8") });
322
+ return EXIT_OK;
323
+ }
324
+ console.log(out);
325
+ return EXIT_OK;
326
+ }
327
+ process.stdout.write(csv);
328
+ if (!csv.endsWith("\n"))
329
+ process.stdout.write("\n");
330
+ return EXIT_OK;
331
+ }
332
+ console.error(USAGE);
333
+ return EXIT_USAGE;
334
+ }
335
+ catch (err) {
336
+ if (isJson())
337
+ return emitErr("people", err);
338
+ console.error(err instanceof Error ? err.message : String(err));
339
+ return exitCodeForError(err);
340
+ }
341
+ }
@@ -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
+ }
@@ -0,0 +1,3 @@
1
+ /** Same kebab rules as the Store silo. Kept here so the CLI does not import app lib. */
2
+ export declare const SHOP_ID_RE: RegExp;
3
+ export declare const VARIANT_ID_RE: RegExp;
@@ -0,0 +1,3 @@
1
+ /** Same kebab rules as the Store silo. Kept here so the CLI does not import app lib. */
2
+ export const SHOP_ID_RE = /^[a-z0-9][a-z0-9-]{1,39}$/;
3
+ export const VARIANT_ID_RE = /^[a-z0-9][a-z0-9-]{0,39}$/;
@@ -0,0 +1 @@
1
+ export declare function runShop(args: string[]): Promise<number>;