@anyday/sanity-workspace-manager 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.
package/dist/cli.mjs CHANGED
@@ -1,8 +1,8 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/cli.ts
4
- import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync } from "node:fs";
5
- import { resolve } from "node:path";
4
+ import { existsSync as existsSync4, readFileSync as readFileSync5, writeFileSync } from "node:fs";
5
+ import { join as join4, resolve } from "node:path";
6
6
  import { createInterface } from "node:readline/promises";
7
7
  import { parseArgs } from "node:util";
8
8
 
@@ -174,6 +174,10 @@ var workspace_manager_schema_default = {
174
174
  $schema: {
175
175
  type: "string"
176
176
  },
177
+ $generated: {
178
+ description: "Set by the CLI's generate: a note and a hash that check uses to spot hand edits.",
179
+ type: "string"
180
+ },
177
181
  id: {
178
182
  description: "Stable id stored on jobs and used as the Vercel project prefix.",
179
183
  type: "string",
@@ -452,7 +456,7 @@ function humanize(name) {
452
456
  return words.charAt(0).toUpperCase() + words.slice(1);
453
457
  }
454
458
 
455
- // src/init/extract.ts
459
+ // src/generate/extract.ts
456
460
  var PLUGIN_TYPES = /^(sanity\.|system\.|assist\.|media\.tag$|translation\.metadata$|fieldTranslation\.metadata$)/;
457
461
  var Extract = class {
458
462
  byName;
@@ -519,7 +523,8 @@ var Extract = class {
519
523
  }
520
524
  };
521
525
 
522
- // src/init/draft.ts
526
+ // src/generate/draft.ts
527
+ var SETTINGS_ROLES = ["siteName", "domain", "logo", "favicon"];
523
528
  var SYSTEM_FIELDS = /* @__PURE__ */ new Set(["_rev", "_createdAt", "_updatedAt", "_system"]);
524
529
  var ENV_DEFAULTS = {
525
530
  projectId: "NEXT_PUBLIC_SANITY_PROJECT_ID",
@@ -540,21 +545,33 @@ function suggestNames(repo) {
540
545
  const id = name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 32);
541
546
  return { id: id || "site", title: humanize(name.replace(/[-_]+/g, " ").trim()) || "Site" };
542
547
  }
543
- function draftManifest(repo, answers, live = {}) {
548
+ var guessSingletons = (schema) => schema.contentDocuments.map((type) => type.name).filter((name) => /settings$/i.test(name));
549
+ function sectionCandidates(schema, singletons) {
550
+ const setupTypes = draftLanguages(schema, () => {
551
+ })?.setupTypes ?? [];
552
+ return schema.contentDocuments.map((type) => type.name).filter((name) => !singletons.includes(name) && !setupTypes.includes(name));
553
+ }
554
+ function draftManifest(repo, answers, live = {}, choices) {
544
555
  const notes = [];
545
556
  const note = (message) => void notes.push(message);
546
557
  const schema = new Extract(repo.types);
547
- const singletons = schema.contentDocuments.map((type) => type.name).filter((name) => /settings$/i.test(name));
548
- if (singletons.length)
549
- note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
550
- else note("No singletons found: list your settings documents in content.singletons");
551
- const homePage = findHomePage(schema, singletons);
552
- if (homePage) note(`Home page: ${homePage.documentId}.${homePage.field}`);
553
- else note("No home page reference found: set content.homePage by hand");
558
+ const singletons = choices?.singletons ?? guessSingletons(schema);
559
+ const homePage = choices?.homePage ?? findHomePage(schema, singletons);
560
+ if (!choices) {
561
+ if (singletons.length)
562
+ note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
563
+ else note("No singletons found: list your settings documents in content.singletons");
564
+ if (homePage) note(`Home page: ${homePage.documentId}.${homePage.field}`);
565
+ else note("No home page reference found: set content.homePage by hand");
566
+ }
554
567
  const home = homePage ?? { documentId: singletons[0] ?? "siteSettings", field: "homePage" };
555
568
  const languages = draftLanguages(schema, note);
556
- const sections = schema.contentDocuments.map((type) => type.name).filter((name) => !singletons.includes(name) && !languages?.setupTypes.includes(name)).map((name) => [name, humanize(name)]);
557
- note(`Copy sections list every content type (${sections.length}): remove the ones not to offer`);
569
+ const sections = sectionCandidates(schema, singletons).map((name) => [
570
+ name,
571
+ humanize(name)
572
+ ]);
573
+ if (!choices)
574
+ note(`Copy sections list every content type (${sections.length}): remove the ones not to offer`);
558
575
  const siteEnv = Object.fromEntries(
559
576
  Object.entries(ENV_PATTERNS).map(([key, pattern]) => {
560
577
  const matches = repo.envNames.filter((name) => pattern.test(name));
@@ -593,13 +610,40 @@ function draftManifest(repo, answers, live = {}) {
593
610
  sections,
594
611
  singletons,
595
612
  homePage: home,
596
- starter: draftStarter(schema, home, live, note)
613
+ starter: draftStarter(schema, home, live, note, Boolean(choices))
597
614
  },
598
- brand: draftBrand(schema, home, live, note)
615
+ brand: draftBrand(
616
+ schema,
617
+ home,
618
+ live,
619
+ note,
620
+ choices?.settingsFields ?? guessSettingsFields(schema, home.documentId),
621
+ Boolean(choices)
622
+ )
599
623
  },
600
624
  notes
601
625
  };
602
626
  }
627
+ function fitsRole(schema, documentId, role, field) {
628
+ const value = schema.fields(documentId)[field]?.value;
629
+ if (!value) return false;
630
+ if (role === "logo" || role === "favicon") return schema.isImage(value);
631
+ if (role === "siteName")
632
+ return value.type === "string" || schema.inlineName(value) === "internationalizedArrayString";
633
+ return true;
634
+ }
635
+ var SITE_NAME_FIELDS = ["siteName", "siteTitle", "title", "name"];
636
+ function guessSettingsFields(schema, documentId) {
637
+ return Object.fromEntries(
638
+ SETTINGS_ROLES.map((role) => {
639
+ const names = role === "siteName" ? SITE_NAME_FIELDS : [role];
640
+ return [role, names.find((name) => fitsRole(schema, documentId, role, name)) ?? null];
641
+ })
642
+ );
643
+ }
644
+ var themeTypeOf = (schema) => schema.contentDocuments.find((type) => /theme/i.test(type.name))?.name ?? null;
645
+ var colorRolesOf = (schema, themeId) => Object.entries(schema.fields(themeId)).filter(([, { value }]) => schema.colorShape(value)).map(([role]) => role);
646
+ var fontFieldsOf = (schema, themeId) => Object.entries(schema.fields(themeId)).filter(([, { value }]) => schema.fontShape(value)).map(([field]) => field);
603
647
  function findHomePage(schema, singletons) {
604
648
  for (const documentId of singletons) {
605
649
  const fields = schema.fields(documentId);
@@ -638,17 +682,17 @@ function draftLanguages(schema, note) {
638
682
  note(`Language documents: ${type.name}, ids like ${document._id}; check the id pattern`);
639
683
  return { document, setupTypes };
640
684
  }
641
- function draftStarter(schema, home, live, note) {
685
+ function draftStarter(schema, home, live, note, quiet = false) {
642
686
  if (live.starter?.length) {
643
687
  const siteName = siteNameOf(live.settings);
644
688
  const language = live.starter.find((doc) => typeof doc.language === "string")?.language;
645
689
  const ids = new Set(live.starter.map((doc) => doc._id));
646
- const documents = live.starter.map((doc) => placeholders(stripSystem(doc), siteName, language));
647
- const outside = live.starter.flatMap((doc) => references(doc).filter((ref) => !ids.has(ref)));
648
- if (outside.length)
649
- note(
650
- `Starter pages link to documents not in the starter: ${[...new Set(outside)].join(", ")}`
651
- );
690
+ const dropped = [];
691
+ const documents = live.starter.map(
692
+ (doc) => placeholders(dropOutsideLinks(stripSystem(doc), ids, dropped), siteName, language)
693
+ );
694
+ if (dropped.length)
695
+ note(`Starter: removed what links outside the picked pages: ${dropped.join(", ")}`);
652
696
  const liveHome = live.settings?.[home.field]?._ref;
653
697
  const homeId = liveHome && ids.has(liveHome) ? liveHome : live.starter[0]._id;
654
698
  note(`Starter: ${documents.length} documents from the live project, home ${homeId}`);
@@ -660,15 +704,15 @@ function draftStarter(schema, home, live, note) {
660
704
  const page = { _id: "starter-home", _type: type };
661
705
  if ("title" in fields) page.title = "{{siteName}}";
662
706
  if ("language" in fields) page.language = "{{language}}";
663
- note("Starter is one empty home page: run with --live to pick real pages instead");
707
+ if (!quiet) note("Starter is one empty home page: run with --live to pick real pages instead");
664
708
  return { homeId: "starter-home", documents: [page] };
665
709
  }
666
- function draftBrand(schema, home, live, note) {
710
+ function draftBrand(schema, home, live, note, roles, quiet = false) {
667
711
  const settingsFields = schema.fields(home.documentId);
668
712
  const fields = {};
669
- const siteName = settingsFields.siteName;
713
+ const siteName = roles.siteName ? settingsFields[roles.siteName] : void 0;
670
714
  if (siteName) {
671
- fields.siteName = schema.inlineName(siteName.value) === "internationalizedArrayString" ? [
715
+ fields[roles.siteName] = schema.inlineName(siteName.value) === "internationalizedArrayString" ? [
672
716
  {
673
717
  _key: "{{language}}",
674
718
  _type: "internationalizedArrayStringValue",
@@ -678,21 +722,22 @@ function draftBrand(schema, home, live, note) {
678
722
  ] : "{{siteName}}";
679
723
  }
680
724
  fields[home.field] = { _type: "reference", _ref: "{{homePageId}}" };
681
- if (settingsFields.domain) fields.domain = "{{domain}}";
682
- for (const [field, id] of [
725
+ if (roles.domain && settingsFields[roles.domain]) fields[roles.domain] = "{{domain}}";
726
+ for (const [role, id] of [
683
727
  ["logo", "logoAssetId"],
684
728
  ["favicon", "faviconAssetId"]
685
729
  ]) {
686
- const value = settingsFields[field]?.value;
687
- if (!value || !schema.isImage(value)) continue;
730
+ const field = roles[role];
731
+ const value = field ? settingsFields[field]?.value : void 0;
732
+ if (!field || !value || !schema.isImage(value)) continue;
688
733
  fields[field] = {
689
734
  _type: "image",
690
735
  asset: { _type: "reference", _ref: `{{${id}}}` },
691
- ...field === "logo" && "alt" in (value.attributes ?? {}) ? { alt: "{{siteName}}" } : {}
736
+ ...role === "logo" && "alt" in (value.attributes ?? {}) ? { alt: "{{siteName}}" } : {}
692
737
  };
693
738
  }
694
- const themeType = schema.contentDocuments.find((type) => /theme/i.test(type.name));
695
- const themeId = themeType?.name ?? "themeSettings";
739
+ const themeType = themeTypeOf(schema);
740
+ const themeId = themeType ?? "themeSettings";
696
741
  if (!themeType) note("No theme document found: brand colours and fonts left empty");
697
742
  const themeFields = schema.fields(themeId);
698
743
  let color = { $color: "{{hex}}" };
@@ -716,7 +761,7 @@ function draftBrand(schema, home, live, note) {
716
761
  label: typeof current?.label === "string" ? current.label : humanize(role)
717
762
  });
718
763
  }
719
- if (colorRoles.length && !live.theme)
764
+ if (colorRoles.length && !live.theme && !quiet)
720
765
  note("Colour defaults are #000000: run with --live to read them");
721
766
  const fontShape = Object.values(themeFields).map(({ value }) => schema.fontShape(value)).find(Boolean);
722
767
  const fonts = fontShape ? {
@@ -734,7 +779,8 @@ function draftBrand(schema, home, live, note) {
734
779
  const defaults = live.theme ? compactColors(stripSystem(live.theme)) : {};
735
780
  delete defaults._id;
736
781
  delete defaults._type;
737
- if (!live.theme) note("Theme defaults are empty: run with --live to copy the current theme");
782
+ if (!live.theme && !quiet)
783
+ note("Theme defaults are empty: run with --live to copy the current theme");
738
784
  const linked = Object.keys(defaults).filter((field) => hasReference(defaults[field]));
739
785
  for (const field of linked) delete defaults[field];
740
786
  if (linked.length) {
@@ -772,18 +818,41 @@ function placeholders(value, siteName, language, key, parent) {
772
818
  }
773
819
  return value;
774
820
  }
775
- function keepFileDefaults(draft, existing) {
776
- const defaults = draft.manifest.brand.theme.defaults;
777
- const previous = existing?.brand?.theme?.defaults ?? {};
778
- const kept = Object.keys(previous).filter(
779
- (field) => !(field in defaults) && hasReference(previous[field])
821
+ var hasReference = (value) => JSON.stringify(value ?? null).includes('"_ref":');
822
+ var linksOutside = (value, ids) => references(value).some((ref) => !ids.has(ref));
823
+ function dropOutsideLinks(doc, ids, dropped) {
824
+ const walk = (value, path) => {
825
+ if (Array.isArray(value)) {
826
+ return value.flatMap((item, index) => {
827
+ const at = `${path}[${item?._key ?? index}]`;
828
+ if (!linksOutside(item, ids) || hasArray(item)) return [walk(item, at)];
829
+ dropped.push(`${doc._id} ${at}`);
830
+ return [];
831
+ });
832
+ }
833
+ if (!value || typeof value !== "object") return value;
834
+ const result = {};
835
+ for (const [key, inner] of Object.entries(value)) {
836
+ if (!Array.isArray(inner) && linksOutside(inner, ids) && !hasArray(inner)) {
837
+ dropped.push(`${doc._id} ${path ? `${path}.` : ""}${key}`);
838
+ continue;
839
+ }
840
+ result[key] = walk(inner, path ? `${path}.${key}` : key);
841
+ }
842
+ return dropDanglingMarks(result);
843
+ };
844
+ return walk(doc, "");
845
+ }
846
+ var hasArray = (value) => Boolean(value && typeof value === "object") && Object.values(value).some((inner) => Array.isArray(inner) || hasArray(inner));
847
+ function dropDanglingMarks(block) {
848
+ if (!Array.isArray(block.markDefs) || !Array.isArray(block.children)) return block;
849
+ const defs = new Set(block.markDefs.map((def) => def._key));
850
+ const decorator = (mark) => /^[a-z][a-z-]*$/i.test(mark) && mark.length < 12;
851
+ block.children = block.children.map(
852
+ (child) => Array.isArray(child.marks) ? { ...child, marks: child.marks.filter((mark) => defs.has(mark) || decorator(mark)) } : child
780
853
  );
781
- for (const field of kept) defaults[field] = previous[field];
782
- if (kept.length)
783
- draft.notes.push(`Kept from the existing manifest's theme defaults: ${kept.join(", ")}`);
784
- return kept;
854
+ return block;
785
855
  }
786
- var hasReference = (value) => JSON.stringify(value ?? null).includes('"_ref":');
787
856
  function references(value) {
788
857
  if (Array.isArray(value)) return value.flatMap(references);
789
858
  if (value && typeof value === "object") {
@@ -803,16 +872,603 @@ function compactColors(value) {
803
872
  return value;
804
873
  }
805
874
 
806
- // src/init/live.ts
875
+ // src/generate/build.ts
876
+ var BUILT_IN_FIELDS = /* @__PURE__ */ new Set(["_id", "_type", "_key"]);
877
+ function buildManifest(input) {
878
+ const { repo, config, live, previous } = input;
879
+ const schema = new Extract(repo.types);
880
+ const draft = draftManifest(
881
+ repo,
882
+ {
883
+ id: config.id,
884
+ title: config.title,
885
+ organizationId: config.organizationId,
886
+ ...config.vercelTeamId ? { vercelTeamId: config.vercelTeamId } : {},
887
+ schemaSourceProjectId: config.schemaSourceProjectId,
888
+ ...config.studioOrigins ? { studioOrigins: config.studioOrigins } : {}
889
+ },
890
+ live ?? {},
891
+ {
892
+ singletons: config.singletons,
893
+ homePage: config.homePage,
894
+ settingsFields: config.settingsFields
895
+ }
896
+ );
897
+ const kept = new Set(config.themeFiles ?? []);
898
+ const notes = draft.notes.filter((text) => !/studioOrigins|^Studio: |No GitHub remote/.test(text)).flatMap((text) => {
899
+ const [lead, list] = text.split(/: (?=[^:]+$)/);
900
+ if (!lead.startsWith("Left out of theme defaults") || !list) return [text];
901
+ const left = list.split(", ").filter((field) => !kept.has(field));
902
+ return left.length ? [`${lead}: ${left.join(", ")}`] : [];
903
+ });
904
+ const note = (message) => void notes.push(message);
905
+ const { manifest } = draft;
906
+ manifest.site = { ...manifest.site, ...config.site };
907
+ manifest.content.sections = Object.entries(config.sections).filter(
908
+ (entry) => entry[1] !== false
909
+ );
910
+ const theme = manifest.brand.theme;
911
+ const previousRoles = new Map(previous?.brand.colorRoles.map((role) => [role.role, role]));
912
+ for (const role of manifest.brand.colorRoles) {
913
+ role.description = config.colorRoles[role.role] ?? "";
914
+ const before = previousRoles.get(role.role);
915
+ if (!live?.theme && before) Object.assign(role, { default: before.default, label: before.label });
916
+ }
917
+ const themeFields = schema.fields(themeTypeOf(schema) ?? theme.documentId);
918
+ if (live?.theme) {
919
+ for (const role of manifest.brand.colorRoles) delete theme.defaults[role.role];
920
+ for (const field of config.themeFiles ?? [])
921
+ if (live.theme[field] !== void 0) theme.defaults[field] = live.theme[field];
922
+ } else if (previous) {
923
+ theme.defaults = { ...previous.brand.theme.defaults };
924
+ }
925
+ for (const field of Object.keys(theme.defaults).filter((field2) => !(field2 in themeFields))) {
926
+ delete theme.defaults[field];
927
+ note(`Theme default ${field} is gone from the schema: dropped`);
928
+ }
929
+ if (config.starter?.length !== 0 && !live) {
930
+ if (previous) manifest.content.starter = previous.content.starter;
931
+ else note("Starter is one empty home page until generate can read the live pages");
932
+ }
933
+ manifest.content.starter = fitStarter(schema, manifest.content.starter, note);
934
+ if (!live) {
935
+ if (!previousRoles.size && manifest.brand.colorRoles.length)
936
+ note("Colour defaults are #000000: sign in to Sanity and run generate again");
937
+ if (!previous) note("Theme defaults are empty: sign in to Sanity and run generate again");
938
+ }
939
+ return { manifest, notes };
940
+ }
941
+ function fitStarter(schema, starter, note) {
942
+ const documents = starter.documents.flatMap((value) => {
943
+ const doc = value;
944
+ if (!schema.has(doc._type)) {
945
+ if (doc._id === starter.homeId)
946
+ throw new Error(
947
+ `The starter home page's type ${doc._type} is gone: pick starter pages again`
948
+ );
949
+ note(`Starter ${doc._id} dropped: type ${doc._type} is gone from the schema`);
950
+ return [];
951
+ }
952
+ const fields = schema.fields(doc._type);
953
+ const gone = Object.keys(doc).filter((key) => !BUILT_IN_FIELDS.has(key) && !(key in fields));
954
+ if (gone.length) note(`Starter ${doc._id}: dropped ${gone.join(", ")}, gone from ${doc._type}`);
955
+ return [Object.fromEntries(Object.entries(doc).filter(([key]) => !gone.includes(key)))];
956
+ });
957
+ return { ...starter, documents };
958
+ }
959
+
960
+ // src/generate/config.ts
807
961
  import { existsSync, readFileSync } from "node:fs";
962
+ import { Ajv as Ajv2 } from "ajv";
963
+
964
+ // config.schema.json
965
+ var config_schema_default = {
966
+ $schema: "http://json-schema.org/draft-07/schema#",
967
+ $id: "https://github.com/anydayagency/anyday-workspace-manager-sanity/workspace-manager.config.schema.json",
968
+ title: "Workspace manager config",
969
+ description: "The decisions behind a site's workspace-manager.json. Edit this file, then run `sanity-workspace-manager generate`.",
970
+ type: "object",
971
+ additionalProperties: false,
972
+ required: [
973
+ "id",
974
+ "title",
975
+ "organizationId",
976
+ "schemaSourceProjectId",
977
+ "site",
978
+ "singletons",
979
+ "homePage",
980
+ "settingsFields",
981
+ "sections",
982
+ "colorRoles"
983
+ ],
984
+ definitions: {
985
+ nullableField: {
986
+ type: ["string", "null"]
987
+ }
988
+ },
989
+ properties: {
990
+ $schema: {
991
+ type: "string"
992
+ },
993
+ id: {
994
+ description: "Permanent: jobs store it and it prefixes each site's Vercel project.",
995
+ type: "string",
996
+ pattern: "^[a-z0-9][a-z0-9-]{0,31}$"
997
+ },
998
+ title: {
999
+ description: "The name shown in the Workspaces app.",
1000
+ type: "string"
1001
+ },
1002
+ organizationId: {
1003
+ description: "The Sanity organisation new workspaces are created in.",
1004
+ type: "string"
1005
+ },
1006
+ vercelTeamId: {
1007
+ description: "The client's own Vercel team; leave out for Anyday's.",
1008
+ type: "string"
1009
+ },
1010
+ schemaSourceProjectId: {
1011
+ description: "The Sanity project new workspaces copy their schema from.",
1012
+ type: "string"
1013
+ },
1014
+ dataset: {
1015
+ description: "The dataset live values are read from. Default: production.",
1016
+ type: "string"
1017
+ },
1018
+ site: {
1019
+ type: "object",
1020
+ additionalProperties: false,
1021
+ required: ["repo", "branch"],
1022
+ properties: {
1023
+ repo: {
1024
+ description: "owner/name on GitHub.",
1025
+ type: "string",
1026
+ pattern: "^[\\w.-]+/[\\w.-]+$"
1027
+ },
1028
+ branch: {
1029
+ type: "string"
1030
+ },
1031
+ revalidatePath: {
1032
+ type: "string"
1033
+ }
1034
+ }
1035
+ },
1036
+ studioOrigins: {
1037
+ description: "The Studios that open new workspaces, e.g. https://acme.sanity.studio.",
1038
+ type: "array",
1039
+ items: {
1040
+ type: "string",
1041
+ pattern: "^https://"
1042
+ }
1043
+ },
1044
+ singletons: {
1045
+ description: "Settings documents: one each, never offered for copy.",
1046
+ type: "array",
1047
+ items: {
1048
+ type: "string"
1049
+ }
1050
+ },
1051
+ homePage: {
1052
+ description: "The settings field that references the home page.",
1053
+ type: "object",
1054
+ additionalProperties: false,
1055
+ required: ["documentId", "field"],
1056
+ properties: {
1057
+ documentId: {
1058
+ type: "string"
1059
+ },
1060
+ field: {
1061
+ type: "string"
1062
+ }
1063
+ }
1064
+ },
1065
+ settingsFields: {
1066
+ description: "The field on the home page's settings document that holds each brand value; null for none.",
1067
+ type: "object",
1068
+ additionalProperties: false,
1069
+ properties: {
1070
+ siteName: {
1071
+ $ref: "#/definitions/nullableField"
1072
+ },
1073
+ domain: {
1074
+ $ref: "#/definitions/nullableField"
1075
+ },
1076
+ logo: {
1077
+ $ref: "#/definitions/nullableField"
1078
+ },
1079
+ favicon: {
1080
+ $ref: "#/definitions/nullableField"
1081
+ }
1082
+ }
1083
+ },
1084
+ sections: {
1085
+ description: "Each content type, with its label in the Copy picker, or false to leave it out.",
1086
+ type: "object",
1087
+ additionalProperties: {
1088
+ anyOf: [
1089
+ {
1090
+ type: "string",
1091
+ minLength: 1
1092
+ },
1093
+ {
1094
+ const: false
1095
+ }
1096
+ ]
1097
+ }
1098
+ },
1099
+ colorRoles: {
1100
+ description: "Each colour role on the theme, with where it is used, shown to whoever sets up a brand.",
1101
+ type: "object",
1102
+ additionalProperties: {
1103
+ type: "string"
1104
+ }
1105
+ },
1106
+ themeFiles: {
1107
+ description: "Theme fields with uploaded files, e.g. fonts, to copy into new sites. Fonts are often licensed per brand.",
1108
+ type: "array",
1109
+ items: {
1110
+ type: "string"
1111
+ }
1112
+ },
1113
+ starter: {
1114
+ description: "What Start fresh writes: live pages (`home`, slugs or ids), or [] for one empty home page.",
1115
+ type: "array",
1116
+ items: {
1117
+ type: "string"
1118
+ }
1119
+ }
1120
+ }
1121
+ };
1122
+
1123
+ // src/generate/config.ts
1124
+ var CONFIG_FILE = "workspace-manager.config.json";
1125
+ var MANIFEST_FILE = "workspace-manager.json";
1126
+ var SCHEMA_REF = "./node_modules/@anyday/sanity-workspace-manager/config.schema.json";
1127
+ var validate2 = new Ajv2({ allErrors: true }).compile(config_schema_default);
1128
+ function parseConfig(json) {
1129
+ if (validate2(json)) return json;
1130
+ const problems = validate2.errors.map((error) => `${error.instancePath || "/"} ${error.message}`);
1131
+ throw new Error(`Invalid ${CONFIG_FILE}:
1132
+ ${problems.join("\n")}`);
1133
+ }
1134
+ function readJsonFile(path) {
1135
+ if (!existsSync(path)) return null;
1136
+ try {
1137
+ return JSON.parse(readFileSync(path, "utf8"));
1138
+ } catch {
1139
+ throw new Error(`${path} isn't valid JSON`);
1140
+ }
1141
+ }
1142
+ function formatConfig(config) {
1143
+ const { $schema, ...rest2 } = config;
1144
+ const ordered = {
1145
+ $schema: $schema ?? SCHEMA_REF,
1146
+ id: rest2.id,
1147
+ title: rest2.title,
1148
+ organizationId: rest2.organizationId,
1149
+ ...rest2.vercelTeamId ? { vercelTeamId: rest2.vercelTeamId } : {},
1150
+ schemaSourceProjectId: rest2.schemaSourceProjectId,
1151
+ ...rest2.dataset && rest2.dataset !== "production" ? { dataset: rest2.dataset } : {},
1152
+ site: rest2.site,
1153
+ ...rest2.studioOrigins?.length ? { studioOrigins: rest2.studioOrigins } : {},
1154
+ singletons: rest2.singletons,
1155
+ homePage: rest2.homePage,
1156
+ settingsFields: rest2.settingsFields,
1157
+ sections: rest2.sections,
1158
+ colorRoles: rest2.colorRoles,
1159
+ ...rest2.themeFiles?.length ? { themeFiles: rest2.themeFiles } : {},
1160
+ ...rest2.starter !== void 0 ? { starter: rest2.starter } : {}
1161
+ };
1162
+ return `${JSON.stringify(ordered, null, 2)}
1163
+ `;
1164
+ }
1165
+ var holds = (template, placeholder) => JSON.stringify(template ?? null).includes(`{{${placeholder}}}`);
1166
+ function configFromManifest(manifest, schema) {
1167
+ const { content, brand } = manifest;
1168
+ const fields = Object.entries(brand.settings.fields);
1169
+ const fieldFor = (placeholder, except) => fields.find(
1170
+ ([field, template]) => field !== except && field !== content.homePage.field && holds(template, placeholder)
1171
+ )?.[0] ?? null;
1172
+ const logo = fieldFor("logoAssetId");
1173
+ const offered = Object.fromEntries(content.sections);
1174
+ const defaults = brand.theme.defaults;
1175
+ return {
1176
+ id: manifest.id,
1177
+ title: manifest.title,
1178
+ organizationId: manifest.organizationId,
1179
+ ...manifest.vercelTeamId ? { vercelTeamId: manifest.vercelTeamId } : {},
1180
+ schemaSourceProjectId: manifest.schemaSourceProjectId,
1181
+ site: manifest.site,
1182
+ ...manifest.studioOrigins ? { studioOrigins: manifest.studioOrigins } : {},
1183
+ singletons: content.singletons,
1184
+ homePage: content.homePage,
1185
+ settingsFields: {
1186
+ siteName: fieldFor("siteName", logo ?? void 0),
1187
+ domain: fieldFor("domain"),
1188
+ logo,
1189
+ favicon: fieldFor("faviconAssetId")
1190
+ },
1191
+ sections: {
1192
+ ...offered,
1193
+ ...Object.fromEntries(
1194
+ sectionCandidates(schema, content.singletons).filter((type) => !(type in offered)).map((type) => [type, false])
1195
+ )
1196
+ },
1197
+ colorRoles: Object.fromEntries(brand.colorRoles.map((role) => [role.role, role.description])),
1198
+ themeFiles: Object.keys(defaults).filter(
1199
+ (field) => JSON.stringify(defaults[field]).includes('"_ref":')
1200
+ )
1201
+ };
1202
+ }
1203
+
1204
+ // src/generate/resolve.ts
1205
+ var yes = (answer) => /^y(es)?$/i.test(answer.trim());
1206
+ var ROLE_NAMES = { siteName: "site name", domain: "domain", logo: "logo", favicon: "favicon" };
1207
+ async function resolveIdentity(options) {
1208
+ const { saved, flags, repo, ask, lookups, note } = options;
1209
+ const known = { ...saved, ...flags };
1210
+ const fresh = options.askAgain || !saved.id;
1211
+ const pick = async (key, text, suggestion) => flags[key] !== void 0 ? flags[key] : !fresh && saved[key] !== void 0 ? saved[key] : ask({ text, suggestion: saved[key] ?? suggestion });
1212
+ const names = suggestNames(repo.repo);
1213
+ const id = await pick("id", "Project type id, short and lowercase", names.id);
1214
+ const title = await pick("title", "Name shown in the app", names.title);
1215
+ const project = await pick(
1216
+ "schemaSourceProjectId",
1217
+ "Sanity project to copy schemas from",
1218
+ repo.projectId ?? ""
1219
+ );
1220
+ if (!project) throw new Error("Pass --project <id>: the Sanity project to copy schemas from");
1221
+ const org = flags.organizationId ?? (!fresh ? saved.organizationId : void 0) ?? await ask({
1222
+ text: "Sanity organisation to create workspaces in",
1223
+ suggestion: saved.organizationId ?? await lookups.organizationOf?.(project).catch(() => "") ?? ""
1224
+ });
1225
+ if (!org) throw new Error("Pass --org <id>: the Sanity organisation for new workspaces");
1226
+ const vercelTeamId = flags.vercelTeamId ?? (fresh ? await ask({
1227
+ text: "Vercel team for the sites, only if it's the client's own (Enter for Anyday's)",
1228
+ suggestion: saved.vercelTeamId ?? ""
1229
+ }) : saved.vercelTeamId);
1230
+ const site = known.site ?? {
1231
+ repo: repo.repo ?? await ask({ text: "GitHub repo of the site (owner/name)", suggestion: "" }),
1232
+ branch: repo.branch ?? "main"
1233
+ };
1234
+ if (!site.repo) throw new Error("No GitHub remote found: add one, or set site.repo in the config");
1235
+ let studioOrigins = known.studioOrigins;
1236
+ if (!studioOrigins?.length || options.askAgain) {
1237
+ const found = await lookups.studioOriginsOf?.(project).catch(() => void 0);
1238
+ studioOrigins = found?.length ? found : studioOrigins;
1239
+ }
1240
+ if (!studioOrigins?.length)
1241
+ note(`No Studio found: add studioOrigins to the config, e.g. ["https://acme.sanity.studio"]`);
1242
+ return {
1243
+ ...saved.$schema ? { $schema: saved.$schema } : {},
1244
+ id,
1245
+ title,
1246
+ organizationId: org,
1247
+ ...vercelTeamId ? { vercelTeamId } : {},
1248
+ schemaSourceProjectId: project,
1249
+ ...known.dataset ? { dataset: known.dataset } : {},
1250
+ site,
1251
+ ...studioOrigins?.length ? { studioOrigins } : {},
1252
+ ...known.themeFiles ? { themeFiles: known.themeFiles } : {},
1253
+ ...known.starter !== void 0 ? { starter: known.starter } : {}
1254
+ };
1255
+ }
1256
+ async function resolveChoices(options) {
1257
+ const { saved, schema, ask, note } = options;
1258
+ const contentTypes = schema.contentDocuments.map((type) => type.name);
1259
+ let singletons = guessSingletons(schema);
1260
+ if (saved.singletons) {
1261
+ for (const type of saved.singletons.filter((type2) => !contentTypes.includes(type2)))
1262
+ note(`Singleton ${type} is gone from the schema: dropped`);
1263
+ const known = /* @__PURE__ */ new Set([...saved.singletons, ...Object.keys(saved.sections ?? {})]);
1264
+ const added = [];
1265
+ for (const type of singletons.filter((type2) => !known.has(type2))) {
1266
+ const answer = await ask({
1267
+ text: `New type ${type} looks like settings. Treat it as a singleton, never offered for copy?`,
1268
+ suggestion: "y",
1269
+ choices: ["y", "n"]
1270
+ });
1271
+ if (yes(answer)) added.push(type);
1272
+ }
1273
+ singletons = [...saved.singletons.filter((type) => contentTypes.includes(type)), ...added];
1274
+ } else if (singletons.length) {
1275
+ note(`Singletons guessed from names ending in "Settings": ${singletons.join(", ")}`);
1276
+ }
1277
+ const homePage = await resolveHomePage(saved, schema, singletons, ask, note);
1278
+ const settingsFields = await resolveSettingsFields(saved, schema, homePage.documentId, ask, note);
1279
+ const sections = await resolveNamed({
1280
+ saved: saved.sections,
1281
+ current: sectionCandidates(schema, singletons),
1282
+ fresh: (type) => humanize(type),
1283
+ what: "Copy section",
1284
+ ask,
1285
+ note,
1286
+ asked: async (type) => yes(
1287
+ await ask({
1288
+ text: `New content type ${type}: offer it as a copy section?`,
1289
+ suggestion: "y",
1290
+ choices: ["y", "n"]
1291
+ })
1292
+ ) ? humanize(type) : false
1293
+ });
1294
+ if (options.askAgain) {
1295
+ for (const [type, label] of Object.entries(sections)) {
1296
+ const answer = await ask({
1297
+ text: `Offer ${type} as a copy section?`,
1298
+ suggestion: label === false ? "n" : "y",
1299
+ choices: ["y", "n"]
1300
+ });
1301
+ sections[type] = yes(answer) ? label || humanize(type) : false;
1302
+ }
1303
+ } else if (!saved.sections)
1304
+ note("Copy sections list every content type: set the ones not to offer to false");
1305
+ const themeId = themeTypeOf(schema);
1306
+ const roles = themeId ? colorRolesOf(schema, themeId) : [];
1307
+ const colorRoles = await resolveNamed({
1308
+ saved: saved.colorRoles,
1309
+ current: roles,
1310
+ fresh: () => "",
1311
+ what: "Colour role",
1312
+ ask,
1313
+ note,
1314
+ asked: (role) => ask({
1315
+ text: `New colour role ${role}: where is it used? (a short description)`,
1316
+ suggestion: ""
1317
+ })
1318
+ });
1319
+ const undescribed = Object.keys(colorRoles).filter((role) => !colorRoles[role]);
1320
+ if (undescribed.length)
1321
+ note(`Colour roles without a description in the config: ${undescribed.join(", ")}`);
1322
+ const fontFields = themeId ? fontFieldsOf(schema, themeId) : [];
1323
+ let themeFiles = saved.themeFiles;
1324
+ if (themeFiles) {
1325
+ for (const field of themeFiles.filter((field2) => !fontFields.includes(field2)))
1326
+ note(`Theme file field ${field} is gone from the schema: dropped from themeFiles`);
1327
+ themeFiles = themeFiles.filter((field) => fontFields.includes(field));
1328
+ } else if (fontFields.length && !saved.id) {
1329
+ const answer = await ask({
1330
+ text: `Copy the live fonts (${fontFields.join(", ")}) into new sites? Fonts are often licensed per brand`,
1331
+ suggestion: "n",
1332
+ choices: ["y", "n"]
1333
+ });
1334
+ themeFiles = yes(answer) ? fontFields : [];
1335
+ }
1336
+ return {
1337
+ singletons,
1338
+ homePage,
1339
+ settingsFields,
1340
+ sections,
1341
+ colorRoles,
1342
+ ...themeFiles ? { themeFiles } : {}
1343
+ };
1344
+ }
1345
+ async function resolveHomePage(saved, schema, singletons, ask, note) {
1346
+ const isReference = (documentId, field) => {
1347
+ const value = schema.fields(documentId)[field]?.value;
1348
+ return singletons.includes(documentId) && Boolean(value && schema.referenceTarget(value));
1349
+ };
1350
+ if (saved.homePage && isReference(saved.homePage.documentId, saved.homePage.field))
1351
+ return saved.homePage;
1352
+ const candidates = singletons.flatMap(
1353
+ (documentId) => Object.keys(schema.fields(documentId)).filter((field) => isReference(documentId, field)).map((field) => `${documentId}.${field}`)
1354
+ );
1355
+ if (!candidates.length)
1356
+ throw new Error("No singleton has a reference field to use as the home page");
1357
+ const guess = findHomePage(schema, singletons);
1358
+ const suggestion = guess ? `${guess.documentId}.${guess.field}` : candidates[0];
1359
+ let answer = suggestion;
1360
+ if (saved.homePage) {
1361
+ answer = await ask({
1362
+ text: `${saved.homePage.documentId}.${saved.homePage.field} is gone. Which field is the home page?`,
1363
+ suggestion,
1364
+ choices: candidates
1365
+ });
1366
+ } else note(`Home page: ${suggestion}`);
1367
+ if (!candidates.includes(answer))
1368
+ throw new Error(`Pick the home page from ${candidates.join(", ")}`);
1369
+ const dot = answer.lastIndexOf(".");
1370
+ return { documentId: answer.slice(0, dot), field: answer.slice(dot + 1) };
1371
+ }
1372
+ async function resolveSettingsFields(saved, schema, documentId, ask, note) {
1373
+ const guessed = guessSettingsFields(schema, documentId);
1374
+ if (!saved.settingsFields) return guessed;
1375
+ const result = {};
1376
+ const fields = Object.keys(schema.fields(documentId));
1377
+ for (const role of SETTINGS_ROLES) {
1378
+ const field = saved.settingsFields[role];
1379
+ if (field && fitsRole(schema, documentId, role, field)) {
1380
+ result[role] = field;
1381
+ continue;
1382
+ }
1383
+ const taken = /* @__PURE__ */ new Set([...Object.values(saved.settingsFields), ...Object.values(result)]);
1384
+ const candidates = fields.filter(
1385
+ (candidate) => !taken.has(candidate) && fitsRole(schema, documentId, role, candidate)
1386
+ );
1387
+ if (field) {
1388
+ const answer = await ask({
1389
+ text: `${documentId}.${field} is gone. Which field is the ${ROLE_NAMES[role]}?`,
1390
+ suggestion: guessed[role] && candidates.includes(guessed[role]) ? guessed[role] : "none",
1391
+ choices: [...candidates, "none"]
1392
+ });
1393
+ result[role] = candidates.includes(answer) ? answer : null;
1394
+ if (!result[role]) note(`No ${ROLE_NAMES[role]} field: new sites won't set one`);
1395
+ } else if (field === void 0 && guessed[role]) {
1396
+ const answer = await ask({
1397
+ text: `New field ${documentId}.${guessed[role]}: use it for the ${ROLE_NAMES[role]}?`,
1398
+ suggestion: "y",
1399
+ choices: ["y", "n"]
1400
+ });
1401
+ result[role] = yes(answer) ? guessed[role] : null;
1402
+ } else result[role] = null;
1403
+ }
1404
+ return result;
1405
+ }
1406
+ async function resolveNamed(options) {
1407
+ const { saved, current, ask, note } = options;
1408
+ if (!saved) return Object.fromEntries(current.map((name) => [name, options.fresh(name)]));
1409
+ let added = current.filter((name) => !(name in saved));
1410
+ const entries = [];
1411
+ for (const [name, value] of Object.entries(saved)) {
1412
+ if (current.includes(name)) {
1413
+ entries.push([name, value]);
1414
+ continue;
1415
+ }
1416
+ const renamed = added.length ? await ask({
1417
+ text: `${options.what} ${name} is gone from the schema. Was it renamed?`,
1418
+ suggestion: "no",
1419
+ choices: [...added, "no"]
1420
+ }) : "no";
1421
+ if (added.includes(renamed)) {
1422
+ entries.push([renamed, value]);
1423
+ added = added.filter((name2) => name2 !== renamed);
1424
+ } else note(`${options.what} ${name} is gone from the schema: dropped`);
1425
+ }
1426
+ for (const name of added) entries.push([name, await options.asked(name)]);
1427
+ return Object.fromEntries(entries);
1428
+ }
1429
+
1430
+ // src/generate/stamp.ts
1431
+ import { createHash } from "node:crypto";
1432
+ var NOTE = "GENERATED FILE, DO NOT EDIT. Change workspace-manager.config.json, then run: npx @anyday/sanity-workspace-manager generate";
1433
+ var hashOf = (manifest) => createHash("sha256").update(JSON.stringify(manifest)).digest("hex").slice(0, 16);
1434
+ var unstamped = ({ $generated: _, ...manifest }) => manifest;
1435
+ function stamp(manifest) {
1436
+ const body = unstamped(manifest);
1437
+ return `${JSON.stringify({ $generated: `${NOTE}. sha256:${hashOf(body)}`, ...body }, null, 2)}
1438
+ `;
1439
+ }
1440
+ function stampState(manifest) {
1441
+ const hash = manifest.$generated?.match(/sha256:([0-9a-f]+)$/)?.[1];
1442
+ if (!hash) return "none";
1443
+ return hash === hashOf(unstamped(manifest)) ? "generated" : "edited";
1444
+ }
1445
+ var show = (value) => {
1446
+ const text = JSON.stringify(value);
1447
+ return text.length > 60 ? `${text.slice(0, 57)}...` : text;
1448
+ };
1449
+ function diffJson(before, after, path = "") {
1450
+ if (JSON.stringify(before) === JSON.stringify(after)) return [];
1451
+ const bothObjects = [before, after].every((value) => value && typeof value === "object");
1452
+ if (!bothObjects || Array.isArray(before) !== Array.isArray(after))
1453
+ return [`~ ${path || "."}: ${show(before)} \u2192 ${show(after)}`];
1454
+ const a = before;
1455
+ const b = after;
1456
+ const at = (key) => Array.isArray(before) ? `${path}[${key}]` : path ? `${path}.${key}` : key;
1457
+ return [.../* @__PURE__ */ new Set([...Object.keys(a), ...Object.keys(b)])].flatMap(
1458
+ (key) => !(key in a) ? [`+ ${at(key)}: ${show(b[key])}`] : !(key in b) ? [`- ${at(key)}`] : diffJson(a[key], b[key], at(key))
1459
+ );
1460
+ }
1461
+
1462
+ // src/generate/live.ts
1463
+ import { existsSync as existsSync2, readFileSync as readFileSync2 } from "node:fs";
808
1464
  import { homedir } from "node:os";
809
1465
  import { join } from "node:path";
810
1466
  import { createClient } from "@sanity/client";
811
1467
  function sanityToken() {
812
1468
  if (process.env.SANITY_AUTH_TOKEN) return process.env.SANITY_AUTH_TOKEN;
813
1469
  const config = join(homedir(), ".config", "sanity", "config.json");
814
- if (!existsSync(config)) return null;
815
- return JSON.parse(readFileSync(config, "utf8")).authToken ?? null;
1470
+ if (!existsSync2(config)) return null;
1471
+ return JSON.parse(readFileSync2(config, "utf8")).authToken ?? null;
816
1472
  }
817
1473
  async function projectInfo(projectId, token) {
818
1474
  const response = await fetch(`https://api.sanity.io/v2021-06-07/projects/${projectId}`, {
@@ -872,9 +1528,9 @@ async function readLive(options) {
872
1528
  };
873
1529
  }
874
1530
 
875
- // src/init/repo.ts
1531
+ // src/generate/repo.ts
876
1532
  import { execFileSync } from "node:child_process";
877
- import { existsSync as existsSync2, readdirSync, readFileSync as readFileSync2 } from "node:fs";
1533
+ import { existsSync as existsSync3, readdirSync, readFileSync as readFileSync3 } from "node:fs";
878
1534
  import { join as join2 } from "node:path";
879
1535
  var APP_DIRS = [".", "frontend", "web", "site", "studio", "apps"];
880
1536
  var git = (dir, ...args) => {
@@ -889,24 +1545,24 @@ function repoFromRemote(remote) {
889
1545
  }
890
1546
  function appDirs(root) {
891
1547
  const apps = join2(root, "apps");
892
- const nested = existsSync2(apps) ? readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(apps, entry.name)) : [];
893
- return [...APP_DIRS.map((dir) => join2(root, dir)), ...nested].filter((dir) => existsSync2(dir));
1548
+ const nested = existsSync3(apps) ? readdirSync(apps, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => join2(apps, entry.name)) : [];
1549
+ return [...APP_DIRS.map((dir) => join2(root, dir)), ...nested].filter((dir) => existsSync3(dir));
894
1550
  }
895
1551
  function readRepo(root) {
896
1552
  const dirs = appDirs(root);
897
- const schemaPath = dirs.map((dir) => join2(dir, "sanity.schema.json")).find(existsSync2);
1553
+ const schemaPath = dirs.map((dir) => join2(dir, "sanity.schema.json")).find(existsSync3);
898
1554
  if (!schemaPath) {
899
1555
  throw new Error("No sanity.schema.json: run `npx sanity schema extract` in the Studio first");
900
1556
  }
901
1557
  const envNames = dirs.flatMap((dir) => {
902
1558
  const file = join2(dir, ".env.example");
903
- if (!existsSync2(file)) return [];
904
- return [...readFileSync2(file, "utf8").matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((m) => m[1]);
1559
+ if (!existsSync3(file)) return [];
1560
+ return [...readFileSync3(file, "utf8").matchAll(/^\s*([A-Z][A-Z0-9_]*)\s*=/gm)].map((m) => m[1]);
905
1561
  });
906
- const projectId = dirs.flatMap((dir) => ["sanity.cli.ts", "sanity.config.ts"].map((file) => join2(dir, file))).filter(existsSync2).map((file) => readFileSync2(file, "utf8").match(/projectId:\s*['"]([a-z0-9]+)['"]/)?.[1]).find(Boolean) ?? null;
1562
+ const projectId = dirs.flatMap((dir) => ["sanity.cli.ts", "sanity.config.ts"].map((file) => join2(dir, file))).filter(existsSync3).map((file) => readFileSync3(file, "utf8").match(/projectId:\s*['"]([a-z0-9]+)['"]/)?.[1]).find(Boolean) ?? null;
907
1563
  const hasRevalidateRoute = dirs.some(
908
1564
  (dir) => ["app", "src/app", "pages", "src/pages"].some(
909
- (base) => existsSync2(join2(dir, base, "api/revalidate")) || ["ts", "js"].some((ext) => existsSync2(join2(dir, base, `api/revalidate.${ext}`)))
1565
+ (base) => existsSync3(join2(dir, base, "api/revalidate")) || ["ts", "js"].some((ext) => existsSync3(join2(dir, base, `api/revalidate.${ext}`)))
910
1566
  )
911
1567
  );
912
1568
  return {
@@ -915,16 +1571,19 @@ function readRepo(root) {
915
1571
  envNames: [...new Set(envNames)],
916
1572
  projectId,
917
1573
  hasRevalidateRoute,
918
- types: JSON.parse(readFileSync2(schemaPath, "utf8"))
1574
+ types: JSON.parse(readFileSync3(schemaPath, "utf8"))
919
1575
  };
920
1576
  }
921
1577
 
922
- // src/init/starter.ts
1578
+ // src/generate/starter.ts
1579
+ var StarterError = class extends Error {
1580
+ };
923
1581
  function resolveStarter(entries, pages, homeId) {
924
1582
  const home = pages.find((page) => page._id === homeId);
925
1583
  return entries.map((entry) => {
926
1584
  if (entry === "home") {
927
- if (!homeId) throw new Error("The live settings name no home page: pass its slug instead");
1585
+ if (!homeId)
1586
+ throw new StarterError("The live settings name no home page: pass its slug instead");
928
1587
  return homeId;
929
1588
  }
930
1589
  if (pages.some((page) => page._id === entry)) return entry;
@@ -932,8 +1591,8 @@ function resolveStarter(entries, pages, homeId) {
932
1591
  if (matches.length > 1 && home?.language)
933
1592
  matches = matches.filter((page) => page.language === home.language);
934
1593
  if (matches.length === 1) return matches[0]._id;
935
- if (!matches.length) throw new Error(`No page with the slug or id "${entry}"`);
936
- throw new Error(
1594
+ if (!matches.length) throw new StarterError(`No page with the slug or id "${entry}"`);
1595
+ throw new StarterError(
937
1596
  `Several pages have the slug "${entry}": pass one of ${matches.map((page) => page._id).join(", ")}`
938
1597
  );
939
1598
  });
@@ -953,18 +1612,18 @@ function parsePick(answer, count) {
953
1612
  }
954
1613
 
955
1614
  // src/register.ts
956
- import { readFileSync as readFileSync3 } from "node:fs";
1615
+ import { readFileSync as readFileSync4 } from "node:fs";
957
1616
  import { join as join3 } from "node:path";
958
1617
  async function register(options) {
959
1618
  const file = join3(options.dir, "workspace-manager.json");
960
- const manifest = parseManifest(JSON.parse(readFileSync3(file, "utf8")));
1619
+ const manifest = parseManifest(JSON.parse(readFileSync4(file, "utf8")));
961
1620
  configFrom(manifest);
962
1621
  const response = await (options.fetch ?? fetch)(
963
1622
  `${options.api.replace(/\/+$/, "")}/api/project-types`,
964
1623
  {
965
1624
  method: "POST",
966
1625
  headers: { "Authorization": `Bearer ${options.token}`, "Content-Type": "application/json" },
967
- body: JSON.stringify(manifest)
1626
+ body: JSON.stringify(unstamped(manifest))
968
1627
  }
969
1628
  );
970
1629
  const body = await response.json().catch(() => ({}));
@@ -974,166 +1633,342 @@ async function register(options) {
974
1633
  }
975
1634
 
976
1635
  // src/cli.ts
977
- var USAGE = `Usage: sanity-workspace-manager <init|register> [dir] [options]
978
-
979
- init [dir]
1636
+ var USAGE = `Usage: sanity-workspace-manager <generate|check|register> [dir] [options]
980
1637
 
981
- Drafts workspace-manager.json for a site repo from its git remote, .env.example
982
- and sanity.schema.json (run \`npx sanity schema extract\` first).
1638
+ generate [dir]
1639
+ Writes workspace-manager.json from the repo's sanity.schema.json (run \`npx sanity schema extract\`
1640
+ first), the live Sanity project and workspace-manager.config.json. Saves your answers in the
1641
+ config and, next time, asks only about what changed in the schema.
983
1642
 
984
1643
  Options:
985
- --yes Accept the suggested id, title and project without asking
1644
+ --yes Take the suggested answers without asking
1645
+ --offline Don't read the live project; keep the live values already in the manifest
1646
+ --diff Show what would change, and write nothing
1647
+ --ask-again Ask the saved questions again, e.g. to change copy sections or starter pages
986
1648
  --project <id> The Sanity project new workspaces copy their schema from
987
- --org <id> The Sanity organisation new workspaces are created in (found with --live)
1649
+ --org <id> The Sanity organisation new workspaces are created in
988
1650
  --vercel-team <id> The client's own Vercel team for the sites (default: Anyday's team)
989
1651
  --studio <origin> The Studio that opens new workspaces, e.g. https://acme.sanity.studio
990
- (found with --live from the project's deployed Studio)
991
- --live Read the current theme, settings and starter pages from that project
992
- --dataset <name> Dataset to read with --live (default: production)
993
- --starter <pages> Comma-separated pages to copy as starter content: \`home\`, slugs or
994
- document ids (needs --live; without it, --live offers a list to pick from)
995
- --force Overwrite an existing workspace-manager.json
996
- --print Print the draft instead of writing it
1652
+ --dataset <name> The dataset live values are read from (default: production)
1653
+ --starter <pages> Starter pages from the live site: \`home\`, slugs or ids, comma-separated
1654
+
1655
+ check [dir]
1656
+ Fails when workspace-manager.json is out of date with the schema or was edited by hand.
1657
+ Reads only the repo, so it runs in CI without signing in.
997
1658
 
998
1659
  register [dir]
999
1660
  Checks the repo's workspace-manager.json and saves it through the workspace manager API,
1000
1661
  signed in as you (SANITY_AUTH_TOKEN or \`npx sanity login\`).
1001
1662
 
1002
1663
  Options:
1003
- --api <url> The workspace manager API (or set WORKSPACE_MANAGER_API_URL)`;
1664
+ --api <url> The workspace manager API (or set WORKSPACE_MANAGER_API_URL)
1665
+ --dry-run Check the manifest without saving it
1666
+ --yes Register without asking, even with placeholder colours or fonts`;
1004
1667
  var dirOf = (positional) => resolve(process.env.INIT_CWD ?? ".", positional ?? ".");
1005
- async function registerCommand(argv) {
1006
- const { values, positionals } = parseArgs({
1007
- args: argv,
1008
- allowPositionals: true,
1009
- options: { api: { type: "string" } }
1010
- });
1011
- const api = values.api ?? process.env.WORKSPACE_MANAGER_API_URL;
1012
- if (!api) throw new Error("Pass --api <url> or set WORKSPACE_MANAGER_API_URL");
1013
- const token = sanityToken();
1014
- if (!token) throw new Error("register needs SANITY_AUTH_TOKEN or `npx sanity login`");
1015
- const saved = await register({ dir: dirOf(positionals[0]), api, token });
1016
- console.log(`Registered ${saved.title} (${saved.id})`);
1017
- for (const warning of saved.warnings ?? []) console.error(`Warning: ${warning}`);
1668
+ var readText = (path) => existsSync4(path) ? readFileSync5(path, "utf8") : "";
1669
+ function readManifest(root) {
1670
+ const json = readJsonFile(join4(root, MANIFEST_FILE));
1671
+ return json ? parseManifest(json) : null;
1672
+ }
1673
+ function createPrompt() {
1674
+ const prompt = createInterface({ input: process.stdin, output: process.stderr });
1675
+ const ask = async ({ text, suggestion, choices }) => {
1676
+ const hint = choices ? ` [${choices.join("/")}]` : "";
1677
+ for (; ; ) {
1678
+ const typed = (await prompt.question(`${text}${hint}${suggestion ? ` (${suggestion})` : ""}: `)).trim();
1679
+ const answer = choices?.find((choice) => choice.toLowerCase() === typed.toLowerCase());
1680
+ if (!typed) return suggestion;
1681
+ if (!choices) return typed;
1682
+ if (answer) return answer;
1683
+ if (/^(yes|no)$/i.test(typed) && choices.includes(typed[0].toLowerCase()))
1684
+ return typed[0].toLowerCase();
1685
+ console.error(`Answer one of: ${choices.join(", ")}`);
1686
+ }
1687
+ };
1688
+ return { ask, close: () => prompt.close() };
1018
1689
  }
1019
- async function init(argv) {
1690
+ async function generate(argv) {
1020
1691
  const { values, positionals } = parseArgs({
1021
1692
  args: argv,
1022
1693
  allowPositionals: true,
1023
1694
  options: {
1024
1695
  "yes": { type: "boolean", default: false },
1696
+ "offline": { type: "boolean", default: false },
1697
+ "diff": { type: "boolean", default: false },
1698
+ "ask-again": { type: "boolean", default: false },
1025
1699
  "project": { type: "string" },
1026
1700
  "org": { type: "string" },
1027
1701
  "studio": { type: "string" },
1028
1702
  "vercel-team": { type: "string" },
1029
- "live": { type: "boolean", default: false },
1030
- "dataset": { type: "string", default: "production" },
1031
- "starter": { type: "string" },
1032
- "force": { type: "boolean", default: false },
1033
- "print": { type: "boolean", default: false }
1703
+ "dataset": { type: "string" },
1704
+ "starter": { type: "string" }
1034
1705
  }
1035
1706
  });
1036
1707
  const root = dirOf(positionals[0]);
1037
- const out = resolve(root, "workspace-manager.json");
1038
- if (existsSync3(out) && !values.force && !values.print)
1039
- throw new Error(`${out} exists: pass --force to replace it`);
1040
- if (values.starter && !values.live) throw new Error("--starter needs --live");
1041
- const token = values.live ? sanityToken() : null;
1042
- if (values.live && !token) throw new Error("--live needs SANITY_AUTH_TOKEN or `npx sanity login`");
1043
- const orgFor = async (projectId) => values.org ?? (token && projectId ? await organizationOf(projectId, token) : "");
1708
+ const notes = [];
1709
+ const note = (message) => void notes.push(message);
1044
1710
  const repo = readRepo(root);
1045
- const suggested = { ...suggestNames(repo.repo), project: values.project ?? repo.projectId ?? "" };
1046
- const answers = values.yes ? {
1047
- ...suggested,
1048
- org: await orgFor(suggested.project),
1049
- vercelTeam: values["vercel-team"] ?? ""
1050
- } : await ask({ ...suggested, vercelTeam: values["vercel-team"] ?? "" }, orgFor);
1051
- if (!answers.project)
1052
- throw new Error("Pass --project <id>: the Sanity project to copy schemas from");
1053
- if (!answers.org) throw new Error("Pass --org <id>, or --live to read it from the project");
1054
- const base = {
1055
- id: answers.id,
1056
- title: answers.title,
1057
- organizationId: answers.org,
1058
- ...answers.vercelTeam ? { vercelTeamId: answers.vercelTeam } : {},
1059
- schemaSourceProjectId: answers.project,
1060
- studioOrigins: values.studio ? [values.studio.replace(/\/+$/, "")] : token ? await studioOriginsOf(answers.project, token) : []
1711
+ const schema = new Extract(repo.types);
1712
+ const previous = readManifest(root);
1713
+ let saved = {};
1714
+ const configJson = readJsonFile(join4(root, CONFIG_FILE));
1715
+ if (configJson) saved = parseConfig(configJson);
1716
+ else if (previous) {
1717
+ saved = configFromManifest(previous, schema);
1718
+ note(`Moved the choices in ${MANIFEST_FILE} into ${CONFIG_FILE}`);
1719
+ }
1720
+ const starterFlag = values.starter?.trim();
1721
+ const flags = {
1722
+ ...values.project ? { schemaSourceProjectId: values.project } : {},
1723
+ ...values.org ? { organizationId: values.org } : {},
1724
+ ...values["vercel-team"] ? { vercelTeamId: values["vercel-team"] } : {},
1725
+ ...values.studio ? { studioOrigins: [values.studio.replace(/\/+$/, "")] } : {},
1726
+ ...values.dataset ? { dataset: values.dataset } : {},
1727
+ ...starterFlag !== void 0 ? {
1728
+ starter: starterFlag.split(",").map((entry) => entry.trim()).filter(Boolean)
1729
+ } : {}
1061
1730
  };
1062
- let draft = draftManifest(repo, base);
1063
- if (token) {
1064
- const project = { projectId: answers.project, dataset: values.dataset, token };
1065
- const { pages, homeId } = await listPages({
1066
- ...project,
1067
- pageType: draft.manifest.content.starter.documents[0]._type,
1068
- home: draft.manifest.content.homePage
1069
- });
1070
- const starterIds = values.starter ? resolveStarter(
1071
- values.starter.split(",").map((entry) => entry.trim()).filter(Boolean),
1072
- pages,
1073
- homeId
1074
- ) : values.yes ? [] : await pickStarter(pickable(pages, homeId), homeId);
1075
- const live = await readLive({
1076
- ...project,
1077
- settingsId: draft.manifest.brand.settings.documentId,
1078
- themeId: draft.manifest.brand.theme.documentId,
1079
- starterIds
1731
+ const prompt = values.yes ? null : createPrompt();
1732
+ const ask = prompt?.ask ?? (async ({ text, suggestion }) => {
1733
+ note(`Took "${suggestion}" for: ${text}`);
1734
+ return suggestion;
1735
+ });
1736
+ const askAgain = values["ask-again"];
1737
+ try {
1738
+ let token = values.offline ? null : sanityToken();
1739
+ const fallback = previous ? `keeping the colours, fonts and starter pages already in ${MANIFEST_FILE}` : "with black colours, system fonts and one empty home page";
1740
+ const goOffline = async (reason) => {
1741
+ console.error(`${reason}.`);
1742
+ const answer = await ask({
1743
+ text: `Continue from the schema only, ${fallback}?`,
1744
+ suggestion: "y",
1745
+ choices: ["y", "n"]
1746
+ });
1747
+ if (answer !== "y") throw new Error("Stopped: sign in with `npx sanity login` and run again");
1748
+ return null;
1749
+ };
1750
+ if (!values.offline && !token)
1751
+ token = await goOffline("Not signed in to Sanity: run `npx sanity login` to read live values");
1752
+ const identity = await resolveIdentity({
1753
+ saved,
1754
+ flags,
1755
+ repo,
1756
+ ask,
1757
+ askAgain,
1758
+ note,
1759
+ lookups: token ? {
1760
+ organizationOf: (projectId) => organizationOf(projectId, token),
1761
+ studioOriginsOf: (projectId) => studioOriginsOf(projectId, token)
1762
+ } : {}
1080
1763
  });
1081
- draft = draftManifest(repo, base, live);
1764
+ const choices = await resolveChoices({ saved, schema, ask, askAgain, note });
1765
+ const config = { ...identity, ...choices };
1766
+ let live = null;
1767
+ if (token) {
1768
+ try {
1769
+ live = await readLiveFacts({ config, schema, token, prompt, askAgain });
1770
+ } catch (error) {
1771
+ if (error instanceof StarterError) throw error;
1772
+ token = await goOffline(accessProblem(error, config.schemaSourceProjectId));
1773
+ }
1774
+ }
1775
+ const built = buildManifest({ repo, config, live, previous });
1776
+ configFrom(parseManifest(built.manifest));
1777
+ const configText = formatConfig(config);
1778
+ if (values.diff) {
1779
+ const changes = [
1780
+ ...diffJson(saved, JSON.parse(configText)).map((line) => `${CONFIG_FILE} ${line}`),
1781
+ ...diffJson(previous && unstamped(previous), built.manifest).map(
1782
+ (line) => `${MANIFEST_FILE} ${line}`
1783
+ )
1784
+ ];
1785
+ console.log(changes.length ? changes.join("\n") : "No changes");
1786
+ if (!live) console.error(`
1787
+ Schema only: live values not read, ${fallback}`);
1788
+ return;
1789
+ }
1790
+ writeFileSync(join4(root, CONFIG_FILE), configText);
1791
+ writeFileSync(join4(root, MANIFEST_FILE), stamp(built.manifest));
1792
+ console.error(
1793
+ live ? `Wrote ${MANIFEST_FILE}` : `Wrote ${MANIFEST_FILE} from the schema only, ${fallback}. Sign in and run generate again to read live values`
1794
+ );
1795
+ const attributes = readText(join4(root, ".gitattributes"));
1796
+ if (!attributes.includes(`${MANIFEST_FILE} linguist-generated`))
1797
+ note(
1798
+ `Add \`${MANIFEST_FILE} linguist-generated=true\` to .gitattributes, so GitHub collapses it in PRs`
1799
+ );
1800
+ const all = [...notes, ...built.notes];
1801
+ if (all.length) console.error(`
1802
+ Check:
1803
+ ${all.map((line) => ` - ${line}`).join("\n")}`);
1804
+ } finally {
1805
+ prompt?.close();
1082
1806
  }
1083
- if (existsSync3(out)) keepFileDefaults(draft, readJson(out));
1084
- configFrom(parseManifest(draft.manifest));
1085
- const json = `${JSON.stringify(draft.manifest, null, 2)}
1086
- `;
1087
- if (values.print) process.stdout.write(json);
1088
- else writeFileSync(out, json);
1089
- console.error(`${values.print ? "Drafted" : `Wrote ${out}`}
1090
-
1091
- Check before registering:`);
1092
- for (const note of draft.notes) console.error(` - ${note}`);
1093
1807
  }
1094
- function readJson(path) {
1095
- try {
1096
- return JSON.parse(readFileSync4(path, "utf8"));
1097
- } catch {
1098
- return null;
1099
- }
1808
+ function accessProblem(error, projectId) {
1809
+ const message = error.message ?? String(error);
1810
+ const status = error.statusCode ?? Number(message.match(/\b(401|403)\b/)?.[1]);
1811
+ if (status === 401)
1812
+ return "Your Sanity session is missing or expired: run `npx sanity login` to read live values";
1813
+ if (status === 403)
1814
+ return `Your Sanity account can't read project ${projectId}: ask one of its Administrators to add you`;
1815
+ return `Couldn't read the live project ${projectId}: ${message}`;
1100
1816
  }
1101
- async function ask(suggested, orgFor) {
1102
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
1103
- const question = async (label, fallback) => (await prompt.question(`${label}${fallback ? ` (${fallback})` : ""}: `)).trim() || fallback;
1104
- try {
1105
- const id = await question("Project type id, short and lowercase", suggested.id);
1106
- const title = await question("Name shown in the app", suggested.title);
1107
- const project = await question("Sanity project to copy schemas from", suggested.project);
1108
- const org = await question("Sanity organisation to create workspaces in", await orgFor(project));
1109
- const vercelTeam = await question(
1110
- "Vercel team for the sites, only if it's the client's own (Enter for Anyday's)",
1111
- suggested.vercelTeam
1817
+ async function readLiveFacts(options) {
1818
+ const { config, schema, token, prompt } = options;
1819
+ const project = {
1820
+ projectId: config.schemaSourceProjectId,
1821
+ dataset: config.dataset ?? "production",
1822
+ token
1823
+ };
1824
+ const homeField = schema.fields(config.homePage.documentId)[config.homePage.field];
1825
+ const pageType = (homeField && schema.referenceTarget(homeField.value)) ?? "page";
1826
+ const { pages, homeId } = await listPages({ ...project, pageType, home: config.homePage });
1827
+ if (config.starter === void 0 || options.askAgain) {
1828
+ const picked = prompt ? await pickStarter(prompt, pickable(pages, homeId), homeId) : [];
1829
+ config.starter = picked.map(
1830
+ (id) => id === homeId ? "home" : pages.find((page) => page._id === id)?.slug ?? id
1112
1831
  );
1113
- return { id, title, project, org, vercelTeam };
1114
- } finally {
1115
- prompt.close();
1116
1832
  }
1833
+ const starterIds = resolveStarter(config.starter, pages, homeId);
1834
+ return readLive({
1835
+ ...project,
1836
+ settingsId: config.homePage.documentId,
1837
+ themeId: themeTypeOf(schema) ?? "themeSettings",
1838
+ starterIds
1839
+ });
1117
1840
  }
1118
- async function pickStarter(pages, homeId) {
1841
+ async function pickStarter(prompt, pages, homeId) {
1119
1842
  if (!pages.length) return [];
1120
1843
  console.error("\nPages of the live site:");
1121
1844
  pages.forEach((page, index) => {
1122
1845
  const label = `${page.title ?? page._id} (/${page.slug})`;
1123
1846
  console.error(` ${index + 1}. ${label}${page._id === homeId ? " \u2190 home" : ""}`);
1124
1847
  });
1125
- const prompt = createInterface({ input: process.stdin, output: process.stderr });
1126
- try {
1127
- const answer = await prompt.question(
1128
- "Starter pages, by number (e.g. 1,3; Enter for one empty home page): "
1129
- );
1130
- return parsePick(answer, pages.length).map((index) => pages[index]._id);
1131
- } finally {
1132
- prompt.close();
1848
+ for (; ; ) {
1849
+ const answer = await prompt.ask({
1850
+ text: "Starter pages, by number (e.g. 1,3; Enter for one empty home page)",
1851
+ suggestion: ""
1852
+ });
1853
+ try {
1854
+ return parsePick(answer, pages.length).map((index) => pages[index]._id);
1855
+ } catch (error) {
1856
+ console.error(error.message);
1857
+ }
1858
+ }
1859
+ }
1860
+ async function check(argv) {
1861
+ const { positionals } = parseArgs({ args: argv, allowPositionals: true, options: {} });
1862
+ const root = dirOf(positionals[0]);
1863
+ const configJson = readJsonFile(join4(root, CONFIG_FILE));
1864
+ if (!configJson) throw new Error(`No ${CONFIG_FILE}: run \`sanity-workspace-manager generate\``);
1865
+ const config = parseConfig(configJson);
1866
+ const manifest = readManifest(root);
1867
+ if (!manifest) throw new Error(`No ${MANIFEST_FILE}: run \`sanity-workspace-manager generate\``);
1868
+ const failures = [];
1869
+ const state = stampState(manifest);
1870
+ if (state === "edited")
1871
+ failures.push(`${MANIFEST_FILE} was edited by hand: put the change in ${CONFIG_FILE} instead`);
1872
+ if (state === "none") failures.push(`${MANIFEST_FILE} wasn't made by generate`);
1873
+ const repo = readRepo(root);
1874
+ const schema = new Extract(repo.types);
1875
+ const notes = [];
1876
+ const note = (message) => void notes.push(message);
1877
+ const ask = async ({ text, suggestion }) => {
1878
+ failures.push(text);
1879
+ return suggestion;
1880
+ };
1881
+ const identity = await resolveIdentity({
1882
+ saved: config,
1883
+ flags: {},
1884
+ repo,
1885
+ ask,
1886
+ askAgain: false,
1887
+ lookups: {},
1888
+ note
1889
+ });
1890
+ const next = {
1891
+ ...identity,
1892
+ ...await resolveChoices({ saved: config, schema, ask, askAgain: false, note })
1893
+ };
1894
+ const built = buildManifest({ repo, config: next, live: null, previous: manifest });
1895
+ failures.push(
1896
+ ...diffJson(JSON.parse(formatConfig(config)), JSON.parse(formatConfig(next))).map(
1897
+ (line) => `${CONFIG_FILE} ${line}`
1898
+ ),
1899
+ ...diffJson(unstamped(manifest), built.manifest).map((line) => `${MANIFEST_FILE} ${line}`)
1900
+ );
1901
+ if (!failures.length) {
1902
+ console.log(`${MANIFEST_FILE} is up to date`);
1903
+ return;
1904
+ }
1905
+ console.error(
1906
+ `${MANIFEST_FILE} is out of date:
1907
+ ${failures.map((line) => ` ${line}`).join("\n")}`
1908
+ );
1909
+ console.error("\nRun `npx @anyday/sanity-workspace-manager generate` and commit the result.");
1910
+ process.exit(1);
1911
+ }
1912
+ function placeholderValues(manifest) {
1913
+ const { colorRoles, theme } = manifest.brand;
1914
+ const found = [];
1915
+ if (colorRoles.length && colorRoles.every((role) => /^#0{6}$/.test(role.default)))
1916
+ found.push("black colours");
1917
+ const hasFonts = Object.values(theme.defaults).some(
1918
+ (value) => JSON.stringify(value).includes('"_ref":')
1919
+ );
1920
+ if (theme.font && !hasFonts) found.push("system fonts");
1921
+ return found;
1922
+ }
1923
+ async function registerCommand(argv) {
1924
+ const { values, positionals } = parseArgs({
1925
+ args: argv,
1926
+ allowPositionals: true,
1927
+ options: {
1928
+ "api": { type: "string" },
1929
+ "dry-run": { type: "boolean", default: false },
1930
+ "yes": { type: "boolean", default: false }
1931
+ }
1932
+ });
1933
+ const dir = dirOf(positionals[0]);
1934
+ const manifest = readManifest(dir);
1935
+ if (!manifest) throw new Error(`No ${MANIFEST_FILE} in ${dir}`);
1936
+ configFrom(manifest);
1937
+ if (stampState(manifest) === "edited")
1938
+ console.error(`Warning: ${MANIFEST_FILE} was edited by hand since generate wrote it`);
1939
+ const placeholders2 = placeholderValues(manifest);
1940
+ if (placeholders2.length) {
1941
+ console.error(`New sites will use ${placeholders2.join(" and ")}.`);
1942
+ if (!values["dry-run"] && !values.yes) {
1943
+ const prompt = createPrompt();
1944
+ try {
1945
+ const answer = await prompt.ask({
1946
+ text: "Register anyway?",
1947
+ suggestion: "n",
1948
+ choices: ["y", "n"]
1949
+ });
1950
+ if (answer !== "y")
1951
+ throw new Error("Not registered: run generate signed in to Sanity first");
1952
+ } finally {
1953
+ prompt.close();
1954
+ }
1955
+ }
1956
+ }
1957
+ if (values["dry-run"]) {
1958
+ console.log(`Valid: ${manifest.title} (${manifest.id}) is ready to register`);
1959
+ return;
1133
1960
  }
1961
+ const api = values.api ?? process.env.WORKSPACE_MANAGER_API_URL;
1962
+ if (!api) throw new Error("Pass --api <url> or set WORKSPACE_MANAGER_API_URL");
1963
+ const token = sanityToken();
1964
+ if (!token) throw new Error("register needs SANITY_AUTH_TOKEN or `npx sanity login`");
1965
+ const saved = await register({ dir, api, token });
1966
+ console.log(`Registered ${saved.title} (${saved.id})`);
1967
+ for (const warning of saved.warnings ?? []) console.error(`Warning: ${warning}`);
1134
1968
  }
1135
1969
  var commands = {
1136
- init,
1970
+ generate,
1971
+ check,
1137
1972
  register: registerCommand
1138
1973
  };
1139
1974
  var [command, ...rest] = process.argv.slice(2);