create-apollo-monorepo 0.9.2 → 0.9.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/index.mjs +249 -179
  2. package/package.json +2 -2
package/index.mjs CHANGED
@@ -16,7 +16,7 @@
16
16
 
17
17
  import { execSync } from "node:child_process";
18
18
  import { randomBytes } from "node:crypto";
19
- import { existsSync, mkdirSync, writeFileSync } from "node:fs";
19
+ import { existsSync, mkdirSync, writeFileSync, symlinkSync, rmSync } from "node:fs";
20
20
  import { resolve, basename } from "node:path";
21
21
  import { createInterface } from "node:readline";
22
22
 
@@ -27,7 +27,7 @@ const BACKEND_BRANCH = "main";
27
27
  const BACKEND_PATH = "apps/backend";
28
28
  const FRONTEND_PATH = "apps/frontend";
29
29
  const PROXY_PATH = "apps/proxy";
30
- const MIN_NODE_MAJOR = 20;
30
+ const MIN_NODE_MAJOR = 22;
31
31
 
32
32
  // Default dev ports. Kept in sync with scripts/with-env.mjs and the proxy
33
33
  // template. .env.local is the runtime source of truth — these are the
@@ -62,7 +62,9 @@ ${COLORS.bold}Flags:${COLORS.reset}
62
62
  --backend-url <url> Backend git URL (default: ${BACKEND_REPO_URL})
63
63
  --backend-branch <name> Backend branch to track (default: ${BACKEND_BRANCH})
64
64
  -d, --db <url> DATABASE_URL for backend
65
- -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3001 single-origin / 3000 separate)
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.)
66
68
  -l, --locale <code> NEXT_PUBLIC_DEFAULT_LOCALE (default: en)
67
69
  --admin-prefix <path> Backend prefix in single-origin mode — sets Next.js
68
70
  assetPrefix on the backend AND the path the frontend
@@ -124,6 +126,26 @@ function commandExists(cmd) {
124
126
 
125
127
  // ─── Arg Parsing ─────────────────────────────────────────────────────────────
126
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
+
127
149
  function parseArgs(argv) {
128
150
  const args = argv.slice(2);
129
151
  const flags = {
@@ -140,55 +162,16 @@ function parseArgs(argv) {
140
162
  help: false,
141
163
  };
142
164
 
143
- let i = 0;
144
- while (i < args.length) {
165
+ for (let i = 0; i < args.length; i++) {
145
166
  const arg = args[i];
146
- switch (arg) {
147
- case "-h":
148
- case "--help":
149
- flags.help = true;
150
- break;
151
- case "--frontend-name":
152
- flags.frontendName = args[++i];
153
- break;
154
- case "--backend-url":
155
- flags.backendUrl = args[++i];
156
- break;
157
- case "--backend-branch":
158
- flags.backendBranch = args[++i];
159
- break;
160
- case "-d":
161
- case "--db":
162
- flags.db = args[++i];
163
- break;
164
- case "-u":
165
- case "--url":
166
- flags.url = args[++i];
167
- break;
168
- case "-l":
169
- case "--locale":
170
- flags.locale = args[++i];
171
- break;
172
- case "--admin-prefix":
173
- case "--asset-prefix": // legacy alias
174
- flags.adminPrefix = args[++i];
175
- break;
176
- case "--skip-install":
177
- flags.skipInstall = true;
178
- break;
179
- case "--skip-submodule":
180
- flags.skipSubmodule = true;
181
- break;
182
- default:
183
- if (arg.startsWith("-")) {
184
- fatal(`Unknown flag: ${arg}\nRun with --help for usage.`);
185
- }
186
- if (flags.directory) {
187
- fatal(`Unexpected argument: ${arg}\nOnly one directory name is allowed.`);
188
- }
189
- flags.directory = arg;
167
+ const spec = FLAG_TABLE[arg];
168
+ if (spec) {
169
+ flags[spec.key] = "value" in spec ? spec.value : args[++i];
170
+ continue;
190
171
  }
191
- i++;
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;
192
175
  }
193
176
 
194
177
  return flags;
@@ -243,29 +226,22 @@ function preflight(flags) {
243
226
  }
244
227
  success(`Node.js ${process.versions.node}`);
245
228
 
246
- if (!commandExists("git")) {
247
- fatal("git is required but not found.\n Install: https://git-scm.com/");
248
- }
249
- success("git found");
250
-
251
- if (!commandExists("pnpm")) {
252
- warn("pnpm not found — install with: npm i -g pnpm (required for workspaces)");
253
- } else {
254
- success("pnpm found");
255
- }
256
-
257
229
  // bun is required at runtime by apps/backend (apollo-cms): db:push, db:seed,
258
230
  // dev:cron, plugins:build, the upgrade pipeline, and pre-commit hooks all
259
231
  // shell out to `bun`. The scaffold itself works without bun, but `pnpm dev`,
260
232
  // `pnpm backend:setup`, and `pnpm backend:upgrade` will fail without it.
261
- if (!commandExists("bun")) {
262
- warn(
263
- "bun not found — required by apps/backend for db:push, db:seed, dev:cron,\n" +
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, dev:cron,\n" +
264
238
  " plugins:build, and the upgrade pipeline.\n" +
265
- " Install: curl -fsSL https://bun.sh/install | bash (or: brew install bun)",
266
- );
267
- } else {
268
- success("bun found");
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);
269
245
  }
270
246
 
271
247
  const targetDir = resolve(flags.directory);
@@ -288,24 +264,21 @@ function createPrompt() {
288
264
 
289
265
  async function gatherEnv(flags, { singleOrigin }) {
290
266
  let dbUrl = flags.db;
291
- // In single-origin mode the frontend is the public entry default :3001.
292
- // In separate-origins mode the backend is the public CMS URL default :3000.
293
- // Public origin = frontend in single-origin mode (frontend rewrites /admin/*,
294
- // /api/*, /uploads/* to the backend), backend in separate-origins mode.
295
- // For `pnpm dev:rp` switch this to http://localhost:${PROXY_PORT} in .env.local.
296
- // Derived from the PORT constants so changing a default updates both the
297
- // .env.local seed and the prompt's suggested value.
298
- const defaultSiteUrl = singleOrigin
299
- ? `http://localhost:${DEFAULT_FRONTEND_PORT}`
300
- : `http://localhost:${DEFAULT_BACKEND_PORT}`;
301
- let siteUrl = flags.url ?? defaultSiteUrl;
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 ?? "";
302
275
  let locale = flags.locale ?? "en";
303
276
 
304
277
  if (flags.db && !isValidDbUrl(flags.db)) fatal("--db must start with postgresql:// or postgres://");
305
278
  if (flags.url && !isValidUrl(flags.url)) fatal("--url must start with http:// or https://");
306
279
  if (flags.locale && !isValidLocale(flags.locale)) fatal("--locale must be a 2-5 character code (e.g., en, th)");
307
280
 
308
- const needsPrompt = !dbUrl || !flags.url || !flags.locale;
281
+ const needsPrompt = !dbUrl || !flags.locale;
309
282
  if (!needsPrompt) return { dbUrl, siteUrl, locale };
310
283
 
311
284
  const { ask, close } = createPrompt();
@@ -321,16 +294,6 @@ async function gatherEnv(flags, { singleOrigin }) {
321
294
  }
322
295
  }
323
296
 
324
- if (!flags.url) {
325
- const ans = await ask(
326
- `\n ${COLORS.bold}NEXT_PUBLIC_SITE_URL${COLORS.reset} ${COLORS.dim}[${siteUrl}]${COLORS.reset}\n > `,
327
- );
328
- if (ans) {
329
- if (isValidUrl(ans)) siteUrl = ans;
330
- else warn(`Invalid URL, using default: ${siteUrl}`);
331
- }
332
- }
333
-
334
297
  if (!flags.locale) {
335
298
  const ans = await ask(
336
299
  `\n ${COLORS.bold}Default locale${COLORS.reset} ${COLORS.dim}[${locale}]${COLORS.reset}\n > `,
@@ -435,33 +398,7 @@ function writeRootPackageJson(targetDir, dirName) {
435
398
  devDependencies: {
436
399
  concurrently: "^9.0.0",
437
400
  },
438
- engines: { node: ">=20", pnpm: ">=9" },
439
- pnpm: {
440
- // apollo-cms transitively pulls multiple esbuild versions (0.18, 0.25, 0.27).
441
- // pnpm's binary symlink can pick the wrong platform binary for esbuild's
442
- // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
443
- // Allow listed packages to run their build/postinstall scripts; the rest
444
- // are skipped (pnpm 10 default is empty allow-list).
445
- onlyBuiltDependencies: [
446
- "@parcel/watcher",
447
- "@rolldown/binding-darwin-arm64",
448
- "@rolldown/binding-linux-arm64-gnu",
449
- "@rolldown/binding-linux-x64-gnu",
450
- "@sentry/cli",
451
- "@swc/core",
452
- "@swc/core-darwin-arm64",
453
- "@swc/core-darwin-x64",
454
- "@swc/core-linux-arm64-gnu",
455
- "@swc/core-linux-x64-gnu",
456
- "better-sqlite3",
457
- "core-js",
458
- "core-js-pure",
459
- "esbuild",
460
- "msw",
461
- "sharp",
462
- "unrs-resolver",
463
- ],
464
- },
401
+ engines: { node: ">=22", pnpm: ">=10" },
465
402
  };
466
403
  writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
467
404
 
@@ -475,17 +412,77 @@ function writeRootPackageJson(targetDir, dirName) {
475
412
  "strict-peer-dependencies=false",
476
413
  "public-hoist-pattern[]=*esbuild*",
477
414
  "public-hoist-pattern[]=*types*",
415
+ // Next.js 16's instrumentation hook (used by Sentry / OpenTelemetry
416
+ // shipped via apollo-cms) loads require-in-the-middle / import-in-the-middle
417
+ // at runtime via a bare require. Under pnpm's strict node_modules these
418
+ // sit deep inside .pnpm/ and the resolver can't see them — Next then
419
+ // crashes on boot with "Failed to load external module require-in-the-middle".
420
+ // Hoisting them (plus their `shimmer` dep) to the root node_modules fixes it.
421
+ "public-hoist-pattern[]=*require-in-the-middle*",
422
+ "public-hoist-pattern[]=*import-in-the-middle*",
423
+ "public-hoist-pattern[]=*shimmer*",
478
424
  "shell-emulator=true",
479
425
  "",
480
426
  ].join("\n"),
481
427
  );
482
428
  }
483
429
 
430
+ // Link an app's .env.local to the monorepo root .env.local so the root file
431
+ // stays the single source of truth. Next.js only reads .env.local from its
432
+ // own CWD, so this symlink lets `cd apps/<app> && next dev|build` see the
433
+ // shared config. Falls back to a regular copy (with a warning) on platforms
434
+ // where symlink creation requires elevation (Windows non-admin).
435
+ function linkRootEnvLocal(targetDir, appRelPath, fallbackLines) {
436
+ const envPath = resolve(targetDir, appRelPath, ".env.local");
437
+ // appRelPath is `apps/<name>` (2 segments deep) → `../../.env.local`.
438
+ const depth = appRelPath.split("/").filter(Boolean).length;
439
+ const linkTarget = "../".repeat(depth) + ".env.local";
440
+ if (existsSync(envPath)) rmSync(envPath);
441
+ try {
442
+ symlinkSync(linkTarget, envPath);
443
+ success(`${appRelPath}/.env.local → ${linkTarget} (symlink)`);
444
+ } catch {
445
+ writeFileSync(envPath, [...fallbackLines, ""].join("\n"));
446
+ warn(
447
+ `symlink failed — wrote a copy at ${appRelPath}/.env.local (keep it in sync with the root file manually).`,
448
+ );
449
+ }
450
+ }
451
+
484
452
  function writePnpmWorkspace(targetDir) {
485
- writeFileSync(
486
- resolve(targetDir, "pnpm-workspace.yaml"),
487
- "packages:\n - 'apps/*'\n - 'apps/cms-plugins/*'\n",
488
- );
453
+ // pnpm 10 reads `onlyBuiltDependencies` from pnpm-workspace.yaml (the
454
+ // package.json#pnpm location is deprecated for workspaces). apollo-cms
455
+ // transitively pulls multiple esbuild versions (0.18, 0.25, 0.27); pnpm's
456
+ // binary symlink can pick the wrong platform binary for esbuild's
457
+ // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
458
+ // Allow listed packages to run their build/postinstall scripts; the rest
459
+ // are skipped (pnpm 10 default is empty allow-list).
460
+ const yaml = [
461
+ "packages:",
462
+ " - 'apps/*'",
463
+ " - 'apps/cms-plugins/*'",
464
+ "",
465
+ "onlyBuiltDependencies:",
466
+ " - '@parcel/watcher'",
467
+ " - '@rolldown/binding-darwin-arm64'",
468
+ " - '@rolldown/binding-linux-arm64-gnu'",
469
+ " - '@rolldown/binding-linux-x64-gnu'",
470
+ " - '@sentry/cli'",
471
+ " - '@swc/core'",
472
+ " - '@swc/core-darwin-arm64'",
473
+ " - '@swc/core-darwin-x64'",
474
+ " - '@swc/core-linux-arm64-gnu'",
475
+ " - '@swc/core-linux-x64-gnu'",
476
+ " - 'better-sqlite3'",
477
+ " - 'core-js'",
478
+ " - 'core-js-pure'",
479
+ " - 'esbuild'",
480
+ " - 'msw'",
481
+ " - 'sharp'",
482
+ " - 'unrs-resolver'",
483
+ "",
484
+ ].join("\n");
485
+ writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), yaml);
489
486
  }
490
487
 
491
488
  // Single-origin nginx reference. Only emitted when adminPrefix is set, since
@@ -812,7 +809,7 @@ function parseTarget(input) {
812
809
  scripts: {
813
810
  start: "node server.mjs",
814
811
  },
815
- engines: { node: ">=20" },
812
+ engines: { node: ">=22" },
816
813
  };
817
814
  writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
818
815
 
@@ -899,7 +896,7 @@ function writeRootGitignore(targetDir) {
899
896
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
900
897
  }
901
898
 
902
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl }) {
899
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace }) {
903
900
  const header = adminPrefix
904
901
  ? [
905
902
  "# Single-origin monorepo dev env",
@@ -932,42 +929,49 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
932
929
  `DATABASE_URL=${dbUrl}`,
933
930
  `APOLLO_SECRET=${authSecret}`,
934
931
  `CRON_SECRET=${cronSecret}`,
935
- "# Public origin. In dev, leave blank to auto-derive from FRONTEND_PORT",
936
- "# (single-origin) or BACKEND_PORT (separate-origins) via scripts/with-env.mjs.",
937
- "# In prod set to the real domain (e.g. https://cms.example.com).",
932
+ "# Public origin. Leave blank so the CMS respects the incoming request",
933
+ "# origin (works for localhost, preview URLs, and prod domains automatically).",
934
+ "# Set this ONLY when background jobs (cron / triggered emails) need a fixed",
935
+ "# origin outside a request scope — e.g. NEXT_PUBLIC_SITE_URL=https://cms.example.com",
938
936
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
939
937
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
940
938
  "",
941
939
  ];
942
940
 
941
+ lines.push("# ── Backend (apps/backend) ───────────────────────────────────────");
943
942
  if (adminPrefix) {
944
943
  lines.push(
945
- "# ── Backend (apps/backend) ───────────────────────────────────────",
946
944
  "# Single-origin admin/asset prefix. The frontend rewrites this path",
947
945
  "# to the backend, so backend chunks (served at <prefix>/_next/static)",
948
946
  "# and admin pages share one rewrite.",
949
947
  `APOLLO_ASSET_PREFIX=${adminPrefix}`,
950
- "# Project-specific plugins — keeps the apollo-cms submodule clean.",
951
- `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
952
- "",
953
- "# ── Frontend (apps/frontend) ─────────────────────────────────────",
948
+ );
949
+ }
950
+ lines.push(
951
+ "# Project-specific plugins keeps the apollo-cms submodule clean.",
952
+ `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
953
+ "# PM2 namespace — groups this project's processes so you can",
954
+ "# `pm2 stop <namespace>` / `pm2 restart <namespace>` independently of",
955
+ "# other projects on the same host. Consumed by ecosystem.config.cjs.",
956
+ `PM2_NAMESPACE=${pm2Namespace}`,
957
+ "",
958
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
959
+ );
960
+ if (adminPrefix) {
961
+ lines.push(
954
962
  "# Where the frontend's rewrites point internally. In dev, leave blank",
955
963
  "# to auto-derive http://127.0.0.1:${BACKEND_PORT} via scripts/with-env.mjs.",
956
964
  "# In prod set to a private hostname (e.g. http://backend.internal:3000).",
957
965
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
958
- "",
959
966
  );
960
967
  } else {
961
968
  lines.push(
962
- "# ── Backend (apps/backend) ───────────────────────────────────────",
963
- "# Project-specific plugins keeps the apollo-cms submodule clean.",
964
- `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
965
- "",
966
- "# ── Frontend (apps/frontend) ─────────────────────────────────────",
967
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
968
- "",
969
+ "# Where the frontend reaches the backend in separate-origins mode.",
970
+ "# Defaults to the backend's dev port; override in prod to the real CMS host.",
971
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`,
969
972
  );
970
973
  }
974
+ lines.push("");
971
975
 
972
976
  writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
973
977
  }
@@ -1101,7 +1105,7 @@ export default config;
1101
1105
  ]
1102
1106
  : [
1103
1107
  `# Public URL of the backend (used by your client code)`,
1104
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
1108
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`,
1105
1109
  "",
1106
1110
  ];
1107
1111
  writeFileSync(resolve(dir, ".env.local.example"), envLocalLines.join("\n"));
@@ -1488,55 +1492,111 @@ console.log("✓ env check passed");
1488
1492
  // since Next.js handles that internally and the proxy is single-threaded by design).
1489
1493
  function writePm2Config(targetDir, dirName, adminPrefix) {
1490
1494
  const singleOrigin = !!adminPrefix;
1495
+ // The proxy `env` block conditionally includes ADMIN_PREFIX in single-origin
1496
+ // mode. Built outside the template so we don't emit a stray comma or blank line.
1497
+ const proxyEnv = [
1498
+ "NODE_ENV: 'production',",
1499
+ "PROXY_PORT,",
1500
+ "FRONTEND_PORT,",
1501
+ "BACKEND_PORT,",
1502
+ ...(singleOrigin ? [`ADMIN_PREFIX: '${adminPrefix}',`] : []),
1503
+ ].map((l) => " " + l).join("\n");
1504
+
1491
1505
  const config = `// PM2 ecosystem config for ${dirName}.
1506
+ //
1507
+ // Loads .env.local at the top so PM2 picks up PROXY_PORT / FRONTEND_PORT /
1508
+ // BACKEND_PORT / PM2_NAMESPACE without a shell wrapper. The .env.local file
1509
+ // is the single source of truth — edit ports there, not here.
1510
+ //
1492
1511
  // Usage:
1493
1512
  // pnpm install
1494
1513
  // pnpm backend:upgrade
1495
1514
  // pnpm build
1496
- // pm2 start ecosystem.config.cjs # FE + BE
1497
- // pm2 start ecosystem.config.cjs --only proxy # add reverse proxy
1515
+ // pm2 start ecosystem.config.cjs # FE + BE + proxy + cron
1516
+ // pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1498
1517
  // pm2 start ecosystem.config.cjs --only cron # self-hosted cron fallback
1518
+ // pm2 stop \${PM2_NAMESPACE} # stop everything in this project
1499
1519
  // pm2 save && pm2 startup # persist across reboots
1500
1520
 
1521
+ const fs = require('fs');
1522
+ const path = require('path');
1523
+
1524
+ // Inline .env.local parser. Kept tiny (no dotenv dep) so the ecosystem file
1525
+ // stays runnable before \`pnpm install\` finishes.
1526
+ const envPath = path.resolve(__dirname, '.env.local');
1527
+ if (fs.existsSync(envPath)) {
1528
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\\n')) {
1529
+ const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*?)\\s*$/i);
1530
+ if (!m) continue;
1531
+ const k = m[1];
1532
+ let v = m[2];
1533
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
1534
+ if (process.env[k] === undefined) process.env[k] = v;
1535
+ }
1536
+ }
1537
+
1538
+ const NAMESPACE = process.env.PM2_NAMESPACE || '${dirName}';
1539
+ const FRONTEND_PORT = Number(process.env.FRONTEND_PORT) || 3001;
1540
+ const BACKEND_PORT = Number(process.env.BACKEND_PORT) || 3002;
1541
+ const PROXY_PORT = Number(process.env.PROXY_PORT) || 3030;
1542
+
1543
+ // Backend's internal URL (loopback). Cron MUST target this, never the proxy or
1544
+ // frontend — going through the FE just adds a failure mode (HTML error → JSON
1545
+ // parse blowup in dev-cron.ts).
1546
+ const BACKEND_INTERNAL_URL = process.env.BACKEND_INTERNAL_URL || \`http://127.0.0.1:\${BACKEND_PORT}\`;
1547
+
1501
1548
  module.exports = {
1502
1549
  apps: [
1503
1550
  {
1504
- name: "frontend",
1505
- cwd: "./apps/frontend",
1506
- script: "node_modules/next/dist/bin/next",
1507
- args: "start",
1508
- env: { NODE_ENV: "production", PORT: Number(process.env.FRONTEND_PORT) || 3001 },
1509
- max_memory_restart: "1G",
1551
+ name: \`frontend:\${FRONTEND_PORT}\`,
1552
+ namespace: NAMESPACE,
1553
+ cwd: './apps/frontend',
1554
+ script: 'node_modules/next/dist/bin/next',
1555
+ args: 'start',
1556
+ env: {
1557
+ NODE_ENV: 'production',
1558
+ PORT: FRONTEND_PORT,
1559
+ // Next.js rewrites read this at request time. Without it the FE
1560
+ // falls back to http://localhost:3000 and every /admin, /api, and
1561
+ // /uploads request returns ECONNREFUSED.
1562
+ BACKEND_INTERNAL_URL,
1563
+ },
1564
+ max_memory_restart: '1G',
1510
1565
  autorestart: true,
1511
1566
  },
1512
1567
  {
1513
- name: "backend",
1514
- cwd: "./apps/backend",
1515
- script: "node_modules/next/dist/bin/next",
1516
- args: "start",
1517
- env: { NODE_ENV: "production", PORT: Number(process.env.BACKEND_PORT) || 3002 },
1518
- max_memory_restart: "1G",
1568
+ name: \`backend:\${BACKEND_PORT}\`,
1569
+ namespace: NAMESPACE,
1570
+ cwd: './apps/backend',
1571
+ script: 'node_modules/next/dist/bin/next',
1572
+ args: 'start',
1573
+ env: { NODE_ENV: 'production', PORT: BACKEND_PORT },
1574
+ max_memory_restart: '1G',
1519
1575
  autorestart: true,
1520
1576
  },
1521
1577
  {
1522
- name: "proxy",
1523
- script: "./apps/proxy/server.mjs",
1578
+ name: \`proxy:\${PROXY_PORT}\`,
1579
+ namespace: NAMESPACE,
1580
+ script: './apps/proxy/server.mjs',
1524
1581
  env: {
1525
- NODE_ENV: "production",
1526
- PROXY_PORT: Number(process.env.PROXY_PORT) || 3030,
1527
- FRONTEND_PORT: Number(process.env.FRONTEND_PORT) || 3001,
1528
- BACKEND_PORT: Number(process.env.BACKEND_PORT) || 3002,
1529
- ${singleOrigin ? `ADMIN_PREFIX: "${adminPrefix}",` : ""}
1582
+ ${proxyEnv}
1530
1583
  },
1531
- max_memory_restart: "256M",
1584
+ max_memory_restart: '256M',
1532
1585
  autorestart: true,
1533
1586
  },
1534
1587
  {
1535
- name: "cron",
1536
- cwd: "./apps/backend",
1537
- script: "scripts/dev-cron.ts",
1538
- interpreter: "bun",
1539
- env: { NODE_ENV: "production" },
1588
+ name: 'cron',
1589
+ namespace: NAMESPACE,
1590
+ cwd: './apps/backend',
1591
+ script: 'scripts/dev-cron.ts',
1592
+ interpreter: 'bun',
1593
+ env: {
1594
+ NODE_ENV: 'production',
1595
+ // dev-cron.ts derives its target from NEXT_PUBLIC_SITE_URL. Override
1596
+ // it to the backend loopback so cron skips the FE/proxy entirely.
1597
+ NEXT_PUBLIC_SITE_URL: BACKEND_INTERNAL_URL,
1598
+ CRON_SECRET: process.env.CRON_SECRET,
1599
+ },
1540
1600
  autorestart: true,
1541
1601
  },
1542
1602
  ],
@@ -1815,12 +1875,18 @@ The installer scaffolds \`ecosystem.config.cjs\`. To supervise everything:
1815
1875
 
1816
1876
  \`\`\`bash
1817
1877
  pm2 start ecosystem.config.cjs # FE + BE + proxy + cron (all four)
1818
- pm2 start ecosystem.config.cjs --only frontend,backend # just the two apps
1878
+ pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1819
1879
  pm2 save && pm2 startup # persist across reboots
1820
- pm2 logs # tail all processes
1821
- pm2 reload all # zero-downtime restart
1880
+ pm2 logs \${PM2_NAMESPACE:-${dirName}} # tail just this project's logs
1881
+ pm2 reload \${PM2_NAMESPACE:-${dirName}} # zero-downtime restart (namespace-scoped)
1882
+ pm2 stop \${PM2_NAMESPACE:-${dirName}} # stop only this project
1822
1883
  \`\`\`
1823
1884
 
1885
+ All four processes are grouped under the \`PM2_NAMESPACE\` set in \`.env.local\`
1886
+ (defaults to \`${dirName}\`), so namespace commands target only this project
1887
+ even when other PM2 apps share the host. Process names embed the bound port
1888
+ (\`frontend:3001\`, \`backend:3002\`, \`proxy:3030\`) for quick \`pm2 ls\` triage.
1889
+
1824
1890
  The proxy and cron processes can be omitted with \`--only\` if you front the
1825
1891
  apps with nginx/Caddy or use platform cron (system cron, k8s CronJob).
1826
1892
 
@@ -1944,7 +2010,7 @@ async function main() {
1944
2010
  writeRootPackageJson(targetDir, dirName);
1945
2011
  writePnpmWorkspace(targetDir);
1946
2012
  writeRootGitignore(targetDir);
1947
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
2013
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace: dirName });
1948
2014
  writeReadme(targetDir, dirName, frontendName, adminPrefix);
1949
2015
  writeClaudeMd(targetDir, adminPrefix);
1950
2016
  if (adminPrefix) writeNginxSample(targetDir, adminPrefix);
@@ -1995,23 +2061,27 @@ async function main() {
1995
2061
  );
1996
2062
  }
1997
2063
 
1998
- // Mirror env to backend
1999
- const backendEnvLines = [
2064
+ const backendFallback = [
2000
2065
  `DATABASE_URL=${dbUrl}`,
2001
2066
  `APOLLO_SECRET=${authSecret}`,
2002
2067
  `CRON_SECRET=${cronSecret}`,
2003
2068
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
2004
2069
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
2005
2070
  `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
2071
+ `PM2_NAMESPACE=${dirName}`,
2006
2072
  ];
2007
- if (adminPrefix) {
2008
- backendEnvLines.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
2009
- }
2010
- backendEnvLines.push("");
2011
- writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnvLines.join("\n"));
2012
- success("apps/backend/.env.local");
2073
+ if (adminPrefix) backendFallback.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
2074
+ linkRootEnvLocal(targetDir, BACKEND_PATH, backendFallback);
2013
2075
  }
2014
2076
 
2077
+ // Frontend gets the same treatment — Next.js running in apps/frontend
2078
+ // can't see the root file otherwise, and next.config.ts references
2079
+ // BACKEND_INTERNAL_URL for the rewrites destination.
2080
+ const frontendFallback = adminPrefix
2081
+ ? [`BACKEND_INTERNAL_URL=${backendInternalUrl}`]
2082
+ : [`NEXT_PUBLIC_BACKEND_URL=${siteUrl || `http://localhost:${DEFAULT_BACKEND_PORT}`}`];
2083
+ linkRootEnvLocal(targetDir, FRONTEND_PATH, frontendFallback);
2084
+
2015
2085
  // ── Step 7: Install ──
2016
2086
  if (flags.skipInstall) {
2017
2087
  step(7, "Skipping dependency installation (--skip-install)");
package/package.json CHANGED
@@ -1,13 +1,13 @@
1
1
  {
2
2
  "name": "create-apollo-monorepo",
3
- "version": "0.9.2",
3
+ "version": "0.9.4",
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"
7
7
  },
8
8
  "type": "module",
9
9
  "engines": {
10
- "node": ">=20"
10
+ "node": ">=22"
11
11
  },
12
12
  "keywords": [
13
13
  "apollo",