create-apollo-monorepo 0.9.1 → 0.9.3

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 +7 -7
  2. package/index.mjs +373 -108
  3. package/package.json +3 -3
package/README.md CHANGED
@@ -6,15 +6,15 @@ mounted as a **git submodule** backend (read-only — pull updates only).
6
6
  ## Usage
7
7
 
8
8
  ```bash
9
- npx create-apollo-monorepo thamc-new
9
+ npx create-apollo-monorepo my-site
10
10
  ```
11
11
 
12
12
  With flags:
13
13
 
14
14
  ```bash
15
- npx create-apollo-monorepo thamc-new \
16
- --frontend-name "@thamc/frontend" \
17
- --db "postgresql://user:pass@localhost:5432/thamc" \
15
+ npx create-apollo-monorepo my-site \
16
+ --frontend-name "@my-site/frontend" \
17
+ --db "postgresql://user:pass@localhost:5432/my-site" \
18
18
  --url "http://localhost:3000" \
19
19
  --locale th
20
20
  ```
@@ -22,9 +22,9 @@ npx create-apollo-monorepo thamc-new \
22
22
  ## Result
23
23
 
24
24
  ```
25
- thamc-new/
25
+ my-site/
26
26
  ├── apps/
27
- │ ├── frontend/ ← @thamc/frontend (Next.js skeleton)
27
+ │ ├── frontend/ ← @my-site/frontend (Next.js skeleton)
28
28
  │ └── backend/ ← git submodule → apollo-cms
29
29
  ├── package.json ← root workspace
30
30
  ├── pnpm-workspace.yaml
@@ -89,7 +89,7 @@ subdomains (e.g. `cms.example.com` + `example.com`).
89
89
  ## After install
90
90
 
91
91
  ```bash
92
- cd thamc-new
92
+ cd my-site
93
93
  pnpm backend:setup # push schema + seed apollo-cms
94
94
  pnpm dev # frontend :3001 + backend :3000 in parallel
95
95
  ```
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,14 @@ 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
+
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;
31
38
 
32
39
  const COLORS = {
33
40
  reset: "\x1b[0m",
@@ -46,9 +53,9 @@ ${COLORS.bold}Usage:${COLORS.reset}
46
53
  npx create-apollo-monorepo <directory> [flags]
47
54
 
48
55
  ${COLORS.bold}Examples:${COLORS.reset}
49
- npx create-apollo-monorepo thamc-new
50
- npx create-apollo-monorepo thamc-new --frontend-name "@thamc/frontend"
51
- npx create-apollo-monorepo thamc-new --backend-branch develop --skip-install
56
+ npx create-apollo-monorepo my-site
57
+ npx create-apollo-monorepo my-site --frontend-name "@my-site/frontend"
58
+ npx create-apollo-monorepo my-site --backend-branch develop --skip-install
52
59
 
53
60
  ${COLORS.bold}Flags:${COLORS.reset}
54
61
  --frontend-name <name> Frontend package name (default: "@<dir>/frontend")
@@ -283,7 +290,14 @@ async function gatherEnv(flags, { singleOrigin }) {
283
290
  let dbUrl = flags.db;
284
291
  // In single-origin mode the frontend is the public entry → default :3001.
285
292
  // In separate-origins mode the backend is the public CMS URL → default :3000.
286
- const defaultSiteUrl = singleOrigin ? "http://localhost:3001" : "http://localhost: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}`;
287
301
  let siteUrl = flags.url ?? defaultSiteUrl;
288
302
  let locale = flags.locale ?? "en";
289
303
 
@@ -355,16 +369,17 @@ function writeRootPackageJson(targetDir, dirName) {
355
369
  // rebuild and the backend's plugin loader picks up the fresh
356
370
  // dist/server.mjs on next request.
357
371
  dev:
358
- "pnpm predev:setup && concurrently -k -n FE,BE,PL -c blue,magenta,yellow \"pnpm --filter ./apps/frontend dev\" \"pnpm --filter ./apps/backend exec next dev\" \"pnpm cms-plugins:dev\"",
372
+ "pnpm predev:setup && concurrently -k -n FE,BE,PL -c blue,magenta,yellow \"pnpm dev:frontend\" \"pnpm dev:backend\" \"pnpm cms-plugins:dev\"",
359
373
  // Optional single-origin dev: same as `pnpm dev` but adds a Node.js
360
- // reverse proxy on :3030 that fronts FE :3001 + BE :3000. Use it when
361
- // you want one URL, shared cookies, and no CORS — without nginx.
374
+ // reverse proxy on :3030 (PROXY_PORT) that fronts FE :3001 (FRONTEND_PORT)
375
+ // + BE :3002 (BACKEND_PORT). Ports come from .env.local; override there.
362
376
  "dev:rp":
363
- "pnpm predev:setup && concurrently -k -n FE,BE,PL,PX -c blue,magenta,yellow,cyan \"pnpm --filter ./apps/frontend dev\" \"pnpm --filter ./apps/backend exec next dev\" \"pnpm cms-plugins:dev\" \"pnpm dev:proxy\"",
364
- "dev:proxy": "node apps/proxy/server.mjs",
365
- "dev:frontend": "pnpm --filter ./apps/frontend dev",
377
+ "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\"",
378
+ "dev:proxy": "node scripts/with-env.mjs node apps/proxy/server.mjs",
379
+ "dev:frontend":
380
+ "node scripts/with-env.mjs --port=FRONTEND_PORT pnpm --filter ./apps/frontend exec next dev",
366
381
  "dev:backend":
367
- "pnpm predev:setup && pnpm --filter ./apps/backend exec next dev",
382
+ "pnpm predev:setup && node scripts/with-env.mjs --port=BACKEND_PORT pnpm --filter ./apps/backend exec next dev",
368
383
  "dev:cron": "pnpm --filter ./apps/backend exec bun scripts/dev-cron.ts",
369
384
  // Build pipeline: cms-plugins → apollo-cms's own plugins → backend → frontend.
370
385
  // `prebuild` runs first (npm/pnpm convention) and fails fast if the
@@ -375,17 +390,19 @@ function writeRootPackageJson(targetDir, dirName) {
375
390
  "build:backend":
376
391
  "pnpm cms-plugins:build && pnpm --filter ./apps/backend exec bun run plugins:build && pnpm --filter ./apps/backend build",
377
392
  "build:frontend": "pnpm --filter ./apps/frontend build",
378
- // Production start. Runs FE :3001 + BE :3000 in parallel via
379
- // \`next start\`. Run \`pnpm backend:upgrade\` BEFORE this — start does
380
- // NOT run migrations on boot (zero-downtime restart safety).
393
+ // Production start. Runs FE :FRONTEND_PORT + BE :BACKEND_PORT (defaults
394
+ // 3001 + 3002 from .env.local). Run \`pnpm backend:upgrade\` BEFORE this —
395
+ // start does NOT run migrations on boot (zero-downtime restart safety).
381
396
  start:
382
- "concurrently -k -n FE,BE -c blue,magenta \"pnpm --filter ./apps/frontend start\" \"pnpm --filter ./apps/backend exec next start\"",
383
- // Single-origin production: FE + BE + Node reverse proxy on :3030.
397
+ "concurrently -k -n FE,BE -c blue,magenta \"pnpm start:frontend\" \"pnpm start:backend\"",
398
+ // Single-origin production: FE + BE + Node reverse proxy on :PROXY_PORT.
384
399
  "start:rp":
385
- "concurrently -k -n FE,BE,PX -c blue,magenta,cyan \"pnpm --filter ./apps/frontend start\" \"pnpm --filter ./apps/backend exec next start\" \"pnpm start:proxy\"",
386
- "start:proxy": "NODE_ENV=production node apps/proxy/server.mjs",
387
- "start:frontend": "pnpm --filter ./apps/frontend start",
388
- "start:backend": "pnpm --filter ./apps/backend exec next start",
400
+ "concurrently -k -n FE,BE,PX -c blue,magenta,cyan \"pnpm start:frontend\" \"pnpm start:backend\" \"pnpm start:proxy\"",
401
+ "start:proxy": "NODE_ENV=production node scripts/with-env.mjs node apps/proxy/server.mjs",
402
+ "start:frontend":
403
+ "node scripts/with-env.mjs --port=FRONTEND_PORT pnpm --filter ./apps/frontend exec next start",
404
+ "start:backend":
405
+ "node scripts/with-env.mjs --port=BACKEND_PORT pnpm --filter ./apps/backend exec next start",
389
406
  // Self-hosted scheduler fallback. Vercel deploys use Vercel Cron via
390
407
  // apps/backend/vercel.json — skip this on Vercel. For Docker / VPS
391
408
  // you can either run \`pnpm start:cron\` alongside \`pnpm start\` (PM2
@@ -418,33 +435,7 @@ function writeRootPackageJson(targetDir, dirName) {
418
435
  devDependencies: {
419
436
  concurrently: "^9.0.0",
420
437
  },
421
- engines: { node: ">=20", pnpm: ">=9" },
422
- pnpm: {
423
- // apollo-cms transitively pulls multiple esbuild versions (0.18, 0.25, 0.27).
424
- // pnpm's binary symlink can pick the wrong platform binary for esbuild's
425
- // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
426
- // Allow listed packages to run their build/postinstall scripts; the rest
427
- // are skipped (pnpm 10 default is empty allow-list).
428
- onlyBuiltDependencies: [
429
- "@parcel/watcher",
430
- "@rolldown/binding-darwin-arm64",
431
- "@rolldown/binding-linux-arm64-gnu",
432
- "@rolldown/binding-linux-x64-gnu",
433
- "@sentry/cli",
434
- "@swc/core",
435
- "@swc/core-darwin-arm64",
436
- "@swc/core-darwin-x64",
437
- "@swc/core-linux-arm64-gnu",
438
- "@swc/core-linux-x64-gnu",
439
- "better-sqlite3",
440
- "core-js",
441
- "core-js-pure",
442
- "esbuild",
443
- "msw",
444
- "sharp",
445
- "unrs-resolver",
446
- ],
447
- },
438
+ engines: { node: ">=22", pnpm: ">=10" },
448
439
  };
449
440
  writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
450
441
 
@@ -458,17 +449,77 @@ function writeRootPackageJson(targetDir, dirName) {
458
449
  "strict-peer-dependencies=false",
459
450
  "public-hoist-pattern[]=*esbuild*",
460
451
  "public-hoist-pattern[]=*types*",
452
+ // Next.js 16's instrumentation hook (used by Sentry / OpenTelemetry
453
+ // shipped via apollo-cms) loads require-in-the-middle / import-in-the-middle
454
+ // at runtime via a bare require. Under pnpm's strict node_modules these
455
+ // sit deep inside .pnpm/ and the resolver can't see them — Next then
456
+ // crashes on boot with "Failed to load external module require-in-the-middle".
457
+ // Hoisting them (plus their `shimmer` dep) to the root node_modules fixes it.
458
+ "public-hoist-pattern[]=*require-in-the-middle*",
459
+ "public-hoist-pattern[]=*import-in-the-middle*",
460
+ "public-hoist-pattern[]=*shimmer*",
461
461
  "shell-emulator=true",
462
462
  "",
463
463
  ].join("\n"),
464
464
  );
465
465
  }
466
466
 
467
+ // Link an app's .env.local to the monorepo root .env.local so the root file
468
+ // stays the single source of truth. Next.js only reads .env.local from its
469
+ // own CWD, so this symlink lets `cd apps/<app> && next dev|build` see the
470
+ // shared config. Falls back to a regular copy (with a warning) on platforms
471
+ // where symlink creation requires elevation (Windows non-admin).
472
+ function linkRootEnvLocal(targetDir, appRelPath, fallbackLines) {
473
+ const envPath = resolve(targetDir, appRelPath, ".env.local");
474
+ // appRelPath is `apps/<name>` (2 segments deep) → `../../.env.local`.
475
+ const depth = appRelPath.split("/").filter(Boolean).length;
476
+ const linkTarget = "../".repeat(depth) + ".env.local";
477
+ if (existsSync(envPath)) rmSync(envPath);
478
+ try {
479
+ symlinkSync(linkTarget, envPath);
480
+ success(`${appRelPath}/.env.local → ${linkTarget} (symlink)`);
481
+ } catch {
482
+ writeFileSync(envPath, [...fallbackLines, ""].join("\n"));
483
+ warn(
484
+ `symlink failed — wrote a copy at ${appRelPath}/.env.local (keep it in sync with the root file manually).`,
485
+ );
486
+ }
487
+ }
488
+
467
489
  function writePnpmWorkspace(targetDir) {
468
- writeFileSync(
469
- resolve(targetDir, "pnpm-workspace.yaml"),
470
- "packages:\n - 'apps/*'\n - 'apps/cms-plugins/*'\n",
471
- );
490
+ // pnpm 10 reads `onlyBuiltDependencies` from pnpm-workspace.yaml (the
491
+ // package.json#pnpm location is deprecated for workspaces). apollo-cms
492
+ // transitively pulls multiple esbuild versions (0.18, 0.25, 0.27); pnpm's
493
+ // binary symlink can pick the wrong platform binary for esbuild's
494
+ // postinstall version check, failing with "Expected X.Y.Z but got A.B.C".
495
+ // Allow listed packages to run their build/postinstall scripts; the rest
496
+ // are skipped (pnpm 10 default is empty allow-list).
497
+ const yaml = [
498
+ "packages:",
499
+ " - 'apps/*'",
500
+ " - 'apps/cms-plugins/*'",
501
+ "",
502
+ "onlyBuiltDependencies:",
503
+ " - '@parcel/watcher'",
504
+ " - '@rolldown/binding-darwin-arm64'",
505
+ " - '@rolldown/binding-linux-arm64-gnu'",
506
+ " - '@rolldown/binding-linux-x64-gnu'",
507
+ " - '@sentry/cli'",
508
+ " - '@swc/core'",
509
+ " - '@swc/core-darwin-arm64'",
510
+ " - '@swc/core-darwin-x64'",
511
+ " - '@swc/core-linux-arm64-gnu'",
512
+ " - '@swc/core-linux-x64-gnu'",
513
+ " - 'better-sqlite3'",
514
+ " - 'core-js'",
515
+ " - 'core-js-pure'",
516
+ " - 'esbuild'",
517
+ " - 'msw'",
518
+ " - 'sharp'",
519
+ " - 'unrs-resolver'",
520
+ "",
521
+ ].join("\n");
522
+ writeFileSync(resolve(targetDir, "pnpm-workspace.yaml"), yaml);
472
523
  }
473
524
 
474
525
  // Single-origin nginx reference. Only emitted when adminPrefix is set, since
@@ -545,10 +596,43 @@ function writeProxyApp(targetDir, dirName, adminPrefix) {
545
596
 
546
597
  import http from "node:http";
547
598
  import net from "node:net";
599
+ import { readFileSync, existsSync } from "node:fs";
600
+ import { resolve, dirname } from "node:path";
601
+ import { fileURLToPath } from "node:url";
602
+
603
+ // Load root .env.local so the proxy honors PROXY_PORT/FRONTEND_PORT/BACKEND_PORT
604
+ // alongside the frontend & backend. Process env still wins (12-factor friendly).
605
+ (function loadRootEnv() {
606
+ try {
607
+ const here = dirname(fileURLToPath(import.meta.url));
608
+ const envPath = resolve(here, "../../.env.local");
609
+ if (!existsSync(envPath)) return;
610
+ for (const raw of readFileSync(envPath, "utf8").split(/\\r?\\n/)) {
611
+ const line = raw.trim();
612
+ if (!line || line.startsWith("#")) continue;
613
+ const eq = line.indexOf("=");
614
+ if (eq < 0) continue;
615
+ const key = line.slice(0, eq).trim();
616
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(key)) continue;
617
+ if (process.env[key] !== undefined) continue;
618
+ let val = line.slice(eq + 1).trim();
619
+ if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
620
+ val = val.slice(1, -1);
621
+ }
622
+ process.env[key] = val;
623
+ }
624
+ } catch {
625
+ // Best-effort. The proxy still runs on its hardcoded fallbacks.
626
+ }
627
+ })();
548
628
 
549
- const PORT = Number(process.env.PORT ?? 3030);
550
- const BACKEND = parseTarget(process.env.BACKEND ?? "http://127.0.0.1:3000");
551
- const FRONTEND = parseTarget(process.env.FRONTEND ?? "http://127.0.0.1:3001");
629
+ // Backward-compatible: PROXY_PORT/FRONTEND_PORT/BACKEND_PORT are the new names;
630
+ // PORT/FRONTEND/BACKEND are still honored if explicitly set in process.env.
631
+ const PORT = Number(process.env.PROXY_PORT ?? process.env.PORT ?? 3030);
632
+ const BACKEND_HOST = process.env.BACKEND ?? \`http://127.0.0.1:\${process.env.BACKEND_PORT ?? 3002}\`;
633
+ const FRONTEND_HOST = process.env.FRONTEND ?? \`http://127.0.0.1:\${process.env.FRONTEND_PORT ?? 3001}\`;
634
+ const BACKEND = parseTarget(BACKEND_HOST);
635
+ const FRONTEND = parseTarget(FRONTEND_HOST);
552
636
 
553
637
  const ADMIN_PREFIX = process.env.ADMIN_PREFIX ?? "${prefix}";
554
638
  // Pipe-separated list of /api/<segment> paths that route to the backend.
@@ -762,7 +846,7 @@ function parseTarget(input) {
762
846
  scripts: {
763
847
  start: "node server.mjs",
764
848
  },
765
- engines: { node: ">=20" },
849
+ engines: { node: ">=22" },
766
850
  };
767
851
  writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
768
852
 
@@ -830,6 +914,10 @@ The Node proxy is intended for dev and small self-hosted deploys.
830
914
  }
831
915
 
832
916
  function writeRootGitignore(targetDir) {
917
+ // NOTE: .env.local is intentionally NOT ignored — it holds shared dev port
918
+ // defaults (PROXY_PORT/FRONTEND_PORT/BACKEND_PORT) and other non-secret
919
+ // workspace knobs that should travel with the repo. Keep real secrets in
920
+ // .env (ignored) or apps/backend/.env.local (ignored by the submodule).
833
921
  const ignore = [
834
922
  "node_modules",
835
923
  ".pnpm-store",
@@ -838,8 +926,6 @@ function writeRootGitignore(targetDir) {
838
926
  "dist",
839
927
  "build",
840
928
  ".env",
841
- ".env.local",
842
- ".env*.local",
843
929
  "*.log",
844
930
  ".DS_Store",
845
931
  "",
@@ -847,16 +933,16 @@ function writeRootGitignore(targetDir) {
847
933
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
848
934
  }
849
935
 
850
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl }) {
936
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace }) {
851
937
  const header = adminPrefix
852
938
  ? [
853
939
  "# Single-origin monorepo dev env",
854
- "# Public origin = frontend (apps/frontend on :3001). Frontend rewrites /admin/*,",
855
- "# /api/*, /uploads/*, and the asset prefix to the backend (apps/backend on :3000).",
940
+ `# Public origin = frontend (apps/frontend on :${DEFAULT_FRONTEND_PORT}). Frontend rewrites /admin/*,`,
941
+ `# /api/*, /uploads/*, and the asset prefix to the backend (apps/backend on :${DEFAULT_BACKEND_PORT}).`,
856
942
  ]
857
943
  : [
858
944
  "# Separate-origins monorepo dev env",
859
- "# Backend runs at http://localhost:3000, frontend at http://localhost:3001.",
945
+ `# Backend runs at http://localhost:${DEFAULT_BACKEND_PORT}, frontend at http://localhost:${DEFAULT_FRONTEND_PORT}.`,
860
946
  "# To switch to single-origin: set APOLLO_ASSET_PREFIX (default: /admin) and",
861
947
  "# BACKEND_INTERNAL_URL, then add rewrites() to apps/frontend/next.config.ts.",
862
948
  ];
@@ -864,10 +950,25 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
864
950
  const lines = [
865
951
  ...header,
866
952
  "",
953
+ "# ── Dev server ports ─────────────────────────────────────────────",
954
+ "# Single source of truth for local ports. Consumed by:",
955
+ "# • apps/proxy/server.mjs (PROXY_PORT, FRONTEND_PORT, BACKEND_PORT)",
956
+ "# • scripts/with-env.mjs (maps FRONTEND_PORT/BACKEND_PORT → PORT",
957
+ "# when launching apps/frontend & apps/backend,",
958
+ "# and derives NEXT_PUBLIC_SITE_URL /",
959
+ "# BACKEND_INTERNAL_URL when those are unset)",
960
+ "# Override any of these; the proxy/frontend/backend will follow.",
961
+ `PROXY_PORT=${DEFAULT_PROXY_PORT}`,
962
+ `FRONTEND_PORT=${DEFAULT_FRONTEND_PORT}`,
963
+ `BACKEND_PORT=${DEFAULT_BACKEND_PORT}`,
964
+ "",
867
965
  "# ── Shared (consumed by both apps) ───────────────────────────────",
868
966
  `DATABASE_URL=${dbUrl}`,
869
967
  `APOLLO_SECRET=${authSecret}`,
870
968
  `CRON_SECRET=${cronSecret}`,
969
+ "# Public origin. In dev, leave blank to auto-derive from FRONTEND_PORT",
970
+ "# (single-origin) or BACKEND_PORT (separate-origins) via scripts/with-env.mjs.",
971
+ "# In prod set to the real domain (e.g. https://cms.example.com).",
871
972
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
872
973
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
873
974
  "",
@@ -882,8 +983,15 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
882
983
  `APOLLO_ASSET_PREFIX=${adminPrefix}`,
883
984
  "# Project-specific plugins — keeps the apollo-cms submodule clean.",
884
985
  `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
986
+ "# PM2 namespace — groups this project's processes so you can",
987
+ "# `pm2 stop <namespace>` / `pm2 restart <namespace>` independently of",
988
+ "# other projects on the same host. Consumed by ecosystem.config.cjs.",
989
+ `PM2_NAMESPACE=${pm2Namespace}`,
885
990
  "",
886
991
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
992
+ "# Where the frontend's rewrites point internally. In dev, leave blank",
993
+ "# to auto-derive http://127.0.0.1:${BACKEND_PORT} via scripts/with-env.mjs.",
994
+ "# In prod set to a private hostname (e.g. http://backend.internal:3000).",
887
995
  `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
888
996
  "",
889
997
  );
@@ -892,6 +1000,10 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
892
1000
  "# ── Backend (apps/backend) ───────────────────────────────────────",
893
1001
  "# Project-specific plugins — keeps the apollo-cms submodule clean.",
894
1002
  `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
1003
+ "# PM2 namespace — groups this project's processes so you can",
1004
+ "# `pm2 stop <namespace>` / `pm2 restart <namespace>` independently of",
1005
+ "# other projects on the same host. Consumed by ecosystem.config.cjs.",
1006
+ `PM2_NAMESPACE=${pm2Namespace}`,
895
1007
  "",
896
1008
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
897
1009
  `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
@@ -913,9 +1025,12 @@ function writeFrontendApp(targetDir, frontendName, siteUrl, adminPrefix, backend
913
1025
  version: "0.0.0",
914
1026
  private: true,
915
1027
  scripts: {
916
- dev: "next dev -p 3001",
1028
+ // No -p flag: Next.js reads PORT from env. The repo-root scripts use
1029
+ // scripts/with-env.mjs --port=FRONTEND_PORT to inject the right value
1030
+ // from .env.local (default 3001).
1031
+ dev: "next dev",
917
1032
  build: "next build",
918
- start: "next start -p 3001",
1033
+ start: "next start",
919
1034
  lint: "next lint",
920
1035
  typecheck: "tsc --noEmit",
921
1036
  },
@@ -1045,7 +1160,8 @@ export default config;
1045
1160
 
1046
1161
  writeFileSync(
1047
1162
  resolve(dir, ".gitignore"),
1048
- ["node_modules", ".next", "dist", ".env.local", ""].join("\n"),
1163
+ // .env.local is intentionally committed — it only contains dev port hints.
1164
+ ["node_modules", ".next", "dist", ""].join("\n"),
1049
1165
  );
1050
1166
 
1051
1167
  // Vercel project config for the frontend. Skip cron + region pinning is
@@ -1273,6 +1389,88 @@ console.log(\`Run: pnpm install && pnpm cms-plugins:build && pnpm dev:backend\`)
1273
1389
  writeFileSync(resolve(scriptsDir, "new-cms-plugin.mjs"), script);
1274
1390
  }
1275
1391
 
1392
+ // Small launcher that loads root .env.local, applies port defaults
1393
+ // (PROXY_PORT=3030 / FRONTEND_PORT=3001 / BACKEND_PORT=3002), optionally maps
1394
+ // one of them onto PORT (Next.js reads PORT), and execs the rest of argv.
1395
+ //
1396
+ // Usage:
1397
+ // node scripts/with-env.mjs [--port=VAR_NAME] <command> [args...]
1398
+ function writeWithEnvScript(targetDir) {
1399
+ const scriptsDir = resolve(targetDir, "scripts");
1400
+ mkdirSync(scriptsDir, { recursive: true });
1401
+ const script = `#!/usr/bin/env node
1402
+ // Loads <repo-root>/.env.local so frontend / backend / proxy all share one
1403
+ // source of truth for ports. Process env still wins.
1404
+ //
1405
+ // Usage:
1406
+ // node scripts/with-env.mjs [--port=VAR_NAME] <command> [args...]
1407
+ // --port=FRONTEND_PORT copies process.env.FRONTEND_PORT onto PORT before exec,
1408
+ // so \`next dev\` and \`next start\` pick up the right port without --port flags.
1409
+
1410
+ import { existsSync, readFileSync } from "node:fs";
1411
+ import { spawn } from "node:child_process";
1412
+ import { dirname, resolve } from "node:path";
1413
+ import { fileURLToPath } from "node:url";
1414
+
1415
+ const here = dirname(fileURLToPath(import.meta.url));
1416
+ const envPath = resolve(here, "../.env.local");
1417
+
1418
+ if (existsSync(envPath)) {
1419
+ for (const raw of readFileSync(envPath, "utf8").split(/\\r?\\n/)) {
1420
+ const line = raw.trim();
1421
+ if (!line || line.startsWith("#")) continue;
1422
+ const eq = line.indexOf("=");
1423
+ if (eq < 0) continue;
1424
+ const k = line.slice(0, eq).trim();
1425
+ if (!/^[A-Z_][A-Z0-9_]*$/i.test(k)) continue;
1426
+ if (process.env[k] !== undefined) continue;
1427
+ let v = line.slice(eq + 1).trim();
1428
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) {
1429
+ v = v.slice(1, -1);
1430
+ }
1431
+ process.env[k] = v;
1432
+ }
1433
+ }
1434
+
1435
+ // Fallbacks — keep in sync with apps/proxy/server.mjs and PM2 config.
1436
+ process.env.PROXY_PORT ||= "3030";
1437
+ process.env.FRONTEND_PORT ||= "3001";
1438
+ process.env.BACKEND_PORT ||= "3002";
1439
+
1440
+ // Derive URL vars from PORTs when unset. Dev-friendly: edit a port and
1441
+ // everything follows. Prod-friendly: set these explicitly to real hostnames
1442
+ // (e.g. https://cms.example.com, http://backend.internal:3000) and the
1443
+ // derivation is bypassed.
1444
+ //
1445
+ // NEXT_PUBLIC_SITE_URL is the public origin — defaults to the frontend port
1446
+ // (frontend rewrites /admin, /api, /uploads to the backend). For \`pnpm dev:rp\`
1447
+ // set it to http://localhost:\${PROXY_PORT} so Better Auth / OAuth callbacks
1448
+ // land on the unified origin.
1449
+ process.env.NEXT_PUBLIC_SITE_URL ||= \`http://localhost:\${process.env.FRONTEND_PORT}\`;
1450
+ process.env.BACKEND_INTERNAL_URL ||= \`http://127.0.0.1:\${process.env.BACKEND_PORT}\`;
1451
+
1452
+ const args = process.argv.slice(2);
1453
+ if (args[0]?.startsWith("--port=")) {
1454
+ const name = args.shift().slice(7);
1455
+ if (process.env[name]) process.env.PORT = process.env[name];
1456
+ }
1457
+
1458
+ if (!args.length) {
1459
+ console.error("Usage: node scripts/with-env.mjs [--port=VAR] <command> [args...]");
1460
+ process.exit(2);
1461
+ }
1462
+
1463
+ const [cmd, ...rest] = args;
1464
+ const child = spawn(cmd, rest, {
1465
+ stdio: "inherit",
1466
+ env: process.env,
1467
+ shell: process.platform === "win32",
1468
+ });
1469
+ child.on("exit", (code, sig) => process.exit(sig ? 1 : code ?? 0));
1470
+ `;
1471
+ writeFileSync(resolve(scriptsDir, "with-env.mjs"), script);
1472
+ }
1473
+
1276
1474
  // Pre-build env check. Fails the build before \`next build\` runs if backend
1277
1475
  // required vars are missing, so users don't sit through a 30s build only to
1278
1476
  // hit a runtime error on first request.
@@ -1332,55 +1530,111 @@ console.log("✓ env check passed");
1332
1530
  // since Next.js handles that internally and the proxy is single-threaded by design).
1333
1531
  function writePm2Config(targetDir, dirName, adminPrefix) {
1334
1532
  const singleOrigin = !!adminPrefix;
1533
+ // The proxy `env` block conditionally includes ADMIN_PREFIX in single-origin
1534
+ // mode. Built outside the template so we don't emit a stray comma or blank line.
1535
+ const proxyEnv = [
1536
+ "NODE_ENV: 'production',",
1537
+ "PROXY_PORT,",
1538
+ "FRONTEND_PORT,",
1539
+ "BACKEND_PORT,",
1540
+ ...(singleOrigin ? [`ADMIN_PREFIX: '${adminPrefix}',`] : []),
1541
+ ].map((l) => " " + l).join("\n");
1542
+
1335
1543
  const config = `// PM2 ecosystem config for ${dirName}.
1544
+ //
1545
+ // Loads .env.local at the top so PM2 picks up PROXY_PORT / FRONTEND_PORT /
1546
+ // BACKEND_PORT / PM2_NAMESPACE without a shell wrapper. The .env.local file
1547
+ // is the single source of truth — edit ports there, not here.
1548
+ //
1336
1549
  // Usage:
1337
1550
  // pnpm install
1338
1551
  // pnpm backend:upgrade
1339
1552
  // pnpm build
1340
- // pm2 start ecosystem.config.cjs # FE + BE
1341
- // pm2 start ecosystem.config.cjs --only proxy # add reverse proxy
1553
+ // pm2 start ecosystem.config.cjs # FE + BE + proxy + cron
1554
+ // pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1342
1555
  // pm2 start ecosystem.config.cjs --only cron # self-hosted cron fallback
1556
+ // pm2 stop \${PM2_NAMESPACE} # stop everything in this project
1343
1557
  // pm2 save && pm2 startup # persist across reboots
1344
1558
 
1559
+ const fs = require('fs');
1560
+ const path = require('path');
1561
+
1562
+ // Inline .env.local parser. Kept tiny (no dotenv dep) so the ecosystem file
1563
+ // stays runnable before \`pnpm install\` finishes.
1564
+ const envPath = path.resolve(__dirname, '.env.local');
1565
+ if (fs.existsSync(envPath)) {
1566
+ for (const line of fs.readFileSync(envPath, 'utf8').split('\\n')) {
1567
+ const m = line.match(/^\\s*([A-Z0-9_]+)\\s*=\\s*(.*?)\\s*$/i);
1568
+ if (!m) continue;
1569
+ const k = m[1];
1570
+ let v = m[2];
1571
+ if ((v.startsWith('"') && v.endsWith('"')) || (v.startsWith("'") && v.endsWith("'"))) v = v.slice(1, -1);
1572
+ if (process.env[k] === undefined) process.env[k] = v;
1573
+ }
1574
+ }
1575
+
1576
+ const NAMESPACE = process.env.PM2_NAMESPACE || '${dirName}';
1577
+ const FRONTEND_PORT = Number(process.env.FRONTEND_PORT) || 3001;
1578
+ const BACKEND_PORT = Number(process.env.BACKEND_PORT) || 3002;
1579
+ const PROXY_PORT = Number(process.env.PROXY_PORT) || 3030;
1580
+
1581
+ // Backend's internal URL (loopback). Cron MUST target this, never the proxy or
1582
+ // frontend — going through the FE just adds a failure mode (HTML error → JSON
1583
+ // parse blowup in dev-cron.ts).
1584
+ const BACKEND_INTERNAL_URL = process.env.BACKEND_INTERNAL_URL || \`http://127.0.0.1:\${BACKEND_PORT}\`;
1585
+
1345
1586
  module.exports = {
1346
1587
  apps: [
1347
1588
  {
1348
- name: "frontend",
1349
- cwd: "./apps/frontend",
1350
- script: "node_modules/next/dist/bin/next",
1351
- args: "start",
1352
- env: { NODE_ENV: "production", PORT: 3001 },
1353
- max_memory_restart: "1G",
1589
+ name: \`frontend:\${FRONTEND_PORT}\`,
1590
+ namespace: NAMESPACE,
1591
+ cwd: './apps/frontend',
1592
+ script: 'node_modules/next/dist/bin/next',
1593
+ args: 'start',
1594
+ env: {
1595
+ NODE_ENV: 'production',
1596
+ PORT: FRONTEND_PORT,
1597
+ // Next.js rewrites read this at request time. Without it the FE
1598
+ // falls back to http://localhost:3000 and every /admin, /api, and
1599
+ // /uploads request returns ECONNREFUSED.
1600
+ BACKEND_INTERNAL_URL,
1601
+ },
1602
+ max_memory_restart: '1G',
1354
1603
  autorestart: true,
1355
1604
  },
1356
1605
  {
1357
- name: "backend",
1358
- cwd: "./apps/backend",
1359
- script: "node_modules/next/dist/bin/next",
1360
- args: "start",
1361
- env: { NODE_ENV: "production", PORT: 3000 },
1362
- max_memory_restart: "1G",
1606
+ name: \`backend:\${BACKEND_PORT}\`,
1607
+ namespace: NAMESPACE,
1608
+ cwd: './apps/backend',
1609
+ script: 'node_modules/next/dist/bin/next',
1610
+ args: 'start',
1611
+ env: { NODE_ENV: 'production', PORT: BACKEND_PORT },
1612
+ max_memory_restart: '1G',
1363
1613
  autorestart: true,
1364
1614
  },
1365
1615
  {
1366
- name: "proxy",
1367
- script: "./apps/proxy/server.mjs",
1616
+ name: \`proxy:\${PROXY_PORT}\`,
1617
+ namespace: NAMESPACE,
1618
+ script: './apps/proxy/server.mjs',
1368
1619
  env: {
1369
- NODE_ENV: "production",
1370
- PORT: 3030,
1371
- BACKEND: "http://127.0.0.1:3000",
1372
- FRONTEND: "http://127.0.0.1:3001",
1373
- ${singleOrigin ? `ADMIN_PREFIX: "${adminPrefix}",` : ""}
1620
+ ${proxyEnv}
1374
1621
  },
1375
- max_memory_restart: "256M",
1622
+ max_memory_restart: '256M',
1376
1623
  autorestart: true,
1377
1624
  },
1378
1625
  {
1379
- name: "cron",
1380
- cwd: "./apps/backend",
1381
- script: "scripts/dev-cron.ts",
1382
- interpreter: "bun",
1383
- env: { NODE_ENV: "production" },
1626
+ name: 'cron',
1627
+ namespace: NAMESPACE,
1628
+ cwd: './apps/backend',
1629
+ script: 'scripts/dev-cron.ts',
1630
+ interpreter: 'bun',
1631
+ env: {
1632
+ NODE_ENV: 'production',
1633
+ // dev-cron.ts derives its target from NEXT_PUBLIC_SITE_URL. Override
1634
+ // it to the backend loopback so cron skips the FE/proxy entirely.
1635
+ NEXT_PUBLIC_SITE_URL: BACKEND_INTERNAL_URL,
1636
+ CRON_SECRET: process.env.CRON_SECRET,
1637
+ },
1384
1638
  autorestart: true,
1385
1639
  },
1386
1640
  ],
@@ -1659,12 +1913,18 @@ The installer scaffolds \`ecosystem.config.cjs\`. To supervise everything:
1659
1913
 
1660
1914
  \`\`\`bash
1661
1915
  pm2 start ecosystem.config.cjs # FE + BE + proxy + cron (all four)
1662
- pm2 start ecosystem.config.cjs --only frontend,backend # just the two apps
1916
+ pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1663
1917
  pm2 save && pm2 startup # persist across reboots
1664
- pm2 logs # tail all processes
1665
- pm2 reload all # zero-downtime restart
1918
+ pm2 logs \${PM2_NAMESPACE:-${dirName}} # tail just this project's logs
1919
+ pm2 reload \${PM2_NAMESPACE:-${dirName}} # zero-downtime restart (namespace-scoped)
1920
+ pm2 stop \${PM2_NAMESPACE:-${dirName}} # stop only this project
1666
1921
  \`\`\`
1667
1922
 
1923
+ All four processes are grouped under the \`PM2_NAMESPACE\` set in \`.env.local\`
1924
+ (defaults to \`${dirName}\`), so namespace commands target only this project
1925
+ even when other PM2 apps share the host. Process names embed the bound port
1926
+ (\`frontend:3001\`, \`backend:3002\`, \`proxy:3030\`) for quick \`pm2 ls\` triage.
1927
+
1668
1928
  The proxy and cron processes can be omitted with \`--only\` if you front the
1669
1929
  apps with nginx/Caddy or use platform cron (system cron, k8s CronJob).
1670
1930
 
@@ -1758,7 +2018,7 @@ async function main() {
1758
2018
 
1759
2019
  if (!flags.directory) {
1760
2020
  log(HELP_TEXT);
1761
- fatal("Please provide a directory name.\n Example: npx create-apollo-monorepo thamc-new");
2021
+ fatal("Please provide a directory name.\n Example: npx create-apollo-monorepo my-site");
1762
2022
  }
1763
2023
 
1764
2024
  log(`\n${COLORS.bold}${COLORS.cyan} Apollo CMS Monorepo Installer${COLORS.reset}\n`);
@@ -1777,7 +2037,7 @@ async function main() {
1777
2037
  // Required by apollo-cms's /api/cron route and scripts/dev-cron.ts in dev.
1778
2038
  // Without it the dev cron loop hits 403 "CRON_SECRET not configured".
1779
2039
  const cronSecret = randomBytes(24).toString("hex");
1780
- const backendInternalUrl = "http://localhost:3000";
2040
+ const backendInternalUrl = `http://localhost:${DEFAULT_BACKEND_PORT}`;
1781
2041
  success(`Frontend pkg name: ${frontendName}`);
1782
2042
  success(`Admin prefix: ${adminPrefix || "(disabled — separate origins)"}`);
1783
2043
 
@@ -1788,17 +2048,18 @@ async function main() {
1788
2048
  writeRootPackageJson(targetDir, dirName);
1789
2049
  writePnpmWorkspace(targetDir);
1790
2050
  writeRootGitignore(targetDir);
1791
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
2051
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace: dirName });
1792
2052
  writeReadme(targetDir, dirName, frontendName, adminPrefix);
1793
2053
  writeClaudeMd(targetDir, adminPrefix);
1794
2054
  if (adminPrefix) writeNginxSample(targetDir, adminPrefix);
1795
2055
  writeProxyApp(targetDir, dirName, adminPrefix);
1796
2056
  writeCheckEnvScript(targetDir);
2057
+ writeWithEnvScript(targetDir);
1797
2058
  writePm2Config(targetDir, dirName, adminPrefix);
1798
2059
  success(
1799
2060
  `package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md, CLAUDE.md${
1800
2061
  adminPrefix ? ", nginx.conf.sample" : ""
1801
- }, apps/proxy, ecosystem.config.cjs, scripts/check-env.mjs`,
2062
+ }, apps/proxy, ecosystem.config.cjs, scripts/check-env.mjs, scripts/with-env.mjs`,
1802
2063
  );
1803
2064
 
1804
2065
  // ── Step 4: git init ──
@@ -1838,23 +2099,27 @@ async function main() {
1838
2099
  );
1839
2100
  }
1840
2101
 
1841
- // Mirror env to backend
1842
- const backendEnvLines = [
2102
+ const backendFallback = [
1843
2103
  `DATABASE_URL=${dbUrl}`,
1844
2104
  `APOLLO_SECRET=${authSecret}`,
1845
2105
  `CRON_SECRET=${cronSecret}`,
1846
2106
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
1847
2107
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
1848
2108
  `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
2109
+ `PM2_NAMESPACE=${dirName}`,
1849
2110
  ];
1850
- if (adminPrefix) {
1851
- backendEnvLines.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
1852
- }
1853
- backendEnvLines.push("");
1854
- writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnvLines.join("\n"));
1855
- success("apps/backend/.env.local");
2111
+ if (adminPrefix) backendFallback.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
2112
+ linkRootEnvLocal(targetDir, BACKEND_PATH, backendFallback);
1856
2113
  }
1857
2114
 
2115
+ // Frontend gets the same treatment — Next.js running in apps/frontend
2116
+ // can't see the root file otherwise, and next.config.ts references
2117
+ // BACKEND_INTERNAL_URL for the rewrites destination.
2118
+ const frontendFallback = adminPrefix
2119
+ ? [`BACKEND_INTERNAL_URL=${backendInternalUrl}`]
2120
+ : [`NEXT_PUBLIC_BACKEND_URL=${siteUrl}`];
2121
+ linkRootEnvLocal(targetDir, FRONTEND_PATH, frontendFallback);
2122
+
1858
2123
  // ── Step 7: Install ──
1859
2124
  if (flags.skipInstall) {
1860
2125
  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.1",
3
+ "version": "0.9.3",
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",
@@ -21,7 +21,7 @@
21
21
  ],
22
22
  "repository": {
23
23
  "type": "git",
24
- "url": "https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git",
24
+ "url": "git+https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git",
25
25
  "directory": "installer-monorepo"
26
26
  },
27
27
  "license": "MIT"