create-apollo-monorepo 0.9.2 → 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 (2) hide show
  1. package/index.mjs +182 -74
  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
@@ -435,33 +435,7 @@ function writeRootPackageJson(targetDir, dirName) {
435
435
  devDependencies: {
436
436
  concurrently: "^9.0.0",
437
437
  },
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
- },
438
+ engines: { node: ">=22", pnpm: ">=10" },
465
439
  };
466
440
  writeFileSync(resolve(targetDir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
467
441
 
@@ -475,17 +449,77 @@ function writeRootPackageJson(targetDir, dirName) {
475
449
  "strict-peer-dependencies=false",
476
450
  "public-hoist-pattern[]=*esbuild*",
477
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*",
478
461
  "shell-emulator=true",
479
462
  "",
480
463
  ].join("\n"),
481
464
  );
482
465
  }
483
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
+
484
489
  function writePnpmWorkspace(targetDir) {
485
- writeFileSync(
486
- resolve(targetDir, "pnpm-workspace.yaml"),
487
- "packages:\n - 'apps/*'\n - 'apps/cms-plugins/*'\n",
488
- );
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);
489
523
  }
490
524
 
491
525
  // Single-origin nginx reference. Only emitted when adminPrefix is set, since
@@ -812,7 +846,7 @@ function parseTarget(input) {
812
846
  scripts: {
813
847
  start: "node server.mjs",
814
848
  },
815
- engines: { node: ">=20" },
849
+ engines: { node: ">=22" },
816
850
  };
817
851
  writeFileSync(resolve(dir, "package.json"), JSON.stringify(pkg, null, 2) + "\n");
818
852
 
@@ -899,7 +933,7 @@ function writeRootGitignore(targetDir) {
899
933
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
900
934
  }
901
935
 
902
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl }) {
936
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace }) {
903
937
  const header = adminPrefix
904
938
  ? [
905
939
  "# Single-origin monorepo dev env",
@@ -949,6 +983,10 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
949
983
  `APOLLO_ASSET_PREFIX=${adminPrefix}`,
950
984
  "# Project-specific plugins — keeps the apollo-cms submodule clean.",
951
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}`,
952
990
  "",
953
991
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
954
992
  "# Where the frontend's rewrites point internally. In dev, leave blank",
@@ -962,6 +1000,10 @@ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecre
962
1000
  "# ── Backend (apps/backend) ───────────────────────────────────────",
963
1001
  "# Project-specific plugins — keeps the apollo-cms submodule clean.",
964
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}`,
965
1007
  "",
966
1008
  "# ── Frontend (apps/frontend) ─────────────────────────────────────",
967
1009
  `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
@@ -1488,55 +1530,111 @@ console.log("✓ env check passed");
1488
1530
  // since Next.js handles that internally and the proxy is single-threaded by design).
1489
1531
  function writePm2Config(targetDir, dirName, adminPrefix) {
1490
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
+
1491
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
+ //
1492
1549
  // Usage:
1493
1550
  // pnpm install
1494
1551
  // pnpm backend:upgrade
1495
1552
  // pnpm build
1496
- // pm2 start ecosystem.config.cjs # FE + BE
1497
- // 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
1498
1555
  // pm2 start ecosystem.config.cjs --only cron # self-hosted cron fallback
1556
+ // pm2 stop \${PM2_NAMESPACE} # stop everything in this project
1499
1557
  // pm2 save && pm2 startup # persist across reboots
1500
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
+
1501
1586
  module.exports = {
1502
1587
  apps: [
1503
1588
  {
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",
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',
1510
1603
  autorestart: true,
1511
1604
  },
1512
1605
  {
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",
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',
1519
1613
  autorestart: true,
1520
1614
  },
1521
1615
  {
1522
- name: "proxy",
1523
- script: "./apps/proxy/server.mjs",
1616
+ name: \`proxy:\${PROXY_PORT}\`,
1617
+ namespace: NAMESPACE,
1618
+ script: './apps/proxy/server.mjs',
1524
1619
  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}",` : ""}
1620
+ ${proxyEnv}
1530
1621
  },
1531
- max_memory_restart: "256M",
1622
+ max_memory_restart: '256M',
1532
1623
  autorestart: true,
1533
1624
  },
1534
1625
  {
1535
- name: "cron",
1536
- cwd: "./apps/backend",
1537
- script: "scripts/dev-cron.ts",
1538
- interpreter: "bun",
1539
- 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
+ },
1540
1638
  autorestart: true,
1541
1639
  },
1542
1640
  ],
@@ -1815,12 +1913,18 @@ The installer scaffolds \`ecosystem.config.cjs\`. To supervise everything:
1815
1913
 
1816
1914
  \`\`\`bash
1817
1915
  pm2 start ecosystem.config.cjs # FE + BE + proxy + cron (all four)
1818
- pm2 start ecosystem.config.cjs --only frontend,backend # just the two apps
1916
+ pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
1819
1917
  pm2 save && pm2 startup # persist across reboots
1820
- pm2 logs # tail all processes
1821
- 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
1822
1921
  \`\`\`
1823
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
+
1824
1928
  The proxy and cron processes can be omitted with \`--only\` if you front the
1825
1929
  apps with nginx/Caddy or use platform cron (system cron, k8s CronJob).
1826
1930
 
@@ -1944,7 +2048,7 @@ async function main() {
1944
2048
  writeRootPackageJson(targetDir, dirName);
1945
2049
  writePnpmWorkspace(targetDir);
1946
2050
  writeRootGitignore(targetDir);
1947
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl });
2051
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, cronSecret, adminPrefix, backendInternalUrl, pm2Namespace: dirName });
1948
2052
  writeReadme(targetDir, dirName, frontendName, adminPrefix);
1949
2053
  writeClaudeMd(targetDir, adminPrefix);
1950
2054
  if (adminPrefix) writeNginxSample(targetDir, adminPrefix);
@@ -1995,23 +2099,27 @@ async function main() {
1995
2099
  );
1996
2100
  }
1997
2101
 
1998
- // Mirror env to backend
1999
- const backendEnvLines = [
2102
+ const backendFallback = [
2000
2103
  `DATABASE_URL=${dbUrl}`,
2001
2104
  `APOLLO_SECRET=${authSecret}`,
2002
2105
  `CRON_SECRET=${cronSecret}`,
2003
2106
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
2004
2107
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
2005
2108
  `APOLLO_EXTRA_PLUGINS_DIR=../cms-plugins`,
2109
+ `PM2_NAMESPACE=${dirName}`,
2006
2110
  ];
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");
2111
+ if (adminPrefix) backendFallback.push(`APOLLO_ASSET_PREFIX=${adminPrefix}`);
2112
+ linkRootEnvLocal(targetDir, BACKEND_PATH, backendFallback);
2013
2113
  }
2014
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
+
2015
2123
  // ── Step 7: Install ──
2016
2124
  if (flags.skipInstall) {
2017
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.2",
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",