@sandboxsonny/ace-kit 0.3.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
@@ -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.4.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
  }
@@ -566,6 +584,26 @@ async function resolveLogin(options) {
566
584
  return { token: stored.token, email: stored.email, source: options.store.description };
567
585
  }
568
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
+
569
607
  // src/cli/login-commands.ts
570
608
  var loginUsage = `ace-kit login: sign in to CMS Ace from the command line.
571
609
 
@@ -589,19 +627,8 @@ async function readApiUrl(commandArguments, environment) {
589
627
  options: { "api-url": { type: "string" }, help: { type: "boolean" } }
590
628
  });
591
629
  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
- }
630
+ const apiUrl = await resolveApiUrl(values["api-url"], environment);
631
+ return apiUrl === null ? null : { apiUrl };
605
632
  }
606
633
  function webAddress(address) {
607
634
  try {
@@ -611,7 +638,6 @@ function webAddress(address) {
611
638
  return null;
612
639
  }
613
640
  }
614
- var isLocal = (apiUrl) => ["localhost", "127.0.0.1", "[::1]"].includes(new URL(apiUrl).hostname);
615
641
  async function loginCommand(commandArguments, environment) {
616
642
  const read = await readApiUrl(commandArguments, environment);
617
643
  if (!read) return 1;
@@ -620,7 +646,7 @@ async function loginCommand(commandArguments, environment) {
620
646
  return 0;
621
647
  }
622
648
  const { apiUrl } = read;
623
- if (apiUrl.startsWith("http://") && !isLocal(apiUrl)) {
649
+ if (apiUrl.startsWith("http://") && !isLocalApiUrl(apiUrl)) {
624
650
  environment.error(
625
651
  `Warning: ${apiUrl} is not encrypted (http://), so the login could be read on the way. Use https:// if you can.`
626
652
  );
@@ -729,11 +755,382 @@ async function whoamiCommand(commandArguments, environment) {
729
755
  }
730
756
  }
731
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
+
732
1128
  // src/cli/main.ts
733
1129
  var usage = `ace-kit: tools for sites built on CMS Ace.
734
1130
 
735
1131
  Commands:
736
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.
737
1134
  ace-kit login Sign in from the command line (opens the admin app).
738
1135
  ace-kit logout Sign out.
739
1136
  ace-kit whoami Say who you are signed in as.
@@ -745,6 +1142,8 @@ async function runCli(commandArguments, environment) {
745
1142
  switch (command) {
746
1143
  case "generate":
747
1144
  return generateCommand(rest, environment);
1145
+ case "sync":
1146
+ return syncCommand(rest, environment);
748
1147
  case "login":
749
1148
  return loginCommand(rest, environment);
750
1149
  case "logout":