create-apollo-monorepo 0.2.0 → 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 +431 -43
  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,25 +326,70 @@ 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",
325
343
  "backend:setup": "pnpm --filter ./apps/backend setup",
326
344
  },
327
345
  engines: { node: ">=20", pnpm: ">=9" },
328
- packageManager: "pnpm@9.0.0",
346
+ pnpm: {
347
+ // apollo-cms transitively pulls multiple esbuild versions (0.18, 0.25, 0.27).
348
+ // pnpm's binary symlink can pick the wrong platform binary for esbuild's
349
+ // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
350
+ // Allow listed packages to run their build/postinstall scripts; the rest
351
+ // are skipped (pnpm 10 default is empty allow-list).
352
+ onlyBuiltDependencies: [
353
+ "@swc/core",
354
+ "@swc/core-darwin-arm64",
355
+ "@swc/core-darwin-x64",
356
+ "@swc/core-linux-arm64-gnu",
357
+ "@swc/core-linux-x64-gnu",
358
+ "esbuild",
359
+ "msw",
360
+ "sharp",
361
+ "unrs-resolver",
362
+ "@rolldown/binding-darwin-arm64",
363
+ "@rolldown/binding-linux-x64-gnu",
364
+ "@rolldown/binding-linux-arm64-gnu",
365
+ "better-sqlite3",
366
+ "core-js",
367
+ "core-js-pure",
368
+ ],
369
+ },
329
370
  };
330
371
  writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
372
+
373
+ // .npmrc — keep peer-deps lenient and hoist esbuild's platform binaries so
374
+ // its postinstall version check resolves the correct one across nested
375
+ // dependency trees.
376
+ writeFileSync(
377
+ resolve(targetDir, ".npmrc"),
378
+ [
379
+ "auto-install-peers=true",
380
+ "strict-peer-dependencies=false",
381
+ "public-hoist-pattern[]=*esbuild*",
382
+ "public-hoist-pattern[]=*types*",
383
+ "shell-emulator=true",
384
+ "",
385
+ ].join("\n"),
386
+ );
331
387
  }
332
388
 
333
389
  function writePnpmWorkspace(targetDir) {
334
390
  writeFileSync(
335
391
  resolve(targetDir, "pnpm-workspace.yaml"),
336
- "packages:\n - 'apps/*'\n",
392
+ "packages:\n - 'apps/*'\n - 'apps/cms-plugins/*'\n",
337
393
  );
338
394
  }
339
395
 
@@ -355,8 +411,8 @@ function writeRootGitignore(targetDir) {
355
411
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
356
412
  }
357
413
 
358
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl }) {
359
- const header = assetPrefix
414
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl }) {
415
+ const header = adminPrefix
360
416
  ? [
361
417
  "# Single-origin monorepo dev env",
362
418
  "# Public origin = frontend (apps/frontend on :3001). Frontend rewrites /admin/*,",
@@ -365,8 +421,8 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
365
421
  : [
366
422
  "# Separate-origins monorepo dev env",
367
423
  "# Backend runs at http://localhost:3000, frontend at http://localhost:3001.",
368
- "# To switch to single-origin: set APOLLO_ASSET_PREFIX, BACKEND_INTERNAL_URL,",
369
- "# 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.",
370
426
  ];
371
427
 
372
428
  const lines = [
@@ -375,23 +431,32 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
375
431
  "# ── Shared (consumed by both apps) ───────────────────────────────",
376
432
  `DATABASE_URL=${dbUrl}`,
377
433
  `APOLLO_SECRET=${authSecret}`,
434
+ `CRON_SECRET=${cronSecret}`,
378
435
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
379
436
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
380
437
  "",
381
438
  ];
382
439
 
383
- if (assetPrefix) {
440
+ if (adminPrefix) {
384
441
  lines.push(
385
442
  "# ── Backend (apps/backend) ───────────────────────────────────────",
386
- `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`,
387
449
  "",
388
450
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
389
451
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
390
- `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
391
452
  "",
392
453
  );
393
454
  } else {
394
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
+ "",
395
460
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
396
461
  `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
397
462
  "",
@@ -401,7 +466,7 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPref
401
466
  writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
402
467
  }
403
468
 
404
- function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl) {
469
+ function writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl) {
405
470
  const dir = resolve(targetDir, FRONTEND_PATH);
406
471
  mkdirSync(dir, { recursive: true });
407
472
  mkdirSync(resolve(dir, "src/app"), { recursive: true });
@@ -462,20 +527,30 @@ function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backend
462
527
  );
463
528
 
464
529
  // Frontend Next.js config:
465
- // - Single-origin mode (assetPrefix set): rewrite all backend paths.
466
- // - 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
467
534
  // are reachable on their own ports/subdomains.
468
- 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
469
545
  ? `import type { NextConfig } from "next";
470
546
 
471
547
  const BACKEND = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
472
- const ASSET_PREFIX = process.env.NEXT_PUBLIC_APOLLO_ASSET_PREFIX ?? "${assetPrefix}";
473
548
 
474
549
  const config: NextConfig = {
475
550
  reactStrictMode: true,
476
551
  async rewrites() {
477
552
  return [
478
- // Apollo CMS admin UI
553
+ // Apollo CMS admin UI (and its chunks when APOLLO_ASSET_PREFIX is /admin)
479
554
  { source: "/admin/:path*", destination: \`\${BACKEND}/admin/:path*\` },
480
555
  // Apollo CMS APIs (REST + auth + cron + email + health + mcp + …)
481
556
  { source: "/api/auth/:path*", destination: \`\${BACKEND}/api/auth/:path*\` },
@@ -490,9 +565,7 @@ const config: NextConfig = {
490
565
  { source: "/uploads/:path*", destination: \`\${BACKEND}/uploads/:path*\` },
491
566
  // Sentry tunnel (no-op if SENTRY_DSN isn't set on the backend)
492
567
  { source: "/monitoring", destination: \`\${BACKEND}/monitoring\` },
493
- // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)
494
- { source: \`\${ASSET_PREFIX}/:path*\`, destination: \`\${BACKEND}\${ASSET_PREFIX}/:path*\` },
495
- ];
568
+ ${extraPrefixRewrite} ];
496
569
  },
497
570
  };
498
571
 
@@ -511,12 +584,10 @@ export default config;
511
584
  `;
512
585
  writeFileSync(resolve(dir, "next.config.ts"), nextConfigContent);
513
586
 
514
- const envLocalLines = assetPrefix
587
+ const envLocalLines = adminPrefix
515
588
  ? [
516
589
  `# Internal backend URL (used by Next.js rewrites — not exposed to browser)`,
517
590
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
518
- `# Mirror of backend's APOLLO_ASSET_PREFIX`,
519
- `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
520
591
  "",
521
592
  ]
522
593
  : [
@@ -540,10 +611,218 @@ export default config;
540
611
  resolve(dir, ".gitignore"),
541
612
  ["node_modules", ".next", "dist", ".env.local", ""].join("\n"),
542
613
  );
614
+
615
+ // Vercel project config for the frontend. Skip cron + region pinning is
616
+ // optional; configure Root Directory + "Include all submodules" in the
617
+ // Vercel UI when linking the project.
618
+ const vercelJson = {
619
+ $schema: "https://openapi.vercel.sh/vercel.json",
620
+ regions: ["sin1"],
621
+ };
622
+ writeFileSync(resolve(dir, "vercel.json"), JSON.stringify(vercelJson, null, 2) + "\n");
623
+
624
+ writeFileSync(
625
+ resolve(dir, ".vercelignore"),
626
+ ["node_modules", ".next", ".env.local", ""].join("\n"),
627
+ );
543
628
  }
544
629
 
545
- function writeReadme(targetDir, dirName, frontendName, assetPrefix) {
546
- 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;
547
826
 
548
827
  const originSection = singleOrigin
549
828
  ? `## Routing model — single origin
@@ -557,7 +836,7 @@ paths to the backend so /_next/* doesn't collide:
557
836
  | \`/admin/*\` | \`apps/backend\` |
558
837
  | \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/cron\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\` | \`apps/backend\` |
559
838
  | \`/uploads/*\` | \`apps/backend\` (media) |
560
- | \`${assetPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
839
+ | \`${adminPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
561
840
 
562
841
  The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
563
842
  If you need your own API, namespace it under \`/api/internal/*\` or similar.
@@ -585,7 +864,7 @@ The two apps run on separate origins. Default ports:
585
864
 
586
865
  \`NEXT_PUBLIC_SITE_URL\` should point at whichever origin you treat as the
587
866
  public CMS URL (typically the backend). To switch to **single-origin** later,
588
- re-run the installer with \`--asset-prefix /cms-assets\` or set
867
+ re-run the installer with \`--admin-prefix /admin\` or set
589
868
  \`APOLLO_ASSET_PREFIX\` in \`apps/backend/.env.local\` and add the matching
590
869
  \`rewrites()\` block to \`apps/frontend/next.config.ts\`.
591
870
  `;
@@ -614,6 +893,39 @@ pnpm dev # runs frontend (3001) + backend (3000) in parallel
614
893
  \`\`\`
615
894
 
616
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
+
617
929
  ## Updating the backend
618
930
 
619
931
  Apollo CMS is tracked as a git submodule. Pull the latest:
@@ -629,6 +941,71 @@ Do **not** edit files inside \`apps/backend\`. Open issues / PRs in the
629
941
 
630
942
  Shared dev env lives in the root \`.env.local\`. The backend reads its own
631
943
  \`apps/backend/.env.local\` (already populated by the installer).
944
+
945
+ ## Deploy on Vercel
946
+
947
+ Two Vercel projects, one repo. Each project picks up its own Root Directory.
948
+
949
+ ### 1) Backend project
950
+
951
+ - **Import** this repo into Vercel as a new project.
952
+ - **Root Directory**: \`apps/backend\`
953
+ - **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm --filter ./apps/backend build\`
954
+ - **Install Command**: leave empty (handled in build)
955
+ - **Settings → Git → Include all submodules: ON** (Vercel checks out an empty \`apps/backend\` otherwise)
956
+ - **Environment variables**:
957
+ \`\`\`
958
+ DATABASE_URL=postgresql://…
959
+ APOLLO_SECRET=<openssl rand -hex 32>
960
+ NEXT_PUBLIC_SITE_URL=https://yourdomain.com # the PUBLIC origin
961
+ NEXT_PUBLIC_DEFAULT_LOCALE=en
962
+ ${singleOrigin ? `APOLLO_ASSET_PREFIX=${adminPrefix}` : "# APOLLO_ASSET_PREFIX=/cms-assets # only when single-origin"}
963
+ CRON_SECRET=<random> # protects /api/cron
964
+ # Storage on Vercel cannot use local FS — pick one:
965
+ # Vercel Blob: BLOB_READ_WRITE_TOKEN=…
966
+ # S3 / R2 / Spaces: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION, S3_ENDPOINT
967
+ APOLLO_DISABLE_LOCAL_STORAGE=1
968
+ \`\`\`
969
+ - **Cron**: \`apps/backend/vercel.json\` declares \`/api/cron\` on a 5-minute schedule (from apollo-cms upstream).
970
+
971
+ ### 2) Frontend project
972
+
973
+ - **Import the same repo** as a separate Vercel project.
974
+ - **Root Directory**: \`apps/frontend\`
975
+ - **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm --filter ./apps/frontend build\`
976
+ - **Install Command**: leave empty
977
+ - **Settings → Git → Include all submodules: ON**
978
+ - **Environment variables**:
979
+ \`\`\`
980
+ NEXT_PUBLIC_SITE_URL=https://yourdomain.com
981
+ ${singleOrigin
982
+ ? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app`
983
+ : ` NEXT_PUBLIC_BACKEND_URL=https://<your-backend>.vercel.app`
984
+ }
985
+ \`\`\`
986
+
987
+ ### 3) Custom domain
988
+
989
+ Attach \`yourdomain.com\` to the **frontend** project${singleOrigin ? " (in single-origin mode it's the public entry point)" : ""}. The backend stays on its \`*.vercel.app\` URL${singleOrigin ? " — that's what the rewrite proxies to" : ""}.
990
+
991
+ ### 4) Skip duplicate builds (optional)
992
+
993
+ Each push triggers both projects to rebuild. Add an **Ignored Build Step** in
994
+ each project's Settings → Git:
995
+
996
+ - **Backend**: \`git diff HEAD^ HEAD --quiet -- apps/backend\` (exits 0 → skip build)
997
+ - **Frontend**: \`git diff HEAD^ HEAD --quiet -- apps/frontend\`
998
+
999
+ ### Gotchas
1000
+
1001
+ - **Submodule must be initialized** on Vercel — the "Include all submodules"
1002
+ toggle is the most common reason builds fail with a missing \`apps/backend\`.
1003
+ - **OAuth callbacks** for email providers must use the public domain:
1004
+ \`https://yourdomain.com/api/email/oauth/callback\`.${singleOrigin ? " The frontend rewrite forwards it to the backend." : ""}
1005
+ - **Cron** runs on the backend project only. Vercel sends \`Authorization:
1006
+ Bearer $CRON_SECRET\` automatically when \`CRON_SECRET\` is set.${singleOrigin ? `
1007
+ - **Better Auth** uses \`trustedProxyHeaders: true\` so the rewrite proxy's
1008
+ \`x-forwarded-host\` lands cookies at the public origin without extra config.` : ""}
632
1009
  `;
633
1010
  writeFileSync(resolve(targetDir, "README.md"), readme);
634
1011
  }
@@ -657,13 +1034,16 @@ async function main() {
657
1034
 
658
1035
  // ── Step 2: Gather env ──
659
1036
  step(2, "Configuring environment");
660
- const assetPrefix = normalizeAssetPrefix(flags.assetPrefix);
661
- const singleOrigin = !!assetPrefix;
1037
+ const adminPrefix =normalizeAdminPrefix(flags.adminPrefix);
1038
+ const singleOrigin = !!adminPrefix;
662
1039
  const { dbUrl, siteUrl, locale } = await gatherEnv(flags, { singleOrigin });
663
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");
664
1044
  const backendInternalUrl = "http://localhost:3000";
665
1045
  success(`Frontend pkg name: ${frontendName}`);
666
- success(`Asset prefix: ${assetPrefix || "(disabled — separate origins)"}`);
1046
+ success(`Asset prefix: ${adminPrefix || "(disabled — separate origins)"}`);
667
1047
 
668
1048
  // ── Step 3: Scaffold root ──
669
1049
  step(3, "Scaffolding monorepo root");
@@ -672,8 +1052,8 @@ async function main() {
672
1052
  writeRootPackageJson(targetDir, dirName);
673
1053
  writePnpmWorkspace(targetDir);
674
1054
  writeRootGitignore(targetDir);
675
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl });
676
- writeReadme(targetDir, dirName, frontendName, assetPrefix);
1055
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
1056
+ writeReadme(targetDir, dirName, frontendName, adminPrefix);
677
1057
  success("package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md");
678
1058
 
679
1059
  // ── Step 4: git init ──
@@ -683,9 +1063,15 @@ async function main() {
683
1063
 
684
1064
  // ── Step 5: Frontend skeleton ──
685
1065
  step(5, "Creating frontend app");
686
- writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl);
1066
+ writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl);
687
1067
  success(`apps/frontend (${frontendName})`);
688
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
+
689
1075
  // ── Step 6: Backend submodule ──
690
1076
  if (flags.skipSubmodule) {
691
1077
  step(6, "Skipping git submodule (--skip-submodule)");
@@ -711,11 +1097,13 @@ async function main() {
711
1097
  const backendEnvLines = [
712
1098
  `DATABASE_URL=${dbUrl}`,
713
1099
  `APOLLO_SECRET=${authSecret}`,
1100
+ `CRON_SECRET=${cronSecret}`,
714
1101
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
715
1102
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
1103
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
716
1104
  ];
717
- if (assetPrefix) {
718
- backendEnvLines.push(`APOLLO_ASSET_PREFIX=${assetPrefix}`);
1105
+ if (adminPrefix) {
1106
+ backendEnvLines.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
719
1107
  }
720
1108
  backendEnvLines.push("");
721
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.2.0",
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"