@cmssy/cli 10.5.1 → 10.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.
@@ -2,5 +2,28 @@ import { createCmssyPage } from "@cmssy/next/server";
2
2
  import { cmssy } from "@/cmssy.config";
3
3
  import { blocks } from "@/cmssy/blocks";
4
4
  import { CmssyEditor } from "@/cmssy/editor";
5
+ import { CmssyLayoutSlot } from "@/cmssy/layout-slot";
5
6
 
6
- export default createCmssyPage(cmssy, blocks, { editor: CmssyEditor });
7
+ type PageProps = {
8
+ params: Promise<{ path?: string[] }>;
9
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
10
+ };
11
+
12
+ const CmssyPage = createCmssyPage(cmssy, blocks, { editor: CmssyEditor });
13
+
14
+ // The header and the footer are layout blocks, mounted here rather than in
15
+ // app/layout.tsx: this route knows its path, and the language prefix in that
16
+ // path is what says which language to render them in. Reading it from the
17
+ // request header instead would make every page dynamic.
18
+ export default async function Page(props: PageProps) {
19
+ const { path } = await props.params;
20
+ return (
21
+ <>
22
+ <CmssyLayoutSlot position="header" path={path} />
23
+ <main>
24
+ <CmssyPage {...props} />
25
+ </main>
26
+ <CmssyLayoutSlot position="footer" path={path} />
27
+ </>
28
+ );
29
+ }
@@ -2,6 +2,7 @@ import { createCmssyEditPage } from "@cmssy/next/server";
2
2
  import { cmssy } from "@/cmssy.config";
3
3
  import { blocks } from "@/cmssy/blocks";
4
4
  import { CmssyEditor } from "@/cmssy/editor";
5
+ import { CmssyLayoutSlot } from "@/cmssy/layout-slot";
5
6
 
6
7
  // The route the middleware rewrites a verified editor request onto. The public
7
8
  // pages stay static, and a static page never sees the query string that would
@@ -10,4 +11,26 @@ import { CmssyEditor } from "@/cmssy/editor";
10
11
  // Delete this file and the editor preview goes blank.
11
12
  export const dynamic = "force-dynamic";
12
13
 
13
- export default createCmssyEditPage(cmssy, blocks, { editor: CmssyEditor });
14
+ type PageProps = {
15
+ params: Promise<{ path?: string[] }>;
16
+ searchParams: Promise<Record<string, string | string[] | undefined>>;
17
+ };
18
+
19
+ const CmssyEditPage = createCmssyEditPage(cmssy, blocks, {
20
+ editor: CmssyEditor,
21
+ });
22
+
23
+ // The same chrome the public route renders - the editor frames THIS page, so a
24
+ // header missing here is a header missing in the editor.
25
+ export default async function EditPage(props: PageProps) {
26
+ const { path } = await props.params;
27
+ return (
28
+ <>
29
+ <CmssyLayoutSlot position="header" path={path} />
30
+ <main>
31
+ <CmssyEditPage {...props} />
32
+ </main>
33
+ <CmssyLayoutSlot position="footer" path={path} />
34
+ </>
35
+ );
36
+ }
@@ -0,0 +1,169 @@
1
+ import { cache } from "react";
2
+ import { CmssyServerLayout, graphqlRequest } from "@cmssy/react";
3
+ import type { CmssyLayoutGroup } from "@cmssy/react";
4
+ import { resolveEditorLayoutBlockData } from "@cmssy/react/internal-server";
5
+ import { resolveEditorOrigin } from "@cmssy/next";
6
+ import { isCmssyEditMode } from "@cmssy/next/server";
7
+ import { cmssy } from "@/cmssy.config";
8
+ import { blocks } from "@/cmssy/blocks";
9
+ import { EditableLayout } from "@/cmssy/editable-layout";
10
+
11
+ // The header and the footer are layout blocks: content, not markup. Fetching
12
+ // and rendering them is your app's, so both queries live here.
13
+ const LAYOUTS_QUERY = `query PublicPageLayouts($workspaceSlug: String!, $pageSlug: String!, $previewSecret: String) {
14
+ public {
15
+ page {
16
+ layouts(workspaceSlug: $workspaceSlug, pageSlug: $pageSlug, previewSecret: $previewSecret) {
17
+ position
18
+ blocks { id type content style advanced order isActive }
19
+ settings { desktopWidth mobileBehavior }
20
+ }
21
+ }
22
+ }
23
+ }`;
24
+
25
+ const SITE_CONFIG_QUERY = `query PublicSiteConfig($workspaceSlug: String!) {
26
+ public {
27
+ siteConfig(workspaceSlug: $workspaceSlug) {
28
+ defaultLanguage
29
+ enabledLanguages
30
+ }
31
+ }
32
+ }`;
33
+
34
+ // Both slots render on every page; `cache` makes that one request each.
35
+ const fetchLayouts = cache(
36
+ async (pageSlug: string, previewSecret: string | null) => {
37
+ try {
38
+ const data = await graphqlRequest<{
39
+ public?: { page?: { layouts?: CmssyLayoutGroup[] | null } | null } | null;
40
+ }>(
41
+ cmssy,
42
+ LAYOUTS_QUERY,
43
+ { workspaceSlug: cmssy.workspaceSlug, pageSlug, previewSecret },
44
+ { public: true, retry: {} },
45
+ "page layouts",
46
+ );
47
+ return data.public?.page?.layouts ?? [];
48
+ } catch (error) {
49
+ console.error("[cmssy] page layouts could not be fetched", error);
50
+ return [];
51
+ }
52
+ },
53
+ );
54
+
55
+ const fetchLocales = cache(async () => {
56
+ try {
57
+ const data = await graphqlRequest<{
58
+ public?: {
59
+ siteConfig?: {
60
+ defaultLanguage: string | null;
61
+ enabledLanguages: string[] | null;
62
+ } | null;
63
+ } | null;
64
+ }>(
65
+ cmssy,
66
+ SITE_CONFIG_QUERY,
67
+ { workspaceSlug: cmssy.workspaceSlug },
68
+ { public: true, retry: {} },
69
+ "site config",
70
+ );
71
+ const config = data.public?.siteConfig;
72
+ // The languages are the workspace's, never this repo's.
73
+ const defaultLocale = config?.defaultLanguage ?? "";
74
+ const enabled = config?.enabledLanguages ?? [];
75
+ return {
76
+ defaultLocale,
77
+ locales: enabled.length > 0 ? enabled : defaultLocale ? [defaultLocale] : [],
78
+ };
79
+ } catch (error) {
80
+ console.error("[cmssy] site config could not be fetched", error);
81
+ return { defaultLocale: "", locales: [] as string[] };
82
+ }
83
+ });
84
+
85
+ export interface CmssyLayoutSlotProps {
86
+ position: "header" | "footer";
87
+ /**
88
+ * The catch-all segments of the route rendering this slot, as routed. The
89
+ * language prefix in them IS the language - reading it here keeps the route
90
+ * statically renderable, where reading the request header would force every
91
+ * page dynamic and kill ISR.
92
+ */
93
+ path?: string[];
94
+ /** The page whose layout to render. Defaults to the site-wide header/footer. */
95
+ page?: string;
96
+ }
97
+
98
+ /**
99
+ * The site-wide header and footer, rendered the way each mode needs them:
100
+ *
101
+ * - published traffic: server-rendered layout blocks, no client cost;
102
+ * - the editor: the same blocks through the edit bridge, fetched with the
103
+ * preview secret, so what you see is the draft you are editing.
104
+ *
105
+ * Getting this wrong is invisible - the site looks right while the editor shows
106
+ * a header it can select but not fill, or the published version of one.
107
+ */
108
+ export async function CmssyLayoutSlot({
109
+ position,
110
+ path,
111
+ page = "/",
112
+ }: CmssyLayoutSlotProps) {
113
+ const [editMode, { defaultLocale, locales }] = await Promise.all([
114
+ isCmssyEditMode(),
115
+ fetchLocales(),
116
+ ]);
117
+
118
+ const [first] = path ?? [];
119
+ const locale =
120
+ first && first !== defaultLocale && locales.includes(first)
121
+ ? first
122
+ : defaultLocale;
123
+
124
+ const groups = await fetchLayouts(
125
+ page,
126
+ editMode ? cmssy.draftSecret : null,
127
+ );
128
+
129
+ if (!editMode) {
130
+ return (
131
+ <CmssyServerLayout
132
+ groups={groups}
133
+ blocks={blocks}
134
+ position={position}
135
+ locale={locale}
136
+ defaultLocale={defaultLocale}
137
+ enabledLocales={locales}
138
+ config={cmssy}
139
+ />
140
+ );
141
+ }
142
+
143
+ const origin = resolveEditorOrigin(cmssy.editorOrigin);
144
+ // The editor canvas renders stored content, and for a relation field that is
145
+ // raw ids - so the server-resolved content is handed over with it.
146
+ const editorData = await resolveEditorLayoutBlockData({
147
+ groups,
148
+ blocks,
149
+ position,
150
+ locale,
151
+ defaultLocale,
152
+ enabledLocales: locales,
153
+ isPreview: true,
154
+ config: cmssy,
155
+ });
156
+
157
+ return (
158
+ <EditableLayout
159
+ groups={groups}
160
+ position={position}
161
+ locale={locale}
162
+ defaultLocale={defaultLocale}
163
+ enabledLocales={locales}
164
+ edit={{ editorOrigin: Array.isArray(origin) ? origin[0] : origin }}
165
+ data={editorData.data}
166
+ resolvedContent={editorData.content}
167
+ />
168
+ );
169
+ }
package/dist/index.js CHANGED
@@ -168,6 +168,7 @@ var FRAMEWORKS = [
168
168
  "cmssy/blocks.ts",
169
169
  "cmssy/editor.tsx",
170
170
  "cmssy/editable-layout.tsx",
171
+ "cmssy/layout-slot.tsx",
171
172
  "blocks/hero/block.ts",
172
173
  "blocks/hero/Hero.tsx",
173
174
  "app/[[...path]]/page.tsx",
@@ -661,14 +662,18 @@ function runInit(options, deps) {
661
662
  }
662
663
 
663
664
  // src/link.ts
664
- import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
665
- import { join as join4 } from "path";
665
+ import { existsSync as existsSync5, readFileSync as readFileSync5, writeFileSync as writeFileSync3 } from "fs";
666
+ import { join as join5 } from "path";
666
667
  import {
667
668
  buildEditorUrl,
668
669
  checkDraftSecret,
669
670
  checkWorkspaceReachable
670
671
  } from "@cmssy/core/preflight";
671
672
 
673
+ // src/env-load.ts
674
+ import { existsSync as existsSync4, readFileSync as readFileSync4 } from "fs";
675
+ import { join as join4 } from "path";
676
+
672
677
  // src/env-file.ts
673
678
  function parseEnvFile(content) {
674
679
  const vars = {};
@@ -702,6 +707,16 @@ function applyEnv(vars, env) {
702
707
  return applied;
703
708
  }
704
709
 
710
+ // src/env-load.ts
711
+ var ENV_FILES = [".env.local", ".env"];
712
+ function loadEnvFiles(cwd, env) {
713
+ for (const file of ENV_FILES) {
714
+ const path = join4(cwd, file);
715
+ if (!existsSync4(path)) continue;
716
+ applyEnv(parseEnvFile(readFileSync4(path, "utf8")), env);
717
+ }
718
+ }
719
+
705
720
  // src/env-write.ts
706
721
  function needsQuoting(value) {
707
722
  return /[\s#"']/.test(value) || value === "";
@@ -729,14 +744,6 @@ function mergeEnvContent(existing, updates) {
729
744
  }
730
745
 
731
746
  // src/link.ts
732
- var ENV_FILES = [".env.local", ".env"];
733
- function loadEnvFiles(cwd, env) {
734
- for (const file of ENV_FILES) {
735
- const path = join4(cwd, file);
736
- if (!existsSync4(path)) continue;
737
- applyEnv(parseEnvFile(readFileSync4(path, "utf8")), env);
738
- }
739
- }
740
747
  function resolveToken(options, deps) {
741
748
  const token = options.token?.trim() || deps.env.CMSSY_API_TOKEN?.trim();
742
749
  if (!token) {
@@ -883,8 +890,8 @@ function resolvePreviewUrl(options) {
883
890
  return origin;
884
891
  }
885
892
  function writeEnvLocal(cwd, updates) {
886
- const path = join4(cwd, ".env.local");
887
- const existing = existsSync4(path) ? readFileSync4(path, "utf8") : null;
893
+ const path = join5(cwd, ".env.local");
894
+ const existing = existsSync5(path) ? readFileSync5(path, "utf8") : null;
888
895
  writeFileSync3(path, mergeEnvContent(existing, updates));
889
896
  }
890
897
  async function runLink(options, deps) {
@@ -998,12 +1005,323 @@ async function runLink(options, deps) {
998
1005
  }
999
1006
  }
1000
1007
 
1008
+ // src/types-command.ts
1009
+ import { mkdirSync as mkdirSync3, readFileSync as readFileSync6, writeFileSync as writeFileSync4 } from "fs";
1010
+ import { dirname as dirname3, join as join6 } from "path";
1011
+ import { DEFAULT_CMSSY_API_URL } from "@cmssy/core";
1012
+
1013
+ // src/model-types.ts
1014
+ var TEXTUAL = /* @__PURE__ */ new Set([
1015
+ "text",
1016
+ "textarea",
1017
+ "richText",
1018
+ "markdown",
1019
+ "link",
1020
+ "url",
1021
+ "email",
1022
+ "phone",
1023
+ "color",
1024
+ "date",
1025
+ "datetime",
1026
+ "password",
1027
+ "pageSelector"
1028
+ ]);
1029
+ var INDENT = " ";
1030
+ function pascalCase(value) {
1031
+ const parts = value.split(/[^A-Za-z0-9]+/).filter(Boolean);
1032
+ const joined = parts.map((part) => part.charAt(0).toUpperCase() + part.slice(1)).join("");
1033
+ return /^[A-Za-z]/.test(joined) ? joined : `Model${joined}`;
1034
+ }
1035
+ function propertyKey(key) {
1036
+ return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(key) ? key : JSON.stringify(key);
1037
+ }
1038
+ function stringUnion(options) {
1039
+ return options.map((option) => JSON.stringify(option)).join(" | ");
1040
+ }
1041
+ function scalarType(field) {
1042
+ if (TEXTUAL.has(field.type)) {
1043
+ return field.localized ? "CmssyLocalized" : "string";
1044
+ }
1045
+ switch (field.type) {
1046
+ case "number":
1047
+ return "number";
1048
+ case "boolean":
1049
+ return "boolean";
1050
+ case "media":
1051
+ case "file":
1052
+ return field.multiple ? "string[]" : "string";
1053
+ case "select":
1054
+ case "radio":
1055
+ return field.options?.length ? stringUnion(field.options) : "string";
1056
+ case "multiselect":
1057
+ return field.options?.length ? `Array<${stringUnion(field.options)}>` : "string[]";
1058
+ case "relation":
1059
+ return field.relationType === "hasMany" || field.multiple ? "string[]" : "string";
1060
+ case "json":
1061
+ case "table":
1062
+ case "form":
1063
+ return "unknown";
1064
+ default:
1065
+ return "unknown";
1066
+ }
1067
+ }
1068
+ function fieldType(field, depth) {
1069
+ if (field.type === "object") {
1070
+ return field.fields?.length ? objectType(field.fields, depth) : "Record<string, unknown>";
1071
+ }
1072
+ if (field.type === "repeater" || field.type === "list") {
1073
+ if (field.itemFields?.length) {
1074
+ return `Array<${objectType(field.itemFields, depth)}>`;
1075
+ }
1076
+ if (field.itemType) {
1077
+ const item = scalarType({ key: field.key, type: field.itemType });
1078
+ return `${item.includes(" ") ? `Array<${item}>` : `${item}[]`}`;
1079
+ }
1080
+ return "unknown[]";
1081
+ }
1082
+ return scalarType(field);
1083
+ }
1084
+ function docComment(field, pad) {
1085
+ const parts = [];
1086
+ if (field.label && field.label !== field.key) parts.push(field.label);
1087
+ if (field.description) parts.push(field.description);
1088
+ if (field.type === "relation" && field.relationTo) {
1089
+ parts.push(`Record id(s) from \`${field.relationTo.replace(/^model:/, "")}\`.`);
1090
+ }
1091
+ if (!parts.length) return [];
1092
+ return [`${pad}/** ${parts.join(" - ")} */`];
1093
+ }
1094
+ function members(fields, depth) {
1095
+ const pad = INDENT.repeat(depth + 1);
1096
+ const lines = [];
1097
+ for (const field of fields) {
1098
+ if (field.hidden) continue;
1099
+ lines.push(...docComment(field, pad));
1100
+ const optional = field.required ? "" : "?";
1101
+ lines.push(
1102
+ `${pad}${propertyKey(field.key)}${optional}: ${fieldType(field, depth + 1)};`
1103
+ );
1104
+ }
1105
+ return lines;
1106
+ }
1107
+ function objectType(fields, depth) {
1108
+ const body = members(fields, depth);
1109
+ if (!body.length) return "Record<string, unknown>";
1110
+ return ["{", ...body, `${INDENT.repeat(depth)}}`].join("\n");
1111
+ }
1112
+ var PREAMBLE = `/** A translatable field: one string, or one per enabled language. */
1113
+ export type CmssyLocalized = string | Record<string, string>;
1114
+
1115
+ /** A record as \`public.model.records\` returns it, with \`data\` typed. */
1116
+ export interface CmssyRecordOf<Data> {
1117
+ id: string;
1118
+ modelId: string;
1119
+ status: string;
1120
+ createdAt: string;
1121
+ updatedAt: string;
1122
+ data: Data;
1123
+ }`;
1124
+ function generateModelTypes(models, options) {
1125
+ const command = options.command ?? "npx @cmssy/cli types";
1126
+ const sorted = [...models].sort((a, b) => a.slug.localeCompare(b.slug));
1127
+ const used = /* @__PURE__ */ new Map();
1128
+ const typeNames = /* @__PURE__ */ new Map();
1129
+ for (const model of sorted) {
1130
+ let name = pascalCase(model.slug);
1131
+ if (used.has(name)) name = `${name}_${used.size}`;
1132
+ used.set(name, model.slug);
1133
+ typeNames.set(model.slug, name);
1134
+ }
1135
+ const blocks = [
1136
+ `// Generated by \`${command}\` from the "${options.workspace}" workspace.`,
1137
+ "// Do not edit: change the model in the CMS and run the command again.",
1138
+ "",
1139
+ PREAMBLE
1140
+ ];
1141
+ for (const model of sorted) {
1142
+ const name = typeNames.get(model.slug) ?? pascalCase(model.slug);
1143
+ const title = model.name ?? model.slug;
1144
+ const doc = [`/**`, ` * ${title}`];
1145
+ if (model.description) doc.push(` *`, ` * ${model.description}`);
1146
+ doc.push(` *`, ` * Model slug: \`${model.slug}\``);
1147
+ if (model.displayField) doc.push(` * Display field: \`${model.displayField}\``);
1148
+ doc.push(` */`);
1149
+ blocks.push(
1150
+ "",
1151
+ doc.join("\n"),
1152
+ `export interface ${name}Data ${objectType(model.fields, 0)}`,
1153
+ "",
1154
+ `export type ${name}Record = CmssyRecordOf<${name}Data>;`
1155
+ );
1156
+ }
1157
+ const mapEntries = sorted.map((model) => {
1158
+ const name = typeNames.get(model.slug) ?? pascalCase(model.slug);
1159
+ return `${INDENT}${propertyKey(model.slug)}: ${name}Data;`;
1160
+ });
1161
+ blocks.push(
1162
+ "",
1163
+ "/** Every model in the workspace, by slug. */",
1164
+ ["export interface CmssyModels {", ...mapEntries, "}"].join("\n"),
1165
+ "",
1166
+ "export type CmssyModelSlug = keyof CmssyModels;",
1167
+ "",
1168
+ "/** The typed record list `public.model.records` returns for a model. */",
1169
+ "export interface CmssyRecordList<Slug extends CmssyModelSlug> {",
1170
+ `${INDENT}items: Array<CmssyRecordOf<CmssyModels[Slug]>>;`,
1171
+ `${INDENT}total: number;`,
1172
+ `${INDENT}hasMore: boolean;`,
1173
+ "}",
1174
+ ""
1175
+ );
1176
+ return blocks.join("\n");
1177
+ }
1178
+
1179
+ // src/types-command.ts
1180
+ var DEFAULT_OUT = "cmssy/models.ts";
1181
+ var SITE_CONFIG_QUERY = `query CliSiteConfig($workspaceSlug: String!) {
1182
+ public { siteConfig(workspaceSlug: $workspaceSlug) { workspaceId } }
1183
+ }`;
1184
+ var DEFINITIONS_QUERY = `query CliModelDefinitions($workspaceId: String!) {
1185
+ public {
1186
+ model {
1187
+ definitions(workspaceId: $workspaceId) {
1188
+ slug
1189
+ name
1190
+ description
1191
+ displayField
1192
+ fields {
1193
+ key
1194
+ label
1195
+ description
1196
+ type
1197
+ required
1198
+ localized
1199
+ multiple
1200
+ hidden
1201
+ options
1202
+ relationTo
1203
+ relationType
1204
+ fields
1205
+ itemType
1206
+ itemFields
1207
+ }
1208
+ }
1209
+ }
1210
+ }
1211
+ }`;
1212
+ function publicEndpoint(env, org, workspace) {
1213
+ const apiUrl = env.CMSSY_API_URL?.trim() || DEFAULT_CMSSY_API_URL;
1214
+ const base = apiUrl.replace(/\/graphql\/?$/, "").replace(/\/+$/, "");
1215
+ return `${base}/public/${org}/${workspace}/graphql`;
1216
+ }
1217
+ async function request(deps, endpoint, query, variables) {
1218
+ let response;
1219
+ try {
1220
+ response = await deps.fetch(endpoint, {
1221
+ method: "POST",
1222
+ headers: { "content-type": "application/json" },
1223
+ body: JSON.stringify({ query, variables })
1224
+ });
1225
+ } catch (error) {
1226
+ throw new CliError(
1227
+ `could not reach ${endpoint}`,
1228
+ error instanceof Error ? error.message : String(error)
1229
+ );
1230
+ }
1231
+ if (!response.ok) {
1232
+ throw new CliError(
1233
+ `the delivery API answered ${response.status}`,
1234
+ "check CMSSY_ORG_SLUG and CMSSY_WORKSPACE_SLUG - they form the delivery path"
1235
+ );
1236
+ }
1237
+ const body = await response.json();
1238
+ if (body.errors?.length) {
1239
+ throw new CliError(
1240
+ "the delivery API rejected the query",
1241
+ body.errors.map((error) => error.message).join("; ")
1242
+ );
1243
+ }
1244
+ if (!body.data) throw new CliError("the delivery API returned no data");
1245
+ return body.data;
1246
+ }
1247
+ function resolve3(value, fromEnv, variable) {
1248
+ const resolved = value?.trim() || fromEnv?.trim();
1249
+ if (!resolved) {
1250
+ throw new CliError(
1251
+ `no workspace to read: ${variable} is not set`,
1252
+ "run cmssy link first, or pass --org / --workspace"
1253
+ );
1254
+ }
1255
+ return resolved;
1256
+ }
1257
+ async function runTypes(options, deps) {
1258
+ try {
1259
+ loadEnvFiles(deps.cwd, deps.env);
1260
+ const org = resolve3(options.org, deps.env.CMSSY_ORG_SLUG, "CMSSY_ORG_SLUG");
1261
+ const workspace = resolve3(
1262
+ options.workspace,
1263
+ deps.env.CMSSY_WORKSPACE_SLUG,
1264
+ "CMSSY_WORKSPACE_SLUG"
1265
+ );
1266
+ const endpoint = publicEndpoint(deps.env, org, workspace);
1267
+ const site = await request(deps, endpoint, SITE_CONFIG_QUERY, { workspaceSlug: workspace });
1268
+ const workspaceId = site.public.siteConfig?.workspaceId;
1269
+ if (!workspaceId) {
1270
+ throw new CliError(
1271
+ `workspace "${org}/${workspace}" was not found`,
1272
+ "check the slugs under Settings \u2192 Headless"
1273
+ );
1274
+ }
1275
+ const result = await request(deps, endpoint, DEFINITIONS_QUERY, { workspaceId });
1276
+ const models = result.public.model.definitions;
1277
+ if (models.length === 0) {
1278
+ deps.log(
1279
+ `cmssy: the "${workspace}" workspace has no models yet - nothing to generate`
1280
+ );
1281
+ return 0;
1282
+ }
1283
+ const outPath = join6(deps.cwd, options.out?.trim() || DEFAULT_OUT);
1284
+ const source = generateModelTypes(models, { workspace });
1285
+ let previous = null;
1286
+ try {
1287
+ previous = readFileSync6(outPath, "utf8");
1288
+ } catch {
1289
+ previous = null;
1290
+ }
1291
+ if (previous === source) {
1292
+ deps.log(`cmssy: ${options.out ?? DEFAULT_OUT} is up to date`);
1293
+ return 0;
1294
+ }
1295
+ mkdirSync3(dirname3(outPath), { recursive: true });
1296
+ writeFileSync4(outPath, source);
1297
+ const fieldCount = models.reduce(
1298
+ (total, model) => total + model.fields.length,
1299
+ 0
1300
+ );
1301
+ deps.log(
1302
+ `cmssy: wrote ${options.out ?? DEFAULT_OUT} - ${models.length} model${models.length === 1 ? "" : "s"}, ${fieldCount} field${fieldCount === 1 ? "" : "s"}`
1303
+ );
1304
+ deps.log(
1305
+ ` ${models.map((model) => model.slug).join(", ")}`
1306
+ );
1307
+ return 0;
1308
+ } catch (error) {
1309
+ if (error instanceof CliError) {
1310
+ deps.log(`cmssy: ${error.message}`);
1311
+ if (error.fix) deps.log(` ${error.fix}`);
1312
+ return 1;
1313
+ }
1314
+ throw error;
1315
+ }
1316
+ }
1317
+
1001
1318
  // src/index.ts
1002
1319
  var USAGE = [
1003
1320
  "usage: cmssy <command>",
1004
1321
  " cmssy init [--dir <path>] [--force]",
1005
1322
  " cmssy add block <name> [--dir <path>]",
1006
- " cmssy link [--token <cs_...>] [--workspace <slug>] [--preview-url <url>]"
1323
+ " cmssy link [--token <cs_...>] [--workspace <slug>] [--preview-url <url>]",
1324
+ " cmssy types [--out <path>] [--org <slug>] [--workspace <slug>]"
1007
1325
  ].join("\n");
1008
1326
  function flagValue(args, name) {
1009
1327
  const index = args.findIndex((arg) => arg === name);
@@ -1061,6 +1379,22 @@ async function main() {
1061
1379
  process.exitCode = await runLinkCommand(args);
1062
1380
  return;
1063
1381
  }
1382
+ if (command === "types") {
1383
+ process.exitCode = await runTypes(
1384
+ {
1385
+ out: flagValue(args, "--out"),
1386
+ org: flagValue(args, "--org"),
1387
+ workspace: flagValue(args, "--workspace")
1388
+ },
1389
+ {
1390
+ cwd: process.cwd(),
1391
+ env: process.env,
1392
+ log: (line) => console.log(line),
1393
+ fetch: globalThis.fetch
1394
+ }
1395
+ );
1396
+ return;
1397
+ }
1064
1398
  console.error(USAGE);
1065
1399
  process.exitCode = command === void 0 || command === "--help" ? 0 : 1;
1066
1400
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/cli",
3
- "version": "10.5.1",
3
+ "version": "10.6.0",
4
4
  "description": "The cmssy CLI: `cmssy init` wires cmssy into an existing Next.js, Astro or React Router app; `cmssy link` connects it to a workspace and verifies the editor wiring.",
5
5
  "keywords": [
6
6
  "cmssy",
@@ -27,7 +27,7 @@
27
27
  "assets"
28
28
  ],
29
29
  "dependencies": {
30
- "@cmssy/core": "10.5.1"
30
+ "@cmssy/core": "10.6.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^20.0.0",