create-apollo-monorepo 0.3.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -7
- package/index.mjs +318 -45
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -44,19 +44,29 @@ collide:
|
|
|
44
44
|
| Browser path | Goes to |
|
|
45
45
|
| ------------------------------------ | -------------------- |
|
|
46
46
|
| `/`, your custom routes | `apps/frontend` |
|
|
47
|
-
| `/admin/*` | `apps/backend`
|
|
47
|
+
| `/admin/*` | `apps/backend` (admin pages **and** their JS chunks) |
|
|
48
48
|
| `/api/auth/*`, `/api/v1/*`, `/api/admin/*`, `/api/email/*`, `/api/cron`, `/api/health`, `/api/mcp`, `/api/editing-presence/*` | `apps/backend` |
|
|
49
49
|
| `/uploads/*` | `apps/backend` (media) |
|
|
50
|
-
| `/cms-assets/*` | `apps/backend` chunks (via `APOLLO_ASSET_PREFIX`) |
|
|
51
50
|
| `/monitoring` | `apps/backend` (Sentry tunnel, no-op without DSN) |
|
|
52
51
|
|
|
53
|
-
Apollo CMS
|
|
54
|
-
under
|
|
55
|
-
|
|
52
|
+
Apollo CMS reads `APOLLO_ASSET_PREFIX` (default `/admin`) and serves its built
|
|
53
|
+
JS under `<prefix>/_next/static/`. Because the prefix coincides with the admin
|
|
54
|
+
path, a single `/admin/:path*` rewrite covers both pages and chunks — no
|
|
55
|
+
separate asset rewrite is emitted. The frontend **must not** define routes at
|
|
56
|
+
`/admin`, `/api/auth`, `/api/v1`, etc.
|
|
57
|
+
|
|
58
|
+
You can override the prefix:
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
npx create-apollo-monorepo my-app --admin-prefix /cms
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
When the prefix is anything other than `/admin`, the scaffold also emits a
|
|
65
|
+
matching `<prefix>/:path*` rewrite for backend chunks.
|
|
56
66
|
|
|
57
67
|
### Separate origins (fallback)
|
|
58
68
|
|
|
59
|
-
Pass `--
|
|
69
|
+
Pass `--admin-prefix none` (or `off`/`false`/`disabled`) to skip the rewrite
|
|
60
70
|
wiring. The backend runs at `http://localhost:3000` and the frontend at
|
|
61
71
|
`http://localhost:3001`. Useful when you'd rather deploy them on separate
|
|
62
72
|
subdomains (e.g. `cms.example.com` + `example.com`).
|
|
@@ -71,7 +81,7 @@ subdomains (e.g. `cms.example.com` + `example.com`).
|
|
|
71
81
|
| `-d, --db <url>` | _(prompted)_ | `DATABASE_URL` for backend |
|
|
72
82
|
| `-u, --url <url>` | `:3001` single-origin / `:3000` separate | `NEXT_PUBLIC_SITE_URL` |
|
|
73
83
|
| `-l, --locale <code>` | `en` | `NEXT_PUBLIC_DEFAULT_LOCALE` |
|
|
74
|
-
| `--
|
|
84
|
+
| `--admin-prefix <path>` | `/admin` | Single-origin admin/asset namespace; `none` to disable. Alias: `--asset-prefix` |
|
|
75
85
|
| `--skip-install` | off | Don't run `pnpm install` |
|
|
76
86
|
| `--skip-submodule` | off | Don't add the git submodule |
|
|
77
87
|
| `-h, --help` | — | Show help |
|
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
|
-
--
|
|
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
|
-
|
|
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 "--
|
|
161
|
-
|
|
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
|
|
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
|
-
|
|
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 --
|
|
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
|
-
|
|
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,
|
|
397
|
-
const header =
|
|
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
|
|
407
|
-
"#
|
|
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 (
|
|
440
|
+
if (adminPrefix) {
|
|
422
441
|
lines.push(
|
|
423
442
|
"# ── Backend (apps/backend) ───────────────────────────────────────",
|
|
424
|
-
|
|
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,
|
|
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 (
|
|
504
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
598
|
-
|
|
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
|
-
| \`${
|
|
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 \`--
|
|
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:
|
|
@@ -690,7 +950,7 @@ Two Vercel projects, one repo. Each project picks up its own Root Directory.
|
|
|
690
950
|
|
|
691
951
|
- **Import** this repo into Vercel as a new project.
|
|
692
952
|
- **Root Directory**: \`apps/backend\`
|
|
693
|
-
- **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm --filter ./apps/backend build\`
|
|
953
|
+
- **Build Command**: \`cd ../.. && pnpm install --frozen-lockfile && pnpm cms-plugins:build && cp -r ../cms-plugins/* apps/backend/plugins/ 2>/dev/null || true && pnpm --filter ./apps/backend build\`
|
|
694
954
|
- **Install Command**: leave empty (handled in build)
|
|
695
955
|
- **Settings → Git → Include all submodules: ON** (Vercel checks out an empty \`apps/backend\` otherwise)
|
|
696
956
|
- **Environment variables**:
|
|
@@ -699,14 +959,16 @@ 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=${
|
|
962
|
+
${singleOrigin ? `APOLLO_ASSET_PREFIX=${adminPrefix}` : "# APOLLO_ASSET_PREFIX=/admin # only when single-origin"}
|
|
703
963
|
CRON_SECRET=<random> # protects /api/cron
|
|
964
|
+
APOLLO_EXTRA_PLUGINS_DIR=./plugins # picks up copied cms-plugins (see Build Command)
|
|
704
965
|
# Storage on Vercel cannot use local FS — pick one:
|
|
705
966
|
# Vercel Blob: BLOB_READ_WRITE_TOKEN=…
|
|
706
967
|
# S3 / R2 / Spaces: S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, S3_BUCKET, S3_REGION, S3_ENDPOINT
|
|
707
968
|
APOLLO_DISABLE_LOCAL_STORAGE=1
|
|
708
969
|
\`\`\`
|
|
709
970
|
- **Cron**: \`apps/backend/vercel.json\` declares \`/api/cron\` on a 5-minute schedule (from apollo-cms upstream).
|
|
971
|
+
- **About the \`cp\` step**: Turbopack rejects \`outputFileTracingIncludes\` globs that walk above the project root, so the Build Command copies built \`apps/cms-plugins/*/dist\` into \`apps/backend/plugins/\` before \`next build\`. NFT then traces them normally as if they were built-in plugins. Locally this isn't needed because \`APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins\` reads them directly.
|
|
710
972
|
|
|
711
973
|
### 2) Frontend project
|
|
712
974
|
|
|
@@ -719,7 +981,7 @@ Two Vercel projects, one repo. Each project picks up its own Root Directory.
|
|
|
719
981
|
\`\`\`
|
|
720
982
|
NEXT_PUBLIC_SITE_URL=https://yourdomain.com
|
|
721
983
|
${singleOrigin
|
|
722
|
-
? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app
|
|
984
|
+
? ` BACKEND_INTERNAL_URL=https://<your-backend>.vercel.app`
|
|
723
985
|
: ` NEXT_PUBLIC_BACKEND_URL=https://<your-backend>.vercel.app`
|
|
724
986
|
}
|
|
725
987
|
\`\`\`
|
|
@@ -774,13 +1036,16 @@ async function main() {
|
|
|
774
1036
|
|
|
775
1037
|
// ── Step 2: Gather env ──
|
|
776
1038
|
step(2, "Configuring environment");
|
|
777
|
-
const
|
|
778
|
-
const singleOrigin = !!
|
|
1039
|
+
const adminPrefix =normalizeAdminPrefix(flags.adminPrefix);
|
|
1040
|
+
const singleOrigin = !!adminPrefix;
|
|
779
1041
|
const { dbUrl, siteUrl, locale } = await gatherEnv(flags, { singleOrigin });
|
|
780
1042
|
const authSecret = randomBytes(48).toString("base64");
|
|
1043
|
+
// Required by apollo-cms's /api/cron route and scripts/dev-cron.ts in dev.
|
|
1044
|
+
// Without it the dev cron loop hits 403 "CRON_SECRET not configured".
|
|
1045
|
+
const cronSecret = randomBytes(24).toString("hex");
|
|
781
1046
|
const backendInternalUrl = "http://localhost:3000";
|
|
782
1047
|
success(`Frontend pkg name: ${frontendName}`);
|
|
783
|
-
success(`Asset prefix: ${
|
|
1048
|
+
success(`Asset prefix: ${adminPrefix || "(disabled — separate origins)"}`);
|
|
784
1049
|
|
|
785
1050
|
// ── Step 3: Scaffold root ──
|
|
786
1051
|
step(3, "Scaffolding monorepo root");
|
|
@@ -789,8 +1054,8 @@ async function main() {
|
|
|
789
1054
|
writeRootPackageJson(targetDir, dirName);
|
|
790
1055
|
writePnpmWorkspace(targetDir);
|
|
791
1056
|
writeRootGitignore(targetDir);
|
|
792
|
-
writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret,
|
|
793
|
-
writeReadme(targetDir, dirName, frontendName,
|
|
1057
|
+
writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
|
|
1058
|
+
writeReadme(targetDir, dirName, frontendName, adminPrefix);
|
|
794
1059
|
success("package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md");
|
|
795
1060
|
|
|
796
1061
|
// ── Step 4: git init ──
|
|
@@ -800,9 +1065,15 @@ async function main() {
|
|
|
800
1065
|
|
|
801
1066
|
// ── Step 5: Frontend skeleton ──
|
|
802
1067
|
step(5, "Creating frontend app");
|
|
803
|
-
writeFrontendApp(targetDir, frontendName, siteUrl,
|
|
1068
|
+
writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl);
|
|
804
1069
|
success(`apps/frontend (${frontendName})`);
|
|
805
1070
|
|
|
1071
|
+
// ── Step 5b: Custom plugins workspace + example ──
|
|
1072
|
+
step("5b", "Scaffolding apps/cms-plugins/example-plugin");
|
|
1073
|
+
writeExampleCmsPlugin(targetDir, dirName);
|
|
1074
|
+
writeNewCmsPluginScript(targetDir);
|
|
1075
|
+
success("apps/cms-plugins/example-plugin + scripts/new-cms-plugin.mjs");
|
|
1076
|
+
|
|
806
1077
|
// ── Step 6: Backend submodule ──
|
|
807
1078
|
if (flags.skipSubmodule) {
|
|
808
1079
|
step(6, "Skipping git submodule (--skip-submodule)");
|
|
@@ -828,11 +1099,13 @@ async function main() {
|
|
|
828
1099
|
const backendEnvLines = [
|
|
829
1100
|
`DATABASE_URL=${dbUrl}`,
|
|
830
1101
|
`APOLLO_SECRET=${authSecret}`,
|
|
1102
|
+
`CRON_SECRET=${cronSecret}`,
|
|
831
1103
|
`NEXT_PUBLIC_SITE_URL=${siteUrl}`,
|
|
832
1104
|
`NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
|
|
1105
|
+
`APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
|
|
833
1106
|
];
|
|
834
|
-
if (
|
|
835
|
-
backendEnvLines.push(`APOLLO_ASSET_PREFIX=${
|
|
1107
|
+
if (adminPrefix) {
|
|
1108
|
+
backendEnvLines.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
|
|
836
1109
|
}
|
|
837
1110
|
backendEnvLines.push("");
|
|
838
1111
|
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
|
+
"version": "0.5.1",
|
|
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"
|