@sandboxsonny/ace-kit 0.3.0 → 0.5.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.
@@ -55,6 +55,20 @@ function Node({ node, Link }) {
55
55
  function AceRichText({ document, link = PlainLink2 }) {
56
56
  return /* @__PURE__ */ jsx(Fragment, { children: renderNodes(document.content, link) });
57
57
  }
58
+ var warned = /* @__PURE__ */ new Set();
59
+ function AceSections({ sections, components, fallback: Fallback }) {
60
+ if (!Array.isArray(sections)) return null;
61
+ return /* @__PURE__ */ jsx(Fragment, { children: sections.map((section) => {
62
+ const Component = Object.hasOwn(components, section.type) ? components[section.type] : void 0;
63
+ if (Component) return /* @__PURE__ */ jsx(Component, { section }, section.id);
64
+ if (Fallback) return /* @__PURE__ */ jsx(Fallback, { section }, section.id);
65
+ if (process.env.NODE_ENV !== "production" && !warned.has(section.type)) {
66
+ warned.add(section.type);
67
+ console.warn(`AceSections: no component draws the section type "${section.type}", so it was left out.`);
68
+ }
69
+ return null;
70
+ }) });
71
+ }
58
72
  function AceTextArea({ value, multiline = true }) {
59
73
  if (!multiline) return /* @__PURE__ */ jsx(Fragment, { children: textAreaSingleLine(value) });
60
74
  return /* @__PURE__ */ jsx(Fragment, { children: textAreaParagraphs(value).map((paragraph, paragraphIndex) => /* @__PURE__ */ jsx("p", { children: textAreaLines(paragraph).map((line, lineIndex) => /* @__PURE__ */ jsxs(Fragment$1, { children: [
@@ -69,4 +83,4 @@ function usePreviewRefresh(adminOrigin, refresh) {
69
83
  }, [adminOrigin, refresh]);
70
84
  }
71
85
 
72
- export { AceImage, AceLink, AceRichText, AceTextArea, usePreviewRefresh };
86
+ export { AceImage, AceLink, AceRichText, AceSections, AceTextArea, usePreviewRefresh };
package/dist/cli.js CHANGED
@@ -3,6 +3,7 @@
3
3
  // src/cli/environment.ts
4
4
  import { spawn } from "child_process";
5
5
  import { hostname } from "os";
6
+ import { pathToFileURL } from "url";
6
7
 
7
8
  // src/auth/file-store.ts
8
9
  import { mkdir, readFile, rename, writeFile } from "fs/promises";
@@ -170,7 +171,7 @@ function createLastApiUrl(path) {
170
171
  }
171
172
 
172
173
  // src/cli/version.ts
173
- var aceKitVersion = true ? "0.3.0" : "development";
174
+ var aceKitVersion = true ? "0.5.0" : "development";
174
175
 
175
176
  // src/cli/environment.ts
176
177
  var runCommand = (command, commandArguments, input) => new Promise((resolve2, reject) => {
@@ -201,6 +202,19 @@ async function openBrowser(platform, address) {
201
202
  });
202
203
  });
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
+ }
204
218
  async function createRealEnvironment() {
205
219
  try {
206
220
  process.loadEnvFile(".env");
@@ -219,6 +233,8 @@ async function createRealEnvironment() {
219
233
  fetchImplementation: (input, init) => fetch(input, init),
220
234
  sleep: (milliseconds) => new Promise((resolve2) => setTimeout(resolve2, milliseconds)),
221
235
  now: Date.now,
236
+ workingDirectory: process.cwd(),
237
+ importModule,
222
238
  openBrowser: (address) => openBrowser(platform, address),
223
239
  store: await chooseLoginStore({ platform, variables, run: runCommand }),
224
240
  lastApiUrl: createLastApiUrl(defaultLastApiUrlFile(variables, platform))
@@ -348,6 +364,8 @@ function typeOfField(field) {
348
364
  return { type: "string" };
349
365
  case "link":
350
366
  return { type: "AceLinkData", uses: "AceLinkData" };
367
+ case "sections":
368
+ return { type: "AceSection[]", uses: "AceSection" };
351
369
  default:
352
370
  return { type: "unknown" };
353
371
  }
@@ -385,15 +403,29 @@ export {};
385
403
  // src/helpers/package-name.ts
386
404
  var packageName = "@sandboxsonny/ace-kit";
387
405
 
406
+ // src/helpers/platform.ts
407
+ var hostedApiUrl = "https://api.ace-cms.co.uk";
408
+ function domainFromSite(site) {
409
+ const trimmed = site.trim();
410
+ if (trimmed.includes("://")) {
411
+ try {
412
+ return new URL(trimmed).hostname.toLowerCase();
413
+ } catch {
414
+ return trimmed.toLowerCase();
415
+ }
416
+ }
417
+ return trimmed.replace(/\/.*$/, "").replace(/:\d+$/, "").toLowerCase();
418
+ }
419
+
388
420
  // src/cli/generate-command.ts
389
421
  var generateUsage = `ace-kit generate: write TypeScript types for your CMS Ace content types.
390
422
 
391
423
  Usage:
392
- ace-kit generate [--api-url <address>] [--domain <domain>] [--out <file>]
424
+ ace-kit generate [--site <domain>] [--api-url <address>] [--out <file>]
393
425
 
394
426
  Options:
395
- --api-url Where the CMS Ace API lives (or set ACE_API_URL, in the environment or a .env file).
396
- --domain The domain your site has in the CMS (or set ACE_SITE_DOMAIN).
427
+ --site The domain your site has in the CMS, e.g. example.com (or set ACE_SITE_DOMAIN). --domain means the same.
428
+ --api-url Where the CMS Ace API lives (or set ACE_API_URL). Only for a CMS on your own machine.
397
429
  --out Where to write the types (default: src/ace-kit.generated.d.ts).
398
430
  --help Show this message.
399
431
  `;
@@ -403,6 +435,7 @@ async function generateCommand(commandArguments, environment) {
403
435
  args: commandArguments,
404
436
  options: {
405
437
  "api-url": { type: "string" },
438
+ site: { type: "string" },
406
439
  domain: { type: "string" },
407
440
  out: { type: "string" },
408
441
  help: { type: "boolean" }
@@ -412,14 +445,13 @@ async function generateCommand(commandArguments, environment) {
412
445
  environment.out(generateUsage);
413
446
  return 0;
414
447
  }
415
- const apiUrl = values["api-url"] ?? environment.variables.ACE_API_URL;
416
- const siteDomain = values.domain ?? environment.variables.ACE_SITE_DOMAIN;
417
- if (!apiUrl || !siteDomain) {
418
- environment.error(
419
- "Give the API address and the site's domain: --api-url and --domain, or ACE_API_URL and ACE_SITE_DOMAIN."
420
- );
448
+ const apiUrl = values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl;
449
+ const given = values.site ?? values.domain ?? environment.variables.ACE_SITE_DOMAIN;
450
+ if (!given) {
451
+ environment.error("Give the site's domain: --site, or ACE_SITE_DOMAIN.");
421
452
  return 1;
422
453
  }
454
+ const siteDomain = domainFromSite(given);
423
455
  const client = createAceClient({
424
456
  apiUrl,
425
457
  siteDomain,
@@ -566,6 +598,20 @@ async function resolveLogin(options) {
566
598
  return { token: stored.token, email: stored.email, source: options.store.description };
567
599
  }
568
600
 
601
+ // src/cli/api-url.ts
602
+ async function resolveApiUrl(fromOption, environment) {
603
+ const address = fromOption ?? environment.variables.ACE_API_URL ?? await environment.lastApiUrl.read() ?? hostedApiUrl;
604
+ try {
605
+ return normaliseApiUrl(address);
606
+ } catch (error) {
607
+ environment.error(error instanceof Error ? error.message : String(error));
608
+ return null;
609
+ }
610
+ }
611
+ function isLocalApiUrl(apiUrl) {
612
+ return ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
613
+ }
614
+
569
615
  // src/cli/login-commands.ts
570
616
  var loginUsage = `ace-kit login: sign in to CMS Ace from the command line.
571
617
 
@@ -589,19 +635,8 @@ async function readApiUrl(commandArguments, environment) {
589
635
  options: { "api-url": { type: "string" }, help: { type: "boolean" } }
590
636
  });
591
637
  if (values.help) return { help: true };
592
- const address = values["api-url"] ?? environment.variables.ACE_API_URL ?? await environment.lastApiUrl.read();
593
- if (!address) {
594
- environment.error(
595
- "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.)"
596
- );
597
- return null;
598
- }
599
- try {
600
- return { apiUrl: normaliseApiUrl(address) };
601
- } catch (error) {
602
- environment.error(error instanceof Error ? error.message : String(error));
603
- return null;
604
- }
638
+ const apiUrl = await resolveApiUrl(values["api-url"], environment);
639
+ return apiUrl === null ? null : { apiUrl };
605
640
  }
606
641
  function webAddress(address) {
607
642
  try {
@@ -611,7 +646,6 @@ function webAddress(address) {
611
646
  return null;
612
647
  }
613
648
  }
614
- var isLocal = (apiUrl) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
615
649
  async function loginCommand(commandArguments, environment) {
616
650
  const read = await readApiUrl(commandArguments, environment);
617
651
  if (!read) return 1;
@@ -620,7 +654,7 @@ async function loginCommand(commandArguments, environment) {
620
654
  return 0;
621
655
  }
622
656
  const { apiUrl } = read;
623
- if (apiUrl.startsWith("http://") && !isLocal(apiUrl)) {
657
+ if (apiUrl.startsWith("http://") && !isLocalApiUrl(apiUrl)) {
624
658
  environment.error(
625
659
  `Warning: ${apiUrl} is not encrypted (http://), so the login could be read on the way. Use https:// if you can.`
626
660
  );
@@ -729,11 +763,384 @@ async function whoamiCommand(commandArguments, environment) {
729
763
  }
730
764
  }
731
765
 
766
+ // src/cli/sync-command.ts
767
+ import { parseArgs as parseArgs3 } from "util";
768
+
769
+ // src/sections/find-schemas.ts
770
+ import { readdir } from "fs/promises";
771
+ import { join as join3, relative } from "path";
772
+ var schemaFilePattern = /\.schema\.(?:ts|mts|js|mjs)$/;
773
+ var skippedFolders = /* @__PURE__ */ new Set([
774
+ "node_modules",
775
+ ".git",
776
+ "dist",
777
+ "build",
778
+ ".astro",
779
+ ".react-router",
780
+ ".output",
781
+ "coverage"
782
+ ]);
783
+ async function findSchemaFiles(root, folder = "src") {
784
+ const start = join3(root, folder);
785
+ const found = [];
786
+ async function walk(directory) {
787
+ let entries;
788
+ try {
789
+ entries = await readdir(directory, { withFileTypes: true });
790
+ } catch {
791
+ return;
792
+ }
793
+ for (const entry of entries) {
794
+ const path = join3(directory, entry.name);
795
+ if (entry.isDirectory()) {
796
+ if (!skippedFolders.has(entry.name)) await walk(path);
797
+ } else if (entry.isFile() && schemaFilePattern.test(entry.name) && !entry.name.endsWith(".d.ts")) {
798
+ found.push(path);
799
+ }
800
+ }
801
+ }
802
+ await walk(start);
803
+ return found.sort((first, second) => {
804
+ const [firstName, secondName] = [relative(root, first), relative(root, second)];
805
+ return firstName < secondName ? -1 : firstName > secondName ? 1 : 0;
806
+ });
807
+ }
808
+
809
+ // src/sections/load-schemas.ts
810
+ import { relative as relative2 } from "path";
811
+
812
+ // src/sections/payload.ts
813
+ var handlePattern = /^[a-z0-9]+(-[a-z0-9]+)*$/;
814
+ var maximumHandleLength = 64;
815
+ var maximumNameLength = 200;
816
+ var fieldTypes = [
817
+ "text",
818
+ "textarea",
819
+ "number",
820
+ "checkbox",
821
+ "select",
822
+ "image",
823
+ "richtext",
824
+ "date",
825
+ "link"
826
+ ];
827
+ var isObject = (value) => typeof value === "object" && value !== null && !Array.isArray(value);
828
+ var isHandle = (value) => typeof value === "string" && value.length <= maximumHandleLength && handlePattern.test(value);
829
+ var handleRule = 'lowercase letters, numbers and hyphens (such as "call-to-action")';
830
+ function checkFields(fields, where, problems) {
831
+ if (fields === void 0) return;
832
+ if (!Array.isArray(fields)) {
833
+ problems.push(`${where}: fields must be a list.`);
834
+ return;
835
+ }
836
+ const seen = /* @__PURE__ */ new Set();
837
+ fields.forEach((field, index) => {
838
+ if (!isObject(field)) {
839
+ problems.push(`${where}: field ${index + 1} must be an object with a handle, a label and a type.`);
840
+ return;
841
+ }
842
+ const name = isHandle(field.handle) ? `field "${field.handle}"` : `field ${index + 1}`;
843
+ if (!isHandle(field.handle)) problems.push(`${where}: ${name} needs a handle of ${handleRule}.`);
844
+ else if (seen.has(field.handle)) problems.push(`${where}: ${name} is defined twice.`);
845
+ else seen.add(field.handle);
846
+ if (typeof field.label !== "string" || field.label.trim() === "" || field.label.length > maximumNameLength) {
847
+ problems.push(`${where}: ${name} needs a label.`);
848
+ }
849
+ if (typeof field.type !== "string" || !fieldTypes.includes(field.type)) {
850
+ const given = typeof field.type === "string" ? ` "${field.type}"` : "";
851
+ problems.push(`${where}: ${name} has a type${given} that isn't one of ${fieldTypes.join(", ")}.`);
852
+ } else if (field.type === "select") {
853
+ const { options } = field;
854
+ if (!Array.isArray(options) || options.length === 0 || options.some((option) => typeof option !== "string" || option === "")) {
855
+ problems.push(`${where}: ${name} is a select, so it needs options: a list of the choices, as text.`);
856
+ }
857
+ }
858
+ if (field.required !== void 0 && typeof field.required !== "boolean") {
859
+ problems.push(`${where}: ${name} has a required that isn't true or false.`);
860
+ }
861
+ });
862
+ }
863
+ function checkSectionDefinition(definition, where = "this section") {
864
+ const problems = [];
865
+ if (!isObject(definition)) {
866
+ return [`${where}: the default export must be the result of defineSection({ ... }).`];
867
+ }
868
+ if (!isHandle(definition.handle)) {
869
+ problems.push(`${where}: needs a handle of ${handleRule}.`);
870
+ }
871
+ if (typeof definition.name !== "string" || definition.name.trim() === "" || definition.name.length > maximumNameLength) {
872
+ problems.push(`${where}: needs a name.`);
873
+ }
874
+ checkFields(definition.fields, where, problems);
875
+ if (definition.blocks !== void 0) {
876
+ if (!isObject(definition.blocks)) {
877
+ problems.push(`${where}: blocks must be an object of the kinds of block, by handle.`);
878
+ } else {
879
+ for (const [handle, block] of Object.entries(definition.blocks)) {
880
+ const blockWhere = `${where}, block "${handle}"`;
881
+ if (!isHandle(handle)) problems.push(`${blockWhere}: the handle must be ${handleRule}.`);
882
+ if (!isObject(block)) {
883
+ problems.push(`${blockWhere}: must be an object with a name and fields.`);
884
+ continue;
885
+ }
886
+ if (typeof block.name !== "string" || block.name.trim() === "") problems.push(`${blockWhere}: needs a name.`);
887
+ checkFields(block.fields, blockWhere, problems);
888
+ }
889
+ }
890
+ }
891
+ return problems;
892
+ }
893
+ function apiFields(fields) {
894
+ return (fields ?? []).map((field) => ({
895
+ handle: field.handle,
896
+ label: field.label,
897
+ component_key: field.type,
898
+ config: {
899
+ ...field.required ? { required: true } : {},
900
+ ...field.type === "select" ? { options: [...field.options] } : {}
901
+ }
902
+ }));
903
+ }
904
+ function sectionTypesPayload(definitions) {
905
+ return {
906
+ sections: definitions.map((definition) => ({
907
+ handle: definition.handle,
908
+ name: definition.name,
909
+ fields: apiFields(definition.fields),
910
+ blocks: Object.entries(definition.blocks ?? {}).map(([handle, block]) => ({
911
+ handle,
912
+ name: block.name,
913
+ fields: apiFields(block.fields)
914
+ }))
915
+ }))
916
+ };
917
+ }
918
+
919
+ // src/sections/load-schemas.ts
920
+ async function loadSchemas(files, root, importModule2) {
921
+ const definitions = [];
922
+ const problems = [];
923
+ const handleToFile = /* @__PURE__ */ new Map();
924
+ for (const file of files) {
925
+ const name = relative2(root, file);
926
+ let exported;
927
+ try {
928
+ const module = await importModule2(file);
929
+ exported = module.default;
930
+ } catch (error) {
931
+ problems.push(`${name}: could not be loaded: ${error instanceof Error ? error.message : String(error)}`);
932
+ continue;
933
+ }
934
+ const fileProblems = checkSectionDefinition(exported, name);
935
+ if (fileProblems.length > 0) {
936
+ problems.push(...fileProblems);
937
+ continue;
938
+ }
939
+ const definition = exported;
940
+ const earlier = handleToFile.get(definition.handle);
941
+ if (earlier) {
942
+ problems.push(`${name}: the handle "${definition.handle}" is already used by ${earlier}.`);
943
+ continue;
944
+ }
945
+ handleToFile.set(definition.handle, name);
946
+ definitions.push({ file, definition });
947
+ }
948
+ return { definitions, problems };
949
+ }
950
+
951
+ // src/sections/sync.ts
952
+ var requestTimeoutMilliseconds4 = 2e4;
953
+ async function readMessage2(response, fallback) {
954
+ try {
955
+ const body = await response.json();
956
+ if (typeof body.error === "string") return body.error;
957
+ } catch {
958
+ }
959
+ return fallback;
960
+ }
961
+ async function syncSectionTypes(options) {
962
+ const { apiUrl, siteDomain, token, payload, fetchImplementation } = options;
963
+ const headers = { Accept: "application/json", Authorization: `Bearer ${token}` };
964
+ let sitesResponse;
965
+ try {
966
+ sitesResponse = await fetchImplementation(`${apiUrl}/sites`, {
967
+ headers,
968
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
969
+ });
970
+ } catch {
971
+ return { kind: "unreachable" };
972
+ }
973
+ if (sitesResponse.status === 401) return { kind: "login-ended" };
974
+ if (!sitesResponse.ok)
975
+ return {
976
+ kind: "rejected",
977
+ message: await readMessage2(sitesResponse, `The CMS answered with an error (${sitesResponse.status}).`)
978
+ };
979
+ const sites = await sitesResponse.json().catch(() => []);
980
+ const wanted = siteDomain.trim().toLowerCase();
981
+ const site = sites.find((candidate) => candidate.domain.toLowerCase() === wanted);
982
+ if (!site)
983
+ return { kind: "site-not-found", domain: siteDomain, available: sites.map((candidate) => candidate.domain) };
984
+ let response;
985
+ try {
986
+ response = await fetchImplementation(`${apiUrl}/sites/${site.id}/section-types`, {
987
+ method: "PUT",
988
+ headers: { ...headers, "Content-Type": "application/json" },
989
+ body: JSON.stringify(payload),
990
+ signal: AbortSignal.timeout(requestTimeoutMilliseconds4)
991
+ });
992
+ } catch {
993
+ return { kind: "unreachable" };
994
+ }
995
+ if (response.status === 401) return { kind: "login-ended" };
996
+ if (response.status === 403) {
997
+ return {
998
+ kind: "forbidden",
999
+ message: "Your account can't change this site's sections: only a site's owners and super admins can."
1000
+ };
1001
+ }
1002
+ if (!response.ok)
1003
+ return {
1004
+ kind: "rejected",
1005
+ message: await readMessage2(response, `The CMS answered with an error (${response.status}).`)
1006
+ };
1007
+ const body = await response.json().catch(() => null);
1008
+ return {
1009
+ kind: "synced",
1010
+ site: { id: site.id, domain: site.domain },
1011
+ sections: (body?.sections ?? []).map((section) => ({
1012
+ handle: section.handle,
1013
+ name: section.name,
1014
+ retired: section.retired_at !== void 0
1015
+ })),
1016
+ warnings: body?.warnings ?? []
1017
+ };
1018
+ }
1019
+
1020
+ // src/cli/sync-command.ts
1021
+ var syncUsage = `ace-kit sync: send the section types your code defines to CMS Ace.
1022
+
1023
+ Usage:
1024
+ ace-kit sync [--site <domain>] [--api-url <address>] [--dir <folder>]
1025
+
1026
+ It finds every *.schema.ts file in the folder (src by default), checks them, and makes the CMS's section types for your
1027
+ 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
1028
+ build server (with ACE_TOKEN set).
1029
+
1030
+ Nothing is deleted: a section type, block or field you take out of your code is kept in the CMS, hidden, so the content
1031
+ written in it survives. If any schema file can't be loaded, nothing is synced.
1032
+
1033
+ Options:
1034
+ --site The domain your site has in the CMS, e.g. example.com (default: ACE_SITE_DOMAIN, else localhost).
1035
+ (--domain means the same.)
1036
+ --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.
1037
+ --dir The folder to look in (default: src).
1038
+ --help Show this message.
1039
+ `;
1040
+ var isSwitchedOff = (value) => ["false", "off", "no", "0"].includes((value ?? "").trim().toLowerCase());
1041
+ async function syncCommand(commandArguments, environment) {
1042
+ const { values } = parseArgs3({
1043
+ // eslint-disable-next-line id-denylist -- "args" is the name node's parseArgs gives this option.
1044
+ args: commandArguments,
1045
+ options: {
1046
+ "api-url": { type: "string" },
1047
+ site: { type: "string" },
1048
+ domain: { type: "string" },
1049
+ dir: { type: "string" },
1050
+ // Set by the dev server, which syncs on its own: it stays quiet when there is nothing to do, and keeps to a
1051
+ // local CMS unless told otherwise.
1052
+ automatic: { type: "boolean" },
1053
+ help: { type: "boolean" }
1054
+ }
1055
+ });
1056
+ if (values.help) {
1057
+ environment.out(syncUsage);
1058
+ return 0;
1059
+ }
1060
+ const automatic = values.automatic === true;
1061
+ if (automatic && isSwitchedOff(environment.variables.ACE_SYNC_SECTIONS)) return 0;
1062
+ let apiUrl;
1063
+ try {
1064
+ apiUrl = normaliseApiUrl(values["api-url"] ?? environment.variables.ACE_API_URL ?? hostedApiUrl);
1065
+ } catch (error) {
1066
+ environment.error(error instanceof Error ? error.message : String(error));
1067
+ return 1;
1068
+ }
1069
+ const given = values.site ?? values.domain ?? environment.variables.ACE_SITE_DOMAIN;
1070
+ const siteDomain = given ? domainFromSite(given) : "localhost";
1071
+ const aimedAtDevSite = environment.variables.ACE_DEV_SITE_ACTIVE === "1";
1072
+ if (automatic && !aimedAtDevSite && !isLocalApiUrl(apiUrl) && environment.variables.ACE_SYNC_SECTIONS?.trim().toLowerCase() !== "remote") {
1073
+ environment.out(
1074
+ `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.)`
1075
+ );
1076
+ return 0;
1077
+ }
1078
+ const files = await findSchemaFiles(environment.workingDirectory, values.dir ?? "src");
1079
+ if (files.length === 0) {
1080
+ if (!automatic) {
1081
+ environment.out(
1082
+ `No *.schema.ts files found in ${values.dir ?? "src"}, so nothing was synced (syncing none would retire every section).`
1083
+ );
1084
+ }
1085
+ return 0;
1086
+ }
1087
+ const login = await resolveLogin({
1088
+ apiUrl,
1089
+ variables: environment.variables,
1090
+ store: environment.store,
1091
+ now: environment.now
1092
+ });
1093
+ if (!login) {
1094
+ environment.out(`Section sync is waiting for you to sign in. Run: npx ace-kit login --api-url ${apiUrl}`);
1095
+ return automatic ? 0 : 1;
1096
+ }
1097
+ const loaded = await loadSchemas(files, environment.workingDirectory, environment.importModule);
1098
+ if (loaded.problems.length > 0) {
1099
+ for (const problem of loaded.problems) environment.error(problem);
1100
+ environment.error("Nothing was synced, because syncing only some of your sections would retire the rest.");
1101
+ return 1;
1102
+ }
1103
+ const outcome = await syncSectionTypes({
1104
+ apiUrl,
1105
+ siteDomain,
1106
+ token: login.token,
1107
+ payload: sectionTypesPayload(loaded.definitions.map((entry) => entry.definition)),
1108
+ fetchImplementation: environment.fetchImplementation
1109
+ });
1110
+ switch (outcome.kind) {
1111
+ case "synced": {
1112
+ const defined = outcome.sections.filter((section) => !section.retired).map((section) => section.handle);
1113
+ const retired = outcome.sections.filter((section) => section.retired).map((section) => section.handle);
1114
+ environment.out(
1115
+ `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(", ")}.` : "")
1116
+ );
1117
+ for (const warning of outcome.warnings) environment.error(`Warning: ${warning}`);
1118
+ return 0;
1119
+ }
1120
+ case "login-ended":
1121
+ environment.out(`Your login has ended. Run: npx ace-kit login --api-url ${apiUrl}`);
1122
+ return automatic ? 0 : 1;
1123
+ case "site-not-found":
1124
+ environment.error(
1125
+ `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."
1126
+ );
1127
+ return 1;
1128
+ case "forbidden":
1129
+ case "rejected":
1130
+ environment.error(outcome.message);
1131
+ return 1;
1132
+ case "unreachable":
1133
+ environment.error(`Could not reach the CMS at ${apiUrl}.`);
1134
+ return 1;
1135
+ }
1136
+ }
1137
+
732
1138
  // src/cli/main.ts
733
1139
  var usage = `ace-kit: tools for sites built on CMS Ace.
734
1140
 
735
1141
  Commands:
736
1142
  ace-kit generate Write TypeScript types for your content types.
1143
+ ace-kit sync Send the section types your code defines to the CMS.
737
1144
  ace-kit login Sign in from the command line (opens the admin app).
738
1145
  ace-kit logout Sign out.
739
1146
  ace-kit whoami Say who you are signed in as.
@@ -745,6 +1152,8 @@ async function runCli(commandArguments, environment) {
745
1152
  switch (command) {
746
1153
  case "generate":
747
1154
  return generateCommand(rest, environment);
1155
+ case "sync":
1156
+ return syncCommand(rest, environment);
748
1157
  case "login":
749
1158
  return loginCommand(rest, environment);
750
1159
  case "logout":