@cmssy/cli 9.8.0 → 9.10.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.
Files changed (2) hide show
  1. package/dist/index.js +368 -42
  2. package/package.json +2 -2
package/dist/index.js CHANGED
@@ -3,16 +3,9 @@
3
3
  // src/index.ts
4
4
  import { createInterface } from "readline/promises";
5
5
 
6
- // src/init.ts
7
- import {
8
- copyFileSync,
9
- existsSync,
10
- mkdirSync,
11
- readFileSync,
12
- writeFileSync
13
- } from "fs";
14
- import { dirname, join, resolve } from "path";
15
- import { fileURLToPath } from "url";
6
+ // src/add-block.ts
7
+ import { existsSync as existsSync2, mkdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
8
+ import { dirname, join as join2, resolve } from "path";
16
9
 
17
10
  // src/admin-client.ts
18
11
  var DEFAULT_ADMIN_API_URL = "https://api.cmssy.io/graphql";
@@ -159,11 +152,9 @@ Preview drafts without the editor (the /api/draft path also works on your local
159
152
  `;
160
153
  }
161
154
 
162
- // src/init.ts
163
- var ASSETS_DIR = fileURLToPath(new URL("../assets/init", import.meta.url));
164
- var CLI_PACKAGE_JSON = fileURLToPath(
165
- new URL("../package.json", import.meta.url)
166
- );
155
+ // src/framework.ts
156
+ import { existsSync, readFileSync } from "fs";
157
+ import { join } from "path";
167
158
  var FRAMEWORKS = [
168
159
  {
169
160
  name: "next",
@@ -253,8 +244,272 @@ function detectFramework(pkg) {
253
244
  }
254
245
  return match;
255
246
  }
247
+ function nextSrcPrefix(root) {
248
+ return existsSync(join(root, "src", "app")) ? "src/" : "";
249
+ }
250
+
251
+ // src/add-block.ts
252
+ var BLOCK_NAME_PATTERN = /^[a-z][a-z0-9]*(-[a-z0-9]+)*$/;
253
+ function capitalize(word) {
254
+ return word.charAt(0).toUpperCase() + word.slice(1);
255
+ }
256
+ function blockNames(name) {
257
+ const words = name.split("-");
258
+ const pascal = words.map(capitalize).join("");
259
+ const camel = pascal.charAt(0).toLowerCase() + pascal.slice(1);
260
+ return {
261
+ type: name,
262
+ label: words.map(capitalize).join(" "),
263
+ pascal,
264
+ camel,
265
+ block: `${camel}Block`,
266
+ props: `${camel}Props`
267
+ };
268
+ }
269
+ function nextBlockDefinition(names) {
270
+ return `import { defineBlock } from "@cmssy/react";
271
+ import ${names.pascal}, { ${names.props} } from "./${names.pascal}";
272
+
273
+ export const ${names.block} = defineBlock({
274
+ type: "${names.type}",
275
+ label: "${names.label}",
276
+ component: ${names.pascal},
277
+ props: ${names.props},
278
+ });
279
+ `;
280
+ }
281
+ function nextComponent(names) {
282
+ return `import { fields, type BlockProps } from "@cmssy/react";
283
+
284
+ export const ${names.props} = {
285
+ heading: fields.text({ label: "Heading", required: true }),
286
+ text: fields.textarea({ label: "Text" }),
287
+ };
288
+
289
+ export default function ${names.pascal}({ content }: BlockProps<typeof ${names.props}>) {
290
+ return (
291
+ <section>
292
+ <h2>{content.heading}</h2>
293
+ {content.text ? <p>{content.text}</p> : null}
294
+ </section>
295
+ );
296
+ }
297
+ `;
298
+ }
299
+ function singleFileComponent(names) {
300
+ return `import { fields, type BlockProps } from "@cmssy/react";
301
+
302
+ export const ${names.props} = {
303
+ heading: fields.text({ label: "Heading", required: true }),
304
+ text: fields.textarea({ label: "Text" }),
305
+ };
306
+
307
+ export function ${names.pascal}({ content }: BlockProps<typeof ${names.props}>) {
308
+ return (
309
+ <section>
310
+ <h2>{content.heading}</h2>
311
+ {content.text ? <p>{content.text}</p> : null}
312
+ </section>
313
+ );
314
+ }
315
+ `;
316
+ }
317
+ function insertAfterImports(source, line) {
318
+ const imports = [...source.matchAll(/^import .*$/gm)];
319
+ const last = imports[imports.length - 1];
320
+ if (last === void 0) return `${line}
321
+ ${source}`;
322
+ const end = (last.index ?? 0) + last[0].length;
323
+ return `${source.slice(0, end)}
324
+ ${line}${source.slice(end)}`;
325
+ }
326
+ function appendToBlocksArray(source, identifier) {
327
+ const match = source.match(/export const blocks = \[([\s\S]*?)\]/);
328
+ if (!match) {
329
+ throw new CliError(
330
+ "could not find `export const blocks = [...]` in the block registry",
331
+ `add the block yourself: import it and append ${identifier} to the blocks array`
332
+ );
333
+ }
334
+ const inner = match[1] ?? "";
335
+ let updated;
336
+ if (inner.trim() === "") {
337
+ updated = identifier;
338
+ } else if (inner.includes("\n")) {
339
+ const body = inner.replace(/[\s,]*$/, "");
340
+ const indent = /\n(\s*)\S/.exec(inner)?.[1] ?? " ";
341
+ updated = `${body},
342
+ ${indent}${identifier},
343
+ `;
344
+ } else {
345
+ updated = `${inner.trim().replace(/,$/, "")}, ${identifier}`;
346
+ }
347
+ return source.replace(match[0], `export const blocks = [${updated}]`);
348
+ }
349
+ function frameworkLayout(framework, root, names) {
350
+ if (framework.name === "next") {
351
+ const prefix = nextSrcPrefix(root);
352
+ const componentPath2 = `${prefix}blocks/${names.type}/${names.pascal}.tsx`;
353
+ return {
354
+ registryPath: `${prefix}cmssy/blocks.ts`,
355
+ componentPath: componentPath2,
356
+ files: [
357
+ {
358
+ path: `${prefix}blocks/${names.type}/block.ts`,
359
+ content: nextBlockDefinition(names)
360
+ },
361
+ { path: componentPath2, content: nextComponent(names) }
362
+ ],
363
+ registerInRegistry: (source) => appendToBlocksArray(
364
+ insertAfterImports(
365
+ source,
366
+ `import { ${names.block} } from "@/blocks/${names.type}/block";`
367
+ ),
368
+ names.block
369
+ )
370
+ };
371
+ }
372
+ const base = framework.name === "astro" ? "src/cmssy" : "app/cmssy";
373
+ const componentPath = `${base}/${names.type}.tsx`;
374
+ return {
375
+ registryPath: `${base}/blocks.ts`,
376
+ componentPath,
377
+ files: [{ path: componentPath, content: singleFileComponent(names) }],
378
+ registerInRegistry: (source) => {
379
+ let updated = insertAfterImports(
380
+ source,
381
+ `import { ${names.pascal}, ${names.props} } from "./${names.type}";`
382
+ );
383
+ if (!/import \{[^}]*defineBlock[^}]*\} from "@cmssy\/react"/.test(updated)) {
384
+ updated = insertAfterImports(
385
+ updated,
386
+ `import { defineBlock } from "@cmssy/react";`
387
+ );
388
+ }
389
+ const definition = `export const ${names.block} = defineBlock({
390
+ type: "${names.type}",
391
+ label: "${names.label}",
392
+ component: ${names.pascal},
393
+ props: ${names.props},
394
+ });
395
+
396
+ `;
397
+ const anchor = updated.indexOf("export const blocks =");
398
+ if (anchor === -1) {
399
+ throw new CliError(
400
+ "could not find `export const blocks = [...]` in the block registry",
401
+ `add the block yourself: define ${names.block} with defineBlock and append it to the blocks array`
402
+ );
403
+ }
404
+ updated = `${updated.slice(0, anchor)}${definition}${updated.slice(anchor)}`;
405
+ return appendToBlocksArray(updated, names.block);
406
+ }
407
+ };
408
+ }
409
+ function runAddBlock(options, deps) {
410
+ const { log } = deps;
411
+ try {
412
+ if (!BLOCK_NAME_PATTERN.test(options.name)) {
413
+ throw new CliError(
414
+ `"${options.name}" is not a valid block name`,
415
+ "use kebab-case: cmssy add block pricing-table"
416
+ );
417
+ }
418
+ const root = resolve(deps.cwd, options.dir ?? ".");
419
+ if (!existsSync2(root)) {
420
+ throw new CliError(
421
+ `${root} does not exist`,
422
+ "pass --dir with the app's directory, or run cmssy add block inside it"
423
+ );
424
+ }
425
+ const framework = detectFramework(readPackageJson(root));
426
+ const names = blockNames(options.name);
427
+ const layout = frameworkLayout(framework, root, names);
428
+ const registryFile = join2(root, layout.registryPath);
429
+ if (!existsSync2(registryFile)) {
430
+ throw new CliError(
431
+ `no block registry at ${layout.registryPath}`,
432
+ "run cmssy init first - it wires the registry the editor loads blocks from"
433
+ );
434
+ }
435
+ const registry = readFileSync2(registryFile, "utf8");
436
+ if (registry.includes(names.block) || registry.includes(`type: "${names.type}"`)) {
437
+ throw new CliError(
438
+ `block "${names.type}" is already registered in ${layout.registryPath}`
439
+ );
440
+ }
441
+ for (const file of layout.files) {
442
+ if (existsSync2(join2(root, file.path))) {
443
+ throw new CliError(
444
+ `${file.path} already exists`,
445
+ "pick another name or remove the existing block first"
446
+ );
447
+ }
448
+ }
449
+ const updatedRegistry = layout.registerInRegistry(registry, names);
450
+ log(
451
+ formatResult({
452
+ status: "ok",
453
+ message: `detected ${framework.label} - scaffolding block "${names.type}"`
454
+ })
455
+ );
456
+ for (const file of layout.files) {
457
+ const target = join2(root, file.path);
458
+ mkdirSync(dirname(target), { recursive: true });
459
+ writeFileSync(target, file.content);
460
+ log(formatResult({ status: "ok", message: `wrote ${file.path}` }));
461
+ }
462
+ writeFileSync(registryFile, updatedRegistry);
463
+ log(
464
+ formatResult({
465
+ status: "ok",
466
+ message: `registered ${names.block} in ${layout.registryPath}`
467
+ })
468
+ );
469
+ log("");
470
+ log("Next steps:");
471
+ log(` 1. edit ${layout.componentPath} - define the fields and markup`);
472
+ log(
473
+ ` 2. restart your dev server - the editor picks up "${names.type}" from the next handshake`
474
+ );
475
+ return 0;
476
+ } catch (error) {
477
+ if (error instanceof CliError) {
478
+ log(
479
+ formatResult({
480
+ status: "fail",
481
+ message: error.message,
482
+ fix: error.fix
483
+ })
484
+ );
485
+ return 1;
486
+ }
487
+ log(
488
+ formatResult({
489
+ status: "fail",
490
+ message: error instanceof Error ? error.message : String(error)
491
+ })
492
+ );
493
+ return 1;
494
+ }
495
+ }
496
+
497
+ // src/init.ts
498
+ import {
499
+ copyFileSync,
500
+ existsSync as existsSync3,
501
+ mkdirSync as mkdirSync2,
502
+ readFileSync as readFileSync3,
503
+ writeFileSync as writeFileSync2
504
+ } from "fs";
505
+ import { dirname as dirname2, join as join3, resolve as resolve2 } from "path";
506
+ import { fileURLToPath } from "url";
507
+ var ASSETS_DIR = fileURLToPath(new URL("../assets/init", import.meta.url));
508
+ var CLI_PACKAGE_JSON = fileURLToPath(
509
+ new URL("../package.json", import.meta.url)
510
+ );
256
511
  function frameworkFiles(framework, root) {
257
- const srcPrefix = framework.name === "next" && existsSync(join(root, "src", "app")) ? "src/" : "";
512
+ const srcPrefix = framework.name === "next" ? nextSrcPrefix(root) : "";
258
513
  return [
259
514
  { asset: "env.example", target: ".env.example" },
260
515
  ...framework.files.map((path) => ({
@@ -264,7 +519,7 @@ function frameworkFiles(framework, root) {
264
519
  ];
265
520
  }
266
521
  function cliVersion() {
267
- const pkg = JSON.parse(readFileSync(CLI_PACKAGE_JSON, "utf8"));
522
+ const pkg = JSON.parse(readFileSync3(CLI_PACKAGE_JSON, "utf8"));
268
523
  return pkg.version;
269
524
  }
270
525
  function addDependencies(root, pkg, framework) {
@@ -279,19 +534,19 @@ function addDependencies(root, pkg, framework) {
279
534
  pkg.dependencies = Object.fromEntries(
280
535
  Object.entries(dependencies).sort(([a], [b]) => a.localeCompare(b))
281
536
  );
282
- writeFileSync(
283
- join(root, "package.json"),
537
+ writeFileSync2(
538
+ join3(root, "package.json"),
284
539
  `${JSON.stringify(pkg, null, 2)}
285
540
  `
286
541
  );
287
542
  return missing.map((name) => `${name}@${range}`);
288
543
  }
289
544
  function detectInstallCommand(root) {
290
- if (existsSync(join(root, "pnpm-lock.yaml")) || existsSync(join(root, "pnpm-workspace.yaml"))) {
545
+ if (existsSync3(join3(root, "pnpm-lock.yaml")) || existsSync3(join3(root, "pnpm-workspace.yaml"))) {
291
546
  return "pnpm install";
292
547
  }
293
- if (existsSync(join(root, "yarn.lock"))) return "yarn";
294
- if (existsSync(join(root, "bun.lock")) || existsSync(join(root, "bun.lockb"))) {
548
+ if (existsSync3(join3(root, "yarn.lock"))) return "yarn";
549
+ if (existsSync3(join3(root, "bun.lock")) || existsSync3(join3(root, "bun.lockb"))) {
295
550
  return "bun install";
296
551
  }
297
552
  return "npm install";
@@ -299,9 +554,8 @@ function detectInstallCommand(root) {
299
554
  function frameworkNotes(framework, root, skipped) {
300
555
  const notes = [];
301
556
  if (framework.name === "next") {
302
- const srcPrefix = existsSync(join(root, "src", "app")) ? "src/" : "";
303
- const home = `${srcPrefix}app/page.tsx`;
304
- if (existsSync(join(root, home))) {
557
+ const home = `${nextSrcPrefix(root)}app/page.tsx`;
558
+ if (existsSync3(join3(root, home))) {
305
559
  notes.push({
306
560
  status: "unknown",
307
561
  message: `${home} conflicts with the cmssy catch-all route - delete it and the cmssy page serves /`
@@ -313,7 +567,7 @@ function frameworkNotes(framework, root, skipped) {
313
567
  status: "unknown",
314
568
  message: "the cmssy wiring needs the React integration and a server adapter - run: npx astro add react node"
315
569
  });
316
- if (existsSync(join(root, "src/pages/index.astro"))) {
570
+ if (existsSync3(join3(root, "src/pages/index.astro"))) {
317
571
  notes.push({
318
572
  status: "unknown",
319
573
  message: "src/pages/index.astro shadows the cmssy catch-all for / - delete it and the cmssy page serves /"
@@ -331,8 +585,8 @@ function frameworkNotes(framework, root, skipped) {
331
585
  function runInit(options, deps) {
332
586
  const { log } = deps;
333
587
  try {
334
- const root = resolve(deps.cwd, options.dir ?? ".");
335
- if (!existsSync(root)) {
588
+ const root = resolve2(deps.cwd, options.dir ?? ".");
589
+ if (!existsSync3(root)) {
336
590
  throw new CliError(
337
591
  `${root} does not exist`,
338
592
  "pass --dir with the app's directory, or run cmssy init inside it"
@@ -349,8 +603,8 @@ function runInit(options, deps) {
349
603
  const written = [];
350
604
  const skipped = [];
351
605
  for (const file of frameworkFiles(framework, root)) {
352
- const target = join(root, file.target);
353
- if (existsSync(target) && !options.force) {
606
+ const target = join3(root, file.target);
607
+ if (existsSync3(target) && !options.force) {
354
608
  skipped.push(file.target);
355
609
  log(
356
610
  formatResult({
@@ -360,8 +614,8 @@ function runInit(options, deps) {
360
614
  );
361
615
  continue;
362
616
  }
363
- mkdirSync(dirname(target), { recursive: true });
364
- copyFileSync(join(ASSETS_DIR, framework.name, file.asset), target);
617
+ mkdirSync2(dirname2(target), { recursive: true });
618
+ copyFileSync(join3(ASSETS_DIR, framework.name, file.asset), target);
365
619
  written.push(file.target);
366
620
  log(formatResult({ status: "ok", message: `wrote ${file.target}` }));
367
621
  }
@@ -413,8 +667,8 @@ function runInit(options, deps) {
413
667
  }
414
668
 
415
669
  // src/link.ts
416
- import { existsSync as existsSync2, readFileSync as readFileSync2, writeFileSync as writeFileSync2 } from "fs";
417
- import { join as join2 } from "path";
670
+ import { existsSync as existsSync4, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "fs";
671
+ import { join as join4 } from "path";
418
672
  import {
419
673
  buildEditorUrl,
420
674
  checkDraftSecret,
@@ -484,9 +738,9 @@ function mergeEnvContent(existing, updates) {
484
738
  var ENV_FILES = [".env.local", ".env"];
485
739
  function loadEnvFiles(cwd, env) {
486
740
  for (const file of ENV_FILES) {
487
- const path = join2(cwd, file);
488
- if (!existsSync2(path)) continue;
489
- applyEnv(parseEnvFile(readFileSync2(path, "utf8")), env);
741
+ const path = join4(cwd, file);
742
+ if (!existsSync4(path)) continue;
743
+ applyEnv(parseEnvFile(readFileSync4(path, "utf8")), env);
490
744
  }
491
745
  }
492
746
  function resolveToken(options, deps) {
@@ -521,6 +775,54 @@ function buildDraftPreviewUrls(previewUrl, draftSecret) {
521
775
  exitUrl: `${base}?disable=1`
522
776
  };
523
777
  }
778
+ function draftRouteFix(cwd) {
779
+ const path = `${nextSrcPrefix(cwd)}app/api/draft/route.ts`;
780
+ return [
781
+ `add ${path} so Preview can enter draft mode:`,
782
+ ' import { createDraftRoute } from "@cmssy/next/server";',
783
+ ' import { cmssy } from "@/cmssy.config";',
784
+ " export const GET = createDraftRoute(cmssy);"
785
+ ].join("\n");
786
+ }
787
+ async function checkDraftRouteMounted(previewUrl, fetchImpl, cwd) {
788
+ const base = draftRouteBase(previewUrl);
789
+ if (!base) {
790
+ return {
791
+ status: "fail",
792
+ message: `the workspace preview URL ${previewUrl} is not a valid URL`,
793
+ fix: "set a valid deployed origin as the preview URL: cmssy link --preview-url https://your-site.com"
794
+ };
795
+ }
796
+ let status;
797
+ try {
798
+ status = (await fetchImpl(base, {
799
+ method: "HEAD",
800
+ signal: AbortSignal.timeout(5e3)
801
+ })).status;
802
+ } catch {
803
+ return {
804
+ status: "unknown",
805
+ message: `could not reach ${base} to check the /api/draft route`
806
+ };
807
+ }
808
+ if (status === 404) {
809
+ return {
810
+ status: "fail",
811
+ message: `the /api/draft route is not mounted at ${previewUrl} - Preview will 404`,
812
+ fix: draftRouteFix(cwd)
813
+ };
814
+ }
815
+ if (status === 500) {
816
+ return {
817
+ status: "fail",
818
+ message: `the /api/draft route is mounted but misconfigured at ${previewUrl} - the draft secret must be at least 16 characters`
819
+ };
820
+ }
821
+ return {
822
+ status: "ok",
823
+ message: `the /api/draft route is mounted at ${previewUrl}`
824
+ };
825
+ }
524
826
  async function selectWorkspace(workspaces, options, deps) {
525
827
  if (workspaces.length === 0) {
526
828
  throw new CliError(
@@ -587,9 +889,9 @@ function resolvePreviewUrl(options) {
587
889
  return origin;
588
890
  }
589
891
  function writeEnvLocal(cwd, updates) {
590
- const path = join2(cwd, ".env.local");
591
- const existing = existsSync2(path) ? readFileSync2(path, "utf8") : null;
592
- writeFileSync2(path, mergeEnvContent(existing, updates));
892
+ const path = join4(cwd, ".env.local");
893
+ const existing = existsSync4(path) ? readFileSync4(path, "utf8") : null;
894
+ writeFileSync3(path, mergeEnvContent(existing, updates));
593
895
  }
594
896
  async function runLink(options, deps) {
595
897
  const { cwd, env, log } = deps;
@@ -660,8 +962,17 @@ async function runLink(options, deps) {
660
962
  log(formatResult(reachable));
661
963
  const secretResult = await checkDraftSecret(preflight);
662
964
  log(formatResult(secretResult));
965
+ let draftRouteResult = null;
966
+ if (reachable.previewUrl) {
967
+ draftRouteResult = await checkDraftRouteMounted(
968
+ reachable.previewUrl,
969
+ deps.fetch,
970
+ cwd
971
+ );
972
+ log(formatResult(draftRouteResult));
973
+ }
663
974
  log(formatEditorLink(buildEditorUrl(preflight)));
664
- if (reachable.previewUrl && secretResult.status !== "fail") {
975
+ if (reachable.previewUrl && secretResult.status !== "fail" && draftRouteResult?.status !== "fail") {
665
976
  const draftUrls = buildDraftPreviewUrls(
666
977
  reachable.previewUrl,
667
978
  draftSecret
@@ -670,7 +981,8 @@ async function runLink(options, deps) {
670
981
  log(formatDraftPreviewLink(draftUrls.draftUrl, draftUrls.exitUrl));
671
982
  }
672
983
  }
673
- return reachable.status === "fail" || secretResult.status === "fail" ? 1 : 0;
984
+ const failed = reachable.status === "fail" || secretResult.status === "fail" || draftRouteResult?.status === "fail";
985
+ return failed ? 1 : 0;
674
986
  } catch (error) {
675
987
  if (error instanceof CliError) {
676
988
  log(
@@ -696,6 +1008,7 @@ async function runLink(options, deps) {
696
1008
  var USAGE = [
697
1009
  "usage: cmssy <command>",
698
1010
  " cmssy init [--dir <path>] [--force]",
1011
+ " cmssy add block <name> [--dir <path>]",
699
1012
  " cmssy link [--token <cs_...>] [--workspace <slug>] [--preview-url <url>]"
700
1013
  ].join("\n");
701
1014
  function flagValue(args, name) {
@@ -737,6 +1050,19 @@ async function main() {
737
1050
  );
738
1051
  return;
739
1052
  }
1053
+ if (command === "add") {
1054
+ const [kind, name, ...rest] = args;
1055
+ if (kind !== "block" || name === void 0 || name.startsWith("--")) {
1056
+ console.error(USAGE);
1057
+ process.exitCode = 1;
1058
+ return;
1059
+ }
1060
+ process.exitCode = runAddBlock(
1061
+ { name, dir: flagValue(rest, "--dir") },
1062
+ { cwd: process.cwd(), log: (line) => console.log(line) }
1063
+ );
1064
+ return;
1065
+ }
740
1066
  if (command === "link") {
741
1067
  process.exitCode = await runLinkCommand(args);
742
1068
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cmssy/cli",
3
- "version": "9.8.0",
3
+ "version": "9.10.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": "9.8.0"
30
+ "@cmssy/core": "9.10.0"
31
31
  },
32
32
  "devDependencies": {
33
33
  "@types/node": "^20.0.0",