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