create-apollo-monorepo 0.3.1 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +315 -44
  2. package/package.json +1 -1
package/index.mjs CHANGED
@@ -56,7 +56,12 @@ ${COLORS.bold}Flags:${COLORS.reset}
56
56
  -d, --db <url> DATABASE_URL for backend
57
57
  -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3001 single-origin / 3000 separate)
58
58
  -l, --locale <code> NEXT_PUBLIC_DEFAULT_LOCALE (default: en)
59
- --asset-prefix <path> Single-origin asset namespace (default: /cms-assets, or "none" to disable)
59
+ --admin-prefix <path> Backend prefix in single-origin mode sets Next.js
60
+ adminPrefix on the backend AND the path the frontend
61
+ rewrites to it. Defaults to /admin so backend chunks
62
+ sit alongside admin pages under one rewrite.
63
+ Pass "none" to disable single-origin (separate origins).
64
+ Alias: --asset-prefix
60
65
  --skip-install Skip dependency installation
61
66
  --skip-submodule Skip git submodule add (you'll add it later)
62
67
  -h, --help Show this help message
@@ -122,7 +127,7 @@ function parseArgs(argv) {
122
127
  db: null,
123
128
  url: null,
124
129
  locale: null,
125
- assetPrefix: "/cms-assets",
130
+ adminPrefix: "/admin",
126
131
  skipInstall: false,
127
132
  skipSubmodule: false,
128
133
  help: false,
@@ -157,8 +162,9 @@ function parseArgs(argv) {
157
162
  case "--locale":
158
163
  flags.locale = args[++i];
159
164
  break;
160
- case "--asset-prefix":
161
- flags.assetPrefix = args[++i];
165
+ case "--admin-prefix":
166
+ case "--asset-prefix": // legacy alias
167
+ flags.adminPrefix = args[++i];
162
168
  break;
163
169
  case "--skip-install":
164
170
  flags.skipInstall = true;
@@ -195,19 +201,24 @@ function isValidLocale(code) {
195
201
  return /^[a-z]{2,5}$/i.test(code);
196
202
  }
197
203
 
198
- // Normalize an asset prefix:
204
+ // Normalize the admin/asset prefix:
199
205
  // - empty string / "none" / "off" / "false" → "" (single-origin disabled, fallback to separate origins)
200
206
  // - "/foo" → "/foo"
201
207
  // - "foo" → "/foo"
202
208
  // - "/foo/" → "/foo"
203
- function normalizeAssetPrefix(value) {
209
+ //
210
+ // The same value drives both Next.js `adminPrefix` on the backend AND the
211
+ // path the frontend rewrites at. Defaulting to "/admin" makes backend chunks
212
+ // sit under the admin path so a single `/admin/:path*` rewrite covers
213
+ // everything (admin pages + their JS chunks).
214
+ function normalizeAdminPrefix(value) {
204
215
  if (!value) return "";
205
216
  const trimmed = String(value).trim();
206
217
  if (trimmed === "" || /^(none|off|false|disabled)$/i.test(trimmed)) return "";
207
218
  let p = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
208
219
  p = p.replace(/\/+$/, "");
209
220
  if (!/^\/[a-z0-9._-]+(\/[a-z0-9._-]+)*$/i.test(p)) {
210
- fatal(`Invalid --asset-prefix: ${value}\n Use a path like /cms-assets, or "none" to disable single-origin.`);
221
+ fatal(`Invalid --admin-prefix: ${value}\n Use a path like /admin, or "none" to disable single-origin.`);
211
222
  }
212
223
  return p;
213
224
  }
@@ -315,10 +326,17 @@ function writeRootPackageJson(targetDir, dirName) {
315
326
  private: true,
316
327
  description: `${dirName} monorepo (frontend + apollo-cms backend submodule)`,
317
328
  scripts: {
318
- dev: "pnpm -r --parallel dev",
329
+ dev: "pnpm cms-plugins:build && pnpm -r --parallel dev",
319
330
  "dev:frontend": "pnpm --filter ./apps/frontend dev",
320
- "dev:backend": "pnpm --filter ./apps/backend dev",
321
- build: "pnpm -r build",
331
+ "dev:backend": "pnpm cms-plugins:build && pnpm --filter ./apps/backend dev",
332
+ // cms-plugins must compile to dist/ before the backend boots, otherwise
333
+ // the loader falls back to index.ts which only works under Bun and
334
+ // emits a warning. Build them sequentially: cms-plugins → backend → frontend.
335
+ build:
336
+ "pnpm cms-plugins:build && pnpm --filter ./apps/backend build && pnpm --filter ./apps/frontend build",
337
+ "cms-plugins:build":
338
+ "pnpm --filter './apps/cms-plugins/*' --parallel --if-present build",
339
+ "cms-plugin:new": "node scripts/new-cms-plugin.mjs",
322
340
  lint: "pnpm -r lint",
323
341
  typecheck: "pnpm -r typecheck",
324
342
  "backend:update": "git submodule update --remote --merge apps/backend",
@@ -371,7 +389,7 @@ function writeRootPackageJson(targetDir, dirName) {
371
389
  function writePnpmWorkspace(targetDir) {
372
390
  writeFileSync(
373
391
  resolve(targetDir, "pnpm-workspace.yaml"),
374
- "packages:\n - 'apps/*'\n",
392
+ "packages:\n - 'apps/*'\n - 'apps/cms-plugins/*'\n",
375
393
  );
376
394
  }
377
395
 
@@ -393,8 +411,8 @@ function writeRootGitignore(targetDir) {
393
411
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
394
412
  }
395
413
 
396
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl }) {
397
- const header = assetPrefix
414
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl }) {
415
+ const header = adminPrefix
398
416
  ? [
399
417
  "# Single-origin monorepo dev env",
400
418
  "# Public origin = frontend (apps/frontend on :3001). Frontend rewrites /admin/*,",
@@ -403,8 +421,8 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
403
421
  : [
404
422
  "# Separate-origins monorepo dev env",
405
423
  "# Backend runs at http://localhost:3000, frontend at http://localhost:3001.",
406
- "# To switch to single-origin: set APOLLO_ASSET_PREFIX, BACKEND_INTERNAL_URL,",
407
- "# NEXT_PUBLIC_APOLLO_ASSET_PREFIX, and add rewrites() to apps/frontend/next.config.ts.",
424
+ "# To switch to single-origin: set APOLLO_ASSET_PREFIX (default: /admin) and",
425
+ "# BACKEND_INTERNAL_URL, then add rewrites() to apps/frontend/next.config.ts.",
408
426
  ];
409
427
 
410
428
  const lines = [
@@ -413,23 +431,32 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
413
431
  "# ── Shared (consumed by both apps) ───────────────────────────────",
414
432
  `DATABASE_URL=${dbUrl}`,
415
433
  `APOLLO_SECRET=${authSecret}`,
434
+ `CRON_SECRET=${cronSecret}`,
416
435
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
417
436
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
418
437
  "",
419
438
  ];
420
439
 
421
- if (assetPrefix) {
440
+ if (adminPrefix) {
422
441
  lines.push(
423
442
  "# ── Backend (apps/backend) ───────────────────────────────────────",
424
- `APOLLO_ASSET_PREFIX=${assetPrefix}`,
443
+ "# Single-origin admin/asset prefix. The frontend rewrites this path",
444
+ "# to the backend, so backend chunks (served at <prefix>/_next/static)",
445
+ "# and admin pages share one rewrite.",
446
+ `APOLLO_ASSET_PREFIX=${adminPrefix}`,
447
+ "# Project-specific plugins — keeps the apollo-cms submodule clean.",
448
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
425
449
  "",
426
450
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
427
451
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
428
- `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
429
452
  "",
430
453
  );
431
454
  } else {
432
455
  lines.push(
456
+ "# ── Backend (apps/backend) ───────────────────────────────────────",
457
+ "# Project-specific plugins — keeps the apollo-cms submodule clean.",
458
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
459
+ "",
433
460
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
434
461
  `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
435
462
  "",
@@ -439,7 +466,7 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
439
466
  writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
440
467
  }
441
468
 
442
- function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl) {
469
+ function writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl) {
443
470
  const dir = resolve(targetDir, FRONTEND_PATH);
444
471
  mkdirSync(dir, { recursive: true });
445
472
  mkdirSync(resolve(dir, "src/app"), { recursive: true });
@@ -500,20 +527,30 @@ function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backend
500
527
  );
501
528
 
502
529
  // Frontend Next.js config:
503
- // - Single-origin mode (assetPrefix set): rewrite all backend paths.
504
- // - Separate-origins mode (assetPrefix empty): no rewrites; the two apps
530
+ // - Single-origin mode (adminPrefix set): rewrite backend paths to apps/backend.
531
+ // The chosen prefix is baked into the file at scaffold time — no
532
+ // NEXT_PUBLIC_* env mirror, just `BACKEND_INTERNAL_URL` for the proxy target.
533
+ // - Separate-origins mode (adminPrefix empty): no rewrites; the two apps
505
534
  // are reachable on their own ports/subdomains.
506
- const nextConfigContent = assetPrefix
535
+ //
536
+ // When adminPrefix === "/admin" the explicit admin rewrite already covers
537
+ // backend chunks served under /admin/_next/static (via APOLLO_ASSET_PREFIX
538
+ // on the backend), so no separate prefix rewrite is emitted. For any other
539
+ // prefix the script also emits a rewrite for the assets path.
540
+ const isDefaultAdmin = adminPrefix === "/admin";
541
+ const extraPrefixRewrite = adminPrefix && !isDefaultAdmin
542
+ ? ` // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)\n { source: "${adminPrefix}/:path*", destination: \`\${BACKEND}${adminPrefix}/:path*\` },\n`
543
+ : "";
544
+ const nextConfigContent = adminPrefix
507
545
  ? `import type { NextConfig } from "next";
508
546
 
509
547
  const BACKEND = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
510
- const ASSET_PREFIX = process.env.NEXT_PUBLIC_APOLLO_ASSET_PREFIX ?? "${assetPrefix}";
511
548
 
512
549
  const config: NextConfig = {
513
550
  reactStrictMode: true,
514
551
  async rewrites() {
515
552
  return [
516
- // Apollo CMS admin UI
553
+ // Apollo CMS admin UI (and its chunks when APOLLO_ASSET_PREFIX is /admin)
517
554
  { source: "/admin/:path*", destination: \`\${BACKEND}/admin/:path*\` },
518
555
  // Apollo CMS APIs (REST + auth + cron + email + health + mcp + …)
519
556
  { source: "/api/auth/:path*", destination: \`\${BACKEND}/api/auth/:path*\` },
@@ -528,9 +565,7 @@ const config: NextConfig = {
528
565
  { source: "/uploads/:path*", destination: \`\${BACKEND}/uploads/:path*\` },
529
566
  // Sentry tunnel (no-op if SENTRY_DSN isn't set on the backend)
530
567
  { source: "/monitoring", destination: \`\${BACKEND}/monitoring\` },
531
- // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)
532
- { source: \`\${ASSET_PREFIX}/:path*\`, destination: \`\${BACKEND}\${ASSET_PREFIX}/:path*\` },
533
- ];
568
+ ${extraPrefixRewrite} ];
534
569
  },
535
570
  };
536
571
 
@@ -549,12 +584,10 @@ export default config;
549
584
  `;
550
585
  writeFileSync(resolve(dir, "next.config.ts"), nextConfigContent);
551
586
 
552
- const envLocalLines = assetPrefix
587
+ const envLocalLines = adminPrefix
553
588
  ? [
554
589
  `# Internal backend URL (used by Next.js rewrites — not exposed to browser)`,
555
590
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
556
- `# Mirror of backend's APOLLO_ASSET_PREFIX`,
557
- `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
558
591
  "",
559
592
  ]
560
593
  : [
@@ -594,8 +627,202 @@ export default config;
594
627
  );
595
628
  }
596
629
 
597
- function writeReadme(targetDir, dirName, frontendName, assetPrefix) {
598
- const singleOrigin = !!assetPrefix;
630
+ // ─── Custom plugins scaffold ─────────────────────────────────────────────────
631
+
632
+ function writeExampleCmsPlugin(targetDir, dirName) {
633
+ const slug = "example-plugin";
634
+ const pkgName = `@${dirName}/cms-${slug}`;
635
+ const pluginDir = resolve(targetDir, "apps/cms-plugins", slug);
636
+ mkdirSync(pluginDir, { recursive: true });
637
+
638
+ const manifest = {
639
+ name: slug,
640
+ version: "0.1.0",
641
+ title: "Example Plugin",
642
+ description: "Project-specific plugin scaffold for the monorepo",
643
+ author: "you",
644
+ };
645
+ writeFileSync(resolve(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n");
646
+
647
+ const pkg = {
648
+ name: pkgName,
649
+ version: "0.1.0",
650
+ private: true,
651
+ type: "module",
652
+ exports: {
653
+ "./server": "./dist/server.mjs",
654
+ },
655
+ scripts: {
656
+ build: "node ./build.mjs",
657
+ },
658
+ devDependencies: {
659
+ esbuild: "^0.24.0",
660
+ },
661
+ };
662
+ writeFileSync(resolve(pluginDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
663
+
664
+ // Tiny zero-config build: bundle index.ts → dist/server.mjs as ESM Node.
665
+ const buildMjs = `import { build } from "esbuild";
666
+
667
+ await build({
668
+ entryPoints: ["./index.ts"],
669
+ outfile: "./dist/server.mjs",
670
+ format: "esm",
671
+ platform: "node",
672
+ target: "node20",
673
+ bundle: true,
674
+ // Mark Apollo CMS internals as external — they're provided by the host
675
+ // backend at runtime via the loader's dynamic import().
676
+ external: ["@/*", "next", "react", "react-dom"],
677
+ logLevel: "info",
678
+ });
679
+ `;
680
+ writeFileSync(resolve(pluginDir, "build.mjs"), buildMjs);
681
+
682
+ const tsconfig = {
683
+ compilerOptions: {
684
+ target: "ES2022",
685
+ module: "esnext",
686
+ moduleResolution: "bundler",
687
+ esModuleInterop: true,
688
+ skipLibCheck: true,
689
+ strict: true,
690
+ noEmit: true,
691
+ jsx: "preserve",
692
+ // Resolve `@/...` against the apollo-cms backend so plugin code can
693
+ // type-check against the real types from the submodule.
694
+ baseUrl: "../../backend",
695
+ paths: { "@/*": ["src/*"] },
696
+ },
697
+ include: ["**/*.ts", "**/*.tsx"],
698
+ exclude: ["dist", "node_modules"],
699
+ };
700
+ writeFileSync(resolve(pluginDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
701
+
702
+ const indexTs = `// Example Apollo CMS plugin — runs inside apps/backend at boot.
703
+ //
704
+ // The plugin loader picks up this file via package.json#exports["./server"]
705
+ // → dist/server.mjs (built by \`pnpm cms-plugins:build\`).
706
+ //
707
+ // In dev under Bun the loader can also import index.ts directly (with a
708
+ // warning); in production (Vercel / next start) the dist file is required.
709
+
710
+ import type { PluginDefinition } from "@/lib/plugins/types";
711
+
712
+ const plugin: PluginDefinition = {
713
+ // Register hooks — see apollo-cms's HOOKS export for the full surface.
714
+ registerHooks(hooks, HOOKS) {
715
+ hooks.action(HOOKS.auth.afterLogin, async (ctx) => {
716
+ console.log("[example-plugin] login:", ctx.authUserId);
717
+ });
718
+ },
719
+
720
+ // Register UI slots — inject components into the admin shell.
721
+ // registerUiSlots(slots) { ... }
722
+
723
+ // Register API routes under /api/v1/<your-route>.
724
+ // registerApiRoutes(router) { ... }
725
+ };
726
+
727
+ export default plugin;
728
+ `;
729
+ writeFileSync(resolve(pluginDir, "index.ts"), indexTs);
730
+
731
+ writeFileSync(
732
+ resolve(pluginDir, ".gitignore"),
733
+ ["node_modules", "dist", ""].join("\n"),
734
+ );
735
+
736
+ const readme = `# ${pkgName}
737
+
738
+ Project-specific plugin loaded by the apollo-cms backend via
739
+ \`APOLLO_EXTRA_PLUGINS_DIR\`.
740
+
741
+ ## Develop
742
+
743
+ Run from the **monorepo root** (not this folder):
744
+
745
+ \`\`\`bash
746
+ pnpm dev # builds plugins + starts backend & frontend
747
+ pnpm cms-plugins:build # rebuild after editing
748
+ \`\`\`
749
+
750
+ ## Author a new hook
751
+
752
+ Open \`index.ts\` and add to \`registerHooks\`. The full hook surface lives in
753
+ \`apps/backend/src/lib/plugins/hook-registry.ts\`.
754
+
755
+ ## Add another plugin
756
+
757
+ \`\`\`bash
758
+ pnpm cms-plugin:new my-plugin
759
+ \`\`\`
760
+ `;
761
+ writeFileSync(resolve(pluginDir, "README.md"), readme);
762
+ }
763
+
764
+ function writeNewCmsPluginScript(targetDir) {
765
+ const scriptsDir = resolve(targetDir, "scripts");
766
+ mkdirSync(scriptsDir, { recursive: true });
767
+
768
+ const script = `#!/usr/bin/env node
769
+ // Scaffold a new project-specific apollo-cms plugin under apps/cms-plugins/<slug>.
770
+ // Usage: pnpm cms-plugin:new <slug>
771
+
772
+ import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
773
+ import { dirname, resolve } from "node:path";
774
+ import { fileURLToPath } from "node:url";
775
+
776
+ const __dirname = dirname(fileURLToPath(import.meta.url));
777
+ const monorepoRoot = resolve(__dirname, "..");
778
+ const pluginsRoot = resolve(monorepoRoot, "apps/cms-plugins");
779
+ const template = resolve(pluginsRoot, "example-plugin");
780
+
781
+ const slug = (process.argv[2] ?? "").trim();
782
+ if (!slug) {
783
+ console.error("Usage: pnpm cms-plugin:new <slug>");
784
+ process.exit(1);
785
+ }
786
+ if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
787
+ console.error(\`Invalid slug "\${slug}" — use kebab-case (lowercase, digits, hyphens).\`);
788
+ process.exit(1);
789
+ }
790
+ if (!existsSync(template)) {
791
+ console.error(\`Template not found: \${template}\\nRecreate apps/cms-plugins/example-plugin or restore from a fresh \\\`npx create-apollo-monorepo\\\` scaffold.\`);
792
+ process.exit(1);
793
+ }
794
+
795
+ const target = resolve(pluginsRoot, slug);
796
+ if (existsSync(target)) {
797
+ console.error(\`Plugin already exists: \${target}\`);
798
+ process.exit(1);
799
+ }
800
+
801
+ cpSync(template, target, { recursive: true, filter: (src) => !/[\\\\\\/](node_modules|dist)([\\\\\\/]|$)/.test(src) });
802
+
803
+ // Patch plugin.json
804
+ const manifestPath = resolve(target, "plugin.json");
805
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
806
+ manifest.name = slug;
807
+ manifest.title = slug.replace(/-/g, " ").replace(/\\b\\w/g, (c) => c.toUpperCase());
808
+ manifest.description = \`Project plugin: \${slug}\`;
809
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\\n");
810
+
811
+ // Patch package.json
812
+ const pkgPath = resolve(target, "package.json");
813
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
814
+ const rootPkg = JSON.parse(readFileSync(resolve(monorepoRoot, "package.json"), "utf-8"));
815
+ pkg.name = \`@\${rootPkg.name}/cms-\${slug}\`;
816
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\\n");
817
+
818
+ console.log(\`Created apps/cms-plugins/\${slug}\`);
819
+ console.log(\`Run: pnpm install && pnpm cms-plugins:build && pnpm dev:backend\`);
820
+ `;
821
+ writeFileSync(resolve(scriptsDir, "new-cms-plugin.mjs"), script);
822
+ }
823
+
824
+ function writeReadme(targetDir, dirName, frontendName, adminPrefix) {
825
+ const singleOrigin = !!adminPrefix;
599
826
 
600
827
  const originSection = singleOrigin
601
828
  ? `## Routing model — single origin
@@ -609,7 +836,7 @@ paths to the backend so /_next/* doesn't collide:
609
836
  | \`/admin/*\` | \`apps/backend\` |
610
837
  | \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/cron\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\` | \`apps/backend\` |
611
838
  | \`/uploads/*\` | \`apps/backend\` (media) |
612
- | \`${assetPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
839
+ | \`${adminPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
613
840
 
614
841
  The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
615
842
  If you need your own API, namespace it under \`/api/internal/*\` or similar.
@@ -637,7 +864,7 @@ The two apps run on separate origins. Default ports:
637
864
 
638
865
  \`NEXT_PUBLIC_SITE_URL\` should point at whichever origin you treat as the
639
866
  public CMS URL (typically the backend). To switch to **single-origin** later,
640
- re-run the installer with \`--asset-prefix /cms-assets\` or set
867
+ re-run the installer with \`--admin-prefix /admin\` or set
641
868
  \`APOLLO_ASSET_PREFIX\` in \`apps/backend/.env.local\` and add the matching
642
869
  \`rewrites()\` block to \`apps/frontend/next.config.ts\`.
643
870
  `;
@@ -666,6 +893,39 @@ pnpm dev # runs frontend (3001) + backend (3000) in parallel
666
893
  \`\`\`
667
894
 
668
895
  ${originSection}
896
+ ## Custom plugins
897
+
898
+ Project-specific plugins live in \`apps/cms-plugins/<slug>/\` and are loaded by
899
+ the apollo-cms backend via \`APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins\` (set
900
+ automatically in \`apps/backend/.env.local\`). The submodule stays clean —
901
+ \`pnpm backend:update\` won't conflict with your plugins.
902
+
903
+ ### Author a new plugin
904
+
905
+ \`\`\`bash
906
+ pnpm cms-plugin:new my-plugin
907
+ pnpm install
908
+ pnpm dev
909
+ \`\`\`
910
+
911
+ The scaffolder copies \`apps/cms-plugins/example-plugin/\` to
912
+ \`apps/cms-plugins/my-plugin/\` and renames it. Edit \`index.ts\` to register
913
+ hooks, UI slots, and API routes — see
914
+ \`apps/backend/docs/plugin-system.md\` for the full surface.
915
+
916
+ ### Build pipeline
917
+
918
+ \`pnpm dev\` and \`pnpm build\` run \`pnpm cms-plugins:build\` first to compile
919
+ each plugin's \`index.ts\` → \`dist/server.mjs\` (via esbuild). The backend's
920
+ plugin loader resolves \`dist/server.mjs\` in production; in dev under Bun it
921
+ also accepts \`index.ts\` directly.
922
+
923
+ ### Slug collisions
924
+
925
+ Built-in plugins under \`apps/backend/plugins/\` always win on slug collision.
926
+ If you see *"Plugin slug collision"* in the backend logs, rename your plugin's
927
+ folder + \`plugin.json#name\`.
928
+
669
929
  ## Updating the backend
670
930
 
671
931
  Apollo CMS is tracked as a git submodule. Pull the latest:
@@ -699,7 +959,7 @@ Two Vercel projects, one repo. Each project picks up its own Root Directory.
699
959
  APOLLO_SECRET=<openssl rand -hex 32>
700
960
  NEXT_PUBLIC_SITE_URL=https://yourdomain.com # the PUBLIC origin
701
961
  NEXT_PUBLIC_DEFAULT_LOCALE=en
702
- ${singleOrigin ? `APOLLO_ASSET_PREFIX=${assetPrefix}` : "# APOLLO_ASSET_PREFIX=/cms-assets # only when single-origin"}
962
+ ${singleOrigin ? `APOLLO_ASSET_PREFIX=${adminPrefix}` : "# APOLLO_ASSET_PREFIX=/cms-assets # only when single-origin"}
703
963
  CRON_SECRET=<random> # protects /api/cron
704
964
  # Storage on Vercel cannot use local FS — pick one:
705
965
  # Vercel Blob: BLOB_READ_WRITE_TOKEN=…
@@ -719,7 +979,7 @@ Two Vercel projects, one repo. Each project picks up its own Root Directory.
719
979
  \`\`\`
720
980
  NEXT_PUBLIC_SITE_URL=https://yourdomain.com
721
981
  ${singleOrigin
722
- ? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app\n NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`
982
+ ? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app`
723
983
  : ` NEXT_PUBLIC_BACKEND_URL=https://<your-backend>.vercel.app`
724
984
  }
725
985
  \`\`\`
@@ -774,13 +1034,16 @@ async function main() {
774
1034
 
775
1035
  // ── Step 2: Gather env ──
776
1036
  step(2, "Configuring environment");
777
- const assetPrefix = normalizeAssetPrefix(flags.assetPrefix);
778
- const singleOrigin = !!assetPrefix;
1037
+ const adminPrefix =normalizeAdminPrefix(flags.adminPrefix);
1038
+ const singleOrigin = !!adminPrefix;
779
1039
  const { dbUrl, siteUrl, locale } = await gatherEnv(flags, { singleOrigin });
780
1040
  const authSecret = randomBytes(48).toString("base64");
1041
+ // Required by apollo-cms's /api/cron route and scripts/dev-cron.ts in dev.
1042
+ // Without it the dev cron loop hits 403 "CRON_SECRET not configured".
1043
+ const cronSecret = randomBytes(24).toString("hex");
781
1044
  const backendInternalUrl = "http://localhost:3000";
782
1045
  success(`Frontend pkg name: ${frontendName}`);
783
- success(`Asset prefix: ${assetPrefix || "(disabled — separate origins)"}`);
1046
+ success(`Asset prefix: ${adminPrefix || "(disabled — separate origins)"}`);
784
1047
 
785
1048
  // ── Step 3: Scaffold root ──
786
1049
  step(3, "Scaffolding monorepo root");
@@ -789,8 +1052,8 @@ async function main() {
789
1052
  writeRootPackageJson(targetDir, dirName);
790
1053
  writePnpmWorkspace(targetDir);
791
1054
  writeRootGitignore(targetDir);
792
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl });
793
- writeReadme(targetDir, dirName, frontendName, assetPrefix);
1055
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
1056
+ writeReadme(targetDir, dirName, frontendName, adminPrefix);
794
1057
  success("package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md");
795
1058
 
796
1059
  // ── Step 4: git init ──
@@ -800,9 +1063,15 @@ async function main() {
800
1063
 
801
1064
  // ── Step 5: Frontend skeleton ──
802
1065
  step(5, "Creating frontend app");
803
- writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl);
1066
+ writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl);
804
1067
  success(`apps/frontend (${frontendName})`);
805
1068
 
1069
+ // ── Step 5b: Custom plugins workspace + example ──
1070
+ step("5b", "Scaffolding apps/cms-plugins/example-plugin");
1071
+ writeExampleCmsPlugin(targetDir, dirName);
1072
+ writeNewCmsPluginScript(targetDir);
1073
+ success("apps/cms-plugins/example-plugin + scripts/new-cms-plugin.mjs");
1074
+
806
1075
  // ── Step 6: Backend submodule ──
807
1076
  if (flags.skipSubmodule) {
808
1077
  step(6, "Skipping git submodule (--skip-submodule)");
@@ -828,11 +1097,13 @@ async function main() {
828
1097
  const backendEnvLines = [
829
1098
  `DATABASE_URL=${dbUrl}`,
830
1099
  `APOLLO_SECRET=${authSecret}`,
1100
+ `CRON_SECRET=${cronSecret}`,
831
1101
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
832
1102
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
1103
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
833
1104
  ];
834
- if (assetPrefix) {
835
- backendEnvLines.push(`APOLLO_ASSET_PREFIX=${assetPrefix}`);
1105
+ if (adminPrefix) {
1106
+ backendEnvLines.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
836
1107
  }
837
1108
  backendEnvLines.push("");
838
1109
  writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnvLines.join("\n"));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-apollo-monorepo",
3
- "version": "0.3.1",
3
+ "version": "0.5.0",
4
4
  "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend (single-origin via Next.js rewrites + assetPrefix)",
5
5
  "bin": {
6
6
  "create-apollo-monorepo": "index.mjs"