create-apollo-suite-monorepo 1.0.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.
Files changed (3) hide show
  1. package/README.md +161 -0
  2. package/index.mjs +2021 -0
  3. package/package.json +28 -0
package/index.mjs ADDED
@@ -0,0 +1,2021 @@
1
+ #!/usr/bin/env node
2
+
3
+ // create-apollo-suite-monorepo — Zero-dependency scaffolder for an Apollo CMS monorepo
4
+ // Usage: npx create-apollo-suite-monorepo <directory> [flags]
5
+ //
6
+ // Produces:
7
+ // <directory>/
8
+ // ├── apps/
9
+ // │ ├── frontend/ ← your app (pnpm workspace member)
10
+ // │ └── backend/ ← git submodule → apollo-cms
11
+ // ├── package.json
12
+ // ├── pnpm-workspace.yaml
13
+ // ├── .env.local
14
+ // ├── .gitmodules
15
+ // └── .gitignore
16
+
17
+ import { execSync } from "node:child_process";
18
+ import { randomBytes } from "node:crypto";
19
+ import { existsSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
20
+ import { resolve, basename } from "node:path";
21
+ import { createInterface } from "node:readline";
22
+
23
+ // ─── Constants ───────────────────────────────────────────────────────────────
24
+
25
+ const BACKEND_REPO_URL = "https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git";
26
+ const BACKEND_BRANCH = "main";
27
+ const BACKEND_PATH = "apps/backend";
28
+ const FRONTEND_PATH = "apps/frontend";
29
+ const PROXY_PATH = "apps/proxy";
30
+ const MIN_NODE_MAJOR = 22;
31
+
32
+ // Default dev ports. Kept in sync with scripts/with-env.mjs and the proxy
33
+ // template. .env.local is the runtime source of truth — these are the
34
+ // installer-side fallbacks for computing initial URL defaults.
35
+ const DEFAULT_PROXY_PORT = 3030;
36
+ const DEFAULT_FRONTEND_PORT = 3001;
37
+ const DEFAULT_BACKEND_PORT = 3002;
38
+
39
+ const COLORS = {
40
+ reset: "\x1b[0m",
41
+ bold: "\x1b[1m",
42
+ dim: "\x1b[2m",
43
+ red: "\x1b[31m",
44
+ green: "\x1b[32m",
45
+ yellow: "\x1b[33m",
46
+ cyan: "\x1b[36m",
47
+ };
48
+
49
+ const HELP_TEXT = `
50
+ ${COLORS.bold}create-apollo-suite-monorepo${COLORS.reset} — Scaffold a monorepo with Apollo CMS as a git submodule backend
51
+
52
+ ${COLORS.bold}Usage:${COLORS.reset}
53
+ npx create-apollo-suite-monorepo <directory> [flags]
54
+
55
+ ${COLORS.bold}Examples:${COLORS.reset}
56
+ npx create-apollo-suite-monorepo my-site
57
+ npx create-apollo-suite-monorepo my-site --frontend-name "@my-site/frontend"
58
+ npx create-apollo-suite-monorepo my-site --backend-branch develop --skip-install
59
+
60
+ ${COLORS.bold}Flags:${COLORS.reset}
61
+ --frontend-name <name> Frontend package name (default: "@<dir>/frontend")
62
+ --backend-url <url> Backend git URL (default: ${BACKEND_REPO_URL})
63
+ --backend-branch <name> Backend branch to track (default: ${BACKEND_BRANCH})
64
+ -d, --db <url> DATABASE_URL for backend
65
+ -u, --url <url> NEXT_PUBLIC_SITE_URL (optional — left blank so the CMS
66
+ respects the incoming request origin. Set this only when
67
+ you need a fixed origin for background jobs / cron / emails.)
68
+ -l, --locale <code> NEXT_PUBLIC_DEFAULT_LOCALE (default: en)
69
+ --admin-prefix <path> Backend prefix in single-origin mode — sets Next.js
70
+ assetPrefix on the backend AND the path the frontend
71
+ rewrites to it. Defaults to /admin so backend chunks
72
+ sit alongside admin pages under one rewrite.
73
+ Pass "none" to disable single-origin (separate origins).
74
+ Alias: --asset-prefix
75
+ --skip-install Skip dependency installation
76
+ --skip-submodule Skip git submodule add (you'll add it later)
77
+ -h, --help Show this help message
78
+
79
+ ${COLORS.bold}Single-origin model:${COLORS.reset}
80
+ The frontend is the public entry point. It rewrites these paths to the
81
+ backend so both apps live under one domain without /_next/* collisions:
82
+ /admin/* → backend (admin pages + chunks via APOLLO_ASSET_PREFIX=/admin)
83
+ /api/* → backend (REST API + auth)
84
+ /uploads/* → backend (media)
85
+ Frontend MUST NOT define routes at /admin or /api/auth or /api/v1.
86
+ `;
87
+
88
+ // ─── Helpers ─────────────────────────────────────────────────────────────────
89
+
90
+ function log(msg) {
91
+ console.log(msg);
92
+ }
93
+
94
+ function step(num, msg) {
95
+ log(`\n${COLORS.cyan}[${num}]${COLORS.reset} ${msg}`);
96
+ }
97
+
98
+ function success(msg) {
99
+ log(` ${COLORS.green}✓${COLORS.reset} ${msg}`);
100
+ }
101
+
102
+ function warn(msg) {
103
+ log(` ${COLORS.yellow}⚠${COLORS.reset} ${msg}`);
104
+ }
105
+
106
+ function fatal(msg) {
107
+ console.error(`\n${COLORS.red}Error:${COLORS.reset} ${msg}\n`);
108
+ process.exit(1);
109
+ }
110
+
111
+ function run(cmd, opts = {}) {
112
+ return execSync(cmd, { stdio: "inherit", ...opts });
113
+ }
114
+
115
+ function runSilent(cmd, opts = {}) {
116
+ try {
117
+ return execSync(cmd, { stdio: "pipe", ...opts }).toString().trim();
118
+ } catch {
119
+ return null;
120
+ }
121
+ }
122
+
123
+ function commandExists(cmd) {
124
+ return runSilent(process.platform === "win32" ? `where ${cmd}` : `which ${cmd}`) !== null;
125
+ }
126
+
127
+ // ─── Arg Parsing ─────────────────────────────────────────────────────────────
128
+
129
+ // Flag dispatch table. Each entry: aliases → either { key } (takes a value)
130
+ // or { key, value } (boolean flag). `--asset-prefix` is a legacy alias.
131
+ const FLAG_TABLE = {
132
+ "-h": { key: "help", value: true },
133
+ "--help": { key: "help", value: true },
134
+ "--skip-install": { key: "skipInstall", value: true },
135
+ "--skip-submodule": { key: "skipSubmodule", value: true },
136
+ "--frontend-name": { key: "frontendName" },
137
+ "--backend-url": { key: "backendUrl" },
138
+ "--backend-branch": { key: "backendBranch" },
139
+ "-d": { key: "db" },
140
+ "--db": { key: "db" },
141
+ "-u": { key: "url" },
142
+ "--url": { key: "url" },
143
+ "-l": { key: "locale" },
144
+ "--locale": { key: "locale" },
145
+ "--admin-prefix": { key: "adminPrefix" },
146
+ "--asset-prefix": { key: "adminPrefix" },
147
+ };
148
+
149
+ function parseArgs(argv) {
150
+ const args = argv.slice(2);
151
+ const flags = {
152
+ directory: null,
153
+ frontendName: null,
154
+ backendUrl: BACKEND_REPO_URL,
155
+ backendBranch: BACKEND_BRANCH,
156
+ db: null,
157
+ url: null,
158
+ locale: null,
159
+ adminPrefix: "/admin",
160
+ skipInstall: false,
161
+ skipSubmodule: false,
162
+ help: false,
163
+ };
164
+
165
+ for (let i = 0; i < args.length; i++) {
166
+ const arg = args[i];
167
+ const spec = FLAG_TABLE[arg];
168
+ if (spec) {
169
+ flags[spec.key] = "value" in spec ? spec.value : args[++i];
170
+ continue;
171
+ }
172
+ if (arg.startsWith("-")) fatal(`Unknown flag: ${arg}\nRun with --help for usage.`);
173
+ if (flags.directory) fatal(`Unexpected argument: ${arg}\nOnly one directory name is allowed.`);
174
+ flags.directory = arg;
175
+ }
176
+
177
+ return flags;
178
+ }
179
+
180
+ // ─── Validation ──────────────────────────────────────────────────────────────
181
+
182
+ function isValidDbUrl(url) {
183
+ return /^postgres(ql)?:\/\/.+/.test(url);
184
+ }
185
+
186
+ function isValidUrl(url) {
187
+ return /^https?:\/\/.+/.test(url);
188
+ }
189
+
190
+ function isValidLocale(code) {
191
+ return /^[a-z]{2,5}$/i.test(code);
192
+ }
193
+
194
+ // Normalize the admin/asset prefix:
195
+ // - empty string / "none" / "off" / "false" → "" (single-origin disabled, fallback to separate origins)
196
+ // - "/foo" → "/foo"
197
+ // - "foo" → "/foo"
198
+ // - "/foo/" → "/foo"
199
+ //
200
+ // The same value drives both Next.js `adminPrefix` on the backend AND the
201
+ // path the frontend rewrites at. Defaulting to "/admin" makes backend chunks
202
+ // sit under the admin path so a single `/admin/:path*` rewrite covers
203
+ // everything (admin pages + their JS chunks).
204
+ function normalizeAdminPrefix(value) {
205
+ if (!value) return "";
206
+ const trimmed = String(value).trim();
207
+ if (trimmed === "" || /^(none|off|false|disabled)$/i.test(trimmed)) return "";
208
+ let p = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
209
+ p = p.replace(/\/+$/, "");
210
+ if (!/^\/[a-z0-9._-]+(\/[a-z0-9._-]+)*$/i.test(p)) {
211
+ fatal(`Invalid --admin-prefix: ${value}\n Use a path like /admin, or "none" to disable single-origin.`);
212
+ }
213
+ return p;
214
+ }
215
+
216
+ // ─── Pre-flight ──────────────────────────────────────────────────────────────
217
+
218
+ function preflight(flags) {
219
+ step(1, "Pre-flight checks");
220
+
221
+ const nodeMajor = parseInt(process.versions.node.split(".")[0], 10);
222
+ if (nodeMajor < MIN_NODE_MAJOR) {
223
+ fatal(
224
+ `Node.js ${MIN_NODE_MAJOR}+ is required (found ${process.versions.node}).\n Install: https://nodejs.org/`,
225
+ );
226
+ }
227
+ success(`Node.js ${process.versions.node}`);
228
+
229
+ // bun is required at runtime by apps/backend (apollo-cms): db:push, db:seed,
230
+ // plugins:build, the upgrade pipeline, and pre-commit hooks all
231
+ // shell out to `bun`. The scaffold itself works without bun, but `pnpm dev`,
232
+ // `pnpm backend:setup`, and `pnpm backend:upgrade` will fail without it.
233
+ const TOOLS = [
234
+ { cmd: "git", required: true, missing: "git is required but not found.\n Install: https://git-scm.com/" },
235
+ { cmd: "pnpm", required: false, missing: "pnpm not found — install with: npm i -g pnpm (required for workspaces)" },
236
+ { cmd: "bun", required: false, missing:
237
+ "bun not found — required by apps/backend for db:push, db:seed,\n" +
238
+ " plugins:build, and the upgrade pipeline.\n" +
239
+ " Install: curl -fsSL https://bun.sh/install | bash (or: brew install bun)" },
240
+ ];
241
+ for (const t of TOOLS) {
242
+ if (commandExists(t.cmd)) success(`${t.cmd} found`);
243
+ else if (t.required) fatal(t.missing);
244
+ else warn(t.missing);
245
+ }
246
+
247
+ const targetDir = resolve(flags.directory);
248
+ if (existsSync(targetDir)) {
249
+ fatal(`Directory already exists: ${targetDir}\n Choose a different name or remove it first.`);
250
+ }
251
+ success(`Target: ${targetDir}`);
252
+
253
+ return targetDir;
254
+ }
255
+
256
+ // ─── Prompts ─────────────────────────────────────────────────────────────────
257
+
258
+ function createPrompt() {
259
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
260
+ const ask = (q) => new Promise((res) => rl.question(q, (a) => res(a.trim())));
261
+ const close = () => rl.close();
262
+ return { ask, close };
263
+ }
264
+
265
+ async function gatherEnv(flags) {
266
+ let dbUrl = flags.db;
267
+ // NEXT_PUBLIC_SITE_URL is intentionally NOT prompted. Apollo CMS resolves
268
+ // the public origin in this priority order: NEXT_PUBLIC_SITE_URL >
269
+ // incoming request origin. Leaving it blank lets the runtime respect the
270
+ // actual request origin (works for localhost, preview URLs, prod domains,
271
+ // and reverse proxies without any reconfiguration). Override with --url
272
+ // only when you need a fixed origin for background jobs (cron / triggered
273
+ // emails) that run outside a request scope.
274
+ let siteUrl = flags.url ?? "";
275
+ let locale = flags.locale ?? "en";
276
+
277
+ if (flags.db && !isValidDbUrl(flags.db)) fatal("--db must start with postgresql:// or postgres://");
278
+ if (flags.url && !isValidUrl(flags.url)) fatal("--url must start with http:// or https://");
279
+ if (flags.locale && !isValidLocale(flags.locale)) fatal("--locale must be a 2-5 character code (e.g., en, th)");
280
+
281
+ const needsPrompt = !dbUrl || !flags.locale;
282
+ if (!needsPrompt) return { dbUrl, siteUrl, locale };
283
+
284
+ const { ask, close } = createPrompt();
285
+
286
+ if (!dbUrl) {
287
+ log("");
288
+ while (!dbUrl) {
289
+ const ans = await ask(
290
+ ` ${COLORS.bold}DATABASE_URL${COLORS.reset} ${COLORS.dim}(postgresql://user:pass@host:5432/dbname)${COLORS.reset}\n > `,
291
+ );
292
+ if (isValidDbUrl(ans)) dbUrl = ans;
293
+ else warn("Must start with postgresql:// or postgres://");
294
+ }
295
+ }
296
+
297
+ if (!flags.locale) {
298
+ const ans = await ask(
299
+ `\n ${COLORS.bold}Default locale${COLORS.reset} ${COLORS.dim}[${locale}]${COLORS.reset}\n > `,
300
+ );
301
+ if (ans) {
302
+ if (isValidLocale(ans)) locale = ans;
303
+ else warn(`Invalid locale code, using default: ${locale}`);
304
+ }
305
+ }
306
+
307
+ close();
308
+ return { dbUrl, siteUrl, locale };
309
+ }
310
+
311
+ // ─── Scaffolding ─────────────────────────────────────────────────────────────
312
+
313
+ function writeRootPackageJson(targetDir, dirName) {
314
+ const pkg = {
315
+ name: dirName,
316
+ version: "0.0.0",
317
+ private: true,
318
+ description: `${dirName} monorepo (frontend + apollo-cms backend submodule)`,
319
+ scripts: {
320
+ // We bypass apollo-cms's own `dev` script ("bun install && bun run
321
+ // plugins:build && next dev") because the bash-`&`-then-foreground
322
+ // pattern exits with SIGHUP (129) under pnpm's parallel runner. Instead
323
+ // we explicitly run plugins:build (both ours and apollo-cms's) and
324
+ // `next dev` directly. Nothing extra to start for scheduled jobs — the
325
+ // backend drives its scheduler queue in-process (dev and production).
326
+ "predev:setup":
327
+ "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build",
328
+ // `pnpm dev` runs FE + BE Next.js dev servers (which watch their own
329
+ // src/) plus a parallel cms-plugins watcher, so editing
330
+ // apps/cms-plugins/<slug>/index.ts triggers an esbuild incremental
331
+ // rebuild and the backend's plugin loader picks up the fresh
332
+ // dist/server.mjs on next request.
333
+ dev:
334
+ "pnpm predev:setup && concurrently -k -n FE,BE,PL -c blue,magenta,yellow \"pnpm dev:frontend\" \"pnpm dev:backend\" \"pnpm cms-plugins:dev\"",
335
+ // Optional single-origin dev: same as `pnpm dev` but adds a Node.js
336
+ // reverse proxy on :3030 (PROXY_PORT) that fronts FE :3001 (FRONTEND_PORT)
337
+ // + BE :3002 (BACKEND_PORT). Ports come from .env.local; override there.
338
+ "dev:rp":
339
+ "pnpm predev:setup && concurrently -k -n FE,BE,PL,PX -c blue,magenta,yellow,cyan \"pnpm dev:frontend\" \"pnpm dev:backend\" \"pnpm cms-plugins:dev\" \"pnpm dev:proxy\"",
340
+ "dev:proxy": "node scripts/with-env.mjs node apps/proxy/server.mjs",
341
+ "dev:frontend":
342
+ "node scripts/with-env.mjs --port=FRONTEND_PORT pnpm --filter ./apps/frontend exec next dev",
343
+ "dev:backend":
344
+ "pnpm predev:setup && node scripts/with-env.mjs --port=BACKEND_PORT pnpm --filter ./apps/backend exec next dev",
345
+ // Build pipeline: cms-plugins → apollo-cms's own plugins → backend → frontend.
346
+ // `prebuild` runs first (npm/pnpm convention) and fails fast if the
347
+ // backend's required env vars are missing.
348
+ prebuild: "node scripts/check-env.mjs",
349
+ build:
350
+ "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build && pnpm --filter ./apps/backend build && pnpm --filter ./apps/frontend build",
351
+ "build:backend":
352
+ "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build && pnpm --filter ./apps/backend build",
353
+ "build:frontend": "pnpm --filter ./apps/frontend build",
354
+ // Production start. Runs FE :FRONTEND_PORT + BE :BACKEND_PORT (defaults
355
+ // 3001 + 3002 from .env.local). Run \`pnpm backend:upgrade\` BEFORE this —
356
+ // start does NOT run migrations on boot (zero-downtime restart safety).
357
+ start:
358
+ "concurrently -k -n FE,BE -c blue,magenta \"pnpm start:frontend\" \"pnpm start:backend\"",
359
+ // Single-origin production: FE + BE + Node reverse proxy on :PROXY_PORT.
360
+ "start:rp":
361
+ "concurrently -k -n FE,BE,PX -c blue,magenta,cyan \"pnpm start:frontend\" \"pnpm start:backend\" \"pnpm start:proxy\"",
362
+ "start:proxy": "NODE_ENV=production node scripts/with-env.mjs node apps/proxy/server.mjs",
363
+ "start:frontend":
364
+ "node scripts/with-env.mjs --port=FRONTEND_PORT pnpm --filter ./apps/frontend exec next start",
365
+ "start:backend":
366
+ "node scripts/with-env.mjs --port=BACKEND_PORT pnpm --filter ./apps/backend exec next start",
367
+ "cms-plugins:build":
368
+ "pnpm --filter './apps/cms-plugins/*' --parallel --if-present build",
369
+ "cms-plugins:dev":
370
+ "pnpm --filter './apps/cms-plugins/*' --parallel --if-present dev",
371
+ "cms-plugin:new": "node scripts/new-cms-plugin.mjs",
372
+ lint: "pnpm -r lint",
373
+ typecheck: "pnpm -r typecheck",
374
+ // Stash any local changes inside the submodule so the fast-forward
375
+ // merge can't conflict, then update, then run `pnpm install` at the
376
+ // root so workspace deps pick up any package.json changes pulled in
377
+ // from the new apollo-cms commit. The stash is restored if it was
378
+ // created; otherwise this is a no-op.
379
+ "backend:update":
380
+ "node -e \"const {execSync:e}=require('child_process');const r=s=>e(s,{stdio:'inherit'});const has=e('git -C apps/backend status --porcelain',{encoding:'utf8'}).trim().length>0;if(has)r('git -C apps/backend stash push -u -m backend-update-auto');r('git submodule update --remote --merge apps/backend');r('pnpm install');if(has){try{r('git -C apps/backend stash pop');}catch(_){console.error('[backend:update] stash pop had conflicts — resolve manually with: git -C apps/backend stash list');}}\"",
381
+ // `pnpm setup` is pnpm's CLI bootstrap built-in — must use `run` to
382
+ // forward to the workspace's setup script (apollo-cms's db:push + db:seed).
383
+ "backend:setup": "pnpm --filter ./apps/backend run setup",
384
+ // Full apollo-cms upgrade pipeline: drizzle push + replay
385
+ // src/upgrades/0.1.*.ts data migrations + seed. Run this after
386
+ // `pnpm backend:update` so any data-migration step from the new
387
+ // apollo-cms version is applied — `backend:setup` alone skips it.
388
+ "backend:upgrade": "pnpm --filter ./apps/backend exec bun run upgrade",
389
+ },
390
+ devDependencies: {
391
+ concurrently: "^9.0.0",
392
+ // Optional peer of `next` (and `@better-auth/core`). Nothing imports it
393
+ // eagerly at runtime, but `bun build` resolves every `require()` it can
394
+ // see — including the guarded one in next/dist/server/lib/trace/tracer.js
395
+ // — so an uninstalled optional peer fails the plugin bundle with
396
+ // `error: Could not resolve: "@opentelemetry/api"`. bun install pulls
397
+ // optional peers in automatically, which is why apollo-cms standalone
398
+ // never hits this; pnpm skips them, so the monorepo must ask for it.
399
+ "@opentelemetry/api": "^1.9.0",
400
+ },
401
+ // Pin to pnpm 11 because `pnpm-workspace.yaml` uses the `allowBuilds`
402
+ // map format introduced in v11 (deprecated `onlyBuiltDependencies` is
403
+ // silently ignored on v11 — sharp/esbuild postinstalls then no-op and
404
+ // /admin/media crashes at runtime with "Cannot find module sharp-…").
405
+ // Corepack uses this field to auto-fetch the right pnpm.
406
+ packageManager: "pnpm@11.0.0",
407
+ engines: { node: ">=22", pnpm: ">=11" },
408
+ };
409
+ writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
410
+
411
+ // .npmrc — keep peer-deps lenient and hoist esbuild's platform binaries so
412
+ // its postinstall version check resolves the correct one across nested
413
+ // dependency trees.
414
+ writeFileSync(
415
+ resolve(targetDir, ".npmrc"),
416
+ [
417
+ "auto-install-peers=true",
418
+ "strict-peer-dependencies=false",
419
+ "public-hoist-pattern[]=*esbuild*",
420
+ "public-hoist-pattern[]=*types*",
421
+ "shell-emulator=true",
422
+ "",
423
+ ].join("\n"),
424
+ );
425
+ }
426
+
427
+ // Link an app's .env.local to the monorepo root .env.local so the root file
428
+ // stays the single source of truth. Next.js only reads .env.local from its
429
+ // own CWD, so this symlink lets `cd apps/<app> && next dev|build` see the
430
+ // shared config. Falls back to a regular copy (with a warning) on platforms
431
+ // where symlink creation requires elevation (Windows non-admin).
432
+ function linkRootEnvLocal(targetDir, appRelPath, fallbackLines) {
433
+ const envPath = resolve(targetDir, appRelPath, ".env.local");
434
+ // appRelPath is `apps/<name>` (2 segments deep) → `../../.env.local`.
435
+ const depth = appRelPath.split("/").filter(Boolean).length;
436
+ const linkTarget = "../".repeat(depth) + ".env.local";
437
+ if (existsSync(envPath)) rmSync(envPath);
438
+ try {
439
+ symlinkSync(linkTarget, envPath);
440
+ success(`${appRelPath}/.env.local → ${linkTarget} (symlink)`);
441
+ } catch {
442
+ writeFileSync(envPath, [...fallbackLines, ""].join("\n"));
443
+ warn(
444
+ `symlink failed — wrote a copy at ${appRelPath}/.env.local (keep it in sync with the root file manually).`,
445
+ );
446
+ }
447
+ }
448
+
449
+ function writePnpmWorkspace(targetDir) {
450
+ // pnpm 11 replaced `onlyBuiltDependencies` (list) with `allowBuilds`
451
+ // (map of package -> bool). apollo-cms transitively pulls multiple
452
+ // esbuild versions (0.18, 0.25, 0.27); pnpm's binary symlink can pick
453
+ // the wrong platform binary for esbuild's postinstall version check,
454
+ // failing with "Expected X.Y.Z but got A.B.C". Allow listed packages
455
+ // to run their build/postinstall scripts; the rest are skipped
456
+ // (pnpm 11 default is `strictDepBuilds: true`, empty allow-map).
457
+ // See https://pnpm.io/blog/releases/11.0.
458
+ const yaml = [
459
+ "packages:",
460
+ " - 'apps/*'",
461
+ " - 'apps/cms-plugins/*'",
462
+ // apollo-cms declares `plugins/*` in its own pnpm-workspace.yaml, but a
463
+ // nested workspace file is ignored once the submodule is itself a package
464
+ // of an outer workspace. Without this glob pnpm never installs the
465
+ // built-in plugins' dependencies, and `plugins:build` dies with
466
+ // `error: Could not resolve: "ioredis"` (apollo-api-cache) before the
467
+ // backend can start.
468
+ " - 'apps/backend/plugins/*'",
469
+ "",
470
+ "allowBuilds:",
471
+ " '@parcel/watcher': true",
472
+ " '@rolldown/binding-darwin-arm64': true",
473
+ " '@rolldown/binding-linux-arm64-gnu': true",
474
+ " '@rolldown/binding-linux-x64-gnu': true",
475
+ " '@swc/core': true",
476
+ " '@swc/core-darwin-arm64': true",
477
+ " '@swc/core-darwin-x64': true",
478
+ " '@swc/core-linux-arm64-gnu': true",
479
+ " '@swc/core-linux-x64-gnu': true",
480
+ " 'better-sqlite3': true",
481
+ " 'core-js': true",
482
+ " 'core-js-pure': true",
483
+ " 'esbuild': true",
484
+ " 'msw': true",
485
+ " 'sharp': true",
486
+ " 'unrs-resolver': true",
487
+ "",
488
+ ].join("\n");
489
+ writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), yaml);
490
+ }
491
+
492
+ // Single-origin nginx reference. Only emitted when adminPrefix is set, since
493
+ // in separate-origins mode the two apps live on their own domains/ports and
494
+ // don't need the prefix-aware location ordering.
495
+ function writeNginxSample(targetDir, adminPrefix) {
496
+ const prefix = adminPrefix || "/admin";
497
+ const conf = `# Apollo CMS — single-origin nginx sample.
498
+ # Mirrors apps/frontend/next.config.ts rewrites (frontend :3001 / backend :3000).
499
+ # No caching headers — Next.js sets its own. Add SSL + HTTP→HTTPS in production.
500
+
501
+ upstream apollo_frontend { server 127.0.0.1:3001; }
502
+ upstream apollo_backend { server 127.0.0.1:3000; }
503
+
504
+ server {
505
+ listen 80;
506
+ server_name _;
507
+
508
+ client_max_body_size 50m;
509
+
510
+ proxy_http_version 1.1;
511
+ proxy_set_header Host $host;
512
+ proxy_set_header X-Real-IP $remote_addr;
513
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
514
+ proxy_set_header X-Forwarded-Proto $scheme;
515
+ proxy_set_header X-Forwarded-Host $host;
516
+
517
+ # Backend — admin UI + chunks (APOLLO_ASSET_PREFIX=${prefix}), media
518
+ location = ${prefix} { proxy_pass http://apollo_backend; }
519
+ location ^~ ${prefix}/ { proxy_pass http://apollo_backend; }
520
+ location ^~ /uploads/ { proxy_pass http://apollo_backend; }
521
+
522
+ # Backend SSE — long-lived, no buffering
523
+ location ^~ /api/editing-presence/ {
524
+ proxy_pass http://apollo_backend;
525
+ proxy_buffering off;
526
+ proxy_read_timeout 24h;
527
+ }
528
+
529
+ # Backend APIs (mirror the list in apps/frontend/next.config.ts).
530
+ # \`editing-presence\` is included so the bare path also routes to backend;
531
+ # streaming sub-paths still match the \`^~\` block above first.
532
+ location ~ ^/api/(auth|v1|email|health|mcp|admin|editing-presence)(/|$) {
533
+ proxy_pass http://apollo_backend;
534
+ }
535
+
536
+ # Frontend — everything else
537
+ location / { proxy_pass http://apollo_frontend; }
538
+ }
539
+ `;
540
+ writeFileSync(resolve(targetDir, "nginx.conf.sample"), conf);
541
+ }
542
+
543
+ // Optional Node.js reverse proxy. Mirrors nginx.conf.sample routing rules
544
+ // but runs in-process — opt in via `pnpm dev:rp`. Zero deps, ~80 LOC.
545
+ function writeProxyApp(targetDir, dirName, adminPrefix) {
546
+ const dir = resolve(targetDir, PROXY_PATH);
547
+ mkdirSync(dir, { recursive: true });
548
+
549
+ const prefix = adminPrefix || "/admin";
550
+ const server = `// ${dirName} — single-origin reverse proxy (zero deps).
551
+ // Mirrors ../../nginx.conf.sample. Run via \`pnpm dev:rp\` from the repo root.
552
+ //
553
+ // Routes (in order):
554
+ // ${prefix}, ${prefix}/*, /uploads/* → backend
555
+ // /api/editing-presence/* → backend (SSE)
556
+ // /api/{auth,v1,email,health,mcp,admin,...}/* → backend
557
+ // /__proxy/health → 200 OK (proxy itself)
558
+ // everything else → frontend
559
+ //
560
+ // Production note: this proxy doesn't terminate TLS. For HTTPS, sit behind a
561
+ // load balancer (Caddy, Cloudflare, ALB) or use \`nginx.conf.sample\` instead.
562
+
563
+ import http from "node:http";
564
+ import net from "node:net";
565
+ import { readFileSync, existsSync } from "node:fs";
566
+ import { resolve, dirname } from "node:path";
567
+ import { fileURLToPath } from "node:url";
568
+
569
+ // Load root .env.local so the proxy honors PROXY_PORT/FRONTEND_PORT/BACKEND_PORT
570
+ // alongside the frontend & backend. Process env still wins (12-factor friendly).
571
+ (function loadRootEnv() {
572
+ try {
573
+ const here = dirname(fileURLToPath(import.meta.url));
574
+ const envPath = resolve(here, "../../.env.local");
575
+ if (!existsSync(envPath)) return;
576
+ for (const raw of readFileSync(envPath, "utf8").split(/\\r?\\n/)) {
577
+ const line = raw.trim();
578
+ if (!line || line.startsWith("#")) continue;
579
+ const eq = line.indexOf("=");
580
+ if (eq < 0) continue;
581
+ const key = line.slice(0, eq).trim();
582
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
583
+ if (process.env[key] !== undefined) continue;
584
+ let val = line.slice(eq + 1).trim();
585
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
586
+ val = val.slice(1, -1);
587
+ }
588
+ process.env[key] = val;
589
+ }
590
+ } catch {
591
+ // Best-effort. The proxy still runs on its hardcoded fallbacks.
592
+ }
593
+ })();
594
+
595
+ // Backward-compatible: PROXY_PORT/FRONTEND_PORT/BACKEND_PORT are the new names;
596
+ // PORT/FRONTEND/BACKEND are still honored if explicitly set in process.env.
597
+ const PORT = Number(process.env.PROXY_PORT ?? process.env.PORT ?? 3030);
598
+ const BACKEND_HOST = process.env.BACKEND ?? \`http://127.0.0.1:\${process.env.BACKEND_PORT ?? 3002}\`;
599
+ const FRONTEND_HOST = process.env.FRONTEND ?? \`http://127.0.0.1:\${process.env.FRONTEND_PORT ?? 3001}\`;
600
+ const BACKEND = parseTarget(BACKEND_HOST);
601
+ const FRONTEND = parseTarget(FRONTEND_HOST);
602
+
603
+ const ADMIN_PREFIX = process.env.ADMIN_PREFIX ?? "${prefix}";
604
+ // Pipe-separated list of /api/<segment> paths that route to the backend.
605
+ const BACKEND_API_SEGMENTS = (
606
+ process.env.BACKEND_API_PATHS ??
607
+ "auth|v1|email|health|mcp|admin|editing-presence"
608
+ ).trim();
609
+ const BACKEND_API = new RegExp(\`^/api/(\${BACKEND_API_SEGMENTS})(/|$)\`);
610
+
611
+ // 50MB matches nginx.conf.sample's client_max_body_size and Next.js's
612
+ // Server Actions body limit. Override via MAX_BODY_BYTES (0 = unlimited).
613
+ const MAX_BODY_BYTES = Number(process.env.MAX_BODY_BYTES ?? 50 * 1024 * 1024);
614
+ const UPSTREAM_TIMEOUT_MS = Number(process.env.UPSTREAM_TIMEOUT_MS ?? 60_000);
615
+ // When false (default), strip incoming X-Forwarded-* before adding our own —
616
+ // prevents header spoofing when the proxy faces the public internet directly.
617
+ // Set TRUST_PROXY=1 only when behind another trusted reverse proxy (CDN, LB).
618
+ const TRUST_PROXY = process.env.TRUST_PROXY === "1";
619
+
620
+ // Hop-by-hop headers (RFC 7230 §6.1) — must not be forwarded.
621
+ const HOP_BY_HOP = new Set([
622
+ "connection",
623
+ "keep-alive",
624
+ "proxy-authenticate",
625
+ "proxy-authorization",
626
+ "te",
627
+ "trailer",
628
+ "transfer-encoding",
629
+ "upgrade",
630
+ ]);
631
+
632
+ // Reuse TCP connections to upstreams — eliminates ~1ms per request.
633
+ const backendAgent = new http.Agent({ keepAlive: true });
634
+ const frontendAgent = new http.Agent({ keepAlive: true });
635
+
636
+ function pickUpstream(url) {
637
+ if (url === ADMIN_PREFIX || url.startsWith(\`\${ADMIN_PREFIX}/\`))
638
+ return { ...BACKEND, agent: backendAgent };
639
+ if (url.startsWith("/uploads/")) return { ...BACKEND, agent: backendAgent };
640
+ if (BACKEND_API.test(url)) return { ...BACKEND, agent: backendAgent };
641
+ return { ...FRONTEND, agent: frontendAgent };
642
+ }
643
+
644
+ function sanitizeHeaders(req, clientIp) {
645
+ const out = {};
646
+ for (const [k, v] of Object.entries(req.headers)) {
647
+ const lower = k.toLowerCase();
648
+ if (HOP_BY_HOP.has(lower)) continue;
649
+ // Strip client-supplied X-Forwarded-* unless TRUST_PROXY is set.
650
+ if (!TRUST_PROXY && lower.startsWith("x-forwarded-")) continue;
651
+ out[k] = v;
652
+ }
653
+ // Always set X-Forwarded-* with our own values (append client IP).
654
+ const existingFor = TRUST_PROXY ? req.headers["x-forwarded-for"] : undefined;
655
+ out["x-forwarded-for"] = existingFor ? \`\${existingFor}, \${clientIp}\` : clientIp;
656
+ out["x-forwarded-proto"] = TRUST_PROXY
657
+ ? req.headers["x-forwarded-proto"] ?? "http"
658
+ : "http";
659
+ out["x-forwarded-host"] = TRUST_PROXY
660
+ ? req.headers["x-forwarded-host"] ?? req.headers.host ?? ""
661
+ : req.headers.host ?? "";
662
+ return out;
663
+ }
664
+
665
+ function logError(scope, err, extra) {
666
+ const ts = new Date().toISOString();
667
+ const detail = extra ? \` \${JSON.stringify(extra)}\` : "";
668
+ console.error(\`[\${ts}] proxy:\${scope} \${err.code ?? ""} \${err.message}\${detail}\`);
669
+ }
670
+
671
+ const server = http.createServer((req, res) => {
672
+ // Health check (proxy itself, never forwarded).
673
+ if (req.url === "/__proxy/health") {
674
+ res.writeHead(200, { "content-type": "application/json" });
675
+ res.end(JSON.stringify({ status: "ok", uptime: process.uptime() }));
676
+ return;
677
+ }
678
+
679
+ // Enforce body size limit (DoS protection).
680
+ if (MAX_BODY_BYTES > 0) {
681
+ const declared = Number(req.headers["content-length"] ?? 0);
682
+ if (declared > MAX_BODY_BYTES) {
683
+ res.writeHead(413, { "content-type": "text/plain" });
684
+ res.end("Payload Too Large");
685
+ return;
686
+ }
687
+ let received = 0;
688
+ req.on("data", (chunk) => {
689
+ received += chunk.length;
690
+ if (received > MAX_BODY_BYTES) {
691
+ req.destroy(new Error("Body exceeded MAX_BODY_BYTES"));
692
+ }
693
+ });
694
+ }
695
+
696
+ const upstream = pickUpstream(req.url);
697
+ const proxyReq = http.request(
698
+ {
699
+ host: upstream.host,
700
+ port: upstream.port,
701
+ method: req.method,
702
+ path: req.url,
703
+ headers: sanitizeHeaders(req, req.socket.remoteAddress ?? ""),
704
+ agent: upstream.agent,
705
+ timeout: UPSTREAM_TIMEOUT_MS,
706
+ },
707
+ (proxyRes) => {
708
+ // Strip hop-by-hop headers from upstream response too.
709
+ const safe = {};
710
+ for (const [k, v] of Object.entries(proxyRes.headers)) {
711
+ if (!HOP_BY_HOP.has(k.toLowerCase())) safe[k] = v;
712
+ }
713
+ res.writeHead(proxyRes.statusCode ?? 502, safe);
714
+ proxyRes.pipe(res);
715
+ },
716
+ );
717
+ proxyReq.on("timeout", () => {
718
+ logError("upstream-timeout", new Error("upstream timeout"), { url: req.url });
719
+ proxyReq.destroy(new Error("Upstream timeout"));
720
+ });
721
+ proxyReq.on("error", (err) => {
722
+ logError("upstream", err, { url: req.url });
723
+ if (!res.headersSent) {
724
+ res.writeHead(502, { "content-type": "text/plain" });
725
+ res.end("Bad Gateway");
726
+ }
727
+ if (!req.destroyed) req.destroy();
728
+ });
729
+ req.on("error", (err) => {
730
+ logError("client", err, { url: req.url });
731
+ if (!proxyReq.destroyed) proxyReq.destroy();
732
+ });
733
+ req.pipe(proxyReq);
734
+ });
735
+
736
+ // WebSocket / HTTP upgrade passthrough — required for Next.js HMR.
737
+ server.on("upgrade", (req, clientSocket, head) => {
738
+ const upstream = pickUpstream(req.url);
739
+ const upstreamSocket = net.connect(upstream.port, upstream.host, () => {
740
+ const headers = sanitizeHeaders(req, req.socket.remoteAddress ?? "");
741
+ // Re-add Connection: Upgrade & Upgrade headers stripped by sanitizeHeaders.
742
+ headers["connection"] = "Upgrade";
743
+ if (req.headers.upgrade) headers["upgrade"] = req.headers.upgrade;
744
+ const headerLines = [];
745
+ for (const [k, v] of Object.entries(headers)) {
746
+ const values = Array.isArray(v) ? v : [v];
747
+ for (const single of values) {
748
+ const str = String(single);
749
+ // Defense-in-depth: reject CRLF in header values (header injection).
750
+ if (/[\\r\\n]/.test(str)) continue;
751
+ headerLines.push(\`\${k}: \${str}\`);
752
+ }
753
+ }
754
+ upstreamSocket.write(
755
+ \`\${req.method} \${req.url} HTTP/\${req.httpVersion}\\r\\n\` +
756
+ headerLines.join("\\r\\n") +
757
+ "\\r\\n\\r\\n",
758
+ );
759
+ if (head?.length) upstreamSocket.write(head);
760
+ upstreamSocket.pipe(clientSocket);
761
+ clientSocket.pipe(upstreamSocket);
762
+ });
763
+ upstreamSocket.on("error", (err) => {
764
+ logError("ws-upstream", err, { url: req.url });
765
+ clientSocket.destroy();
766
+ });
767
+ clientSocket.on("error", (err) => {
768
+ logError("ws-client", err, { url: req.url });
769
+ upstreamSocket.destroy();
770
+ });
771
+ });
772
+
773
+ server.listen(PORT, () => {
774
+ console.log(\`apollo-proxy → http://localhost:\${PORT}\`);
775
+ console.log(\` \${ADMIN_PREFIX}/*, /uploads/*, /api/* → \${BACKEND.host}:\${BACKEND.port}\`);
776
+ console.log(\` /* → \${FRONTEND.host}:\${FRONTEND.port}\`);
777
+ console.log(\` TRUST_PROXY=\${TRUST_PROXY} MAX_BODY_BYTES=\${MAX_BODY_BYTES} UPSTREAM_TIMEOUT_MS=\${UPSTREAM_TIMEOUT_MS}\`);
778
+ });
779
+
780
+ // Graceful shutdown — let in-flight requests finish, then exit.
781
+ let shuttingDown = false;
782
+ function shutdown(signal) {
783
+ if (shuttingDown) return;
784
+ shuttingDown = true;
785
+ console.log(\`\\napollo-proxy: \${signal} received, draining...\`);
786
+ server.close(() => {
787
+ backendAgent.destroy();
788
+ frontendAgent.destroy();
789
+ process.exit(0);
790
+ });
791
+ // Hard exit after 10s if connections refuse to drain.
792
+ setTimeout(() => process.exit(1), 10_000).unref();
793
+ }
794
+ process.on("SIGTERM", () => shutdown("SIGTERM"));
795
+ process.on("SIGINT", () => shutdown("SIGINT"));
796
+
797
+ function parseTarget(input) {
798
+ const url = new URL(input.startsWith(":") ? \`http://127.0.0.1\${input}\` : input);
799
+ return { host: url.hostname, port: Number(url.port || 80) };
800
+ }
801
+ `;
802
+ writeFileSync(resolve(dir, "server.mjs"), server);
803
+
804
+ const pkg = {
805
+ name: `@${dirName}/proxy`,
806
+ private: true,
807
+ version: "0.0.0",
808
+ description: "Optional Node.js reverse proxy fronting frontend + backend on a single origin",
809
+ type: "module",
810
+ main: "server.mjs",
811
+ scripts: {
812
+ start: "node server.mjs",
813
+ },
814
+ engines: { node: ">=22" },
815
+ };
816
+ writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
817
+
818
+ const readme = `# @${dirName}/proxy
819
+
820
+ Optional single-origin reverse proxy. Routes \`${prefix}/*\`, \`/api/*\`, and
821
+ \`/uploads/*\` to the backend; everything else to the frontend. Zero deps.
822
+
823
+ ## When to use
824
+
825
+ - You want **one URL** for FE + BE locally (shared cookies, no CORS).
826
+ - You don't want to install nginx for local dev.
827
+
828
+ ## Usage
829
+
830
+ From the repo root:
831
+
832
+ \`\`\`bash
833
+ pnpm dev:rp # runs FE :3001 + BE :3000 + plugins watcher + proxy :3030
834
+ \`\`\`
835
+
836
+ Then open http://localhost:3030.
837
+
838
+ For best results, set \`NEXT_PUBLIC_SITE_URL=http://localhost:3030\` in your
839
+ \`.env.local\` so Better Auth, OAuth callbacks, and email links all use the
840
+ single-origin URL.
841
+
842
+ ## Configuration
843
+
844
+ | Env var | Default | Notes |
845
+ | --------------------- | -------------------------------- | -------------------------------------------------------------- |
846
+ | \`PORT\` | \`3030\` | Public port |
847
+ | \`BACKEND\` | \`http://127.0.0.1:3000\` | apps/backend dev server |
848
+ | \`FRONTEND\` | \`http://127.0.0.1:3001\` | apps/frontend dev server |
849
+ | \`ADMIN_PREFIX\` | \`${prefix}\` | Backend admin path prefix |
850
+ | \`BACKEND_API_PATHS\` | \`auth\\|v1\\|email\\|health\\|mcp\\|admin\\|editing-presence\` | Pipe-separated /api/* segments routed to backend |
851
+ | \`MAX_BODY_BYTES\` | \`52428800\` (50MB) | Reject larger bodies. \`0\` = unlimited |
852
+ | \`UPSTREAM_TIMEOUT_MS\` | \`60000\` | Request timeout to upstream |
853
+ | \`TRUST_PROXY\` | \`1\` | Honors inbound X-Forwarded-* from upstream TLS terminator (nginx/Caddy/CF/LB). Set \`0\` if proxy is directly internet-facing |
854
+
855
+ ## Health check
856
+
857
+ \`GET /__proxy/health\` returns \`{"status":"ok","uptime":<seconds>}\`. Use it for
858
+ liveness probes; the path is reserved by the proxy and never forwarded.
859
+
860
+ ## Better Auth interaction
861
+
862
+ Apollo CMS's Better Auth derives the request origin from \`x-forwarded-host\` /
863
+ \`x-forwarded-proto\`. This proxy sets both correctly, so \`trustedOrigins\` keeps
864
+ working through the proxy. The recent fix in \`src/lib/auth/server.ts\` ensures
865
+ the actual request origin (e.g. \`localhost:3030\`) is added to trusted origins
866
+ even when \`NEXT_PUBLIC_SITE_URL\` points elsewhere.
867
+
868
+ ## Production
869
+
870
+ This proxy doesn't terminate TLS. For HTTPS, either:
871
+
872
+ - Sit it behind a TLS-terminating load balancer (Caddy, Cloudflare, ALB), **or**
873
+ - Use \`nginx.conf.sample\` at the repo root instead — nginx handles TLS, gzip,
874
+ and large-scale traffic better.
875
+
876
+ The Node proxy is intended for dev and small self-hosted deploys.
877
+ `;
878
+ writeFileSync(resolve(dir, "README.md"), readme);
879
+ }
880
+
881
+ function writeRootGitignore(targetDir) {
882
+ // NOTE: .env.local is intentionally NOT ignored — it holds shared dev port
883
+ // defaults (PROXY_PORT/FRONTEND_PORT/BACKEND_PORT) and other non-secret
884
+ // workspace knobs that should travel with the repo. Keep real secrets in
885
+ // .env (ignored) or apps/backend/.env.local (ignored by the submodule).
886
+ const ignore = [
887
+ "node_modules",
888
+ ".pnpm-store",
889
+ ".turbo",
890
+ ".next",
891
+ "dist",
892
+ "build",
893
+ ".env",
894
+ "*.log",
895
+ ".DS_Store",
896
+ "",
897
+ ].join("\n");
898
+ writeFileSync(resolve(targetDir, ".gitignore"), ignore);
899
+ }
900
+
901
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace }) {
902
+ const header = adminPrefix
903
+ ? [
904
+ "# Single-origin monorepo dev env",
905
+ `# Public origin = frontend (apps/frontend on :${DEFAULT_FRONTEND_PORT}). Frontend rewrites /admin/*,`,
906
+ `# /api/*, /uploads/*, and the asset prefix to the backend (apps/backend on :${DEFAULT_BACKEND_PORT}).`,
907
+ ]
908
+ : [
909
+ "# Separate-origins monorepo dev env",
910
+ `# Backend runs at http://localhost:${DEFAULT_BACKEND_PORT}, frontend at http://localhost:${DEFAULT_FRONTEND_PORT}.`,
911
+ "# To switch to single-origin: set APOLLO_ASSET_PREFIX (default: /admin) and",
912
+ "# BACKEND_INTERNAL_URL, then add rewrites() to apps/frontend/next.config.ts.",
913
+ ];
914
+
915
+ const lines = [
916
+ ...header,
917
+ "",
918
+ "# ── Dev server ports ─────────────────────────────────────────────",
919
+ "# Single source of truth for local ports. Consumed by:",
920
+ "# • apps/proxy/server.mjs (PROXY_PORT, FRONTEND_PORT, BACKEND_PORT)",
921
+ "# • scripts/with-env.mjs (maps FRONTEND_PORT/BACKEND_PORT → PORT",
922
+ "# when launching apps/frontend & apps/backend,",
923
+ "# and derives NEXT_PUBLIC_SITE_URL /",
924
+ "# BACKEND_INTERNAL_URL when those are unset)",
925
+ "# Override any of these; the proxy/frontend/backend will follow.",
926
+ `PROXY_PORT=${DEFAULT_PROXY_PORT}`,
927
+ `FRONTEND_PORT=${DEFAULT_FRONTEND_PORT}`,
928
+ `BACKEND_PORT=${DEFAULT_BACKEND_PORT}`,
929
+ "# Honor inbound X-Forwarded-* headers from an upstream TLS terminator",
930
+ "# (nginx / Caddy / Cloudflare / load balancer). Required so Better Auth,",
931
+ "# rate limiting, and audit logs see the real client origin & IP instead",
932
+ "# of the LB's. Set to 0 only if the proxy is directly internet-facing.",
933
+ "TRUST_PROXY=1",
934
+ "",
935
+ "# ── Shared (consumed by both apps) ───────────────────────────────",
936
+ `DATABASE_URL=${dbUrl}`,
937
+ `APOLLO_SECRET=${authSecret}`,
938
+ `CRON_SECRET=${cronSecret}`,
939
+ "# Public origin. Leave blank so the CMS respects the incoming request",
940
+ "# origin (works for localhost, preview URLs, and prod domains automatically).",
941
+ "# Set this ONLY when background jobs (cron / triggered emails) need a fixed",
942
+ "# origin outside a request scope — e.g. NEXT_PUBLIC_SITE_URL=https://cms.example.com",
943
+ `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
944
+ `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
945
+ "",
946
+ ];
947
+
948
+ lines.push("# ── Backend (apps/backend) ───────────────────────────────────────");
949
+ if (adminPrefix) {
950
+ lines.push(
951
+ "# Single-origin admin/asset prefix. The frontend rewrites this path",
952
+ "# to the backend, so backend chunks (served at <prefix>/_next/static)",
953
+ "# and admin pages share one rewrite.",
954
+ `APOLLO_ASSET_PREFIX=${adminPrefix}`,
955
+ );
956
+ }
957
+ lines.push(
958
+ "# Project-specific plugins — keeps the apollo-cms submodule clean.",
959
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
960
+ "# PM2 namespace — groups this project's processes so you can",
961
+ "# `pm2 stop <namespace>` / `pm2 restart <namespace>` independently of",
962
+ "# other projects on the same host. Consumed by ecosystem.config.cjs.",
963
+ `PM2_NAMESPACE=${pm2Namespace}`,
964
+ "",
965
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
966
+ );
967
+ if (adminPrefix) {
968
+ lines.push(
969
+ "# Where the frontend's rewrites point internally. In dev, leave blank",
970
+ "# to auto-derive http://127.0.0.1:${BACKEND_PORT} via scripts/with-env.mjs.",
971
+ "# In prod set to a private hostname (e.g. http://backend.internal:3000).",
972
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
973
+ );
974
+ } else {
975
+ lines.push(
976
+ "# Where the frontend reaches the backend in separate-origins mode.",
977
+ "# Defaults to the backend's dev port; override in prod to the real CMS host.",
978
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`,
979
+ );
980
+ }
981
+ lines.push("");
982
+
983
+ writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
984
+ }
985
+
986
+ function writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl) {
987
+ const dir = resolve(targetDir, FRONTEND_PATH);
988
+ mkdirSync(dir, { recursive: true });
989
+ mkdirSync(resolve(dir, "src/app"), { recursive: true });
990
+ mkdirSync(resolve(dir, "public"), { recursive: true });
991
+
992
+ const pkg = {
993
+ name: frontendName,
994
+ version: "0.0.0",
995
+ private: true,
996
+ scripts: {
997
+ // No -p flag: Next.js reads PORT from env. The repo-root scripts use
998
+ // scripts/with-env.mjs --port=FRONTEND_PORT to inject the right value
999
+ // from .env.local (default 3001).
1000
+ dev: "next dev",
1001
+ build: "next build",
1002
+ start: "next start",
1003
+ lint: "next lint",
1004
+ typecheck: "tsc --noEmit",
1005
+ },
1006
+ dependencies: {
1007
+ next: "^16.0.0",
1008
+ react: "^19.0.0",
1009
+ "react-dom": "^19.0.0",
1010
+ },
1011
+ devDependencies: {
1012
+ "@types/node": "^22.0.0",
1013
+ "@types/react": "^19.0.0",
1014
+ "@types/react-dom": "^19.0.0",
1015
+ typescript: "^5.6.0",
1016
+ },
1017
+ };
1018
+ writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
1019
+
1020
+ writeFileSync(
1021
+ resolve(dir, "tsconfig.json"),
1022
+ JSON.stringify(
1023
+ {
1024
+ compilerOptions: {
1025
+ target: "ES2022",
1026
+ lib: ["dom", "dom.iterable", "esnext"],
1027
+ allowJs: true,
1028
+ skipLibCheck: true,
1029
+ strict: true,
1030
+ noEmit: true,
1031
+ esModuleInterop: true,
1032
+ module: "esnext",
1033
+ moduleResolution: "bundler",
1034
+ resolveJsonModule: true,
1035
+ isolatedModules: true,
1036
+ jsx: "preserve",
1037
+ incremental: true,
1038
+ plugins: [{ name: "next" }],
1039
+ paths: { "@/*": ["./src/*"] },
1040
+ },
1041
+ include: ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
1042
+ exclude: ["node_modules"],
1043
+ },
1044
+ null,
1045
+ 2,
1046
+ ) + "\n",
1047
+ );
1048
+
1049
+ // Frontend Next.js config:
1050
+ // - Single-origin mode (adminPrefix set): rewrite backend paths to apps/backend.
1051
+ // The chosen prefix is baked into the file at scaffold time — no
1052
+ // NEXT_PUBLIC_* env mirror, just `BACKEND_INTERNAL_URL` for the proxy target.
1053
+ // - Separate-origins mode (adminPrefix empty): no rewrites; the two apps
1054
+ // are reachable on their own ports/subdomains.
1055
+ //
1056
+ // When adminPrefix === "/admin" the explicit admin rewrite already covers
1057
+ // backend chunks served under /admin/_next/static (via APOLLO_ASSET_PREFIX
1058
+ // on the backend), so no separate prefix rewrite is emitted. For any other
1059
+ // prefix the script also emits a rewrite for the assets path.
1060
+ const isDefaultAdmin = adminPrefix === "/admin";
1061
+ const extraPrefixRewrite = adminPrefix && !isDefaultAdmin
1062
+ ? ` // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)\n { source: "${adminPrefix}/:path*", destination: \`\${BACKEND}${adminPrefix}/:path*\` },\n`
1063
+ : "";
1064
+ const nextConfigContent = adminPrefix
1065
+ ? `import type { NextConfig } from "next";
1066
+
1067
+ const BACKEND = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
1068
+
1069
+ const config: NextConfig = {
1070
+ reactStrictMode: true,
1071
+ async rewrites() {
1072
+ return [
1073
+ // Apollo CMS admin UI (and its chunks when APOLLO_ASSET_PREFIX is /admin)
1074
+ { source: "/admin/:path*", destination: \`\${BACKEND}/admin/:path*\` },
1075
+ // Apollo CMS APIs (REST + auth + email + health + mcp + …)
1076
+ { source: "/api/auth/:path*", destination: \`\${BACKEND}/api/auth/:path*\` },
1077
+ { source: "/api/v1/:path*", destination: \`\${BACKEND}/api/v1/:path*\` },
1078
+ { source: "/api/email/:path*", destination: \`\${BACKEND}/api/email/:path*\` },
1079
+ { source: "/api/health", destination: \`\${BACKEND}/api/health\` },
1080
+ { source: "/api/mcp", destination: \`\${BACKEND}/api/mcp\` },
1081
+ { source: "/api/editing-presence/:path*", destination: \`\${BACKEND}/api/editing-presence/:path*\` },
1082
+ { source: "/api/admin/:path*", destination: \`\${BACKEND}/api/admin/:path*\` },
1083
+ // Media uploads
1084
+ { source: "/uploads/:path*", destination: \`\${BACKEND}/uploads/:path*\` },
1085
+ ${extraPrefixRewrite} ];
1086
+ },
1087
+ };
1088
+
1089
+ export default config;
1090
+ `
1091
+ : `import type { NextConfig } from "next";
1092
+
1093
+ // Separate-origins mode: backend runs at http://localhost:3000, frontend here.
1094
+ // Switch to single-origin by setting APOLLO_ASSET_PREFIX in apps/backend/.env.local
1095
+ // and adding rewrites() — see this monorepo's README for the recipe.
1096
+ const config: NextConfig = {
1097
+ reactStrictMode: true,
1098
+ };
1099
+
1100
+ export default config;
1101
+ `;
1102
+ writeFileSync(resolve(dir, "next.config.ts"), nextConfigContent);
1103
+
1104
+ const envLocalLines = adminPrefix
1105
+ ? [
1106
+ `# Internal backend URL (used by Next.js rewrites — not exposed to browser)`,
1107
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
1108
+ "",
1109
+ ]
1110
+ : [
1111
+ `# Public URL of the backend (used by your client code)`,
1112
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`,
1113
+ "",
1114
+ ];
1115
+ writeFileSync(resolve(dir, ".env.local.example"), envLocalLines.join("\n"));
1116
+
1117
+ writeFileSync(
1118
+ resolve(dir, "src/app/layout.tsx"),
1119
+ `export const metadata = { title: "Frontend", description: "Apollo CMS frontend" };\n\nexport default function RootLayout({ children }: { children: React.ReactNode }) {\n return (\n <html lang="en">\n <body>{children}</body>\n </html>\n );\n}\n`,
1120
+ );
1121
+
1122
+ writeFileSync(
1123
+ resolve(dir, "src/app/page.tsx"),
1124
+ `export default function Page() {\n return (\n <main style={{ padding: 40, fontFamily: "system-ui" }}>\n <h1>Frontend</h1>\n <p>\n Admin: <a href="/admin">/admin</a>\n </p>\n </main>\n );\n}\n`,
1125
+ );
1126
+
1127
+ writeFileSync(
1128
+ resolve(dir, ".gitignore"),
1129
+ // .env.local is intentionally committed — it only contains dev port hints.
1130
+ ["node_modules", ".next", "dist", ""].join("\n"),
1131
+ );
1132
+ }
1133
+
1134
+ // ─── Custom plugins scaffold ─────────────────────────────────────────────────
1135
+
1136
+ function writeExampleCmsPlugin(targetDir, dirName) {
1137
+ const slug = "example-plugin";
1138
+ const pkgName = `@${dirName}/cms-${slug}`;
1139
+ const pluginDir = resolve(targetDir, "apps/cms-plugins", slug);
1140
+ mkdirSync(pluginDir, { recursive: true });
1141
+
1142
+ const manifest = {
1143
+ name: slug,
1144
+ version: "0.1.0",
1145
+ title: "Example Plugin",
1146
+ description: "Project-specific plugin scaffold for the monorepo",
1147
+ author: "you",
1148
+ };
1149
+ writeFileSync(resolve(pluginDir, "plugin.json"), JSON.stringify(manifest, null, 2) + "\n");
1150
+
1151
+ const pkg = {
1152
+ name: pkgName,
1153
+ version: "0.1.0",
1154
+ private: true,
1155
+ type: "module",
1156
+ exports: {
1157
+ "./server": "./dist/server.mjs",
1158
+ },
1159
+ scripts: {
1160
+ build: "node ./build.mjs",
1161
+ // `dev` is the watcher entry — `pnpm dev` from the monorepo root fans
1162
+ // out to it via `cms-plugins:dev`, so editing index.ts triggers an
1163
+ // incremental rebuild and the backend's plugin loader picks up the new
1164
+ // dist/server.mjs on the next request.
1165
+ dev: "node ./build.mjs --watch",
1166
+ },
1167
+ devDependencies: {
1168
+ esbuild: "^0.24.0",
1169
+ },
1170
+ };
1171
+ writeFileSync(resolve(pluginDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
1172
+
1173
+ // Tiny zero-config build: bundle index.ts → dist/server.mjs as ESM Node.
1174
+ // Pass --watch to keep esbuild's context alive and rebuild on file changes.
1175
+ const buildMjs = `import { context, build } from "esbuild";
1176
+
1177
+ const watch = process.argv.includes("--watch");
1178
+
1179
+ const options = {
1180
+ entryPoints: ["./index.ts"],
1181
+ outfile: "./dist/server.mjs",
1182
+ format: "esm",
1183
+ platform: "node",
1184
+ target: "node20",
1185
+ bundle: true,
1186
+ // Mark Apollo CMS internals as external — they're provided by the host
1187
+ // backend at runtime via the loader's dynamic import().
1188
+ external: ["@/*", "next", "react", "react-dom"],
1189
+ logLevel: "info",
1190
+ };
1191
+
1192
+ if (watch) {
1193
+ const ctx = await context(options);
1194
+ await ctx.watch();
1195
+ console.log("[cms-plugin] watching index.ts → dist/server.mjs");
1196
+ } else {
1197
+ await build(options);
1198
+ }
1199
+ `;
1200
+ writeFileSync(resolve(pluginDir, "build.mjs"), buildMjs);
1201
+
1202
+ const tsconfig = {
1203
+ compilerOptions: {
1204
+ target: "ES2022",
1205
+ module: "esnext",
1206
+ moduleResolution: "bundler",
1207
+ esModuleInterop: true,
1208
+ skipLibCheck: true,
1209
+ strict: true,
1210
+ noEmit: true,
1211
+ jsx: "preserve",
1212
+ // Resolve `@/...` against the apollo-cms backend so plugin code can
1213
+ // type-check against the real types from the submodule.
1214
+ baseUrl: "../../backend",
1215
+ paths: { "@/*": ["src/*"] },
1216
+ },
1217
+ include: ["**/*.ts", "**/*.tsx"],
1218
+ exclude: ["dist", "node_modules"],
1219
+ };
1220
+ writeFileSync(resolve(pluginDir, "tsconfig.json"), JSON.stringify(tsconfig, null, 2) + "\n");
1221
+
1222
+ const indexTs = `// Example Apollo CMS plugin — runs inside apps/backend at boot.
1223
+ //
1224
+ // The plugin loader picks up this file via package.json#exports["./server"]
1225
+ // → dist/server.mjs (built by \`pnpm cms-plugins:build\`).
1226
+ //
1227
+ // In dev under Bun the loader can also import index.ts directly (with a
1228
+ // warning); in production (next start) the dist file is required.
1229
+
1230
+ import type { PluginDefinition } from "@/lib/plugins/types";
1231
+
1232
+ const plugin: PluginDefinition = {
1233
+ // Register hooks — see apollo-cms's HOOKS export for the full surface.
1234
+ registerHooks(hooks, HOOKS) {
1235
+ hooks.action(HOOKS.auth.afterLogin, async (ctx) => {
1236
+ console.log("[example-plugin] login:", ctx.authUserId);
1237
+ });
1238
+ },
1239
+
1240
+ // Register UI slots — inject components into the admin shell.
1241
+ // registerUiSlots(slots) { ... }
1242
+
1243
+ // Register API routes under /api/v1/<your-route>.
1244
+ // registerApiRoutes(router) { ... }
1245
+ };
1246
+
1247
+ export default plugin;
1248
+ `;
1249
+ writeFileSync(resolve(pluginDir, "index.ts"), indexTs);
1250
+
1251
+ writeFileSync(
1252
+ resolve(pluginDir, ".gitignore"),
1253
+ ["node_modules", "dist", ""].join("\n"),
1254
+ );
1255
+
1256
+ const readme = `# ${pkgName}
1257
+
1258
+ Project-specific plugin loaded by the apollo-cms backend via
1259
+ \`APOLLO_EXTRA_PLUGINS_DIR\`.
1260
+
1261
+ ## Develop
1262
+
1263
+ Run from the **monorepo root** (not this folder):
1264
+
1265
+ \`\`\`bash
1266
+ pnpm dev # builds plugins + starts backend & frontend
1267
+ pnpm cms-plugins:build # rebuild after editing
1268
+ \`\`\`
1269
+
1270
+ ## Author a new hook
1271
+
1272
+ Open \`index.ts\` and add to \`registerHooks\`. The full hook surface lives in
1273
+ \`apps/backend/src/lib/plugins/hook-registry.ts\`.
1274
+
1275
+ ## Add another plugin
1276
+
1277
+ \`\`\`bash
1278
+ pnpm cms-plugin:new my-plugin
1279
+ \`\`\`
1280
+ `;
1281
+ writeFileSync(resolve(pluginDir, "README.md"), readme);
1282
+ }
1283
+
1284
+ function writeNewCmsPluginScript(targetDir) {
1285
+ const scriptsDir = resolve(targetDir, "scripts");
1286
+ mkdirSync(scriptsDir, { recursive: true });
1287
+
1288
+ const script = `#!/usr/bin/env node
1289
+ // Scaffold a new project-specific apollo-cms plugin under apps/cms-plugins/<slug>.
1290
+ // Usage: pnpm cms-plugin:new <slug>
1291
+
1292
+ import { cpSync, existsSync, readFileSync, writeFileSync } from "node:fs";
1293
+ import { dirname, resolve } from "node:path";
1294
+ import { fileURLToPath } from "node:url";
1295
+
1296
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1297
+ const monorepoRoot = resolve(__dirname, "..");
1298
+ const pluginsRoot = resolve(monorepoRoot, "apps/cms-plugins");
1299
+ const template = resolve(pluginsRoot, "example-plugin");
1300
+
1301
+ const slug = (process.argv[2] ?? "").trim();
1302
+ if (!slug) {
1303
+ console.error("Usage: pnpm cms-plugin:new <slug>");
1304
+ process.exit(1);
1305
+ }
1306
+ if (!/^[a-z][a-z0-9-]*$/.test(slug)) {
1307
+ console.error(\`Invalid slug "\${slug}" — use kebab-case (lowercase, digits, hyphens).\`);
1308
+ process.exit(1);
1309
+ }
1310
+ if (!existsSync(template)) {
1311
+ console.error(\`Template not found: \${template}\\nRecreate apps/cms-plugins/example-plugin or restore from a fresh \\\`npx create-apollo-suite-monorepo\\\` scaffold.\`);
1312
+ process.exit(1);
1313
+ }
1314
+
1315
+ const target = resolve(pluginsRoot, slug);
1316
+ if (existsSync(target)) {
1317
+ console.error(\`Plugin already exists: \${target}\`);
1318
+ process.exit(1);
1319
+ }
1320
+
1321
+ cpSync(template, target, { recursive: true, filter: (src) => !/[\\\\\\/](node_modules|dist)([\\\\\\/]|$)/.test(src) });
1322
+
1323
+ // Patch plugin.json
1324
+ const manifestPath = resolve(target, "plugin.json");
1325
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
1326
+ manifest.name = slug;
1327
+ manifest.title = slug.replace(/-/g, " ").replace(/\\b\\w/g, (c) => c.toUpperCase());
1328
+ manifest.description = \`Project plugin: \${slug}\`;
1329
+ writeFileSync(manifestPath, JSON.stringify(manifest, null, 2) + "\\n");
1330
+
1331
+ // Patch package.json
1332
+ const pkgPath = resolve(target, "package.json");
1333
+ const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
1334
+ const rootPkg = JSON.parse(readFileSync(resolve(monorepoRoot, "package.json"), "utf-8"));
1335
+ pkg.name = \`@\${rootPkg.name}/cms-\${slug}\`;
1336
+ writeFileSync(pkgPath, JSON.stringify(pkg, null, 2) + "\\n");
1337
+
1338
+ console.log(\`Created apps/cms-plugins/\${slug}\`);
1339
+ console.log(\`Run: pnpm install && pnpm cms-plugins:build && pnpm dev:backend\`);
1340
+ `;
1341
+ writeFileSync(resolve(scriptsDir, "new-cms-plugin.mjs"), script);
1342
+ }
1343
+
1344
+ // Small launcher that loads root .env.local, applies port defaults
1345
+ // (PROXY_PORT=3030 / FRONTEND_PORT=3001 / BACKEND_PORT=3002), optionally maps
1346
+ // one of them onto PORT (Next.js reads PORT), and execs the rest of argv.
1347
+ //
1348
+ // Usage:
1349
+ // node scripts/with-env.mjs [--port=VAR_NAME] <command> [args...]
1350
+ function writeWithEnvScript(targetDir) {
1351
+ const scriptsDir = resolve(targetDir, "scripts");
1352
+ mkdirSync(scriptsDir, { recursive: true });
1353
+ const script = `#!/usr/bin/env node
1354
+ // Loads <repo-root>/.env.local so frontend / backend / proxy all share one
1355
+ // source of truth for ports. Process env still wins.
1356
+ //
1357
+ // Usage:
1358
+ // node scripts/with-env.mjs [--port=VAR_NAME] <command> [args...]
1359
+ // --port=FRONTEND_PORT copies process.env.FRONTEND_PORT onto PORT before exec,
1360
+ // so \`next dev\` and \`next start\` pick up the right port without --port flags.
1361
+
1362
+ import { existsSync, readFileSync } from "node:fs";
1363
+ import { spawn } from "node:child_process";
1364
+ import { dirname, resolve } from "node:path";
1365
+ import { fileURLToPath } from "node:url";
1366
+
1367
+ const here = dirname(fileURLToPath(import.meta.url));
1368
+ const envPath = resolve(here, "../.env.local");
1369
+
1370
+ if (existsSync(envPath)) {
1371
+ for (const raw of readFileSync(envPath, "utf8").split(/\\r?\\n/)) {
1372
+ const line = raw.trim();
1373
+ if (!line || line.startsWith("#")) continue;
1374
+ const eq = line.indexOf("=");
1375
+ if (eq < 0) continue;
1376
+ const k = line.slice(0, eq).trim();
1377
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
1378
+ if (process.env[k] !== undefined) continue;
1379
+ let v = line.slice(eq + 1).trim();
1380
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
1381
+ v = v.slice(1, -1);
1382
+ }
1383
+ process.env[k] = v;
1384
+ }
1385
+ }
1386
+
1387
+ // Fallbacks — keep in sync with apps/proxy/server.mjs and PM2 config.
1388
+ process.env.PROXY_PORT ||= "3030";
1389
+ process.env.FRONTEND_PORT ||= "3001";
1390
+ process.env.BACKEND_PORT ||= "3002";
1391
+
1392
+ // Derive URL vars from PORTs when unset. Dev-friendly: edit a port and
1393
+ // everything follows. Prod-friendly: set these explicitly to real hostnames
1394
+ // (e.g. https://cms.example.com, http://backend.internal:3000) and the
1395
+ // derivation is bypassed.
1396
+ //
1397
+ // NEXT_PUBLIC_SITE_URL is the public origin — defaults to the frontend port
1398
+ // (frontend rewrites /admin, /api, /uploads to the backend). For \`pnpm dev:rp\`
1399
+ // set it to http://localhost:\${PROXY_PORT} so Better Auth / OAuth callbacks
1400
+ // land on the unified origin.
1401
+ process.env.NEXT_PUBLIC_SITE_URL ||= \`http://localhost:\${process.env.FRONTEND_PORT}\`;
1402
+ process.env.BACKEND_INTERNAL_URL ||= \`http://127.0.0.1:\${process.env.BACKEND_PORT}\`;
1403
+
1404
+ const args = process.argv.slice(2);
1405
+ if (args[0]?.startsWith("--port=")) {
1406
+ const name = args.shift().slice(7);
1407
+ if (process.env[name]) process.env.PORT = process.env[name];
1408
+ }
1409
+
1410
+ if (!args.length) {
1411
+ console.error("Usage: node scripts/with-env.mjs [--port=VAR] <command> [args...]");
1412
+ process.exit(2);
1413
+ }
1414
+
1415
+ const [cmd, ...rest] = args;
1416
+ const child = spawn(cmd, rest, {
1417
+ stdio: "inherit",
1418
+ env: process.env,
1419
+ shell: process.platform === "win32",
1420
+ });
1421
+ child.on("exit", (code, sig) => process.exit(sig ? 1 : code ?? 0));
1422
+ `;
1423
+ writeFileSync(resolve(scriptsDir, "with-env.mjs"), script);
1424
+ }
1425
+
1426
+ // Pre-build env check. Fails the build before \`next build\` runs if backend
1427
+ // required vars are missing, so users don't sit through a 30s build only to
1428
+ // hit a runtime error on first request.
1429
+ function writeCheckEnvScript(targetDir) {
1430
+ const scriptsDir = resolve(targetDir, "scripts");
1431
+ mkdirSync(scriptsDir, { recursive: true });
1432
+ const script = `#!/usr/bin/env node
1433
+ // Pre-build sanity check. Reads apps/backend/.env.local and verifies that
1434
+ // required vars are present and non-empty. Skipped when SKIP_ENV_CHECK=1.
1435
+ //
1436
+ // In CI where envs come from the platform (not files), set
1437
+ // SKIP_ENV_CHECK=1 and rely on the platform's own validation.
1438
+
1439
+ import { existsSync, readFileSync } from "node:fs";
1440
+ import { dirname, resolve } from "node:path";
1441
+ import { fileURLToPath } from "node:url";
1442
+
1443
+ if (process.env.SKIP_ENV_CHECK === "1") process.exit(0);
1444
+
1445
+ const REQUIRED = ["DATABASE_URL", "APOLLO_SECRET", "NEXT_PUBLIC_DEFAULT_LOCALE"];
1446
+
1447
+ const __dirname = dirname(fileURLToPath(import.meta.url));
1448
+ const envPath = resolve(__dirname, "../apps/backend/.env.local");
1449
+
1450
+ if (!existsSync(envPath)) {
1451
+ // Allow build to proceed if env vars are coming from process.env directly.
1452
+ const fromProcess = REQUIRED.every((k) => process.env[k]);
1453
+ if (fromProcess) process.exit(0);
1454
+ console.error(
1455
+ \`✗ Missing apps/backend/.env.local and required vars not in process.env.\\n Required: \${REQUIRED.join(", ")}\\n Hint: re-run the installer or copy apps/backend/.env.local.example\`,
1456
+ );
1457
+ process.exit(1);
1458
+ }
1459
+
1460
+ const env = Object.fromEntries(
1461
+ readFileSync(envPath, "utf-8")
1462
+ .split("\\n")
1463
+ .filter((line) => line && !line.startsWith("#"))
1464
+ .map((line) => {
1465
+ const i = line.indexOf("=");
1466
+ return i === -1 ? [line, ""] : [line.slice(0, i).trim(), line.slice(i + 1).trim()];
1467
+ }),
1468
+ );
1469
+
1470
+ const missing = REQUIRED.filter((k) => !env[k] && !process.env[k]);
1471
+ if (missing.length) {
1472
+ console.error(\`✗ Missing required env vars: \${missing.join(", ")}\\n Edit apps/backend/.env.local or set them in process.env.\`);
1473
+ process.exit(1);
1474
+ }
1475
+ console.log("✓ env check passed");
1476
+ `;
1477
+ writeFileSync(resolve(scriptsDir, "check-env.mjs"), script);
1478
+ }
1479
+
1480
+ // PM2 ecosystem config — production process supervision for self-hosted deploys.
1481
+ // Includes FE, BE, and optional proxy in fork mode (no clustering
1482
+ // since Next.js handles that internally and the proxy is single-threaded by design).
1483
+ function writePm2Config(targetDir, dirName, adminPrefix) {
1484
+ const singleOrigin = !!adminPrefix;
1485
+ // The proxy `env` block conditionally includes ADMIN_PREFIX in single-origin
1486
+ // mode. Built outside the template so we don't emit a stray comma or blank line.
1487
+ const proxyEnv = [
1488
+ "NODE_ENV: 'production',",
1489
+ "PROXY_PORT,",
1490
+ "FRONTEND_PORT,",
1491
+ "BACKEND_PORT,",
1492
+ ...(singleOrigin ? [`ADMIN_PREFIX: '${adminPrefix}',`] : []),
1493
+ ].map((l) => " " + l).join("\n");
1494
+
1495
+ const config = `// PM2 ecosystem config for ${dirName}.
1496
+ //
1497
+ // Loads .env.local at the top so PM2 picks up PROXY_PORT / FRONTEND_PORT /
1498
+ // BACKEND_PORT / PM2_NAMESPACE without a shell wrapper. The .env.local file
1499
+ // is the single source of truth — edit ports there, not here.
1500
+ //
1501
+ // Usage:
1502
+ // pnpm install
1503
+ // pnpm backend:upgrade
1504
+ // pnpm build
1505
+ // pm2 start ecosystem.config.cjs # FE + BE + proxy
1506
+ // pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1507
+ // pm2 stop \${PM2_NAMESPACE} # stop everything in this project
1508
+ // pm2 save && pm2 startup # persist across reboots
1509
+
1510
+ const fs = require('fs');
1511
+ const path = require('path');
1512
+
1513
+ // Inline .env.local parser. Kept tiny (no dotenv dep) so the ecosystem file
1514
+ // stays runnable before \`pnpm install\` finishes.
1515
+ const envPath = path.resolve(__dirname, '.env.local');
1516
+ if (fs.existsSync(envPath)) {
1517
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\\n')) {
1518
+ const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*?)\\s*$/i);
1519
+ if (!m) continue;
1520
+ const k = m[1];
1521
+ let v = m[2];
1522
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
1523
+ if (process.env[k] === undefined) process.env[k] = v;
1524
+ }
1525
+ }
1526
+
1527
+ const NAMESPACE = process.env.PM2_NAMESPACE || '${dirName}';
1528
+ const FRONTEND_PORT = Number(process.env.FRONTEND_PORT) || 3001;
1529
+ const BACKEND_PORT = Number(process.env.BACKEND_PORT) || 3002;
1530
+ const PROXY_PORT = Number(process.env.PROXY_PORT) || 3030;
1531
+
1532
+ // Backend's internal URL (loopback). Server-to-server callers MUST target
1533
+ // this, never the proxy or frontend — going through the FE just adds a failure
1534
+ // mode (an HTML error page where JSON was expected).
1535
+ const BACKEND_INTERNAL_URL = process.env.BACKEND_INTERNAL_URL || \`http://127.0.0.1:\${BACKEND_PORT}\`;
1536
+
1537
+ module.exports = {
1538
+ apps: [
1539
+ {
1540
+ name: \`frontend:\${FRONTEND_PORT}\`,
1541
+ namespace: NAMESPACE,
1542
+ cwd: './apps/frontend',
1543
+ script: 'node_modules/next/dist/bin/next',
1544
+ args: 'start',
1545
+ env: {
1546
+ NODE_ENV: 'production',
1547
+ PORT: FRONTEND_PORT,
1548
+ // Next.js rewrites read this at request time. Without it the FE
1549
+ // falls back to http://localhost:3000 and every /admin, /api, and
1550
+ // /uploads request returns ECONNREFUSED.
1551
+ BACKEND_INTERNAL_URL,
1552
+ },
1553
+ max_memory_restart: '1G',
1554
+ autorestart: true,
1555
+ },
1556
+ {
1557
+ name: \`backend:\${BACKEND_PORT}\`,
1558
+ namespace: NAMESPACE,
1559
+ cwd: './apps/backend',
1560
+ script: 'node_modules/next/dist/bin/next',
1561
+ args: 'start',
1562
+ env: { NODE_ENV: 'production', PORT: BACKEND_PORT },
1563
+ max_memory_restart: '1G',
1564
+ autorestart: true,
1565
+ },
1566
+ {
1567
+ name: \`proxy:\${PROXY_PORT}\`,
1568
+ namespace: NAMESPACE,
1569
+ script: './apps/proxy/server.mjs',
1570
+ env: {
1571
+ ${proxyEnv}
1572
+ },
1573
+ max_memory_restart: '256M',
1574
+ autorestart: true,
1575
+ },
1576
+ ],
1577
+ };
1578
+ `;
1579
+ writeFileSync(resolve(targetDir, "ecosystem.config.cjs"), config);
1580
+ }
1581
+
1582
+ function writeClaudeMd(targetDir, adminPrefix) {
1583
+ const singleOrigin = !!adminPrefix;
1584
+ const prefix = adminPrefix || "/admin";
1585
+ const singleOriginSection = singleOrigin
1586
+ ? `## Single-origin routing
1587
+
1588
+ Frontend is the public entry point. It rewrites these paths to the backend (do **not** create matching routes in the frontend):
1589
+
1590
+ - \`/admin/*\`, \`/uploads/*\`
1591
+ - \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\`
1592
+ - \`/_next/static\` under \`APOLLO_ASSET_PREFIX=${prefix}\` (backend chunks)
1593
+
1594
+ Custom frontend APIs must be namespaced (e.g. \`/api/internal/*\`).
1595
+
1596
+ To disable single-origin: remove \`APOLLO_ASSET_PREFIX\` from \`apps/backend/.env.local\` and delete the \`rewrites()\` block in \`apps/frontend/next.config.ts\`.
1597
+
1598
+ Known gotcha: backend's \`/_next/image\` is not rewritten — custom admin code using \`<Image>\` on \`/uploads/*\` will 404. Use plain \`<img>\` or \`unoptimized\`.
1599
+ `
1600
+ : `## Routing model — separate origins
1601
+
1602
+ Frontend and backend run on independent origins. No rewrites; talk to the backend over its public URL.
1603
+ `;
1604
+
1605
+ const content = `# CLAUDE.md
1606
+
1607
+ This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
1608
+
1609
+ ## Repo shape
1610
+
1611
+ pnpm workspace monorepo. Three workspaces under \`apps/\`:
1612
+
1613
+ - \`apps/frontend\` — public Next.js site${singleOrigin ? " (the public origin in single-origin mode)" : ""}.
1614
+ - \`apps/backend\` — **git submodule** pointing at \`apollo-cms\`. Treat as read-only; do not edit files in here. Open PRs upstream.
1615
+ - \`apps/cms-plugins/<slug>\` — project-specific Apollo CMS plugins. Loaded by the backend via \`APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins\`. Each plugin is built with esbuild to \`dist/server.mjs\` before the backend builds.
1616
+ - \`apps/proxy/server.mjs\` — zero-dep Node reverse proxy used for single-origin dev and self-hosted deploys.
1617
+
1618
+ Built-in plugins in \`apps/backend/plugins/\` always win on slug collisions with \`apps/cms-plugins/\`.
1619
+
1620
+ ${singleOriginSection}
1621
+ ## Common commands
1622
+
1623
+ \`\`\`bash
1624
+ pnpm install
1625
+ pnpm backend:setup # first-time: db:push + db:seed
1626
+ pnpm backend:upgrade # release-time: db:push + replay src/upgrades/0.1.*.ts + seed
1627
+ pnpm backend:update # fast-forward the apollo-cms submodule
1628
+
1629
+ pnpm dev # FE + BE + plugin watcher (parallel)
1630
+ pnpm dev:rp # same + reverse proxy on :3030 (single origin, shared cookies)
1631
+ pnpm dev:frontend # FE only
1632
+ pnpm dev:backend # BE only (runs predev:setup to build plugins first)
1633
+
1634
+ pnpm build # cms-plugins → backend plugins → backend → frontend
1635
+ pnpm build:backend
1636
+ pnpm build:frontend
1637
+
1638
+ pnpm start # FE + BE
1639
+ pnpm start:rp # FE + BE + proxy
1640
+
1641
+ pnpm lint # recursive
1642
+ pnpm typecheck # recursive
1643
+
1644
+ pnpm cms-plugin:new <slug> # scaffold a new plugin from example-plugin
1645
+ \`\`\`
1646
+
1647
+ \`pnpm dev\` and \`pnpm build\` both run \`cms-plugins:build\` first (via \`predev:setup\` / build script chain) — plugins must compile to \`dist/server.mjs\` before the backend resolves them in production. In dev under Bun the loader also accepts \`index.ts\` directly.
1648
+
1649
+ Release flow on a server: \`pnpm install && pnpm backend:upgrade && pnpm build && pm2 reload all\`. \`pnpm start\` does **not** run migrations.
1650
+
1651
+ ## Ports
1652
+
1653
+ Configured in \`ecosystem.config.cjs\` (PM2) and consumed by \`apps/proxy/server.mjs\` via env:
1654
+
1655
+ | Service | Port |
1656
+ | -------- | ---- |
1657
+ | proxy | 3030 |
1658
+ | backend | 3000 |
1659
+ | frontend | 3001 |
1660
+
1661
+ Proxy reads \`PORT\`, \`BACKEND\`, \`FRONTEND\`, \`ADMIN_PREFIX\` from env. When using the proxy locally, set \`NEXT_PUBLIC_SITE_URL=http://localhost:3030\` so Better Auth / OAuth callbacks land on the unified origin.
1662
+
1663
+ ## Deploy
1664
+
1665
+ - Self-hosted: build on a runner, rsync to the server, then \`pm2 startOrReload ecosystem.config.cjs --update-env\`. If the backend submodule is private, the deploy runner needs a token with read access.
1666
+
1667
+ ## Submodule discipline
1668
+
1669
+ - Do not edit files under \`apps/backend\` — they belong to the upstream \`apollo-cms\` repo.
1670
+ - After \`pnpm backend:update\`, run \`pnpm install\` (in case package.json changed) then \`pnpm backend:upgrade\` (replays data migrations that \`backend:setup\` skips).
1671
+ `;
1672
+ writeFileSync(resolve(targetDir, "CLAUDE.md"), content);
1673
+ }
1674
+
1675
+ function writeReadme(targetDir, dirName, frontendName, adminPrefix) {
1676
+ const singleOrigin = !!adminPrefix;
1677
+
1678
+ const originSection = singleOrigin
1679
+ ? `## Routing model — single origin
1680
+
1681
+ Both apps share one public origin (the frontend). The frontend rewrites these
1682
+ paths to the backend so /_next/* doesn't collide:
1683
+
1684
+ | Path | Goes to |
1685
+ | --------------------------- | ------------------ |
1686
+ | \`/\` and other frontend routes | \`apps/frontend\` |
1687
+ | \`/admin/*\` | \`apps/backend\` |
1688
+ | \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\` | \`apps/backend\` |
1689
+ | \`/uploads/*\` | \`apps/backend\` (media) |
1690
+ | \`${adminPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
1691
+
1692
+ The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
1693
+ If you need your own API, namespace it under \`/api/internal/*\` or similar.
1694
+
1695
+ ### Reverse proxy
1696
+
1697
+ Two options ship out of the box:
1698
+
1699
+ - **\`apps/proxy\`** (Node.js, zero deps) — opt in with \`pnpm dev:rp\`. Runs FE,
1700
+ BE, plugins watcher, and a reverse proxy on **:3030** so everything sits on
1701
+ one origin (shared cookies, no CORS). Best for local dev and small self-hosted
1702
+ deploys. Configure via \`PORT\`, \`BACKEND\`, \`FRONTEND\`, \`ADMIN_PREFIX\`.
1703
+ - **\`nginx.conf.sample\`** (production) — same routing baked in (frontend on
1704
+ :3001, backend on :3000). Use it when fronting both apps behind a single
1705
+ TLS-terminating proxy — drop in your domain and SSL certs.
1706
+
1707
+ To **disable** single-origin and run the backend on its own subdomain,
1708
+ delete \`APOLLO_ASSET_PREFIX\` from \`apps/backend/.env.local\` and remove the
1709
+ \`rewrites()\` block from \`apps/frontend/next.config.ts\`.
1710
+
1711
+ ### Known limitations
1712
+
1713
+ - The backend's \`/_next/image\` (Next.js Image optimization endpoint) is **not**
1714
+ rewritten — only \`/_next/static\` moves under \`APOLLO_ASSET_PREFIX\`. If your
1715
+ backend admin uses \`<Image>\` to render \`/uploads/*\` media, those will fall
1716
+ back to the frontend's image optimizer and 404. Apollo CMS's stock admin uses
1717
+ plain \`<img>\` for uploaded media so this rarely matters; if you customize the
1718
+ admin and need optimized images, switch to separate-origins mode or set
1719
+ \`unoptimized\` on those \`<Image>\` instances.
1720
+ `
1721
+ : `## Routing model — separate origins
1722
+
1723
+ The two apps run on separate origins. Default ports:
1724
+
1725
+ - Backend (apps/backend): http://localhost:3000
1726
+ - Frontend (apps/frontend): http://localhost:3001
1727
+
1728
+ \`NEXT_PUBLIC_SITE_URL\` should point at whichever origin you treat as the
1729
+ public CMS URL (typically the backend). To switch to **single-origin** later,
1730
+ re-run the installer with \`--admin-prefix /admin\` or set
1731
+ \`APOLLO_ASSET_PREFIX\` in \`apps/backend/.env.local\` and add the matching
1732
+ \`rewrites()\` block to \`apps/frontend/next.config.ts\`.
1733
+ `;
1734
+
1735
+ const readme = `# ${dirName}
1736
+
1737
+ Monorepo with a custom frontend and Apollo CMS as a git submodule backend.
1738
+
1739
+ ## Layout
1740
+
1741
+ \`\`\`
1742
+ ${dirName}/
1743
+ ├── apps/
1744
+ │ ├── frontend/ ← ${frontendName}
1745
+ │ └── backend/ ← git submodule → apollo-cms (read-only, pull updates)
1746
+ ├── package.json
1747
+ └── pnpm-workspace.yaml
1748
+ \`\`\`
1749
+
1750
+ ## Quick start
1751
+
1752
+ \`\`\`bash
1753
+ pnpm install
1754
+ pnpm backend:setup # push schema + seed apollo-cms
1755
+ pnpm dev # runs frontend (3001) + backend (3000) in parallel
1756
+ # or:
1757
+ pnpm dev:rp # same + reverse proxy on :3030 (single-origin, shared cookies)
1758
+ \`\`\`
1759
+
1760
+ When using \`pnpm dev:rp\`, set \`NEXT_PUBLIC_SITE_URL=http://localhost:3030\`
1761
+ in your \`.env.local\` so Better Auth, OAuth callbacks, and email links use
1762
+ the unified origin.
1763
+
1764
+ ${originSection}
1765
+ ## Custom plugins
1766
+
1767
+ Project-specific plugins live in \`apps/cms-plugins/<slug>/\` and are loaded by
1768
+ the apollo-cms backend via \`APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins\` (set
1769
+ automatically in \`apps/backend/.env.local\`). The submodule stays clean —
1770
+ \`pnpm backend:update\` won't conflict with your plugins.
1771
+
1772
+ ### Author a new plugin
1773
+
1774
+ \`\`\`bash
1775
+ pnpm cms-plugin:new my-plugin
1776
+ pnpm install
1777
+ pnpm dev
1778
+ \`\`\`
1779
+
1780
+ The scaffolder copies \`apps/cms-plugins/example-plugin/\` to
1781
+ \`apps/cms-plugins/my-plugin/\` and renames it. Edit \`index.ts\` to register
1782
+ hooks, UI slots, and API routes — see
1783
+ \`apps/backend/docs/plugin-system.md\` for the full surface.
1784
+
1785
+ ### Build pipeline
1786
+
1787
+ \`pnpm dev\` and \`pnpm build\` run \`pnpm cms-plugins:build\` first to compile
1788
+ each plugin's \`index.ts\` → \`dist/server.mjs\` (via esbuild). The backend's
1789
+ plugin loader resolves \`dist/server.mjs\` in production; in dev under Bun it
1790
+ also accepts \`index.ts\` directly.
1791
+
1792
+ ### Slug collisions
1793
+
1794
+ Built-in plugins under \`apps/backend/plugins/\` always win on slug collision.
1795
+ If you see *"Plugin slug collision"* in the backend logs, rename your plugin's
1796
+ folder + \`plugin.json#name\`.
1797
+
1798
+ ## Updating the backend
1799
+
1800
+ Apollo CMS is tracked as a git submodule. Full upgrade flow:
1801
+
1802
+ \`\`\`bash
1803
+ pnpm backend:update # auto-stash, fast-forward apps/backend, run pnpm install
1804
+ pnpm backend:upgrade # drizzle push + replay version migrations + seed
1805
+ \`\`\`
1806
+
1807
+ | Script | What it does |
1808
+ | --- | --- |
1809
+ | \`pnpm backend:update\` | Auto-stashes local submodule changes, fast-forwards \`apps/backend\`, runs \`pnpm install\` so new backend deps are picked up, then restores the stash. |
1810
+ | \`pnpm backend:setup\` | First-time bootstrap: \`db:push\` + \`db:seed\` only |
1811
+ | \`pnpm backend:upgrade\` | Full pipeline: \`db:push\` + replay \`src/upgrades/0.1.*.ts\` data migrations + \`db:seed\`. **Use this** after \`backend:update\` — \`backend:setup\` skips the data-migration phase. |
1812
+
1813
+ Do **not** edit files inside \`apps/backend\`. Open issues / PRs in the
1814
+ \`apollo-cms\` repository upstream.
1815
+
1816
+ ## Environment variables
1817
+
1818
+ Shared dev env lives in the root \`.env.local\`. The backend reads its own
1819
+ \`apps/backend/.env.local\` (already populated by the installer).
1820
+
1821
+ ## Deploy (self-hosted)
1822
+
1823
+ Standard sequence on a VPS / Docker host / bare metal:
1824
+
1825
+ \`\`\`bash
1826
+ pnpm install
1827
+ pnpm backend:upgrade # db push + replay data migrations + seed (run ONCE per release)
1828
+ pnpm build # builds plugins → backend → frontend
1829
+ pnpm start # FE :3001 + BE :3000 — or:
1830
+ pnpm start:rp # adds Node reverse proxy on :3030 (single origin)
1831
+ \`\`\`
1832
+
1833
+ \`pnpm start\` does **not** run migrations on boot — that lets you restart
1834
+ processes without re-running \`drizzle-kit push\` every time. Always run
1835
+ \`pnpm backend:upgrade\` once per release before \`pnpm start\`.
1836
+
1837
+ | Script | What it does |
1838
+ | ------------------- | ------------------------------------------------------------------- |
1839
+ | \`pnpm build\` | Full pipeline: cms-plugins → backend plugins → backend → frontend |
1840
+ | \`pnpm start\` | FE + BE (parallel \`next start\`) |
1841
+ | \`pnpm start:rp\` | FE + BE + reverse proxy on :3030 |
1842
+ | \`pnpm start:proxy\` | Reverse proxy alone (already running FE/BE separately) |
1843
+
1844
+ ### PM2 (recommended for VPS)
1845
+
1846
+ The installer scaffolds \`ecosystem.config.cjs\`. To supervise everything:
1847
+
1848
+ \`\`\`bash
1849
+ pm2 start ecosystem.config.cjs # FE + BE + proxy (all three)
1850
+ pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1851
+ pm2 save && pm2 startup # persist across reboots
1852
+ pm2 logs \${PM2_NAMESPACE:-${dirName}} # tail just this project's logs
1853
+ pm2 reload \${PM2_NAMESPACE:-${dirName}} # zero-downtime restart (namespace-scoped)
1854
+ pm2 stop \${PM2_NAMESPACE:-${dirName}} # stop only this project
1855
+ \`\`\`
1856
+
1857
+ All three processes are grouped under the \`PM2_NAMESPACE\` set in \`.env.local\`
1858
+ (defaults to \`${dirName}\`), so namespace commands target only this project
1859
+ even when other PM2 apps share the host. Process names embed the bound port
1860
+ (\`frontend:3001\`, \`backend:3002\`, \`proxy:3030\`) for quick \`pm2 ls\` triage.
1861
+
1862
+ The proxy process can be omitted with \`--only\` if you front the apps with
1863
+ nginx/Caddy. Scheduled jobs need no extra process — the backend drives its
1864
+ scheduler queue in-process.
1865
+
1866
+ ### Docker / k8s
1867
+
1868
+ For containerized deploys:
1869
+ - Bake the build into the image (\`pnpm install && pnpm build\`)
1870
+ - Set entrypoint to \`pnpm start\` or \`pnpm start:rp\`
1871
+ - Run \`pnpm backend:upgrade\` as a separate init container / Job before app pods start
1872
+ - No cron sidecar needed — the backend drives its scheduler queue in-process
1873
+ `;
1874
+ writeFileSync(resolve(targetDir, "README.md"), readme);
1875
+ }
1876
+
1877
+ // ─── Main ────────────────────────────────────────────────────────────────────
1878
+
1879
+ async function main() {
1880
+ const flags = parseArgs(process.argv);
1881
+
1882
+ if (flags.help) {
1883
+ log(HELP_TEXT);
1884
+ process.exit(0);
1885
+ }
1886
+
1887
+ if (!flags.directory) {
1888
+ log(HELP_TEXT);
1889
+ fatal("Please provide a directory name.\n Example: npx create-apollo-suite-monorepo my-site");
1890
+ }
1891
+
1892
+ log(`\n${COLORS.bold}${COLORS.cyan} Apollo CMS Monorepo Installer${COLORS.reset}\n`);
1893
+
1894
+ // ── Step 1: Pre-flight ──
1895
+ const targetDir = preflight(flags);
1896
+ const dirName = basename(targetDir);
1897
+ const frontendName = flags.frontendName ?? `@${dirName}/frontend`;
1898
+
1899
+ // ── Step 2: Gather env ──
1900
+ step(2, "Configuring environment");
1901
+ const adminPrefix =normalizeAdminPrefix(flags.adminPrefix);
1902
+ const { dbUrl, siteUrl, locale } = await gatherEnv(flags);
1903
+ const authSecret = randomBytes(48).toString("base64");
1904
+ // Authenticates trusted callers of apollo-cms's /api/health?check=ready.
1905
+ // Without it that readiness check answers 403 "CRON_SECRET not configured".
1906
+ const cronSecret = randomBytes(24).toString("hex");
1907
+ const backendInternalUrl = `http://localhost:${DEFAULT_BACKEND_PORT}`;
1908
+ success(`Frontend pkg name: ${frontendName}`);
1909
+ success(`Admin prefix: ${adminPrefix || "(disabled — separate origins)"}`);
1910
+
1911
+ // ── Step 3: Scaffold root ──
1912
+ step(3, "Scaffolding monorepo root");
1913
+ mkdirSync(targetDir, { recursive: true });
1914
+ mkdirSync(resolve(targetDir, "apps"), { recursive: true });
1915
+ writeRootPackageJson(targetDir, dirName);
1916
+ writePnpmWorkspace(targetDir);
1917
+ writeRootGitignore(targetDir);
1918
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace: dirName });
1919
+ writeReadme(targetDir, dirName, frontendName, adminPrefix);
1920
+ writeClaudeMd(targetDir, adminPrefix);
1921
+ if (adminPrefix) writeNginxSample(targetDir, adminPrefix);
1922
+ writeProxyApp(targetDir, dirName, adminPrefix);
1923
+ writeCheckEnvScript(targetDir);
1924
+ writeWithEnvScript(targetDir);
1925
+ writePm2Config(targetDir, dirName, adminPrefix);
1926
+ success(
1927
+ `package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md, CLAUDE.md${
1928
+ adminPrefix ? ", nginx.conf.sample" : ""
1929
+ }, apps/proxy, ecosystem.config.cjs, scripts/check-env.mjs, scripts/with-env.mjs`,
1930
+ );
1931
+
1932
+ // ── Step 4: git init ──
1933
+ step(4, "Initializing git");
1934
+ run("git init -b main", { cwd: targetDir, stdio: "pipe" });
1935
+ success("git repo initialized");
1936
+
1937
+ // ── Step 5: Frontend skeleton ──
1938
+ step(5, "Creating frontend app");
1939
+ writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backendInternalUrl);
1940
+ success(`apps/frontend (${frontendName})`);
1941
+
1942
+ // ── Step 5b: Custom plugins workspace + example ──
1943
+ step("5b", "Scaffolding apps/cms-plugins/example-plugin");
1944
+ writeExampleCmsPlugin(targetDir, dirName);
1945
+ writeNewCmsPluginScript(targetDir);
1946
+ success("apps/cms-plugins/example-plugin + scripts/new-cms-plugin.mjs");
1947
+
1948
+ // ── Step 6: Backend submodule ──
1949
+ if (flags.skipSubmodule) {
1950
+ step(6, "Skipping git submodule (--skip-submodule)");
1951
+ warn(
1952
+ `Add it later:\n cd ${dirName}\n git submodule add -b ${flags.backendBranch} ${flags.backendUrl} ${BACKEND_PATH}`,
1953
+ );
1954
+ } else {
1955
+ step(6, `Adding apollo-cms as git submodule (${BACKEND_PATH})`);
1956
+ try {
1957
+ run(
1958
+ `git submodule add -b ${flags.backendBranch} ${flags.backendUrl} ${BACKEND_PATH}`,
1959
+ { cwd: targetDir },
1960
+ );
1961
+ run(`git submodule update --init --recursive`, { cwd: targetDir });
1962
+ success("submodule added");
1963
+ } catch {
1964
+ fatal(
1965
+ `Failed to add submodule.\n Check the URL and your network, then run:\n cd ${dirName} && git submodule add -b ${flags.backendBranch} ${flags.backendUrl} ${BACKEND_PATH}`,
1966
+ );
1967
+ }
1968
+
1969
+ const backendFallback = [
1970
+ `DATABASE_URL=${dbUrl}`,
1971
+ `APOLLO_SECRET=${authSecret}`,
1972
+ `CRON_SECRET=${cronSecret}`,
1973
+ `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
1974
+ `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
1975
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
1976
+ `PM2_NAMESPACE=${dirName}`,
1977
+ ];
1978
+ if (adminPrefix) backendFallback.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
1979
+ linkRootEnvLocal(targetDir, BACKEND_PATH, backendFallback);
1980
+ }
1981
+
1982
+ // Frontend gets the same treatment — Next.js running in apps/frontend
1983
+ // can't see the root file otherwise, and next.config.ts references
1984
+ // BACKEND_INTERNAL_URL for the rewrites destination.
1985
+ const frontendFallback = adminPrefix
1986
+ ? [`BACKEND_INTERNAL_URL=${backendInternalUrl}`]
1987
+ : [`NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`];
1988
+ linkRootEnvLocal(targetDir, FRONTEND_PATH, frontendFallback);
1989
+
1990
+ // ── Step 7: Install ──
1991
+ if (flags.skipInstall) {
1992
+ step(7, "Skipping dependency installation (--skip-install)");
1993
+ warn(`Run later:\n cd ${dirName} && pnpm install`);
1994
+ } else if (!commandExists("pnpm")) {
1995
+ step(7, "Skipping dependency installation (pnpm not installed)");
1996
+ warn(`Install pnpm and run:\n npm i -g pnpm && cd ${dirName} && pnpm install`);
1997
+ } else {
1998
+ step(7, "Installing dependencies (pnpm)");
1999
+ try {
2000
+ run("pnpm install", { cwd: targetDir });
2001
+ success("dependencies installed");
2002
+ } catch {
2003
+ warn(`Install failed — run manually: cd ${dirName} && pnpm install`);
2004
+ }
2005
+ }
2006
+
2007
+ // ── Done ──
2008
+ log(`
2009
+ ${COLORS.green}${COLORS.bold} Monorepo created!${COLORS.reset}
2010
+
2011
+ ${COLORS.bold}cd${COLORS.reset} ${dirName}
2012
+ ${COLORS.bold}pnpm backend:setup${COLORS.reset} ${COLORS.dim}# push schema + seed apollo-cms${COLORS.reset}
2013
+ ${COLORS.bold}pnpm dev${COLORS.reset} ${COLORS.dim}# frontend :3001 + backend :3000${COLORS.reset}
2014
+
2015
+ ${COLORS.dim}APOLLO_SECRET=${authSecret}${COLORS.reset}
2016
+ `);
2017
+ }
2018
+
2019
+ main().catch((err) => {
2020
+ fatal(err.message ?? String(err));
2021
+ });