@sandboxsonny/ace-kit 0.2.0 → 0.4.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/dist/cli.js CHANGED
@@ -1,8 +1,249 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- // src/generate/cli.ts
4
- import { mkdir, writeFile } from "fs/promises";
5
- import { dirname, resolve } from "path";
3
+ // src/cli/environment.ts
4
+ import { spawn } from "child_process";
5
+ import { hostname } from "os";
6
+ import { pathToFileURL } from "url";
7
+
8
+ // src/auth/file-store.ts
9
+ import { mkdir, readFile, rename, writeFile } from "fs/promises";
10
+ import { homedir } from "os";
11
+ import { dirname, join } from "path";
12
+
13
+ // src/auth/login-store.ts
14
+ function normaliseApiUrl(address) {
15
+ let parsed;
16
+ try {
17
+ parsed = new URL(address.trim());
18
+ } catch {
19
+ throw new Error(`"${address}" is not a web address. Write the API address like http://localhost:8080.`);
20
+ }
21
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
22
+ throw new Error(`The API address must start with http:// or https:// (it was "${address}").`);
23
+ }
24
+ if (parsed.username || parsed.password) {
25
+ throw new Error("The API address must not contain a username or password.");
26
+ }
27
+ return `${parsed.origin}${parsed.pathname.replace(/\/+$/, "")}`;
28
+ }
29
+ function hasExpired(login, now) {
30
+ const endsAt = Date.parse(login.expiresAt);
31
+ return Number.isNaN(endsAt) || endsAt <= now;
32
+ }
33
+ function parseStoredLogin(value) {
34
+ if (typeof value !== "object" || value === null) return null;
35
+ const { token, expiresAt, email } = value;
36
+ if (typeof token !== "string" || token === "" || typeof expiresAt !== "string" || typeof email !== "string")
37
+ return null;
38
+ return { token, expiresAt, email };
39
+ }
40
+
41
+ // src/auth/file-store.ts
42
+ function defaultLoginFile(variables, platform) {
43
+ if (platform === "win32" && variables.APPDATA) return join(variables.APPDATA, "ace-kit", "credentials.json");
44
+ const configFolder = variables.XDG_CONFIG_HOME || join(homedir(), ".config");
45
+ return join(configFolder, "ace-kit", "credentials.json");
46
+ }
47
+ function createFileStore(path) {
48
+ async function readAll() {
49
+ let text;
50
+ try {
51
+ text = await readFile(path, "utf8");
52
+ } catch {
53
+ return {};
54
+ }
55
+ let parsed;
56
+ try {
57
+ parsed = JSON.parse(text);
58
+ } catch {
59
+ return {};
60
+ }
61
+ const found = {};
62
+ if (typeof parsed === "object" && parsed !== null) {
63
+ for (const [address, value] of Object.entries(parsed)) {
64
+ const login = parseStoredLogin(value);
65
+ if (login) found[address] = login;
66
+ }
67
+ }
68
+ return found;
69
+ }
70
+ async function writeAll(logins) {
71
+ await mkdir(dirname(path), { recursive: true, mode: 448 });
72
+ const temporary = `${path}.${process.pid}.tmp`;
73
+ await writeFile(temporary, `${JSON.stringify(logins, null, 2)}
74
+ `, { mode: 384 });
75
+ await rename(temporary, path);
76
+ }
77
+ return {
78
+ description: path,
79
+ async read(apiUrl) {
80
+ return (await readAll())[normaliseApiUrl(apiUrl)] ?? null;
81
+ },
82
+ async write(apiUrl, login) {
83
+ const logins = await readAll();
84
+ logins[normaliseApiUrl(apiUrl)] = login;
85
+ await writeAll(logins);
86
+ },
87
+ async remove(apiUrl) {
88
+ const logins = await readAll();
89
+ delete logins[normaliseApiUrl(apiUrl)];
90
+ await writeAll(logins);
91
+ }
92
+ };
93
+ }
94
+
95
+ // src/auth/keychain-store.ts
96
+ var keychainService = "ace-kit";
97
+ function createKeychainStore(run) {
98
+ async function read(apiUrl) {
99
+ const found = await run("security", [
100
+ "find-generic-password",
101
+ "-s",
102
+ keychainService,
103
+ "-a",
104
+ normaliseApiUrl(apiUrl),
105
+ "-w"
106
+ ]);
107
+ if (found.exitCode !== 0) return null;
108
+ try {
109
+ return parseStoredLogin(JSON.parse(Buffer.from(found.stdout.trim(), "base64").toString("utf8")));
110
+ } catch {
111
+ return null;
112
+ }
113
+ }
114
+ return {
115
+ description: "the macOS Keychain",
116
+ read,
117
+ async write(apiUrl, login) {
118
+ const account = normaliseApiUrl(apiUrl);
119
+ if (/["\\\s]/.test(account)) throw new Error("That API address can't be kept in the Keychain.");
120
+ const secret = Buffer.from(JSON.stringify(login), "utf8").toString("base64");
121
+ await run(
122
+ "security",
123
+ ["-i"],
124
+ `add-generic-password -s "${keychainService}" -a "${account}" -l "Ace Kit login" -w "${secret}" -U
125
+ `
126
+ );
127
+ const kept = await read(apiUrl);
128
+ if (kept?.token !== login.token) throw new Error("The login could not be saved in the macOS Keychain.");
129
+ },
130
+ async remove(apiUrl) {
131
+ await run("security", ["delete-generic-password", "-s", keychainService, "-a", normaliseApiUrl(apiUrl)]);
132
+ }
133
+ };
134
+ }
135
+
136
+ // src/auth/choose-store.ts
137
+ async function chooseLoginStore(options) {
138
+ const file = createFileStore(defaultLoginFile(options.variables, options.platform));
139
+ if (options.platform !== "darwin" || options.variables.ACE_KIT_CREDENTIALS === "file") return file;
140
+ try {
141
+ await options.run("security", ["help"]);
142
+ return createKeychainStore(options.run);
143
+ } catch {
144
+ return file;
145
+ }
146
+ }
147
+
148
+ // src/auth/last-api-url.ts
149
+ import { mkdir as mkdir2, readFile as readFile2, rename as rename2, writeFile as writeFile2 } from "fs/promises";
150
+ import { dirname as dirname2, join as join2 } from "path";
151
+ function defaultLastApiUrlFile(variables, platform) {
152
+ return join2(dirname2(defaultLoginFile(variables, platform)), "last-api-url");
153
+ }
154
+ function createLastApiUrl(path) {
155
+ return {
156
+ async read() {
157
+ try {
158
+ return normaliseApiUrl((await readFile2(path, "utf8")).trim());
159
+ } catch {
160
+ return null;
161
+ }
162
+ },
163
+ async write(apiUrl) {
164
+ await mkdir2(dirname2(path), { recursive: true, mode: 448 });
165
+ const temporary = `${path}.${process.pid}.tmp`;
166
+ await writeFile2(temporary, `${normaliseApiUrl(apiUrl)}
167
+ `, { mode: 384 });
168
+ await rename2(temporary, path);
169
+ }
170
+ };
171
+ }
172
+
173
+ // src/cli/version.ts
174
+ var aceKitVersion = true ? "0.4.0" : "development";
175
+
176
+ // src/cli/environment.ts
177
+ var runCommand = (command, commandArguments, input) => new Promise((resolve2, reject) => {
178
+ const child = spawn(command, commandArguments, { stdio: ["pipe", "pipe", "ignore"] });
179
+ let stdout = "";
180
+ child.stdout.on("data", (chunk) => {
181
+ stdout += chunk.toString("utf8");
182
+ });
183
+ child.on("error", reject);
184
+ child.on("close", (exitCode) => resolve2({ exitCode: exitCode ?? 1, stdout }));
185
+ child.stdin.on("error", () => {
186
+ });
187
+ child.stdin.end(input ?? "");
188
+ });
189
+ function browserCommand(platform, address) {
190
+ if (platform === "darwin") return { command: "open", commandArguments: [address] };
191
+ if (platform === "win32") return { command: "rundll32", commandArguments: ["url.dll,FileProtocolHandler", address] };
192
+ return { command: "xdg-open", commandArguments: [address] };
193
+ }
194
+ async function openBrowser(platform, address) {
195
+ const { command, commandArguments } = browserCommand(platform, address);
196
+ await new Promise((resolve2) => {
197
+ const child = spawn(command, commandArguments, { stdio: "ignore", detached: true });
198
+ child.on("error", () => resolve2());
199
+ child.on("spawn", () => {
200
+ child.unref();
201
+ resolve2();
202
+ });
203
+ });
204
+ }
205
+ async function importModule(path) {
206
+ try {
207
+ return await import(pathToFileURL(path).href);
208
+ } catch (error) {
209
+ if (error.code === "ERR_UNKNOWN_FILE_EXTENSION") {
210
+ throw new Error(
211
+ "this version of Node can't run TypeScript files. Use Node 22.18 or newer, or write the schema in .js.",
212
+ { cause: error }
213
+ );
214
+ }
215
+ throw error;
216
+ }
217
+ }
218
+ async function createRealEnvironment() {
219
+ try {
220
+ process.loadEnvFile(".env");
221
+ } catch {
222
+ }
223
+ const variables = { ...process.env };
224
+ const platform = process.platform;
225
+ return {
226
+ variables,
227
+ platform,
228
+ hostname: hostname(),
229
+ version: aceKitVersion,
230
+ out: (text) => process.stdout.write(`${text}
231
+ `),
232
+ error: (text) => console.error(text),
233
+ fetchImplementation: (input, init) => fetch(input, init),
234
+ sleep: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
235
+ now: Date.now,
236
+ workingDirectory: process.cwd(),
237
+ importModule,
238
+ openBrowser: (address) => openBrowser(platform, address),
239
+ store: await chooseLoginStore({ platform, variables, run: runCommand }),
240
+ lastApiUrl: createLastApiUrl(defaultLastApiUrlFile(variables, platform))
241
+ };
242
+ }
243
+
244
+ // src/cli/generate-command.ts
245
+ import { mkdir as mkdir3, writeFile as writeFile3 } from "fs/promises";
246
+ import { dirname as dirname3, resolve } from "path";
6
247
  import { parseArgs } from "util";
7
248
 
8
249
  // src/client/errors.ts
@@ -123,6 +364,8 @@ function typeOfField(field) {
123
364
  return { type: "string" };
124
365
  case "link":
125
366
  return { type: "AceLinkData", uses: "AceLinkData" };
367
+ case "sections":
368
+ return { type: "AceSection[]", uses: "AceSection" };
126
369
  default:
127
370
  return { type: "unknown" };
128
371
  }
@@ -160,8 +403,8 @@ export {};
160
403
  // src/helpers/package-name.ts
161
404
  var packageName = "@sandboxsonny/ace-kit";
162
405
 
163
- // src/generate/cli.ts
164
- var usage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
406
+ // src/cli/generate-command.ts
407
+ var generateUsage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
165
408
 
166
409
  Usage:
167
410
  ace-kit generate [--api-url <address>] [--domain <domain>] [--out <file>]
@@ -172,13 +415,10 @@ Options:
172
415
  --out Where to write the types (default: src/ace-kit.generated.d.ts).
173
416
  --help Show this message.
174
417
  `;
175
- async function main() {
176
- try {
177
- process.loadEnvFile(".env");
178
- } catch {
179
- }
180
- const { positionals, values } = parseArgs({
181
- allowPositionals: true,
418
+ async function generateCommand(commandArguments, environment) {
419
+ const { values } = parseArgs({
420
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
421
+ args: commandArguments,
182
422
  options: {
183
423
  "api-url": { type: "string" },
184
424
  domain: { type: "string" },
@@ -186,27 +426,746 @@ async function main() {
186
426
  help: { type: "boolean" }
187
427
  }
188
428
  });
189
- if (values.help || positionals[0] !== "generate") {
190
- console.log(usage);
191
- return values.help ? 0 : 1;
429
+ if (values.help) {
430
+ environment.out(generateUsage);
431
+ return 0;
192
432
  }
193
- const apiUrl = values["api-url"] ?? process.env.ACE_API_URL;
194
- const siteDomain = values.domain ?? process.env.ACE_SITE_DOMAIN;
433
+ const apiUrl = values["api-url"] ?? environment.variables.ACE_API_URL;
434
+ const siteDomain = values.domain ?? environment.variables.ACE_SITE_DOMAIN;
195
435
  if (!apiUrl || !siteDomain) {
196
- console.error(
436
+ environment.error(
197
437
  "Give the API address and the site's domain: --api-url and --domain, or ACE_API_URL and ACE_SITE_DOMAIN."
198
438
  );
199
439
  return 1;
200
440
  }
201
- const client = createAceClient({ apiUrl, siteDomain, cacheSeconds: 0 });
441
+ const client = createAceClient({
442
+ apiUrl,
443
+ siteDomain,
444
+ cacheSeconds: 0,
445
+ fetchImplementation: environment.fetchImplementation
446
+ });
202
447
  const contentTypes = await client.getContentTypes();
203
448
  const file = resolve(values.out ?? "src/ace-kit.generated.d.ts");
204
- await mkdir(dirname(file), { recursive: true });
205
- await writeFile(file, generateTypes(contentTypes, packageName, siteDomain));
206
- console.log(`Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`);
449
+ await mkdir3(dirname3(file), { recursive: true });
450
+ await writeFile3(file, generateTypes(contentTypes, packageName, siteDomain));
451
+ environment.out(
452
+ `Wrote types for ${contentTypes.length} content type${contentTypes.length === 1 ? "" : "s"} to ${file}.`
453
+ );
207
454
  return 0;
208
455
  }
209
- main().then(
456
+
457
+ // src/cli/login-commands.ts
458
+ import { parseArgs as parseArgs2 } from "util";
459
+
460
+ // src/auth/device-login.ts
461
+ var DeviceLoginError = class extends Error {
462
+ reason;
463
+ constructor(reason, message) {
464
+ super(message);
465
+ this.name = "DeviceLoginError";
466
+ this.reason = reason;
467
+ }
468
+ };
469
+ var requestTimeoutMilliseconds2 = 15e3;
470
+ var maximumFailures = 5;
471
+ async function post(dependencies, apiUrl, path, body) {
472
+ return dependencies.fetchImplementation(`${apiUrl}${path}`, {
473
+ method: "POST",
474
+ headers: { Accept: "application/json", "Content-Type": "application/json" },
475
+ body: JSON.stringify(body),
476
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds2)
477
+ });
478
+ }
479
+ async function readMessage(response) {
480
+ try {
481
+ const body = await response.json();
482
+ if (typeof body.error === "string") return body.error;
483
+ } catch {
484
+ }
485
+ return `The CMS answered with an error (${response.status}).`;
486
+ }
487
+ async function startDeviceLogin(dependencies) {
488
+ let response;
489
+ try {
490
+ response = await post(dependencies, dependencies.apiUrl, "/auth/device", { client_name: dependencies.clientName });
491
+ } catch {
492
+ throw new DeviceLoginError("unavailable", `Could not reach the CMS at ${dependencies.apiUrl}.`);
493
+ }
494
+ if (response.status === 503) throw new DeviceLoginError("unsupported", await readMessage(response));
495
+ if (response.status === 429)
496
+ throw new DeviceLoginError("unavailable", "Too many attempts. Wait a minute and try again.");
497
+ if (response.status === 404) {
498
+ throw new DeviceLoginError(
499
+ "unsupported",
500
+ `${dependencies.apiUrl} does not offer command-line sign-in. Is it a CMS Ace API, and is it up to date?`
501
+ );
502
+ }
503
+ if (!response.ok) throw new DeviceLoginError("unavailable", await readMessage(response));
504
+ const body = await response.json().catch(() => null);
505
+ const { device_code, user_code, verification_uri, expires_in, interval } = body ?? {};
506
+ if (typeof device_code !== "string" || typeof user_code !== "string" || typeof verification_uri !== "string" || typeof expires_in !== "number" || typeof interval !== "number") {
507
+ throw new DeviceLoginError(
508
+ "unavailable",
509
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
510
+ );
511
+ }
512
+ return {
513
+ deviceCode: device_code,
514
+ userCode: user_code,
515
+ verificationUri: verification_uri,
516
+ expiresInSeconds: expires_in,
517
+ intervalSeconds: Math.max(1, interval)
518
+ };
519
+ }
520
+ async function waitForApproval(dependencies) {
521
+ const { apiUrl, request, sleep, now } = dependencies;
522
+ const deadline = now() + request.expiresInSeconds * 1e3;
523
+ let intervalSeconds = request.intervalSeconds;
524
+ let failures = 0;
525
+ for (; ; ) {
526
+ await sleep(intervalSeconds * 1e3);
527
+ if (now() > deadline)
528
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
529
+ let response;
530
+ try {
531
+ response = await post(dependencies, apiUrl, "/auth/device/token", { device_code: request.deviceCode });
532
+ } catch {
533
+ failures += 1;
534
+ if (failures >= maximumFailures)
535
+ throw new DeviceLoginError("unavailable", `Lost contact with the CMS at ${apiUrl}.`);
536
+ continue;
537
+ }
538
+ if (response.status === 404) {
539
+ throw new DeviceLoginError("expired", "The CMS no longer knows this login. Run ace-kit login again.");
540
+ }
541
+ if (response.status === 429) {
542
+ intervalSeconds += 5;
543
+ continue;
544
+ }
545
+ if (!response.ok) {
546
+ failures += 1;
547
+ if (failures >= maximumFailures) throw new DeviceLoginError("unavailable", await readMessage(response));
548
+ continue;
549
+ }
550
+ failures = 0;
551
+ const body = await response.json().catch(() => null);
552
+ switch (body?.status) {
553
+ case "pending":
554
+ continue;
555
+ case "slow_down":
556
+ intervalSeconds += 5;
557
+ continue;
558
+ case "denied":
559
+ throw new DeviceLoginError("denied", "The login was turned down in the admin app.");
560
+ case "expired":
561
+ throw new DeviceLoginError("expired", "The code ran out before it was approved. Run ace-kit login again.");
562
+ case "approved": {
563
+ const user = body.user;
564
+ if (typeof body.token !== "string" || typeof body.expires_at !== "string" || typeof user?.email !== "string") {
565
+ throw new DeviceLoginError("unavailable", "The CMS approved the login but sent a reply that can't be read.");
566
+ }
567
+ return { token: body.token, expiresAt: body.expires_at, email: user.email };
568
+ }
569
+ default:
570
+ throw new DeviceLoginError(
571
+ "unavailable",
572
+ "The CMS sent a reply this version of ace-kit can't read. Update ace-kit."
573
+ );
574
+ }
575
+ }
576
+ }
577
+
578
+ // src/auth/resolve-login.ts
579
+ async function resolveLogin(options) {
580
+ const fromEnvironment = options.variables.ACE_TOKEN?.trim();
581
+ if (fromEnvironment) return { token: fromEnvironment, source: "the ACE_TOKEN setting" };
582
+ const stored = await options.store.read(options.apiUrl);
583
+ if (!stored || hasExpired(stored, options.now())) return null;
584
+ return { token: stored.token, email: stored.email, source: options.store.description };
585
+ }
586
+
587
+ // src/cli/api-url.ts
588
+ async function resolveApiUrl(fromOption, environment) {
589
+ const address = fromOption ?? environment.variables.ACE_API_URL ?? await environment.lastApiUrl.read();
590
+ if (!address) {
591
+ environment.error(
592
+ "Give the API address: --api-url, or ACE_API_URL in the environment or a .env file. (Once you have signed in, that address is remembered.)"
593
+ );
594
+ return null;
595
+ }
596
+ try {
597
+ return normaliseApiUrl(address);
598
+ } catch (error) {
599
+ environment.error(error instanceof Error ? error.message : String(error));
600
+ return null;
601
+ }
602
+ }
603
+ function isLocalApiUrl(apiUrl) {
604
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
605
+ }
606
+
607
+ // src/cli/login-commands.ts
608
+ var loginUsage = `ace-kit login: sign in to CMS Ace from the command line.
609
+
610
+ Usage:
611
+ ace-kit login [--api-url <address>] Sign in. Opens the admin app, where you type the code shown here.
612
+ ace-kit logout [--api-url <address>] Sign out, and forget the login kept on this machine.
613
+ ace-kit whoami [--api-url <address>] Say who you are signed in as.
614
+
615
+ Options:
616
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL, in the environment or a .env file).
617
+ --help Show this message.
618
+
619
+ The login is kept in the macOS Keychain, or in a file only you can read on other systems. On a build server,
620
+ where nobody can approve a login in a browser, set ACE_TOKEN instead.
621
+ `;
622
+ var requestTimeoutMilliseconds3 = 15e3;
623
+ async function readApiUrl(commandArguments, environment) {
624
+ const { values } = parseArgs2({
625
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
626
+ args: commandArguments,
627
+ options: { "api-url": { type: "string" }, help: { type: "boolean" } }
628
+ });
629
+ if (values.help) return { help: true };
630
+ const apiUrl = await resolveApiUrl(values["api-url"], environment);
631
+ return apiUrl === null ? null : { apiUrl };
632
+ }
633
+ function webAddress(address) {
634
+ try {
635
+ const parsed = new URL(address);
636
+ return parsed.protocol === "http:" || parsed.protocol === "https:" ? parsed.href : null;
637
+ } catch {
638
+ return null;
639
+ }
640
+ }
641
+ async function loginCommand(commandArguments, environment) {
642
+ const read = await readApiUrl(commandArguments, environment);
643
+ if (!read) return 1;
644
+ if ("help" in read) {
645
+ environment.out(loginUsage);
646
+ return 0;
647
+ }
648
+ const { apiUrl } = read;
649
+ if (apiUrl.startsWith("http://") && !isLocalApiUrl(apiUrl)) {
650
+ environment.error(
651
+ `Warning: ${apiUrl} is not encrypted (http://), so the login could be read on the way. Use https:// if you can.`
652
+ );
653
+ }
654
+ try {
655
+ const request = await startDeviceLogin({
656
+ fetchImplementation: environment.fetchImplementation,
657
+ apiUrl,
658
+ clientName: `ace-kit ${environment.version} on ${environment.hostname}`
659
+ });
660
+ const page = webAddress(request.verificationUri);
661
+ if (!page)
662
+ throw new DeviceLoginError(
663
+ "unsupported",
664
+ "The CMS sent an approval address that isn't a web address, so it was not opened."
665
+ );
666
+ environment.out(`Signing in to ${apiUrl}
667
+ `);
668
+ environment.out(` 1. Go to ${page}`);
669
+ environment.out(` 2. Type ${request.userCode}
670
+ `);
671
+ environment.out("Check the code on that page matches. Waiting for you to approve it (Ctrl-C to cancel)...");
672
+ await environment.openBrowser(page).catch(() => void 0);
673
+ const login = await waitForApproval({
674
+ fetchImplementation: environment.fetchImplementation,
675
+ apiUrl,
676
+ request,
677
+ sleep: environment.sleep,
678
+ now: environment.now
679
+ });
680
+ await environment.store.write(apiUrl, login);
681
+ await environment.lastApiUrl.write(apiUrl).catch(() => void 0);
682
+ environment.out(`
683
+ Signed in as ${login.email}. The login is kept in ${environment.store.description}.`);
684
+ return 0;
685
+ } catch (error) {
686
+ environment.error(`
687
+ ${error instanceof Error ? error.message : String(error)}`);
688
+ return 1;
689
+ }
690
+ }
691
+ async function logoutCommand(commandArguments, environment) {
692
+ const read = await readApiUrl(commandArguments, environment);
693
+ if (!read) return 1;
694
+ if ("help" in read) {
695
+ environment.out(loginUsage);
696
+ return 0;
697
+ }
698
+ const { apiUrl } = read;
699
+ const stored = await environment.store.read(apiUrl);
700
+ if (!stored) {
701
+ environment.out(`Not signed in to ${apiUrl}.`);
702
+ return 0;
703
+ }
704
+ const ended = await environment.fetchImplementation(`${apiUrl}/auth/logout`, {
705
+ method: "POST",
706
+ headers: { Authorization: `Bearer ${stored.token}` },
707
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
708
+ }).then(
709
+ (response) => response.ok || response.status === 401,
710
+ () => false
711
+ );
712
+ await environment.store.remove(apiUrl);
713
+ environment.out(
714
+ 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.`
715
+ );
716
+ return 0;
717
+ }
718
+ async function whoamiCommand(commandArguments, environment) {
719
+ const read = await readApiUrl(commandArguments, environment);
720
+ if (!read) return 1;
721
+ if ("help" in read) {
722
+ environment.out(loginUsage);
723
+ return 0;
724
+ }
725
+ const { apiUrl } = read;
726
+ const login = await resolveLogin({
727
+ apiUrl,
728
+ variables: environment.variables,
729
+ store: environment.store,
730
+ now: environment.now
731
+ });
732
+ if (!login) {
733
+ environment.out(`Not signed in to ${apiUrl}. Run: ace-kit login`);
734
+ return 1;
735
+ }
736
+ try {
737
+ const response = await environment.fetchImplementation(`${apiUrl}/auth/me`, {
738
+ headers: { Accept: "application/json", Authorization: `Bearer ${login.token}` },
739
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds3)
740
+ });
741
+ if (response.status === 401) {
742
+ environment.out(`The login from ${login.source} has ended. Run: ace-kit login`);
743
+ return 1;
744
+ }
745
+ if (!response.ok) {
746
+ environment.error(`The CMS answered with an error (${response.status}).`);
747
+ return 1;
748
+ }
749
+ const me = await response.json();
750
+ environment.out(`Signed in to ${apiUrl} as ${String(me.email)}, using the login in ${login.source}.`);
751
+ return 0;
752
+ } catch {
753
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
754
+ return 1;
755
+ }
756
+ }
757
+
758
+ // src/cli/sync-command.ts
759
+ import { parseArgs as parseArgs3 } from "util";
760
+
761
+ // src/sections/find-schemas.ts
762
+ import { readdir } from "fs/promises";
763
+ import { join as join3, relative } from "path";
764
+ var schemaFilePattern = /\.schema\.(?:ts|mts|js|mjs)$/;
765
+ var skippedFolders = /* @__PURE__ */ new Set([
766
+ "node_modules",
767
+ ".git",
768
+ "dist",
769
+ "build",
770
+ ".astro",
771
+ ".react-router",
772
+ ".output",
773
+ "coverage"
774
+ ]);
775
+ async function findSchemaFiles(root, folder = "src") {
776
+ const start = join3(root, folder);
777
+ const found = [];
778
+ async function walk(directory) {
779
+ let entries;
780
+ try {
781
+ entries = await readdir(directory, { withFileTypes: true });
782
+ } catch {
783
+ return;
784
+ }
785
+ for (const entry of entries) {
786
+ const path = join3(directory, entry.name);
787
+ if (entry.isDirectory()) {
788
+ if (!skippedFolders.has(entry.name)) await walk(path);
789
+ } else if (entry.isFile() && schemaFilePattern.test(entry.name) && !entry.name.endsWith(".d.ts")) {
790
+ found.push(path);
791
+ }
792
+ }
793
+ }
794
+ await walk(start);
795
+ return found.sort((first, second) => {
796
+ const [firstName, secondName] = [relative(root, first), relative(root, second)];
797
+ return firstName < secondName ? -1 : firstName > secondName ? 1 : 0;
798
+ });
799
+ }
800
+
801
+ // src/sections/load-schemas.ts
802
+ import { relative as relative2 } from "path";
803
+
804
+ // src/sections/payload.ts
805
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
806
+ var maximumHandleLength = 64;
807
+ var maximumNameLength = 200;
808
+ var fieldTypes = [
809
+ "text",
810
+ "textarea",
811
+ "number",
812
+ "checkbox",
813
+ "select",
814
+ "image",
815
+ "richtext",
816
+ "date",
817
+ "link"
818
+ ];
819
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
820
+ var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
821
+ var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
822
+ function checkFields(fields, where, problems) {
823
+ if (fields === void 0) return;
824
+ if (!Array.isArray(fields)) {
825
+ problems.push(`${where}: fields must be a list.`);
826
+ return;
827
+ }
828
+ const seen = /* @__PURE__ */ new Set();
829
+ fields.forEach((field, index) => {
830
+ if (!isObject(field)) {
831
+ problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
832
+ return;
833
+ }
834
+ const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
835
+ if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
836
+ else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
837
+ else seen.add(field.handle);
838
+ if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
839
+ problems.push(`${where}: ${name} needs a label.`);
840
+ }
841
+ if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
842
+ const given = typeof field.type === "string" ? ` "${field.type}"` : "";
843
+ problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
844
+ } else if (field.type === "select") {
845
+ const { options } = field;
846
+ if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
847
+ problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
848
+ }
849
+ }
850
+ if (field.required !== void 0 && typeof field.required !== "boolean") {
851
+ problems.push(`${where}: ${name} has a required that isn't true or false.`);
852
+ }
853
+ });
854
+ }
855
+ function checkSectionDefinition(definition, where = "this section") {
856
+ const problems = [];
857
+ if (!isObject(definition)) {
858
+ return [`${where}: the default export must be the result of defineSection({ ... }).`];
859
+ }
860
+ if (!isHandle(definition.handle)) {
861
+ problems.push(`${where}: needs a handle of ${handleRule}.`);
862
+ }
863
+ if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
864
+ problems.push(`${where}: needs a name.`);
865
+ }
866
+ checkFields(definition.fields, where, problems);
867
+ if (definition.blocks !== void 0) {
868
+ if (!isObject(definition.blocks)) {
869
+ problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
870
+ } else {
871
+ for (const [handle, block] of Object.entries(definition.blocks)) {
872
+ const blockWhere = `${where}, block "${handle}"`;
873
+ if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
874
+ if (!isObject(block)) {
875
+ problems.push(`${blockWhere}: must be an object with a name and fields.`);
876
+ continue;
877
+ }
878
+ if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
879
+ checkFields(block.fields, blockWhere, problems);
880
+ }
881
+ }
882
+ }
883
+ return problems;
884
+ }
885
+ function apiFields(fields) {
886
+ return (fields ?? []).map((field) => ({
887
+ handle: field.handle,
888
+ label: field.label,
889
+ component_key: field.type,
890
+ config: {
891
+ ...field.required ? { required: true } : {},
892
+ ...field.type === "select" ? { options: [...field.options] } : {}
893
+ }
894
+ }));
895
+ }
896
+ function sectionTypesPayload(definitions) {
897
+ return {
898
+ sections: definitions.map((definition) => ({
899
+ handle: definition.handle,
900
+ name: definition.name,
901
+ fields: apiFields(definition.fields),
902
+ blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
903
+ handle,
904
+ name: block.name,
905
+ fields: apiFields(block.fields)
906
+ }))
907
+ }))
908
+ };
909
+ }
910
+
911
+ // src/sections/load-schemas.ts
912
+ async function loadSchemas(files, root, importModule2) {
913
+ const definitions = [];
914
+ const problems = [];
915
+ const handleToFile = /* @__PURE__ */ new Map();
916
+ for (const file of files) {
917
+ const name = relative2(root, file);
918
+ let exported;
919
+ try {
920
+ const module = await importModule2(file);
921
+ exported = module.default;
922
+ } catch (error) {
923
+ problems.push(`${name}: could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
924
+ continue;
925
+ }
926
+ const fileProblems = checkSectionDefinition(exported, name);
927
+ if (fileProblems.length > 0) {
928
+ problems.push(...fileProblems);
929
+ continue;
930
+ }
931
+ const definition = exported;
932
+ const earlier = handleToFile.get(definition.handle);
933
+ if (earlier) {
934
+ problems.push(`${name}: the handle "${definition.handle}" is already used by ${earlier}.`);
935
+ continue;
936
+ }
937
+ handleToFile.set(definition.handle, name);
938
+ definitions.push({ file, definition });
939
+ }
940
+ return { definitions, problems };
941
+ }
942
+
943
+ // src/sections/sync.ts
944
+ var requestTimeoutMilliseconds4 = 2e4;
945
+ async function readMessage2(response, fallback) {
946
+ try {
947
+ const body = await response.json();
948
+ if (typeof body.error === "string") return body.error;
949
+ } catch {
950
+ }
951
+ return fallback;
952
+ }
953
+ async function syncSectionTypes(options) {
954
+ const { apiUrl, siteDomain, token, payload, fetchImplementation } = options;
955
+ const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
956
+ let sitesResponse;
957
+ try {
958
+ sitesResponse = await fetchImplementation(`${apiUrl}/sites`, {
959
+ headers,
960
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
961
+ });
962
+ } catch {
963
+ return { kind: "unreachable" };
964
+ }
965
+ if (sitesResponse.status === 401) return { kind: "login-ended" };
966
+ if (!sitesResponse.ok)
967
+ return {
968
+ kind: "rejected",
969
+ message: await readMessage2(sitesResponse, `The CMS answered with an error (${sitesResponse.status}).`)
970
+ };
971
+ const sites = await sitesResponse.json().catch(() => []);
972
+ const wanted = siteDomain.trim().toLowerCase();
973
+ const site = sites.find((candidate) => candidate.domain.toLowerCase() === wanted);
974
+ if (!site)
975
+ return { kind: "site-not-found", domain: siteDomain, available: sites.map((candidate) => candidate.domain) };
976
+ let response;
977
+ try {
978
+ response = await fetchImplementation(`${apiUrl}/sites/${site.id}/section-types`, {
979
+ method: "PUT",
980
+ headers: { ...headers, "Content-Type": "application/json" },
981
+ body: JSON.stringify(payload),
982
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
983
+ });
984
+ } catch {
985
+ return { kind: "unreachable" };
986
+ }
987
+ if (response.status === 401) return { kind: "login-ended" };
988
+ if (response.status === 403) {
989
+ return {
990
+ kind: "forbidden",
991
+ message: "Your account can't change this site's sections: only a site's owners and super admins can."
992
+ };
993
+ }
994
+ if (!response.ok)
995
+ return {
996
+ kind: "rejected",
997
+ message: await readMessage2(response, `The CMS answered with an error (${response.status}).`)
998
+ };
999
+ const body = await response.json().catch(() => null);
1000
+ return {
1001
+ kind: "synced",
1002
+ site: { id: site.id, domain: site.domain },
1003
+ sections: (body?.sections ?? []).map((section) => ({
1004
+ handle: section.handle,
1005
+ name: section.name,
1006
+ retired: section.retired_at !== void 0
1007
+ })),
1008
+ warnings: body?.warnings ?? []
1009
+ };
1010
+ }
1011
+
1012
+ // src/cli/sync-command.ts
1013
+ var syncUsage = `ace-kit sync: send the section types your code defines to CMS Ace.
1014
+
1015
+ Usage:
1016
+ ace-kit sync [--api-url <address>] [--domain <domain>] [--dir <folder>]
1017
+
1018
+ It finds every *.schema.ts file in the folder (src by default), checks them, and makes the CMS's section types for your
1019
+ 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
1020
+ build server (with ACE_TOKEN set).
1021
+
1022
+ Nothing is deleted: a section type, block or field you take out of your code is kept in the CMS, hidden, so the content
1023
+ written in it survives. If any schema file can't be loaded, nothing is synced.
1024
+
1025
+ Options:
1026
+ --api-url Where the CMS Ace API lives (default: ACE_API_URL, else http://localhost:8080).
1027
+ --domain The domain your site has in the CMS (default: ACE_SITE_DOMAIN, else localhost).
1028
+ --dir The folder to look in (default: src).
1029
+ --help Show this message.
1030
+ `;
1031
+ var defaultApiUrl = "http://localhost:8080";
1032
+ var isSwitchedOff = (value) => ["false", "off", "no", "0"].includes((value ?? "").trim().toLowerCase());
1033
+ async function syncCommand(commandArguments, environment) {
1034
+ const { values } = parseArgs3({
1035
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
1036
+ args: commandArguments,
1037
+ options: {
1038
+ "api-url": { type: "string" },
1039
+ domain: { type: "string" },
1040
+ dir: { type: "string" },
1041
+ // Set by the dev server, which syncs on its own: it stays quiet when there is nothing to do, and keeps to a
1042
+ // local CMS unless told otherwise.
1043
+ automatic: { type: "boolean" },
1044
+ help: { type: "boolean" }
1045
+ }
1046
+ });
1047
+ if (values.help) {
1048
+ environment.out(syncUsage);
1049
+ return 0;
1050
+ }
1051
+ const automatic = values.automatic === true;
1052
+ if (automatic && isSwitchedOff(environment.variables.ACE_SYNC_SECTIONS)) return 0;
1053
+ let apiUrl;
1054
+ try {
1055
+ apiUrl = normaliseApiUrl(values["api-url"] ?? environment.variables.ACE_API_URL ?? defaultApiUrl);
1056
+ } catch (error) {
1057
+ environment.error(error instanceof Error ? error.message : String(error));
1058
+ return 1;
1059
+ }
1060
+ const siteDomain = values.domain ?? environment.variables.ACE_SITE_DOMAIN ?? "localhost";
1061
+ const aimedAtDevSite = environment.variables.ACE_DEV_SITE_ACTIVE === "1";
1062
+ if (automatic && !aimedAtDevSite && !isLocalApiUrl(apiUrl) && environment.variables.ACE_SYNC_SECTIONS?.trim().toLowerCase() !== "remote") {
1063
+ environment.out(
1064
+ `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.)`
1065
+ );
1066
+ return 0;
1067
+ }
1068
+ const files = await findSchemaFiles(environment.workingDirectory, values.dir ?? "src");
1069
+ if (files.length === 0) {
1070
+ if (!automatic) {
1071
+ environment.out(
1072
+ `No *.schema.ts files found in ${values.dir ?? "src"}, so nothing was synced (syncing none would retire every section).`
1073
+ );
1074
+ }
1075
+ return 0;
1076
+ }
1077
+ const login = await resolveLogin({
1078
+ apiUrl,
1079
+ variables: environment.variables,
1080
+ store: environment.store,
1081
+ now: environment.now
1082
+ });
1083
+ if (!login) {
1084
+ environment.out(`Section sync is waiting for you to sign in. Run: npx ace-kit login --api-url ${apiUrl}`);
1085
+ return automatic ? 0 : 1;
1086
+ }
1087
+ const loaded = await loadSchemas(files, environment.workingDirectory, environment.importModule);
1088
+ if (loaded.problems.length > 0) {
1089
+ for (const problem of loaded.problems) environment.error(problem);
1090
+ environment.error("Nothing was synced, because syncing only some of your sections would retire the rest.");
1091
+ return 1;
1092
+ }
1093
+ const outcome = await syncSectionTypes({
1094
+ apiUrl,
1095
+ siteDomain,
1096
+ token: login.token,
1097
+ payload: sectionTypesPayload(loaded.definitions.map((entry) => entry.definition)),
1098
+ fetchImplementation: environment.fetchImplementation
1099
+ });
1100
+ switch (outcome.kind) {
1101
+ case "synced": {
1102
+ const defined = outcome.sections.filter((section) => !section.retired).map((section) => section.handle);
1103
+ const retired = outcome.sections.filter((section) => section.retired).map((section) => section.handle);
1104
+ environment.out(
1105
+ `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(", ")}.` : "")
1106
+ );
1107
+ for (const warning of outcome.warnings) environment.error(`Warning: ${warning}`);
1108
+ return 0;
1109
+ }
1110
+ case "login-ended":
1111
+ environment.out(`Your login has ended. Run: npx ace-kit login --api-url ${apiUrl}`);
1112
+ return automatic ? 0 : 1;
1113
+ case "site-not-found":
1114
+ environment.error(
1115
+ `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 --domain."
1116
+ );
1117
+ return 1;
1118
+ case "forbidden":
1119
+ case "rejected":
1120
+ environment.error(outcome.message);
1121
+ return 1;
1122
+ case "unreachable":
1123
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
1124
+ return 1;
1125
+ }
1126
+ }
1127
+
1128
+ // src/cli/main.ts
1129
+ var usage = `ace-kit: tools for sites built on CMS Ace.
1130
+
1131
+ Commands:
1132
+ ace-kit generate Write TypeScript types for your content types.
1133
+ ace-kit sync Send the section types your code defines to the CMS.
1134
+ ace-kit login Sign in from the command line (opens the admin app).
1135
+ ace-kit logout Sign out.
1136
+ ace-kit whoami Say who you are signed in as.
1137
+
1138
+ Run a command with --help for more.
1139
+ `;
1140
+ async function runCli(commandArguments, environment) {
1141
+ const [command, ...rest] = commandArguments;
1142
+ switch (command) {
1143
+ case "generate":
1144
+ return generateCommand(rest, environment);
1145
+ case "sync":
1146
+ return syncCommand(rest, environment);
1147
+ case "login":
1148
+ return loginCommand(rest, environment);
1149
+ case "logout":
1150
+ return logoutCommand(rest, environment);
1151
+ case "whoami":
1152
+ return whoamiCommand(rest, environment);
1153
+ case "--help":
1154
+ case "-h":
1155
+ case "help":
1156
+ case void 0:
1157
+ environment.out(usage);
1158
+ return command === void 0 ? 1 : 0;
1159
+ default:
1160
+ environment.error(`Unknown command "${command}".
1161
+
1162
+ ${usage}`);
1163
+ return 1;
1164
+ }
1165
+ }
1166
+
1167
+ // src/cli/bin.ts
1168
+ createRealEnvironment().then((environment) => runCli(process.argv.slice(2), environment)).then(
210
1169
  (code) => process.exit(code),
211
1170
  (error) => {
212
1171
  console.error(error instanceof Error ? error.message : error);