@sandboxsonny/ace-kit 0.5.0 → 0.6.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.
Files changed (64) hide show
  1. package/CHANGELOG.md +6 -0
  2. package/dist/astro-integration.js +1 -1
  3. package/dist/chunk-7M26C36M.js +35 -0
  4. package/dist/cli.js +14 -1179
  5. package/dist/environment-T5K5ZJ2M.js +222 -0
  6. package/dist/main-YRQQWLHX.js +1454 -0
  7. package/dist/vite.js +1 -1
  8. package/docs/command-line.md +30 -1
  9. package/docs/getting-started.md +8 -0
  10. package/package.json +5 -2
  11. package/templates/astro/.dockerignore +7 -0
  12. package/templates/astro/.github/dependabot.yml +19 -0
  13. package/templates/astro/.github/workflows/ci.yml +23 -0
  14. package/templates/astro/.prettierignore +6 -0
  15. package/templates/astro/.prettierrc.json +20 -0
  16. package/templates/astro/.vscode/extensions.json +4 -0
  17. package/templates/astro/.vscode/launch.json +11 -0
  18. package/templates/astro/AGENTS.md +22 -0
  19. package/templates/astro/Dockerfile +17 -0
  20. package/templates/astro/_gitignore +26 -0
  21. package/templates/astro/astro.config.mjs +18 -0
  22. package/templates/astro/eslint.config.js +56 -0
  23. package/templates/astro/package.json +41 -0
  24. package/templates/astro/public/favicon.ico +0 -0
  25. package/templates/astro/public/favicon.svg +9 -0
  26. package/templates/astro/src/ace-kit.generated.d.ts +24 -0
  27. package/templates/astro/src/components/PageContent.astro +113 -0
  28. package/templates/astro/src/components/SiteFooter.astro +19 -0
  29. package/templates/astro/src/components/SiteHeader.astro +121 -0
  30. package/templates/astro/src/components/sections/Faq.astro +54 -0
  31. package/templates/astro/src/components/sections/Faq.schema.ts +17 -0
  32. package/templates/astro/src/components/sections/Gallery.astro +74 -0
  33. package/templates/astro/src/components/sections/Gallery.schema.ts +20 -0
  34. package/templates/astro/src/components/sections/Hero.astro +58 -0
  35. package/templates/astro/src/components/sections/Hero.schema.ts +14 -0
  36. package/templates/astro/src/layouts/BaseLayout.astro +36 -0
  37. package/templates/astro/src/pages/[...slug].astro +31 -0
  38. package/templates/astro/src/styles/global.css +56 -0
  39. package/templates/astro/src/types/page.d.ts +4 -0
  40. package/templates/astro/tsconfig.json +11 -0
  41. package/templates/astro/vitest.config.ts +4 -0
  42. package/templates/astro.section-types.json +109 -0
  43. package/templates/react-router/.prettierignore +5 -0
  44. package/templates/react-router/.prettierrc.json +11 -0
  45. package/templates/react-router/_gitignore +7 -0
  46. package/templates/react-router/app/ace-kit.generated.d.ts +18 -0
  47. package/templates/react-router/app/app.css +172 -0
  48. package/templates/react-router/app/components/page-content.tsx +32 -0
  49. package/templates/react-router/app/components/site-footer.tsx +9 -0
  50. package/templates/react-router/app/components/site-header.tsx +49 -0
  51. package/templates/react-router/app/entry.server.tsx +1 -0
  52. package/templates/react-router/app/root.tsx +75 -0
  53. package/templates/react-router/app/routes/page.test.tsx +64 -0
  54. package/templates/react-router/app/routes/page.tsx +22 -0
  55. package/templates/react-router/app/routes.ts +4 -0
  56. package/templates/react-router/app/types/page.d.ts +4 -0
  57. package/templates/react-router/eslint.config.js +76 -0
  58. package/templates/react-router/package.json +51 -0
  59. package/templates/react-router/public/favicon.svg +9 -0
  60. package/templates/react-router/react-router.config.ts +6 -0
  61. package/templates/react-router/tsconfig.json +22 -0
  62. package/templates/react-router/vite.config.ts +10 -0
  63. package/templates/react-router/vitest.config.ts +15 -0
  64. /package/dist/{chunk-WM5NOQE3.js → chunk-G5FGBOFT.js} +0 -0
@@ -0,0 +1,1454 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ hasExpired,
4
+ normaliseApiUrl
5
+ } from "./chunk-7M26C36M.js";
6
+
7
+ // src/cli/create-command.ts
8
+ import { readFile as readFile2 } from "fs/promises";
9
+ import { join as join2, relative, resolve as resolve2 } from "path";
10
+ import { parseArgs as parseArgs2 } from "util";
11
+
12
+ // src/auth/resolve-login.ts
13
+ async function resolveLogin(options) {
14
+ const fromEnvironment = options.variables.ACE_TOKEN?.trim();
15
+ if (fromEnvironment) return { token: fromEnvironment, source: "the ACE_TOKEN setting" };
16
+ const stored = await options.store.read(options.apiUrl);
17
+ if (!stored || hasExpired(stored, options.now())) return null;
18
+ return { token: stored.token, email: stored.email, source: options.store.description };
19
+ }
20
+
21
+ // src/create/create-site.ts
22
+ import { cp, mkdir, readdir, readFile, rename, writeFile } from "fs/promises";
23
+ import { basename, dirname, join, resolve } from "path";
24
+
25
+ // src/helpers/package-name.ts
26
+ var packageName = "@sandboxsonny/ace-kit";
27
+
28
+ // src/helpers/platform.ts
29
+ var hostedApiUrl = "https://api.ace-cms.co.uk";
30
+ function domainFromSite(site) {
31
+ const trimmed = site.trim();
32
+ if (trimmed.includes("://")) {
33
+ try {
34
+ return new URL(trimmed).hostname.toLowerCase();
35
+ } catch {
36
+ return trimmed.toLowerCase();
37
+ }
38
+ }
39
+ return trimmed.replace(/\/.*$/, "").replace(/:\d+$/, "").toLowerCase();
40
+ }
41
+
42
+ // src/create/create-site.ts
43
+ var frameworks = ["astro", "react-router"];
44
+ var validName = /^[\p{L}\p{N}][\p{L}\p{N} .,-]{0,59}$/u;
45
+ var validDomain = /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/;
46
+ function nameFromFolder(folder) {
47
+ return basename(resolve(folder)).split(/[-_.\s]+/).filter(Boolean).map((word) => word.charAt(0).toUpperCase() + word.slice(1)).join(" ");
48
+ }
49
+ function packageNameFromFolder(folder) {
50
+ return basename(resolve(folder)).toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^[._-]+|[-]+$/g, "");
51
+ }
52
+ function checkDomain(site) {
53
+ const domain = domainFromSite(site);
54
+ if (!validDomain.test(domain))
55
+ throw new Error(`"${site}" is not a domain: give the site's domain, for example example.com.`);
56
+ return domain;
57
+ }
58
+ async function isEmptyOrMissing(folder) {
59
+ try {
60
+ return (await readdir(folder)).length === 0;
61
+ } catch (error) {
62
+ if (error.code === "ENOENT") return true;
63
+ throw error;
64
+ }
65
+ }
66
+ async function listFiles(folder, base = folder) {
67
+ const files = [];
68
+ for (const entry of await readdir(folder, { withFileTypes: true })) {
69
+ const path = join(folder, entry.name);
70
+ if (entry.isDirectory()) files.push(...await listFiles(path, base));
71
+ else files.push(path.slice(base.length + 1));
72
+ }
73
+ return files;
74
+ }
75
+ async function writeNew(folder, file, text) {
76
+ await mkdir(dirname(join(folder, file)), { recursive: true });
77
+ await writeFile(join(folder, file), text, { flag: "wx" });
78
+ }
79
+ function replaceOnce(file, text, pattern, replacement) {
80
+ if (!pattern.test(text)) throw new Error(`the starter's ${file} has changed: could not find ${pattern}.`);
81
+ return text.replace(pattern, () => replacement);
82
+ }
83
+ async function planSite(options) {
84
+ const folder = resolve(options.folder);
85
+ const domain = checkDomain(options.site);
86
+ const name = options.name ?? nameFromFolder(folder);
87
+ const siteName = packageNameFromFolder(folder);
88
+ if (!validName.test(name))
89
+ throw new Error(
90
+ `"${name}" can't be the site's name: use letters, numbers, spaces, full stops, commas and hyphens, up to 60.`
91
+ );
92
+ if (!siteName)
93
+ throw new Error(`Can't make a package name from "${basename(folder)}": give the folder a name with letters in it.`);
94
+ if (!await isEmptyOrMissing(folder))
95
+ throw new Error(`${folder} is not empty. Choose a folder that doesn't exist yet.`);
96
+ return { folder, domain, name, packageName: siteName };
97
+ }
98
+ async function createSite(options) {
99
+ const { folder, domain, name, packageName: siteName } = await planSite(options);
100
+ const template = join(options.templatesDirectory, options.framework);
101
+ await mkdir(folder, { recursive: true });
102
+ await cp(template, folder, { recursive: true });
103
+ await rename(join(folder, "_gitignore"), join(folder, ".gitignore"));
104
+ const kitSpec = options.kitVersion === "development" ? "latest" : `^${options.kitVersion}`;
105
+ const manifestPath = join(folder, "package.json");
106
+ const manifest = JSON.parse(await readFile(manifestPath, "utf8"));
107
+ manifest.name = siteName;
108
+ if (!(packageName in manifest.dependencies))
109
+ throw new Error(`the starter's package.json does not depend on ${packageName}.`);
110
+ manifest.dependencies[packageName] = kitSpec;
111
+ await writeFile(manifestPath, `${JSON.stringify(manifest, null, 2)}
112
+ `);
113
+ const wording = [
114
+ ["Example Site. A test site for CMS Ace.", `${name}.`],
115
+ ["A simple example site for testing CMS Ace.", `${name}, built on CMS Ace.`],
116
+ ["Example Site", name]
117
+ ];
118
+ for (const file of await listFiles(folder)) {
119
+ if (file === "package.json" || file.endsWith(".ico")) continue;
120
+ const path = join(folder, file);
121
+ const original = await readFile(path, "utf8");
122
+ if (original.includes("\0")) continue;
123
+ let text = original;
124
+ for (const [from, to] of wording) text = text.replaceAll(from, () => to);
125
+ if (file === "astro.config.mjs")
126
+ text = replaceOnce(file, text, /site: "https?:\/\/[^"]+"/, `site: "https://${domain}"`);
127
+ if (text !== original) await writeFile(path, text);
128
+ }
129
+ const settings = [
130
+ "# Where CMS Ace runs. Leave these out for the hosted CMS Ace; they are for a CMS on your own machine.",
131
+ "# ACE_API_URL=http://localhost:8080",
132
+ "# ACE_PREVIEW_ADMIN_ORIGIN=http://localhost:5173",
133
+ ""
134
+ ];
135
+ const extraSettings = Object.entries(options.environmentSettings ?? {}).map(([name2, value]) => `${name2}=${value}`);
136
+ if (options.framework === "astro") {
137
+ await writeNew(
138
+ folder,
139
+ ".env.example",
140
+ [
141
+ "# The site's domain in CMS Ace is the `site` setting in astro.config.mjs. Set this only to override it.",
142
+ "# ACE_SITE_DOMAIN=" + domain,
143
+ "",
144
+ ...settings
145
+ ].join("\n")
146
+ );
147
+ if (extraSettings.length > 0) await writeNew(folder, ".env", `${extraSettings.join("\n")}
148
+ `);
149
+ } else {
150
+ const domainLine = `ACE_SITE_DOMAIN=${domain}
151
+ `;
152
+ await writeNew(folder, ".env.example", [`# The site's domain in CMS Ace.`, domainLine, ...settings].join("\n"));
153
+ await writeNew(folder, ".env", [domainLine.trim(), ...extraSettings].join("\n") + "\n");
154
+ }
155
+ await writeNew(folder, "README.md", readme({ name, domain, framework: options.framework }));
156
+ return { folder, framework: options.framework, name, packageName: siteName, domain };
157
+ }
158
+ function readme(site) {
159
+ const frameworkName = site.framework === "astro" ? "Astro" : "React Router";
160
+ return `# ${site.name}
161
+
162
+ A ${frameworkName} site on [CMS Ace](https://github.com/sandboxSonny/ace-kit#readme), made with \`npx ${packageName} create\`. Its domain in the CMS is \`${site.domain}\`.
163
+
164
+ ## Start
165
+
166
+ \`npm install\`, then \`npm run dev\`. The dev server works on your own private copy of the site in CMS Ace, so nothing you try touches the live one. (\`npx ace-kit login\` signs you in again if you need to.)
167
+
168
+ ## Commands
169
+
170
+ - \`npm run dev\` runs the site, syncing the section types in your code to the CMS.
171
+ - \`npm run generate\` writes TypeScript types for your content types (run it after changing them).
172
+ - \`npm run check\` runs Prettier, ESLint, the type check, the tests and a build.
173
+
174
+ ## Sections
175
+
176
+ Each kind of page section is a component with a \`*.schema.ts\` file beside it saying what an editor can fill in. See [Sections](https://github.com/sandboxSonny/ace-kit/blob/main/docs/sections.md) in the Ace Kit docs.
177
+ `;
178
+ }
179
+
180
+ // src/create/starter-content.ts
181
+ var richText = (text) => ({
182
+ type: "doc",
183
+ content: text.split("\n\n").map((paragraph) => ({ type: "paragraph", content: [{ type: "text", text: paragraph }] }))
184
+ });
185
+ var frameworksWithSections = ["astro"];
186
+ function pageFields(framework) {
187
+ const fields = [
188
+ { componentKey: "text", label: "Headline", handle: "headline", config: { required: true } },
189
+ { componentKey: "richtext", label: "Body", handle: "body", config: {} },
190
+ { componentKey: "image", label: "Hero image", handle: "hero", config: {} },
191
+ { componentKey: "date", label: "Published", handle: "published", config: {} },
192
+ { componentKey: "link", label: "Button", handle: "button", config: {} }
193
+ ];
194
+ if (frameworksWithSections.includes(framework))
195
+ fields.push({ componentKey: "sections", label: "Content", handle: "content", config: {} });
196
+ return fields;
197
+ }
198
+ function starterEntries(framework, siteName) {
199
+ const withSections = frameworksWithSections.includes(framework);
200
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
201
+ const entries = [
202
+ {
203
+ handle: "home",
204
+ title: "Home",
205
+ description: `Welcome to ${siteName}.`,
206
+ values: {
207
+ headline: `Welcome to ${siteName}`,
208
+ body: richText(
209
+ "Edit this text in the CMS and reload the page to see it change.\n\nPages are rendered on each request, so there is nothing to rebuild."
210
+ ),
211
+ button: { href: "https://github.com/sandboxSonny/ace-kit", label: "See Ace Kit" },
212
+ ...withSections && {
213
+ content: [
214
+ {
215
+ id: "home-hero",
216
+ type: "hero",
217
+ values: {
218
+ heading: "Build pages from sections",
219
+ subheading: "Each section below is a component in this site. Add, reorder and fill them in from the CMS.",
220
+ button: { href: "/pages/about", label: "About this site" }
221
+ },
222
+ blocks: []
223
+ },
224
+ {
225
+ id: "home-faq",
226
+ type: "faq",
227
+ values: { heading: "Questions" },
228
+ blocks: [
229
+ {
230
+ id: "home-faq-1",
231
+ type: "question",
232
+ values: {
233
+ question: "What is a section?",
234
+ answer: richText("A part of a page with its own fields, defined in this site's code.")
235
+ }
236
+ },
237
+ {
238
+ id: "home-faq-2",
239
+ type: "question",
240
+ values: {
241
+ question: "What is a block?",
242
+ answer: richText("One item of a list inside a section, like one of these questions.")
243
+ }
244
+ },
245
+ {
246
+ id: "home-faq-3",
247
+ type: "question",
248
+ values: {
249
+ question: "Can I change the order?",
250
+ answer: richText("Yes: move sections and blocks up and down in the editor.")
251
+ }
252
+ }
253
+ ]
254
+ }
255
+ ]
256
+ }
257
+ }
258
+ },
259
+ {
260
+ handle: "about",
261
+ title: "About",
262
+ description: `About ${siteName}.`,
263
+ values: {
264
+ headline: "About",
265
+ body: richText(
266
+ "Say who you are and what you do.\n\nThis text is an entry in the CMS: editing it there changes this page."
267
+ ),
268
+ published: today,
269
+ ...withSections && {
270
+ content: [
271
+ { id: "about-gallery", type: "gallery", values: { heading: "Gallery", published: today }, blocks: [] }
272
+ ]
273
+ }
274
+ }
275
+ },
276
+ {
277
+ handle: "contact",
278
+ title: "Contact",
279
+ description: `How to reach ${siteName}.`,
280
+ values: {
281
+ headline: "Contact",
282
+ body: richText("Tell people how to get in touch."),
283
+ // A link to another entry of the site: it needs About's ID, which exists by now.
284
+ button: (entryIds) => ({ entryId: entryIds.about, label: "Learn about us" })
285
+ }
286
+ }
287
+ ];
288
+ return entries;
289
+ }
290
+
291
+ // src/create/seed-site.ts
292
+ var requestTimeoutMilliseconds = 6e4;
293
+ var CmsError = class extends Error {
294
+ };
295
+ async function createAndSeedSite(options) {
296
+ const { apiUrl, token, name, domain, framework, fetchImplementation } = options;
297
+ async function request(method, path, body) {
298
+ let response;
299
+ try {
300
+ response = await fetchImplementation(`${apiUrl}${path}`, {
301
+ method,
302
+ headers: {
303
+ Accept: "application/json",
304
+ Authorization: `Bearer ${token}`,
305
+ ...body === void 0 ? {} : { "Content-Type": "application/json" }
306
+ },
307
+ body: body === void 0 ? void 0 : JSON.stringify(body),
308
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds)
309
+ });
310
+ } catch (error) {
311
+ const cause = error.cause;
312
+ const reason = cause?.code ?? cause?.message ?? (error instanceof Error ? error.message : String(error));
313
+ throw new CmsError(`Could not reach the CMS at ${apiUrl} (${reason}).`);
314
+ }
315
+ const text = await response.text();
316
+ let reply = null;
317
+ try {
318
+ reply = text ? JSON.parse(text) : null;
319
+ } catch {
320
+ }
321
+ if (response.status === 401) throw new CmsError("The login has ended. Run: npx ace-kit login");
322
+ if (!response.ok) {
323
+ const message = reply?.error;
324
+ const readable = typeof message === "string" ? message.replace(/\p{Cc}/gu, "").slice(0, 300) : "";
325
+ throw new CmsError(readable || `The CMS answered with an error (${response.status}).`);
326
+ }
327
+ return reply;
328
+ }
329
+ let siteMade = false;
330
+ try {
331
+ const sites = await request("GET", "/sites");
332
+ const existing = sites.find((site2) => site2.kind !== "dev" && site2.domain.toLowerCase() === domain);
333
+ if (existing) return { kind: "exists", name: existing.name };
334
+ const site = await request("POST", "/sites", {
335
+ name,
336
+ domain,
337
+ ...options.previewUrl && { preview_url: options.previewUrl }
338
+ });
339
+ siteMade = true;
340
+ const sitePath = `/sites/${site.id}`;
341
+ if (frameworksWithSections.includes(framework))
342
+ await request("PUT", `${sitePath}/section-types`, options.sectionTypes);
343
+ const components = await request("GET", "/components");
344
+ const fields = pageFields(framework).map(({ componentKey, label, handle, config }) => {
345
+ const component = components.find((candidate) => candidate.key === componentKey);
346
+ if (!component) throw new CmsError(`The CMS has no "${componentKey}" field type.`);
347
+ return { component_id: component.id, label, handle, config };
348
+ });
349
+ const pagesType = await request("POST", `${sitePath}/content-types`, {
350
+ name: "Pages",
351
+ handle: "pages",
352
+ fields
353
+ });
354
+ const entryIds = {};
355
+ for (const entry of starterEntries(framework, name)) {
356
+ const values = Object.fromEntries(
357
+ Object.entries(entry.values).map(([handle, value]) => [
358
+ handle,
359
+ typeof value === "function" ? value(entryIds) : value
360
+ ])
361
+ );
362
+ const created = await request("POST", `${sitePath}/entries`, {
363
+ content_type_id: pagesType.id,
364
+ handle: entry.handle,
365
+ title: entry.title,
366
+ description: entry.description,
367
+ values
368
+ });
369
+ entryIds[entry.handle] = created.id;
370
+ }
371
+ const link = (label, handle) => ({
372
+ label,
373
+ kind: "entry",
374
+ entry_id: entryIds[handle],
375
+ children: []
376
+ });
377
+ await request("POST", `${sitePath}/menus`, {
378
+ name: "Main menu",
379
+ handle: "main-menu",
380
+ items: [link("Home", "home"), link("About", "about"), link("Contact", "contact")]
381
+ });
382
+ return { kind: "seeded", siteId: site.id, entries: Object.keys(entryIds).length };
383
+ } catch (error) {
384
+ if (error instanceof CmsError) return { kind: "failed", message: error.message, siteMade };
385
+ throw error;
386
+ }
387
+ }
388
+
389
+ // src/cli/api-url.ts
390
+ async function resolveApiUrl(fromOption, environment) {
391
+ const address = fromOption ?? environment.variables.ACE_API_URL ?? await environment.lastApiUrl.read() ?? hostedApiUrl;
392
+ try {
393
+ return normaliseApiUrl(address);
394
+ } catch (error) {
395
+ environment.error(error instanceof Error ? error.message : String(error));
396
+ return null;
397
+ }
398
+ }
399
+ function isLocalApiUrl(apiUrl) {
400
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
401
+ }
402
+
403
+ // src/cli/login-commands.ts
404
+ import { parseArgs } from "util";
405
+
406
+ // src/auth/device-login.ts
407
+ var DeviceLoginError = class extends Error {
408
+ reason;
409
+ constructor(reason, message) {
410
+ super(message);
411
+ this.name = "DeviceLoginError";
412
+ this.reason = reason;
413
+ }
414
+ };
415
+ var requestTimeoutMilliseconds2 = 15e3;
416
+ var maximumFailures = 5;
417
+ async function post(dependencies, apiUrl, path, body) {
418
+ return dependencies.fetchImplementation(`${apiUrl}${path}`, {
419
+ method: "POST",
420
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
421
+ body: JSON.stringify(body),
422
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds2)
423
+ });
424
+ }
425
+ async function readMessage(response) {
426
+ try {
427
+ const body = await response.json();
428
+ if (typeof body.error === "string") return body.error;
429
+ } catch {
430
+ }
431
+ return `The CMS answered with an error (${response.status}).`;
432
+ }
433
+ async function startDeviceLogin(dependencies) {
434
+ let response;
435
+ try {
436
+ response = await post(dependencies, dependencies.apiUrl, "/auth/device", { client_name: dependencies.clientName });
437
+ } catch {
438
+ throw new DeviceLoginError("unavailable", `Could not reach the CMS at ${dependencies.apiUrl}.`);
439
+ }
440
+ if (response.status === 503) throw new DeviceLoginError("unsupported", await readMessage(response));
441
+ if (response.status === 429)
442
+ throw new DeviceLoginError("unavailable", "Too many attempts. Wait a minute and try again.");
443
+ if (response.status === 404) {
444
+ throw new DeviceLoginError(
445
+ "unsupported",
446
+ `${dependencies.apiUrl} does not offer command-line sign-in. Is it a CMS Ace API, and is it up to date?`
447
+ );
448
+ }
449
+ if (!response.ok) throw new DeviceLoginError("unavailable", await readMessage(response));
450
+ const body = await response.json().catch(() => null);
451
+ const { device_code, user_code, verification_uri, expires_in, interval } = body ?? {};
452
+ if (typeof device_code !== "string" || typeof user_code !== "string" || typeof verification_uri !== "string" || typeof expires_in !== "number" || typeof interval !== "number") {
453
+ throw new DeviceLoginError(
454
+ "unavailable",
455
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
456
+ );
457
+ }
458
+ return {
459
+ deviceCode: device_code,
460
+ userCode: user_code,
461
+ verificationUri: verification_uri,
462
+ expiresInSeconds: expires_in,
463
+ intervalSeconds: Math.max(1, interval)
464
+ };
465
+ }
466
+ async function waitForApproval(dependencies) {
467
+ const { apiUrl, request, sleep, now } = dependencies;
468
+ const deadline = now() + request.expiresInSeconds * 1e3;
469
+ let intervalSeconds = request.intervalSeconds;
470
+ let failures = 0;
471
+ for (; ; ) {
472
+ await sleep(intervalSeconds * 1e3);
473
+ if (now() > deadline)
474
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
475
+ let response;
476
+ try {
477
+ response = await post(dependencies, apiUrl, "/auth/device/token", { device_code: request.deviceCode });
478
+ } catch {
479
+ failures += 1;
480
+ if (failures >= maximumFailures)
481
+ throw new DeviceLoginError("unavailable", `Lost contact with the CMS at ${apiUrl}.`);
482
+ continue;
483
+ }
484
+ if (response.status === 404) {
485
+ throw new DeviceLoginError("expired", "The CMS no longer knows this login. Run ace-kit login again.");
486
+ }
487
+ if (response.status === 429) {
488
+ intervalSeconds += 5;
489
+ continue;
490
+ }
491
+ if (!response.ok) {
492
+ failures += 1;
493
+ if (failures >= maximumFailures) throw new DeviceLoginError("unavailable", await readMessage(response));
494
+ continue;
495
+ }
496
+ failures = 0;
497
+ const body = await response.json().catch(() => null);
498
+ switch (body?.status) {
499
+ case "pending":
500
+ continue;
501
+ case "slow_down":
502
+ intervalSeconds += 5;
503
+ continue;
504
+ case "denied":
505
+ throw new DeviceLoginError("denied", "The login was turned down in the admin app.");
506
+ case "expired":
507
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
508
+ case "approved": {
509
+ const user = body.user;
510
+ if (typeof body.token !== "string" || typeof body.expires_at !== "string" || typeof user?.email !== "string") {
511
+ throw new DeviceLoginError("unavailable", "The CMS approved the login but sent a reply that can't be read.");
512
+ }
513
+ return { token: body.token, expiresAt: body.expires_at, email: user.email };
514
+ }
515
+ default:
516
+ throw new DeviceLoginError(
517
+ "unavailable",
518
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
519
+ );
520
+ }
521
+ }
522
+ }
523
+
524
+ // src/cli/login-commands.ts
525
+ var loginUsage = `ace-kit login: sign in to CMS Ace from the command line.
526
+
527
+ Usage:
528
+ ace-kit login [--api-url <address>] Sign in. Opens the admin app, where you type the code shown here.
529
+ ace-kit logout [--api-url <address>] Sign out, and forget the login kept on this machine.
530
+ ace-kit whoami [--api-url <address>] Say who you are signed in as.
531
+
532
+ Options:
533
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL, in the environment or a .env file).
534
+ --help Show this message.
535
+
536
+ The login is kept in the macOS Keychain, or in a file only you can read on other systems. On a build server,
537
+ where nobody can approve a login in a browser, set ACE_TOKEN instead.
538
+ `;
539
+ var requestTimeoutMilliseconds3 = 15e3;
540
+ async function readApiUrl(commandArguments, environment) {
541
+ const { values } = parseArgs({
542
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
543
+ args: commandArguments,
544
+ options: { "api-url": { type: "string" }, help: { type: "boolean" } }
545
+ });
546
+ if (values.help) return { help: true };
547
+ const apiUrl = await resolveApiUrl(values["api-url"], environment);
548
+ return apiUrl === null ? null : { apiUrl };
549
+ }
550
+ function webAddress(address) {
551
+ try {
552
+ const parsed = new URL(address);
553
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
554
+ } catch {
555
+ return null;
556
+ }
557
+ }
558
+ async function loginCommand(commandArguments, environment) {
559
+ const read = await readApiUrl(commandArguments, environment);
560
+ if (!read) return 1;
561
+ if ("help" in read) {
562
+ environment.out(loginUsage);
563
+ return 0;
564
+ }
565
+ const { apiUrl } = read;
566
+ if (apiUrl.startsWith("http://") && !isLocalApiUrl(apiUrl)) {
567
+ environment.error(
568
+ `Warning: ${apiUrl} is not encrypted (http://), so the login could be read on the way. Use https:// if you can.`
569
+ );
570
+ }
571
+ try {
572
+ await signIn(apiUrl, environment);
573
+ return 0;
574
+ } catch (error) {
575
+ environment.error(`
576
+ ${error instanceof Error ? error.message : String(error)}`);
577
+ return 1;
578
+ }
579
+ }
580
+ async function signIn(apiUrl, environment) {
581
+ const request = await startDeviceLogin({
582
+ fetchImplementation: environment.fetchImplementation,
583
+ apiUrl,
584
+ clientName: `ace-kit ${environment.version} on ${environment.hostname}`
585
+ });
586
+ const page = webAddress(request.verificationUri);
587
+ if (!page)
588
+ throw new DeviceLoginError(
589
+ "unsupported",
590
+ "The CMS sent an approval address that isn't a web address, so it was not opened."
591
+ );
592
+ environment.out(`Signing in to ${apiUrl}
593
+ `);
594
+ environment.out(` 1. Go to ${page}`);
595
+ environment.out(` 2. Type ${request.userCode}
596
+ `);
597
+ environment.out("Check the code on that page matches. Waiting for you to approve it (Ctrl-C to cancel)...");
598
+ await environment.openBrowser(page).catch(() => void 0);
599
+ const login = await waitForApproval({
600
+ fetchImplementation: environment.fetchImplementation,
601
+ apiUrl,
602
+ request,
603
+ sleep: environment.sleep,
604
+ now: environment.now
605
+ });
606
+ await environment.store.write(apiUrl, login);
607
+ await environment.lastApiUrl.write(apiUrl).catch(() => void 0);
608
+ environment.out(`
609
+ Signed in as ${login.email}. The login is kept in ${environment.store.description}.`);
610
+ return login;
611
+ }
612
+ async function logoutCommand(commandArguments, environment) {
613
+ const read = await readApiUrl(commandArguments, environment);
614
+ if (!read) return 1;
615
+ if ("help" in read) {
616
+ environment.out(loginUsage);
617
+ return 0;
618
+ }
619
+ const { apiUrl } = read;
620
+ const stored = await environment.store.read(apiUrl);
621
+ if (!stored) {
622
+ environment.out(`Not signed in to ${apiUrl}.`);
623
+ return 0;
624
+ }
625
+ const ended = await environment.fetchImplementation(`${apiUrl}/auth/logout`, {
626
+ method: "POST",
627
+ headers: { Authorization: `Bearer ${stored.token}` },
628
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
629
+ }).then(
630
+ (response) => response.ok || response.status === 401,
631
+ () => false
632
+ );
633
+ await environment.store.remove(apiUrl);
634
+ environment.out(
635
+ ended ? `Signed out of ${apiUrl}.` : `Forgot the login for ${apiUrl} on this machine, but could not reach the CMS to end it there. It ends by itself after 30 days unused; a super admin can also end it from the Sessions screen.`
636
+ );
637
+ return 0;
638
+ }
639
+ async function whoamiCommand(commandArguments, environment) {
640
+ const read = await readApiUrl(commandArguments, environment);
641
+ if (!read) return 1;
642
+ if ("help" in read) {
643
+ environment.out(loginUsage);
644
+ return 0;
645
+ }
646
+ const { apiUrl } = read;
647
+ const login = await resolveLogin({
648
+ apiUrl,
649
+ variables: environment.variables,
650
+ store: environment.store,
651
+ now: environment.now
652
+ });
653
+ if (!login) {
654
+ environment.out(`Not signed in to ${apiUrl}. Run: ace-kit login`);
655
+ return 1;
656
+ }
657
+ try {
658
+ const response = await environment.fetchImplementation(`${apiUrl}/auth/me`, {
659
+ headers: { Accept: "application/json", Authorization: `Bearer ${login.token}` },
660
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
661
+ });
662
+ if (response.status === 401) {
663
+ environment.out(`The login from ${login.source} has ended. Run: ace-kit login`);
664
+ return 1;
665
+ }
666
+ if (!response.ok) {
667
+ environment.error(`The CMS answered with an error (${response.status}).`);
668
+ return 1;
669
+ }
670
+ const me = await response.json();
671
+ environment.out(`Signed in to ${apiUrl} as ${String(me.email)}, using the login in ${login.source}.`);
672
+ return 0;
673
+ } catch {
674
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
675
+ return 1;
676
+ }
677
+ }
678
+
679
+ // src/cli/create-command.ts
680
+ var createUsage = `ace-kit create: make a new site that uses CMS Ace.
681
+
682
+ Usage:
683
+ ace-kit create <folder> <domain> [--framework astro|react-router] [--name <name>] [--api-url <address>]
684
+
685
+ It signs you in if you aren't already (your browser opens to approve it), makes the site in CMS Ace with starter
686
+ content (a Pages type, Home, About and Contact, a main menu), and copies a starter site into the folder with your
687
+ site's name, domain and package name filled in. If a site with that domain already exists in CMS Ace it is left alone
688
+ and only the folder is made.
689
+
690
+ Options:
691
+ <domain> The domain the site has in CMS Ace, e.g. example.com. (--site <domain> means the same.)
692
+ --framework astro (the default) or react-router.
693
+ --name The name shown to visitors (default: made from the folder's name, so my-site is "My Site").
694
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL). Only for a CMS on your own machine.
695
+ --help Show this message.
696
+ `;
697
+ async function createCommand(commandArguments, environment) {
698
+ const { values, positionals } = parseArgs2({
699
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
700
+ args: commandArguments,
701
+ allowPositionals: true,
702
+ options: {
703
+ site: { type: "string" },
704
+ framework: { type: "string" },
705
+ name: { type: "string" },
706
+ "api-url": { type: "string" },
707
+ help: { type: "boolean" }
708
+ }
709
+ });
710
+ if (values.help) {
711
+ environment.out(createUsage);
712
+ return 0;
713
+ }
714
+ const [folder, domainArgument, ...extra] = positionals;
715
+ const site = values.site ?? domainArgument;
716
+ if (!folder || extra.length > 0 || values.site !== void 0 && domainArgument !== void 0) {
717
+ environment.error(`Give a folder and the site's domain.
718
+
719
+ ${createUsage}`);
720
+ return 1;
721
+ }
722
+ if (!site) {
723
+ environment.error(`Give the site's domain, for example: ace-kit create ${folder} example.com
724
+
725
+ ${createUsage}`);
726
+ return 1;
727
+ }
728
+ const framework = values.framework ?? "astro";
729
+ if (!frameworks.includes(framework)) {
730
+ environment.error(`--framework must be ${frameworks.join(" or ")}, not "${framework}".`);
731
+ return 1;
732
+ }
733
+ const options = { folder: resolve2(environment.workingDirectory, folder), site, name: values.name };
734
+ let plan;
735
+ let apiUrl;
736
+ try {
737
+ plan = await planSite(options);
738
+ apiUrl = normaliseApiUrl(values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl);
739
+ } catch (error) {
740
+ environment.error(error instanceof Error ? error.message : String(error));
741
+ return 1;
742
+ }
743
+ if (apiUrl.startsWith("http://") && !isLocalApiUrl(apiUrl)) {
744
+ environment.error(
745
+ `Warning: ${apiUrl} is not encrypted (http://), so your login could be read on the way. Use https:// if you can.`
746
+ );
747
+ }
748
+ let login = await resolveLogin({
749
+ apiUrl,
750
+ variables: environment.variables,
751
+ store: environment.store,
752
+ now: environment.now
753
+ });
754
+ if (!login) {
755
+ try {
756
+ login = await signIn(apiUrl, environment);
757
+ } catch (error) {
758
+ environment.error(`
759
+ ${error instanceof Error ? error.message : String(error)}`);
760
+ return 1;
761
+ }
762
+ environment.out("");
763
+ }
764
+ let sectionTypes = null;
765
+ if (frameworksWithSections.includes(framework)) {
766
+ sectionTypes = JSON.parse(
767
+ await readFile2(join2(environment.templatesDirectory, `${framework}.section-types.json`), "utf8")
768
+ );
769
+ }
770
+ const onThisMachine = isLocalApiUrl(apiUrl);
771
+ const devPort = framework === "react-router" ? 4322 : 4321;
772
+ const seeded = await createAndSeedSite({
773
+ apiUrl,
774
+ token: login.token,
775
+ name: plan.name,
776
+ domain: plan.domain,
777
+ previewUrl: onThisMachine ? `http://localhost:${devPort}` : void 0,
778
+ framework,
779
+ sectionTypes,
780
+ fetchImplementation: environment.fetchImplementation
781
+ });
782
+ if (seeded.kind === "failed" && !seeded.siteMade) {
783
+ environment.error(`Could not make the site in CMS Ace: ${seeded.message}
784
+ Nothing was made.`);
785
+ return 1;
786
+ }
787
+ const created = await createSite({
788
+ ...options,
789
+ framework,
790
+ templatesDirectory: environment.templatesDirectory,
791
+ environmentSettings: apiUrl === hostedApiUrl ? void 0 : {
792
+ ACE_API_URL: apiUrl,
793
+ ...onThisMachine && { ACE_PREVIEW_ADMIN_ORIGIN: "http://localhost:5173" }
794
+ },
795
+ kitVersion: environment.version
796
+ });
797
+ switch (seeded.kind) {
798
+ case "seeded":
799
+ environment.out(
800
+ `Made ${created.domain} in CMS Ace (${apiUrl}) with a Pages type, ${seeded.entries} entries and a main menu.`
801
+ );
802
+ break;
803
+ case "exists":
804
+ environment.out(
805
+ `Warning: ${created.domain} already exists in CMS Ace (as "${seeded.name}"), so it was left alone: nothing was added to it.`
806
+ );
807
+ break;
808
+ case "failed":
809
+ environment.error(
810
+ `The site ${created.domain} was made in CMS Ace but setting it up stopped: ${seeded.message}
811
+ It is half set up: finish it in the admin app, or delete it there and run this again.`
812
+ );
813
+ break;
814
+ }
815
+ const shown = relative(environment.workingDirectory, created.folder) || ".";
816
+ environment.out(`Made ${created.name} (${created.framework}) in ${shown}.
817
+
818
+ Next:
819
+ 1. cd ${shown}
820
+ 2. npm install
821
+ 3. npm run dev`);
822
+ return seeded.kind === "failed" ? 1 : 0;
823
+ }
824
+
825
+ // src/cli/generate-command.ts
826
+ import { mkdir as mkdir2, writeFile as writeFile2 } from "fs/promises";
827
+ import { dirname as dirname2, resolve as resolve3 } from "path";
828
+ import { parseArgs as parseArgs3 } from "util";
829
+
830
+ // src/client/errors.ts
831
+ var AcePreviewExpiredError = class extends Error {
832
+ constructor() {
833
+ super("The preview link has expired.");
834
+ this.name = "AcePreviewExpiredError";
835
+ }
836
+ };
837
+ var AceUnavailableError = class extends Error {
838
+ constructor(message) {
839
+ super(message);
840
+ this.name = "AceUnavailableError";
841
+ }
842
+ };
843
+
844
+ // src/client/create-client.ts
845
+ var requestTimeoutMilliseconds4 = 5e3;
846
+ var maximumCachedAnswers = 500;
847
+ function createAceClient({
848
+ apiUrl,
849
+ siteDomain,
850
+ cacheSeconds = 5,
851
+ fetchImplementation = (input, init) => fetch(input, init),
852
+ now = Date.now
853
+ }) {
854
+ const cache = /* @__PURE__ */ new Map();
855
+ function forgetSomeAnswers() {
856
+ for (const [path, answer] of cache) if (answer.expiresAt <= now()) cache.delete(path);
857
+ for (const path of cache.keys()) {
858
+ if (cache.size < maximumCachedAnswers) break;
859
+ cache.delete(path);
860
+ }
861
+ }
862
+ async function get(path, options = { cached: true }) {
863
+ const cached = options.cached ? cache.get(path) : void 0;
864
+ if (cached && cached.expiresAt > now()) return cached.value;
865
+ let response;
866
+ try {
867
+ response = await fetchImplementation(`${apiUrl.replace(/\/+$/, "")}${path}`, {
868
+ headers: { Accept: "application/json", "X-Ace-Site-Domain": siteDomain, "X-Forwarded-Host": siteDomain },
869
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
870
+ });
871
+ } catch {
872
+ throw new AceUnavailableError("Could not reach the CMS.");
873
+ }
874
+ let value = null;
875
+ if (response.status === 404) {
876
+ const reply = await response.json().catch(() => ({}));
877
+ if (reply.error === "no site for this domain") {
878
+ throw new AceUnavailableError(`The CMS has no site with the domain "${siteDomain}".`);
879
+ }
880
+ } else if (response.status === 403 && !options.cached) {
881
+ throw new AcePreviewExpiredError();
882
+ } else if (response.ok) {
883
+ value = await response.json().catch(() => {
884
+ throw new AceUnavailableError("The CMS sent a reply that could not be read.");
885
+ });
886
+ } else {
887
+ throw new AceUnavailableError(`The CMS answered with an error (${response.status}).`);
888
+ }
889
+ if (options.cached && cacheSeconds > 0) {
890
+ if (cache.size >= maximumCachedAnswers) forgetSomeAnswers();
891
+ cache.set(path, { expiresAt: now() + cacheSeconds * 1e3, value });
892
+ }
893
+ return value;
894
+ }
895
+ return {
896
+ /**
897
+ * One live entry of a content type, by its handle; null if the site has no such entry. With a preview token the
898
+ * entry the token was made for comes back with its draft applied (never cached); a token that has run out throws
899
+ * AcePreviewExpiredError.
900
+ */
901
+ async getEntry(contentTypeHandle, handle, options = {}) {
902
+ const path = `/public/entries/${encodeURIComponent(handle)}`;
903
+ const entry = options.previewToken ? await get(`${path}?preview=${encodeURIComponent(options.previewToken)}`, {
904
+ cached: false
905
+ }) : await get(path);
906
+ return entry && entry.content_type_handle === contentTypeHandle ? entry : null;
907
+ },
908
+ /** The live entries of a content type, newest first. */
909
+ async getEntries(contentTypeHandle, options = {}) {
910
+ const query = new URLSearchParams({ content_type: contentTypeHandle });
911
+ if (options.limit) query.set("limit", String(options.limit));
912
+ return await get(`/public/entries?${query}`) ?? [];
913
+ },
914
+ /** One of the site's navigation menus by its handle, with every link worked out; null if the site has none. */
915
+ getMenu(handle) {
916
+ return get(`/public/menus/${encodeURIComponent(handle)}`);
917
+ },
918
+ /** What the site's content types and fields are, which is what type generation reads. */
919
+ async getContentTypes() {
920
+ return await get("/public/content-types") ?? [];
921
+ }
922
+ };
923
+ }
924
+
925
+ // src/helpers/generate-types.ts
926
+ var quote = (text) => JSON.stringify(text);
927
+ function typeOfField(field) {
928
+ switch (field.component_key) {
929
+ case "text":
930
+ case "textarea":
931
+ return { type: "string" };
932
+ case "number":
933
+ return { type: "number" };
934
+ case "checkbox":
935
+ return { type: "boolean" };
936
+ case "select": {
937
+ const options = field.config?.options ?? [];
938
+ return { type: options.length > 0 ? options.map(quote).join(" | ") : "string" };
939
+ }
940
+ case "image":
941
+ return { type: "AceImageData", uses: "AceImageData" };
942
+ case "richtext":
943
+ return { type: "AceRichTextDocument", uses: "AceRichTextDocument" };
944
+ case "date":
945
+ return { type: "string" };
946
+ case "link":
947
+ return { type: "AceLinkData", uses: "AceLinkData" };
948
+ case "sections":
949
+ return { type: "AceSection[]", uses: "AceSection" };
950
+ default:
951
+ return { type: "unknown" };
952
+ }
953
+ }
954
+ function generateTypes(contentTypes, packageName2, source) {
955
+ const used = /* @__PURE__ */ new Set();
956
+ const blocks = contentTypes.map((contentType) => {
957
+ const lines = contentType.fields.map((field) => {
958
+ const { type, uses } = typeOfField(field);
959
+ if (uses) used.add(uses);
960
+ const optional = field.config?.required === true ? "" : "?";
961
+ return ` /** ${field.label.replace(/\*\//g, "* /")} */
962
+ ${quote(field.handle)}${optional}: ${type};`;
963
+ });
964
+ const body = lines.length > 0 ? `{
965
+ ${lines.join("\n")}
966
+ }` : "Record<string, never>";
967
+ return ` /** ${contentType.name.replace(/\*\//g, "* /")} */
968
+ ${quote(contentType.handle)}: ${body};`;
969
+ });
970
+ const imports = used.size > 0 ? `import type { ${[...used].sort().join(", ")} } from ${quote(packageName2)};
971
+
972
+ ` : "";
973
+ return `// Generated by ace-kit from ${source}. Do not edit: run \`ace-kit generate\` to update it.
974
+ ${imports}declare module ${quote(packageName2)} {
975
+ interface AceContentTypes {
976
+ ${blocks.join("\n")}
977
+ }
978
+ }
979
+
980
+ export {};
981
+ `;
982
+ }
983
+
984
+ // src/cli/generate-command.ts
985
+ var generateUsage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
986
+
987
+ Usage:
988
+ ace-kit generate [--site <domain>] [--api-url <address>] [--out <file>]
989
+
990
+ Options:
991
+ --site The domain your site has in the CMS, e.g. example.com (or set ACE_SITE_DOMAIN). --domain means the same.
992
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL). Only for a CMS on your own machine.
993
+ --out Where to write the types (default: src/ace-kit.generated.d.ts).
994
+ --help Show this message.
995
+ `;
996
+ async function generateCommand(commandArguments, environment) {
997
+ const { values } = parseArgs3({
998
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
999
+ args: commandArguments,
1000
+ options: {
1001
+ "api-url": { type: "string" },
1002
+ site: { type: "string" },
1003
+ domain: { type: "string" },
1004
+ out: { type: "string" },
1005
+ help: { type: "boolean" }
1006
+ }
1007
+ });
1008
+ if (values.help) {
1009
+ environment.out(generateUsage);
1010
+ return 0;
1011
+ }
1012
+ const apiUrl = values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl;
1013
+ const given = values.site ?? values.domain ?? environment.variables.ACE_SITE_DOMAIN;
1014
+ if (!given) {
1015
+ environment.error("Give the site's domain: --site, or ACE_SITE_DOMAIN.");
1016
+ return 1;
1017
+ }
1018
+ const siteDomain = domainFromSite(given);
1019
+ const client = createAceClient({
1020
+ apiUrl,
1021
+ siteDomain,
1022
+ cacheSeconds: 0,
1023
+ fetchImplementation: environment.fetchImplementation
1024
+ });
1025
+ const contentTypes = await client.getContentTypes();
1026
+ const file = resolve3(values.out ?? "src/ace-kit.generated.d.ts");
1027
+ await mkdir2(dirname2(file), { recursive: true });
1028
+ await writeFile2(file, generateTypes(contentTypes, packageName, siteDomain));
1029
+ environment.out(
1030
+ `Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`
1031
+ );
1032
+ return 0;
1033
+ }
1034
+
1035
+ // src/cli/sync-command.ts
1036
+ import { parseArgs as parseArgs4 } from "util";
1037
+
1038
+ // src/sections/find-schemas.ts
1039
+ import { readdir as readdir2 } from "fs/promises";
1040
+ import { join as join3, relative as relative2 } from "path";
1041
+ var schemaFilePattern = /\.schema\.(?:ts|mts|js|mjs)$/;
1042
+ var skippedFolders = /* @__PURE__ */ new Set([
1043
+ "node_modules",
1044
+ ".git",
1045
+ "dist",
1046
+ "build",
1047
+ ".astro",
1048
+ ".react-router",
1049
+ ".output",
1050
+ "coverage"
1051
+ ]);
1052
+ async function findSchemaFiles(root, folder = "src") {
1053
+ const start = join3(root, folder);
1054
+ const found = [];
1055
+ async function walk(directory) {
1056
+ let entries;
1057
+ try {
1058
+ entries = await readdir2(directory, { withFileTypes: true });
1059
+ } catch {
1060
+ return;
1061
+ }
1062
+ for (const entry of entries) {
1063
+ const path = join3(directory, entry.name);
1064
+ if (entry.isDirectory()) {
1065
+ if (!skippedFolders.has(entry.name)) await walk(path);
1066
+ } else if (entry.isFile() && schemaFilePattern.test(entry.name) && !entry.name.endsWith(".d.ts")) {
1067
+ found.push(path);
1068
+ }
1069
+ }
1070
+ }
1071
+ await walk(start);
1072
+ return found.sort((first, second) => {
1073
+ const [firstName, secondName] = [relative2(root, first), relative2(root, second)];
1074
+ return firstName < secondName ? -1 : firstName > secondName ? 1 : 0;
1075
+ });
1076
+ }
1077
+
1078
+ // src/sections/load-schemas.ts
1079
+ import { relative as relative3 } from "path";
1080
+
1081
+ // src/sections/payload.ts
1082
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
1083
+ var maximumHandleLength = 64;
1084
+ var maximumNameLength = 200;
1085
+ var fieldTypes = [
1086
+ "text",
1087
+ "textarea",
1088
+ "number",
1089
+ "checkbox",
1090
+ "select",
1091
+ "image",
1092
+ "richtext",
1093
+ "date",
1094
+ "link"
1095
+ ];
1096
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
1097
+ var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
1098
+ var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
1099
+ function checkFields(fields, where, problems) {
1100
+ if (fields === void 0) return;
1101
+ if (!Array.isArray(fields)) {
1102
+ problems.push(`${where}: fields must be a list.`);
1103
+ return;
1104
+ }
1105
+ const seen = /* @__PURE__ */ new Set();
1106
+ fields.forEach((field, index) => {
1107
+ if (!isObject(field)) {
1108
+ problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
1109
+ return;
1110
+ }
1111
+ const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
1112
+ if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
1113
+ else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
1114
+ else seen.add(field.handle);
1115
+ if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
1116
+ problems.push(`${where}: ${name} needs a label.`);
1117
+ }
1118
+ if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
1119
+ const given = typeof field.type === "string" ? ` "${field.type}"` : "";
1120
+ problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
1121
+ } else if (field.type === "select") {
1122
+ const { options } = field;
1123
+ if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
1124
+ problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
1125
+ }
1126
+ }
1127
+ if (field.required !== void 0 && typeof field.required !== "boolean") {
1128
+ problems.push(`${where}: ${name} has a required that isn't true or false.`);
1129
+ }
1130
+ });
1131
+ }
1132
+ function checkSectionDefinition(definition, where = "this section") {
1133
+ const problems = [];
1134
+ if (!isObject(definition)) {
1135
+ return [`${where}: the default export must be the result of defineSection({ ... }).`];
1136
+ }
1137
+ if (!isHandle(definition.handle)) {
1138
+ problems.push(`${where}: needs a handle of ${handleRule}.`);
1139
+ }
1140
+ if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
1141
+ problems.push(`${where}: needs a name.`);
1142
+ }
1143
+ checkFields(definition.fields, where, problems);
1144
+ if (definition.blocks !== void 0) {
1145
+ if (!isObject(definition.blocks)) {
1146
+ problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
1147
+ } else {
1148
+ for (const [handle, block] of Object.entries(definition.blocks)) {
1149
+ const blockWhere = `${where}, block "${handle}"`;
1150
+ if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
1151
+ if (!isObject(block)) {
1152
+ problems.push(`${blockWhere}: must be an object with a name and fields.`);
1153
+ continue;
1154
+ }
1155
+ if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
1156
+ checkFields(block.fields, blockWhere, problems);
1157
+ }
1158
+ }
1159
+ }
1160
+ return problems;
1161
+ }
1162
+ function apiFields(fields) {
1163
+ return (fields ?? []).map((field) => ({
1164
+ handle: field.handle,
1165
+ label: field.label,
1166
+ component_key: field.type,
1167
+ config: {
1168
+ ...field.required ? { required: true } : {},
1169
+ ...field.type === "select" ? { options: [...field.options] } : {}
1170
+ }
1171
+ }));
1172
+ }
1173
+ function sectionTypesPayload(definitions) {
1174
+ return {
1175
+ sections: definitions.map((definition) => ({
1176
+ handle: definition.handle,
1177
+ name: definition.name,
1178
+ fields: apiFields(definition.fields),
1179
+ blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
1180
+ handle,
1181
+ name: block.name,
1182
+ fields: apiFields(block.fields)
1183
+ }))
1184
+ }))
1185
+ };
1186
+ }
1187
+
1188
+ // src/sections/load-schemas.ts
1189
+ async function loadSchemas(files, root, importModule) {
1190
+ const definitions = [];
1191
+ const problems = [];
1192
+ const handleToFile = /* @__PURE__ */ new Map();
1193
+ for (const file of files) {
1194
+ const name = relative3(root, file);
1195
+ let exported;
1196
+ try {
1197
+ const module = await importModule(file);
1198
+ exported = module.default;
1199
+ } catch (error) {
1200
+ problems.push(`${name}: could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
1201
+ continue;
1202
+ }
1203
+ const fileProblems = checkSectionDefinition(exported, name);
1204
+ if (fileProblems.length > 0) {
1205
+ problems.push(...fileProblems);
1206
+ continue;
1207
+ }
1208
+ const definition = exported;
1209
+ const earlier = handleToFile.get(definition.handle);
1210
+ if (earlier) {
1211
+ problems.push(`${name}: the handle "${definition.handle}" is already used by ${earlier}.`);
1212
+ continue;
1213
+ }
1214
+ handleToFile.set(definition.handle, name);
1215
+ definitions.push({ file, definition });
1216
+ }
1217
+ return { definitions, problems };
1218
+ }
1219
+
1220
+ // src/sections/sync.ts
1221
+ var requestTimeoutMilliseconds5 = 2e4;
1222
+ async function readMessage2(response, fallback) {
1223
+ try {
1224
+ const body = await response.json();
1225
+ if (typeof body.error === "string") return body.error;
1226
+ } catch {
1227
+ }
1228
+ return fallback;
1229
+ }
1230
+ async function syncSectionTypes(options) {
1231
+ const { apiUrl, siteDomain, token, payload, fetchImplementation } = options;
1232
+ const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
1233
+ let sitesResponse;
1234
+ try {
1235
+ sitesResponse = await fetchImplementation(`${apiUrl}/sites`, {
1236
+ headers,
1237
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds5)
1238
+ });
1239
+ } catch {
1240
+ return { kind: "unreachable" };
1241
+ }
1242
+ if (sitesResponse.status === 401) return { kind: "login-ended" };
1243
+ if (!sitesResponse.ok)
1244
+ return {
1245
+ kind: "rejected",
1246
+ message: await readMessage2(sitesResponse, `The CMS answered with an error (${sitesResponse.status}).`)
1247
+ };
1248
+ const sites = await sitesResponse.json().catch(() => []);
1249
+ const wanted = siteDomain.trim().toLowerCase();
1250
+ const site = sites.find((candidate) => candidate.domain.toLowerCase() === wanted);
1251
+ if (!site)
1252
+ return { kind: "site-not-found", domain: siteDomain, available: sites.map((candidate) => candidate.domain) };
1253
+ let response;
1254
+ try {
1255
+ response = await fetchImplementation(`${apiUrl}/sites/${site.id}/section-types`, {
1256
+ method: "PUT",
1257
+ headers: { ...headers, "Content-Type": "application/json" },
1258
+ body: JSON.stringify(payload),
1259
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds5)
1260
+ });
1261
+ } catch {
1262
+ return { kind: "unreachable" };
1263
+ }
1264
+ if (response.status === 401) return { kind: "login-ended" };
1265
+ if (response.status === 403) {
1266
+ return {
1267
+ kind: "forbidden",
1268
+ message: "Your account can't change this site's sections: only a site's owners and super admins can."
1269
+ };
1270
+ }
1271
+ if (!response.ok)
1272
+ return {
1273
+ kind: "rejected",
1274
+ message: await readMessage2(response, `The CMS answered with an error (${response.status}).`)
1275
+ };
1276
+ const body = await response.json().catch(() => null);
1277
+ return {
1278
+ kind: "synced",
1279
+ site: { id: site.id, domain: site.domain },
1280
+ sections: (body?.sections ?? []).map((section) => ({
1281
+ handle: section.handle,
1282
+ name: section.name,
1283
+ retired: section.retired_at !== void 0
1284
+ })),
1285
+ warnings: body?.warnings ?? []
1286
+ };
1287
+ }
1288
+
1289
+ // src/cli/sync-command.ts
1290
+ var syncUsage = `ace-kit sync: send the section types your code defines to CMS Ace.
1291
+
1292
+ Usage:
1293
+ ace-kit sync [--site <domain>] [--api-url <address>] [--dir <folder>]
1294
+
1295
+ It finds every *.schema.ts file in the folder (src by default), checks them, and makes the CMS's section types for your
1296
+ site match. The dev server does this for you whenever a schema file is saved; run it yourself for a one-off sync, or on a
1297
+ build server (with ACE_TOKEN set).
1298
+
1299
+ Nothing is deleted: a section type, block or field you take out of your code is kept in the CMS, hidden, so the content
1300
+ written in it survives. If any schema file can't be loaded, nothing is synced.
1301
+
1302
+ Options:
1303
+ --site The domain your site has in the CMS, e.g. example.com (default: ACE_SITE_DOMAIN, else localhost).
1304
+ (--domain means the same.)
1305
+ --api-url Where the CMS Ace API lives (default: ACE_API_URL, else the hosted CMS Ace). Only for a CMS on your own machine.
1306
+ --dir The folder to look in (default: src).
1307
+ --help Show this message.
1308
+ `;
1309
+ var isSwitchedOff = (value) => ["false", "off", "no", "0"].includes((value ?? "").trim().toLowerCase());
1310
+ async function syncCommand(commandArguments, environment) {
1311
+ const { values } = parseArgs4({
1312
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
1313
+ args: commandArguments,
1314
+ options: {
1315
+ "api-url": { type: "string" },
1316
+ site: { type: "string" },
1317
+ domain: { type: "string" },
1318
+ dir: { type: "string" },
1319
+ // Set by the dev server, which syncs on its own: it stays quiet when there is nothing to do, and keeps to a
1320
+ // local CMS unless told otherwise.
1321
+ automatic: { type: "boolean" },
1322
+ help: { type: "boolean" }
1323
+ }
1324
+ });
1325
+ if (values.help) {
1326
+ environment.out(syncUsage);
1327
+ return 0;
1328
+ }
1329
+ const automatic = values.automatic === true;
1330
+ if (automatic && isSwitchedOff(environment.variables.ACE_SYNC_SECTIONS)) return 0;
1331
+ let apiUrl;
1332
+ try {
1333
+ apiUrl = normaliseApiUrl(values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl);
1334
+ } catch (error) {
1335
+ environment.error(error instanceof Error ? error.message : String(error));
1336
+ return 1;
1337
+ }
1338
+ const given = values.site ?? values.domain ?? environment.variables.ACE_SITE_DOMAIN;
1339
+ const siteDomain = given ? domainFromSite(given) : "localhost";
1340
+ const aimedAtDevSite = environment.variables.ACE_DEV_SITE_ACTIVE === "1";
1341
+ if (automatic && !aimedAtDevSite && !isLocalApiUrl(apiUrl) && environment.variables.ACE_SYNC_SECTIONS?.trim().toLowerCase() !== "remote") {
1342
+ environment.out(
1343
+ `Section sync is off while ${apiUrl} is a hosted CMS, so a half-written change can't reach a live site. To sync on every save to it anyway, set ACE_SYNC_SECTIONS=remote. (npx ace-kit sync always works.)`
1344
+ );
1345
+ return 0;
1346
+ }
1347
+ const files = await findSchemaFiles(environment.workingDirectory, values.dir ?? "src");
1348
+ if (files.length === 0) {
1349
+ if (!automatic) {
1350
+ environment.out(
1351
+ `No *.schema.ts files found in ${values.dir ?? "src"}, so nothing was synced (syncing none would retire every section).`
1352
+ );
1353
+ }
1354
+ return 0;
1355
+ }
1356
+ const login = await resolveLogin({
1357
+ apiUrl,
1358
+ variables: environment.variables,
1359
+ store: environment.store,
1360
+ now: environment.now
1361
+ });
1362
+ if (!login) {
1363
+ environment.out(`Section sync is waiting for you to sign in. Run: npx ace-kit login --api-url ${apiUrl}`);
1364
+ return automatic ? 0 : 1;
1365
+ }
1366
+ const loaded = await loadSchemas(files, environment.workingDirectory, environment.importModule);
1367
+ if (loaded.problems.length > 0) {
1368
+ for (const problem of loaded.problems) environment.error(problem);
1369
+ environment.error("Nothing was synced, because syncing only some of your sections would retire the rest.");
1370
+ return 1;
1371
+ }
1372
+ const outcome = await syncSectionTypes({
1373
+ apiUrl,
1374
+ siteDomain,
1375
+ token: login.token,
1376
+ payload: sectionTypesPayload(loaded.definitions.map((entry) => entry.definition)),
1377
+ fetchImplementation: environment.fetchImplementation
1378
+ });
1379
+ switch (outcome.kind) {
1380
+ case "synced": {
1381
+ const defined = outcome.sections.filter((section) => !section.retired).map((section) => section.handle);
1382
+ const retired = outcome.sections.filter((section) => section.retired).map((section) => section.handle);
1383
+ environment.out(
1384
+ `Synced ${defined.length} section type${defined.length === 1 ? "" : "s"} (${defined.join(", ")}) to ${outcome.site.domain} (${apiUrl}).` + (retired.length > 0 ? ` No longer in your code, kept but hidden: ${retired.join(", ")}.` : "")
1385
+ );
1386
+ for (const warning of outcome.warnings) environment.error(`Warning: ${warning}`);
1387
+ return 0;
1388
+ }
1389
+ case "login-ended":
1390
+ environment.out(`Your login has ended. Run: npx ace-kit login --api-url ${apiUrl}`);
1391
+ return automatic ? 0 : 1;
1392
+ case "site-not-found":
1393
+ environment.error(
1394
+ `The CMS at ${apiUrl} has no site with the domain "${outcome.domain}" that you can see` + (outcome.available.length > 0 ? ` (your sites: ${outcome.available.join(", ")})` : "") + ". Set ACE_SITE_DOMAIN or pass --site."
1395
+ );
1396
+ return 1;
1397
+ case "forbidden":
1398
+ case "rejected":
1399
+ environment.error(outcome.message);
1400
+ return 1;
1401
+ case "unreachable":
1402
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
1403
+ return 1;
1404
+ }
1405
+ }
1406
+
1407
+ // src/cli/main.ts
1408
+ var usage = `ace-kit: tools for sites built on CMS Ace.
1409
+
1410
+ Commands:
1411
+ ace-kit create Make a new site from a starter.
1412
+ ace-kit generate Write TypeScript types for your content types.
1413
+ ace-kit sync Send the section types your code defines to the CMS.
1414
+ ace-kit login Sign in from the command line (opens the admin app).
1415
+ ace-kit logout Sign out.
1416
+ ace-kit whoami Say who you are signed in as.
1417
+
1418
+ Run a command with --help for more.
1419
+ `;
1420
+ async function runCli(commandArguments, environment) {
1421
+ const [command, ...rest] = commandArguments;
1422
+ switch (command) {
1423
+ case "create":
1424
+ return createCommand(rest, environment);
1425
+ case "generate":
1426
+ return generateCommand(rest, environment);
1427
+ case "sync":
1428
+ return syncCommand(rest, environment);
1429
+ case "login":
1430
+ return loginCommand(rest, environment);
1431
+ case "logout":
1432
+ return logoutCommand(rest, environment);
1433
+ case "whoami":
1434
+ return whoamiCommand(rest, environment);
1435
+ case "--help":
1436
+ case "-h":
1437
+ case "help":
1438
+ case void 0:
1439
+ environment.out(usage);
1440
+ return command === void 0 ? 1 : 0;
1441
+ default:
1442
+ environment.error(`Unknown command "${command}".
1443
+
1444
+ ${usage}`);
1445
+ return 1;
1446
+ }
1447
+ }
1448
+ export {
1449
+ createUsage,
1450
+ generateUsage,
1451
+ loginUsage,
1452
+ runCli,
1453
+ syncUsage
1454
+ };