@fullgrainsoftware/portal 0.9.0

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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Alexander Jessen
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,242 @@
1
+ # @fullgrainsoftware/portal
2
+
3
+ Site SDK for the [Fullgrain Portal](https://www.abdevpanel.com) CMS: declare your
4
+ site's structure in a typed manifest, read CMS-managed content in your pages,
5
+ and sync structural changes with one command.
6
+
7
+ ```bash
8
+ npm install @fullgrainsoftware/portal
9
+ ```
10
+
11
+ ## How it fits together
12
+
13
+ - **The manifest owns structure.** `cms.manifest.ts` in your repo declares
14
+ pages, sections, and fields.
15
+ - **The CMS owns content.** Editors change copy and images in the Fullgrain
16
+ Portal; your site fetches that content at render time.
17
+ - **Seeds run once.** The `seed` blocks in the manifest fill a section the
18
+ first time it syncs — after that, the CMS content is the source of truth
19
+ and drifts from the manifest by design.
20
+
21
+ ## Declare your site
22
+
23
+ ```ts
24
+ // cms.manifest.ts
25
+ import { defineSite } from "@fullgrainsoftware/portal";
26
+
27
+ export default defineSite({
28
+ alias: "my_site",
29
+ pages: [
30
+ {
31
+ slug: "/",
32
+ title: "Home",
33
+ sections: [
34
+ {
35
+ key: "hero",
36
+ title: "Hero",
37
+ kind: "singleton",
38
+ fields: {
39
+ heading: { label: "Heading", type: "text" },
40
+ subheading: { label: "Subheading", type: "text" },
41
+ },
42
+ seed: [{ name: "Hero", values: { heading: "Welcome" } }],
43
+ },
44
+ ],
45
+ },
46
+ ],
47
+ });
48
+ ```
49
+
50
+ ## Read content in your pages
51
+
52
+ ```tsx
53
+ import { fetchPublicSiteData, readSiteContent, cmsItemProps } from "@fullgrainsoftware/portal";
54
+ import manifest from "../cms.manifest";
55
+
56
+ // fetchPublicSiteData takes the site's CMS id (a UUID from your Fullgrain Software
57
+ // contact), NOT the manifest alias. It returns { website }, so unwrap it before
58
+ // passing to readSiteContent — otherwise every section silently reads as seed.
59
+ const data = await fetchPublicSiteData(process.env.WEBSITE_ID!);
60
+ const content = readSiteContent(manifest, data.website);
61
+
62
+ content.hero.heading; // typed field access
63
+
64
+ // Collection sections are arrays; spread cmsItemProps(item) onto each item's
65
+ // wrapper for live-preview click-to-edit.
66
+ content.showcase.map((item) => (
67
+ <div key={item._cms.id} {...cmsItemProps(item)}>
68
+ {item.name}
69
+ </div>
70
+ ));
71
+ ```
72
+
73
+ `fetchPublicSiteData` targets the Fullgrain Portal by default; override with the
74
+ `AB_CMS_URL` environment variable or `{ baseUrl }`.
75
+
76
+ ## Sync structural changes
77
+
78
+ ```bash
79
+ npx fullgrain sync --dry-run # show what would change
80
+ npx fullgrain sync # apply schema changes; seed only brand-new sections
81
+ ```
82
+
83
+ Requires `AB_CMS_API_KEY` in the environment (ask your Fullgrain Software contact).
84
+ The dry-run reports existing content as `skipped: seed` and CMS-only content
85
+ as orphans — both are normal, not errors.
86
+
87
+ ## Commerce (Lite Commerce stores)
88
+
89
+ For sites whose store is enabled in the panel. **Server-only** — the API key
90
+ is a secret; `createCommerce` throws if constructed in the browser.
91
+
92
+ ```ts
93
+ import { createCommerce } from "@fullgrainsoftware/portal";
94
+
95
+ const commerce = createCommerce({ apiKey: process.env.COMMERCE_API_KEY! });
96
+
97
+ const { store, products } = await commerce.listProducts();
98
+ const { product } = await commerce.getProduct("demo-tee");
99
+ const result = await commerce.createCheckout({
100
+ items: [{ variantId: product!.variants[0].id, quantity: 1 }],
101
+ successUrl: "https://your-site.com/thanks",
102
+ cancelUrl: "https://your-site.com/cart",
103
+ });
104
+ if ("url" in result) redirect(result.url); // Stripe Checkout
105
+ else showIssues(result.issues); // sold_out | invalid_items | commerce_paused
106
+ ```
107
+
108
+ `store.checkoutAvailable` says whether buying works right now — the catalog
109
+ keeps serving while a store is paused, so sites can choose to hide the shop
110
+ or show it with purchasing disabled. `store.shippingCents` and
111
+ `store.allowedCountries` mirror what checkout will charge and accept. Ask
112
+ your Fullgrain Software contact for the `COMMERCE_API_KEY`.
113
+
114
+ Cart pages validate against the live catalog (no stock is held):
115
+
116
+ ```ts
117
+ const { lines, issues, subtotalCents } = await commerce.previewCart(cartItems);
118
+ ```
119
+
120
+ Success pages look the order up by Stripe's `{CHECKOUT_SESSION_ID}`
121
+ template value (add it to your `successUrl` as a query param):
122
+
123
+ ```ts
124
+ const { order } = await commerce.getOrderStatus(searchParams.session_id);
125
+ // order: { status, fulfilled, trackingCarrier, items, totalCents, … } | null
126
+ ```
127
+
128
+ ## Inquiries (contact forms)
129
+
130
+ For sites with a contact/booking form. **Server-only** — the API key is a
131
+ secret; `createInquiries` throws if constructed in the browser.
132
+
133
+ ```ts
134
+ import { createInquiries } from "@fullgrainsoftware/portal";
135
+
136
+ const inquiries = createInquiries({ apiKey: process.env.INQUIRY_API_KEY! });
137
+
138
+ const result = await inquiries.submit({
139
+ form: "contact", // lower-kebab, ≤ 40 chars; default "contact"
140
+ name, email, phone, message, // name + message required; email or phone required for "contact"
141
+ fields: { company, subject }, // optional extras, shown as label/value
142
+ rating: 5, // review forms only: a whole number 1–5
143
+ page: "/contact", // optional, where it was sent from
144
+ honeypot: String(formData.get("website") ?? ""), // optional; non-empty = bot
145
+ meta: { ip, userAgent }, // optional; from the request headers
146
+ });
147
+ // { ok: true, id }
148
+ // { ok: false, error: "invalid", issues: Record<string, string> }
149
+ // { ok: false, error: "disabled" | "rate_limited" | "unavailable" }
150
+ ```
151
+
152
+ The site owns its success and error states — `submit` never throws for an
153
+ expected outcome. On `invalid`, show `result.issues` next to the fields; on
154
+ `rate_limited`, say "try again in a few minutes"; on `disabled` (the client
155
+ turned inquiries off in the panel) or `unavailable` (panel unreachable, a
156
+ bad key, or any other unexpected response — already logged via
157
+ `console.error`), fall back to a mailto/phone link rather than a dead form.
158
+ Ask your Fullgrain Software contact for the `INQUIRY_API_KEY` (`abik_…`).
159
+
160
+ ## Bookings (appointments)
161
+
162
+ For sites whose business takes appointments. **Server-only** — the API key
163
+ is a secret; `createBookings` throws if constructed in the browser. Every
164
+ method returns a typed result and never throws for an expected outcome.
165
+
166
+ ```ts
167
+ import { createBookings } from "@fullgrainsoftware/portal";
168
+
169
+ const bookings = createBookings({ apiKey: process.env.BOOKING_API_KEY! });
170
+
171
+ const services = await bookings.listServices();
172
+ if (!services.ok) return fallback(); // "disabled" | "unavailable" → show the phone number
173
+ const { timezone, requirePhone } = services; // services.services: BookingService[]; requirePhone → mark the phone field required
174
+
175
+ const days = await bookings.getAvailability({ serviceId, from: "2026-06-11", to: "2026-06-17" });
176
+ if (!days.ok) return days.error === "invalid" ? badRequest(days.issues) : fallback();
177
+ // days.days: [{ date: "2026-06-11", slots: ["2026-06-11T16:00:00.000Z", …] }]
178
+
179
+ const result = await bookings.book({
180
+ serviceId, start: slot, // one of the returned slots, verbatim
181
+ customer: { name, email, phone }, // email required; phone if the site requires it
182
+ note, fields: { "Intake · Area": "left arm" }, page: "/book",
183
+ honeypot: String(formData.get("website") ?? ""), meta: { ip, userAgent },
184
+ });
185
+ if (!result.ok) { /* "invalid" (result.issues) | "slot_taken" | "too_many_active" | "rate_limited" | "unknown_service" | "disabled" | "unavailable" */ }
186
+ else { /* result.booking: BookingView — its manageUrl is what the customer's emails link to */ }
187
+ await bookings.requestTime({ serviceId, text: "any weekday after 3", customer }); // when no slot fits
188
+ ```
189
+
190
+ Render slots in the business's zone with no library:
191
+ `new Intl.DateTimeFormat("en-US", { timeZone: timezone, weekday: "short", month: "short", day: "numeric", hour: "numeric", minute: "2-digit" }).format(new Date(slot))`.
192
+
193
+ On `slot_taken`, re-fetch the day and ask for another time; on `disabled` or
194
+ `unavailable`, show the phone number rather than a dead picker. Customers
195
+ manage a booking from the `manageUrl` in their emails (the panel hosts that
196
+ page); a site that wants its own can set a manage URL in the panel and call
197
+ `getBooking(token)`, `cancel`, `reschedule`, and `respond` itself. Such a page
198
+ should pass back the `version` it rendered (`cancel(token, { version: booking.version })`)
199
+ so a stale page can't act on a booking that changed since it loaded.
200
+
201
+ ## Reviews
202
+
203
+ For sites with a review form. Submit reviews through `createInquiries` with
204
+ `form: "review"` and a `rating` (1–5). When the client turns on review
205
+ moderation in the panel, each review waits in their inbox until they approve
206
+ it; approved reviews then arrive with your content as `website.reviews`
207
+ (newest first, at most 50) and refresh on the same revalidate ping as any
208
+ content edit.
209
+
210
+ ```ts
211
+ import { readReviews, type CMSReview } from "@fullgrainsoftware/portal";
212
+
213
+ // lib/cms.ts — through the same cached fetch as the content
214
+ export async function getReviews(): Promise<CMSReview[]> {
215
+ const websiteId = process.env.WEBSITE_ID;
216
+ if (!websiteId) return [];
217
+ try {
218
+ return readReviews((await getPublicSiteData(websiteId)).website);
219
+ } catch (err) {
220
+ console.error("[cms] reviews fetch failed:", err);
221
+ return [];
222
+ }
223
+ }
224
+ ```
225
+
226
+ Each review is `{ id, name, rating, text, createdAt }`: `name` is the display
227
+ name the business chose (never the reviewer's email or full name), `rating`
228
+ may be `null`, `createdAt` is ISO 8601 UTC. `readReviews` never throws: seed
229
+ mode, reviews switched off, or an older panel all give `[]` — render nothing
230
+ (or a code-owned fallback) for an empty list, and never invent reviews.
231
+ Don't emit `aggregateRating` structured data from these: self-served reviews
232
+ aren't eligible for review stars in search.
233
+
234
+ ## Live preview
235
+
236
+ `initCmsPreview()` wires a site to the panel's live preview iframe (section
237
+ scrolling, click-to-edit). Call it once client-side; it no-ops outside the
238
+ preview.
239
+
240
+ ## License
241
+
242
+ MIT © Alexander Jessen
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ import { runSync } from "../dist/cli.js";
3
+ process.exit(await runSync(process.argv.slice(2)));
@@ -0,0 +1,169 @@
1
+ export type BookingStatus = "requested" | "pending" | "proposed" | "confirmed" | "completed" | "no_show" | "cancelled" | "expired";
2
+ export interface BookingService {
3
+ id: string;
4
+ slug: string;
5
+ name: string;
6
+ description: string;
7
+ durationMin: number;
8
+ priceLabel: string;
9
+ }
10
+ export interface AvailabilityDay {
11
+ date: string;
12
+ slots: string[];
13
+ }
14
+ export interface BookingView {
15
+ id: string;
16
+ status: BookingStatus;
17
+ service: {
18
+ id: string;
19
+ name: string;
20
+ durationMin: number;
21
+ priceLabel: string;
22
+ };
23
+ timezone: string;
24
+ start: string | null;
25
+ end: string | null;
26
+ previousStart: string | null;
27
+ customer: {
28
+ name: string;
29
+ email: string;
30
+ phone: string | null;
31
+ };
32
+ note: string;
33
+ fields: Record<string, string>;
34
+ requestedText: string | null;
35
+ manageUrl: string;
36
+ can: {
37
+ cancel: boolean;
38
+ reschedule: boolean;
39
+ respond: boolean;
40
+ };
41
+ policy: {
42
+ cancelCutoffMin: number;
43
+ rescheduleCutoffMin: number;
44
+ reschedulesLeft: number;
45
+ contactPhone: string;
46
+ };
47
+ business: {
48
+ name: string;
49
+ location: string;
50
+ };
51
+ /** Bumps on every time change. Pass it back to cancel / reschedule / respond
52
+ * so a page rendered before a change can't act on the changed booking. */
53
+ version: number;
54
+ }
55
+ export interface BookingCustomer {
56
+ name: string;
57
+ email: string;
58
+ phone?: string | null;
59
+ }
60
+ export interface BookInput {
61
+ serviceId: string;
62
+ /** ISO 8601 instant, exactly one of the slots from getAvailability. */
63
+ start: string;
64
+ customer: BookingCustomer;
65
+ note?: string | null;
66
+ fields?: Record<string, string>;
67
+ page?: string | null;
68
+ honeypot?: string | null;
69
+ meta?: {
70
+ ip?: string | null;
71
+ userAgent?: string | null;
72
+ };
73
+ }
74
+ export interface RequestTimeInput {
75
+ serviceId: string;
76
+ text: string;
77
+ customer: BookingCustomer;
78
+ fields?: Record<string, string>;
79
+ page?: string | null;
80
+ honeypot?: string | null;
81
+ meta?: {
82
+ ip?: string | null;
83
+ userAgent?: string | null;
84
+ };
85
+ }
86
+ type Unavailable = {
87
+ ok: false;
88
+ error: "unavailable";
89
+ };
90
+ type Invalid = {
91
+ ok: false;
92
+ error: "invalid";
93
+ issues: Record<string, string>;
94
+ };
95
+ export type ServicesResult = {
96
+ ok: true;
97
+ timezone: string;
98
+ /** The site's "require phone" setting: a booking or request without a phone is `invalid`. */
99
+ requirePhone: boolean;
100
+ services: BookingService[];
101
+ } | {
102
+ ok: false;
103
+ error: "disabled";
104
+ } | Unavailable;
105
+ export type AvailabilityResult = {
106
+ ok: true;
107
+ timezone: string;
108
+ service: BookingService;
109
+ days: AvailabilityDay[];
110
+ } | Invalid | {
111
+ ok: false;
112
+ error: "unknown_service" | "disabled";
113
+ } | Unavailable;
114
+ export type BookResult = {
115
+ ok: true;
116
+ booking: BookingView;
117
+ } | Invalid | {
118
+ ok: false;
119
+ error: "slot_taken" | "too_many_active" | "rate_limited" | "unknown_service" | "disabled";
120
+ } | Unavailable;
121
+ export type RequestTimeResult = {
122
+ ok: true;
123
+ booking: BookingView;
124
+ } | Invalid | {
125
+ ok: false;
126
+ error: "rate_limited" | "unknown_service" | "disabled";
127
+ } | Unavailable;
128
+ export type GetBookingResult = {
129
+ ok: true;
130
+ booking: BookingView;
131
+ } | {
132
+ ok: false;
133
+ error: "not_found" | "disabled";
134
+ } | Unavailable;
135
+ export type ManageResult = {
136
+ ok: true;
137
+ booking: BookingView;
138
+ } | Invalid | {
139
+ ok: false;
140
+ error: "cutoff" | "limit_reached" | "slot_taken" | "invalid_transition" | "not_found" | "disabled";
141
+ } | Unavailable;
142
+ export interface Bookings {
143
+ listServices(): Promise<ServicesResult>;
144
+ getAvailability(args: {
145
+ serviceId: string;
146
+ from: string;
147
+ to?: string;
148
+ }): Promise<AvailabilityResult>;
149
+ book(input: BookInput): Promise<BookResult>;
150
+ requestTime(input: RequestTimeInput): Promise<RequestTimeResult>;
151
+ getBooking(token: string): Promise<GetBookingResult>;
152
+ cancel(token: string, opts?: {
153
+ reason?: string | null;
154
+ version?: number;
155
+ }): Promise<ManageResult>;
156
+ reschedule(token: string, opts: {
157
+ start: string;
158
+ version?: number;
159
+ }): Promise<ManageResult>;
160
+ respond(token: string, opts: {
161
+ accept: boolean;
162
+ version?: number;
163
+ }): Promise<ManageResult>;
164
+ }
165
+ export declare function createBookings(opts: {
166
+ apiKey: string;
167
+ baseUrl?: string;
168
+ }): Bookings;
169
+ export {};
@@ -0,0 +1,96 @@
1
+ /* Bookings (spec 2026-09-28): a site lists services, fetches open slots,
2
+ books or requests a time, and lets the customer act on a booking through
3
+ its manage token. Server-only — the abbk_ key is a secret — and every
4
+ method returns a typed result instead of throwing for an expected
5
+ outcome, so a site can never show a dead picker. */
6
+ const DEFAULT_BASE_URL = "https://www.abdevpanel.com";
7
+ const TIMEOUT_MS = 10_000;
8
+ const versionOf = (o) => (typeof o?.version === "number" ? { version: o.version } : {});
9
+ export function createBookings(opts) {
10
+ if (typeof window !== "undefined") {
11
+ throw new Error("createBookings is server-only — never expose BOOKING_API_KEY to the browser");
12
+ }
13
+ if (!opts.apiKey?.startsWith("abbk_")) {
14
+ throw new Error("createBookings needs a booking API key (abbk_…)");
15
+ }
16
+ const base = (opts.baseUrl ?? process.env.AB_CMS_URL ?? DEFAULT_BASE_URL).replace(/\/$/, "");
17
+ const unavailable = (why) => {
18
+ // The manage token is a bearer credential for the booking: never log it.
19
+ console.error(`[fullgrain bookings] ${why.replace(/\/manage\/[^\s?#)]+/g, "/manage/…")}`);
20
+ return { ok: false, error: "unavailable" };
21
+ };
22
+ async function call(path, init) {
23
+ let res;
24
+ try {
25
+ res = await fetch(`${base}${path}`, {
26
+ method: init.method,
27
+ headers: { Authorization: `Bearer ${opts.apiKey}`, ...(init.body !== undefined ? { "Content-Type": "application/json" } : {}) },
28
+ body: init.body !== undefined ? JSON.stringify(init.body) : undefined,
29
+ cache: "no-store",
30
+ signal: AbortSignal.timeout(TIMEOUT_MS),
31
+ });
32
+ }
33
+ catch (err) {
34
+ return { failed: `request failed: ${err instanceof Error ? err.message : String(err)}` };
35
+ }
36
+ const contentType = res.headers.get("content-type") ?? "";
37
+ if (!contentType.includes("application/json"))
38
+ return { failed: `non-JSON response ${res.status} from ${base}${path}` };
39
+ const body = (await res.json().catch(() => null));
40
+ return { status: res.status, body };
41
+ }
42
+ // One mapper for every method: a 200 with ok:true is the body; the listed
43
+ // error codes pass through by status; anything else (401, 5xx, an error
44
+ // the method never declares, junk) is unavailable — so runtime values
45
+ // never escape a method's declared union.
46
+ function map(f, path, allowed) {
47
+ if ("failed" in f)
48
+ return unavailable(f.failed);
49
+ const { status, body } = f;
50
+ if (status === 200 && body?.ok === true)
51
+ return body;
52
+ if (status === 400 && body?.error === "invalid" && allowed.includes("invalid"))
53
+ return { ok: false, error: "invalid", issues: body.issues ?? {} };
54
+ if (status === 403 && allowed.includes("disabled"))
55
+ return { ok: false, error: "disabled" };
56
+ if (status === 429 && allowed.includes("rate_limited"))
57
+ return { ok: false, error: "rate_limited" };
58
+ if ((status === 404 || status === 409) && typeof body?.error === "string" && allowed.includes(body.error))
59
+ return { ok: false, error: body.error };
60
+ return unavailable(`unexpected response ${status}${body?.error ? ` (${body.error})` : ""} from ${base}${path}`);
61
+ }
62
+ const manage = (token, body) => {
63
+ const path = `/api/booking/manage/${encodeURIComponent(token)}`;
64
+ return call(path, { method: "POST", body }).then((f) => map(f, path, ["invalid", "not_found", "cutoff", "limit_reached", "slot_taken", "invalid_transition", "disabled"]));
65
+ };
66
+ return {
67
+ async listServices() {
68
+ const path = "/api/booking/services";
69
+ const result = map(await call(path, { method: "GET" }), path, ["disabled"]);
70
+ // Always a boolean, even from a panel that predates the field.
71
+ return result.ok ? { ...result, requirePhone: result.requirePhone === true } : result;
72
+ },
73
+ async getAvailability({ serviceId, from, to }) {
74
+ const qs = new URLSearchParams({ service: serviceId, from });
75
+ if (to)
76
+ qs.set("to", to);
77
+ const path = `/api/booking/availability?${qs.toString()}`;
78
+ return map(await call(path, { method: "GET" }), path, ["invalid", "unknown_service", "disabled"]);
79
+ },
80
+ async book(input) {
81
+ const path = "/api/booking/book";
82
+ return map(await call(path, { method: "POST", body: input }), path, ["invalid", "unknown_service", "slot_taken", "too_many_active", "rate_limited", "disabled"]);
83
+ },
84
+ async requestTime(input) {
85
+ const path = "/api/booking/request";
86
+ return map(await call(path, { method: "POST", body: input }), path, ["invalid", "unknown_service", "rate_limited", "disabled"]);
87
+ },
88
+ async getBooking(token) {
89
+ const path = `/api/booking/manage/${encodeURIComponent(token)}`;
90
+ return map(await call(path, { method: "GET" }), path, ["not_found", "disabled"]);
91
+ },
92
+ cancel: (token, o) => manage(token, { action: "cancel", ...(o?.reason ? { reason: o.reason } : {}), ...versionOf(o) }),
93
+ reschedule: (token, o) => manage(token, { action: "reschedule", start: o.start, ...versionOf(o) }),
94
+ respond: (token, o) => manage(token, { action: o.accept ? "accept" : "decline", ...versionOf(o) }),
95
+ };
96
+ }
package/dist/cli.d.ts ADDED
@@ -0,0 +1,16 @@
1
+ type SyncReport = {
2
+ dryRun: boolean;
3
+ website: {
4
+ id: string;
5
+ alias: string;
6
+ name: string;
7
+ };
8
+ created: string[];
9
+ updated: string[];
10
+ seeded: string[];
11
+ skipped: string[];
12
+ orphans: string[];
13
+ };
14
+ export declare function formatReport(report: SyncReport): string;
15
+ export declare function runSync(argv: string[]): Promise<number>;
16
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,116 @@
1
+ import { createJiti } from "jiti";
2
+ import path from "node:path";
3
+ import { siteManifestSchema } from "./manifest.js";
4
+ export function formatReport(report) {
5
+ const lines = [];
6
+ lines.push(`${report.dryRun ? "DRY RUN — no changes applied" : "Applied"} — website "${report.website.name}" (alias ${report.website.alias})`);
7
+ for (const c of report.created)
8
+ lines.push(` + ${c}`);
9
+ for (const u of report.updated)
10
+ lines.push(` * updated: ${u}`);
11
+ for (const s of report.seeded)
12
+ lines.push(` ~ seeded: ${s}`);
13
+ for (const s of report.skipped)
14
+ lines.push(` - skipped: ${s}`);
15
+ for (const o of report.orphans)
16
+ lines.push(` ! orphan: ${o}`);
17
+ if (!report.created.length && !report.updated.length &&
18
+ !report.seeded.length && !report.skipped.length && !report.orphans.length) {
19
+ lines.push(" No changes.");
20
+ }
21
+ return lines.join("\n");
22
+ }
23
+ export async function runSync(argv) {
24
+ if (argv[0] !== "sync") {
25
+ console.error("Usage: fullgrain sync [--dry-run] [--manifest <path>]");
26
+ return 2;
27
+ }
28
+ // Validate flags and FAIL CLOSED on anything unrecognised. `sync` performs an
29
+ // irreversible, content-destroying apply when --dry-run is absent, so a typo
30
+ // like `--dryrun` or `-n` must be an error, never a silent full apply.
31
+ const manifestFlag = argv.indexOf("--manifest");
32
+ const flags = argv.slice(1);
33
+ for (let i = 0; i < flags.length; i++) {
34
+ const arg = flags[i];
35
+ if (arg === "--manifest") {
36
+ i++; // its value is consumed, not a flag
37
+ continue;
38
+ }
39
+ if (arg === "--dry-run")
40
+ continue;
41
+ if (arg.startsWith("-")) {
42
+ console.error(`Unknown flag "${arg}"\nUsage: fullgrain sync [--dry-run] [--manifest <path>]`);
43
+ return 2;
44
+ }
45
+ }
46
+ const dryRun = argv.includes("--dry-run");
47
+ const manifestArg = manifestFlag !== -1 ? argv[manifestFlag + 1] : "cms.manifest.ts";
48
+ if (manifestFlag !== -1 && (!manifestArg || manifestArg.startsWith("--"))) {
49
+ console.error("--manifest requires a path");
50
+ return 2;
51
+ }
52
+ const manifestPath = path.resolve(manifestArg);
53
+ const apiKey = process.env.AB_CMS_API_KEY;
54
+ if (!apiKey) {
55
+ console.error("AB_CMS_API_KEY is not set");
56
+ return 1;
57
+ }
58
+ const baseUrl = (process.env.AB_CMS_URL ?? "https://www.abdevpanel.com").replace(/\/$/, "");
59
+ const jiti = createJiti(import.meta.url);
60
+ let loaded;
61
+ try {
62
+ loaded = await jiti.import(manifestPath, { default: true });
63
+ }
64
+ catch (err) {
65
+ const message = err instanceof Error ? err.message : String(err);
66
+ console.error(`Could not load manifest at ${manifestPath}: ${message}`);
67
+ return 1;
68
+ }
69
+ const parsed = siteManifestSchema.safeParse(loaded);
70
+ if (!parsed.success) {
71
+ console.error("Manifest is invalid:");
72
+ for (const issue of parsed.error.issues) {
73
+ console.error(` - ${issue.path.join(".")}: ${issue.message}`);
74
+ }
75
+ return 1;
76
+ }
77
+ let report;
78
+ try {
79
+ const response = await fetch(`${baseUrl}/api/admin/sync-site`, {
80
+ method: "POST",
81
+ headers: {
82
+ "Content-Type": "application/json",
83
+ Authorization: `Bearer ${apiKey}`,
84
+ },
85
+ body: JSON.stringify({ manifest: parsed.data, dryRun }),
86
+ });
87
+ if (!response.ok) {
88
+ const text = await response.text();
89
+ let parsedBody = null;
90
+ try {
91
+ parsedBody = JSON.parse(text);
92
+ }
93
+ catch {
94
+ parsedBody = null;
95
+ }
96
+ if (parsedBody && typeof parsedBody === "object" && "partialReport" in parsedBody) {
97
+ const body = parsedBody;
98
+ console.error(`Sync failed (${response.status}): ${body.message ?? ""}`);
99
+ console.error("Partial result:");
100
+ console.error(formatReport(body.partialReport));
101
+ }
102
+ else {
103
+ console.error(`Sync failed (${response.status}): ${text}`);
104
+ }
105
+ return 1;
106
+ }
107
+ report = (await response.json());
108
+ }
109
+ catch (err) {
110
+ const message = err instanceof Error ? err.message : String(err);
111
+ console.error(`Could not reach ${baseUrl}: ${message}`);
112
+ return 1;
113
+ }
114
+ console.log(formatReport(report));
115
+ return 0;
116
+ }
@@ -0,0 +1,9 @@
1
+ import type { PublicSiteData } from "./cms-types.js";
2
+ export declare function fetchPublicSiteData(websiteId: string, opts?: {
3
+ baseUrl?: string;
4
+ /** Abort the request after this many ms (default 10000). A hung panel must
5
+ * not hang the awaiting Server Component render. */
6
+ timeoutMs?: number;
7
+ /** Caller-supplied abort signal; combined with the internal timeout. */
8
+ signal?: AbortSignal;
9
+ }): Promise<PublicSiteData>;