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.
- package/index.mjs +249 -179
- 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 =
|
|
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 (
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
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
|
-
|
|
262
|
-
|
|
263
|
-
|
|
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
|
-
|
|
268
|
-
success(
|
|
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
|
-
//
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
//
|
|
295
|
-
//
|
|
296
|
-
//
|
|
297
|
-
//
|
|
298
|
-
|
|
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.
|
|
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: ">=
|
|
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
|
-
|
|
486
|
-
|
|
487
|
-
|
|
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: ">=
|
|
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.
|
|
936
|
-
"#
|
|
937
|
-
"#
|
|
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
|
-
|
|
951
|
-
|
|
952
|
-
|
|
953
|
-
|
|
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
|
-
"#
|
|
963
|
-
"#
|
|
964
|
-
`
|
|
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 #
|
|
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:
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1508
|
-
|
|
1509
|
-
|
|
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:
|
|
1514
|
-
|
|
1515
|
-
|
|
1516
|
-
|
|
1517
|
-
|
|
1518
|
-
|
|
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:
|
|
1523
|
-
|
|
1578
|
+
name: \`proxy:\${PROXY_PORT}\`,
|
|
1579
|
+
namespace: NAMESPACE,
|
|
1580
|
+
script: './apps/proxy/server.mjs',
|
|
1524
1581
|
env: {
|
|
1525
|
-
|
|
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:
|
|
1584
|
+
max_memory_restart: '256M',
|
|
1532
1585
|
autorestart: true,
|
|
1533
1586
|
},
|
|
1534
1587
|
{
|
|
1535
|
-
name:
|
|
1536
|
-
|
|
1537
|
-
|
|
1538
|
-
|
|
1539
|
-
|
|
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
|
|
1878
|
+
pm2 start ecosystem.config.cjs --only proxy # only the reverse proxy
|
|
1819
1879
|
pm2 save && pm2 startup # persist across reboots
|
|
1820
|
-
pm2 logs
|
|
1821
|
-
pm2 reload
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
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": ">=
|
|
10
|
+
"node": ">=22"
|
|
11
11
|
},
|
|
12
12
|
"keywords": [
|
|
13
13
|
"apollo",
|