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.
- package/index.mjs +431 -43
- 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
|
-
--
|
|
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,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
|
-
|
|
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
|
-
|
|
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,
|
|
359
|
-
const header =
|
|
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
|
|
369
|
-
"#
|
|
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 (
|
|
440
|
+
if (adminPrefix) {
|
|
384
441
|
lines.push(
|
|
385
442
|
"# ── Backend (apps/backend) ───────────────────────────────────────",
|
|
386
|
-
|
|
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,
|
|
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 (
|
|
466
|
-
//
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
546
|
-
|
|
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
|
-
| \`${
|
|
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 \`--
|
|
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
|
|
661
|
-
const singleOrigin = !!
|
|
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: ${
|
|
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,
|
|
676
|
-
writeReadme(targetDir, dirName, frontendName,
|
|
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,
|
|
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 (
|
|
718
|
-
backendEnvLines.push(`APOLLO_ASSET_PREFIX=${
|
|
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.
|
|
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"
|