create-apollo-monorepo 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/README.md +35 -1
  2. package/index.mjs +204 -31
  3. package/package.json +5 -3
package/README.md CHANGED
@@ -33,6 +33,34 @@ thamc-new/
33
33
  └── .gitignore
34
34
  ```
35
35
 
36
+ ## Routing modes
37
+
38
+ ### Single-origin (default)
39
+
40
+ Both apps share one public origin (the frontend). The frontend's
41
+ `next.config.ts` rewrites these paths to the backend so `/_next/*` doesn't
42
+ collide:
43
+
44
+ | Browser path | Goes to |
45
+ | ------------------------------------ | -------------------- |
46
+ | `/`, your custom routes | `apps/frontend` |
47
+ | `/admin/*` | `apps/backend` |
48
+ | `/api/auth/*`, `/api/v1/*`, `/api/admin/*`, `/api/email/*`, `/api/cron`, `/api/health`, `/api/mcp`, `/api/editing-presence/*` | `apps/backend` |
49
+ | `/uploads/*` | `apps/backend` (media) |
50
+ | `/cms-assets/*` | `apps/backend` chunks (via `APOLLO_ASSET_PREFIX`) |
51
+ | `/monitoring` | `apps/backend` (Sentry tunnel, no-op without DSN) |
52
+
53
+ Apollo CMS picks up `APOLLO_ASSET_PREFIX=/cms-assets` and serves its built JS
54
+ under that namespace, sidestepping the `/_next/*` collision. The frontend
55
+ **must not** define routes at `/admin`, `/api/auth`, `/api/v1`, etc.
56
+
57
+ ### Separate origins (fallback)
58
+
59
+ Pass `--asset-prefix none` (or `off`/`false`/`disabled`) to skip the rewrite
60
+ wiring. The backend runs at `http://localhost:3000` and the frontend at
61
+ `http://localhost:3001`. Useful when you'd rather deploy them on separate
62
+ subdomains (e.g. `cms.example.com` + `example.com`).
63
+
36
64
  ## Flags
37
65
 
38
66
  | Flag | Default | Description |
@@ -41,8 +69,9 @@ thamc-new/
41
69
  | `--backend-url <url>` | `https://github.com/5Lab-Group-Co-Ltd/apollo-cms.git` | Submodule git URL |
42
70
  | `--backend-branch <name>` | `main` | Submodule branch to track |
43
71
  | `-d, --db <url>` | _(prompted)_ | `DATABASE_URL` for backend |
44
- | `-u, --url <url>` | `http://localhost:3000` | `NEXT_PUBLIC_SITE_URL` |
72
+ | `-u, --url <url>` | `:3001` single-origin / `:3000` separate | `NEXT_PUBLIC_SITE_URL` |
45
73
  | `-l, --locale <code>` | `en` | `NEXT_PUBLIC_DEFAULT_LOCALE` |
74
+ | `--asset-prefix <path>` | `/cms-assets` | Single-origin asset namespace; `none` to disable |
46
75
  | `--skip-install` | off | Don't run `pnpm install` |
47
76
  | `--skip-submodule` | off | Don't add the git submodule |
48
77
  | `-h, --help` | — | Show help |
@@ -55,6 +84,11 @@ pnpm backend:setup # push schema + seed apollo-cms
55
84
  pnpm dev # frontend :3001 + backend :3000 in parallel
56
85
  ```
57
86
 
87
+ In single-origin mode open `http://localhost:3001/admin` (the frontend port);
88
+ the rewrite proxies to the backend and Better Auth's cookies/origins line up
89
+ because `NEXT_PUBLIC_SITE_URL` and the backend's `trustedProxyHeaders` are wired
90
+ to the public origin.
91
+
58
92
  ## Updating the backend
59
93
 
60
94
  ```bash
package/index.mjs CHANGED
@@ -54,11 +54,21 @@ ${COLORS.bold}Flags:${COLORS.reset}
54
54
  --backend-url <url> Backend git URL (default: ${BACKEND_REPO_URL})
55
55
  --backend-branch <name> Backend branch to track (default: ${BACKEND_BRANCH})
56
56
  -d, --db <url> DATABASE_URL for backend
57
- -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3000)
57
+ -u, --url <url> NEXT_PUBLIC_SITE_URL (default: http://localhost:3001 single-origin / 3000 separate)
58
58
  -l, --locale <code> NEXT_PUBLIC_DEFAULT_LOCALE (default: en)
59
+ --asset-prefix <path> Single-origin asset namespace (default: /cms-assets, or "none" to disable)
59
60
  --skip-install Skip dependency installation
60
61
  --skip-submodule Skip git submodule add (you'll add it later)
61
62
  -h, --help Show this help message
63
+
64
+ ${COLORS.bold}Single-origin model:${COLORS.reset}
65
+ The frontend is the public entry point. It rewrites these paths to the
66
+ backend so both apps live under one domain without /_next/* collisions:
67
+ /admin/* → backend
68
+ /api/* → backend (REST API + auth)
69
+ /uploads/* → backend (media)
70
+ <asset-prefix>/* → backend (Next.js chunks via APOLLO_ASSET_PREFIX)
71
+ Frontend MUST NOT define routes at /admin or /api/auth or /api/v1.
62
72
  `;
63
73
 
64
74
  // ─── Helpers ─────────────────────────────────────────────────────────────────
@@ -112,6 +122,7 @@ function parseArgs(argv) {
112
122
  db: null,
113
123
  url: null,
114
124
  locale: null,
125
+ assetPrefix: "/cms-assets",
115
126
  skipInstall: false,
116
127
  skipSubmodule: false,
117
128
  help: false,
@@ -146,6 +157,9 @@ function parseArgs(argv) {
146
157
  case "--locale":
147
158
  flags.locale = args[++i];
148
159
  break;
160
+ case "--asset-prefix":
161
+ flags.assetPrefix = args[++i];
162
+ break;
149
163
  case "--skip-install":
150
164
  flags.skipInstall = true;
151
165
  break;
@@ -181,6 +195,23 @@ function isValidLocale(code) {
181
195
  return /^[a-z]{2,5}$/i.test(code);
182
196
  }
183
197
 
198
+ // Normalize an asset prefix:
199
+ // - empty string / "none" / "off" / "false" → "" (single-origin disabled, fallback to separate origins)
200
+ // - "/foo" → "/foo"
201
+ // - "foo" → "/foo"
202
+ // - "/foo/" → "/foo"
203
+ function normalizeAssetPrefix(value) {
204
+ if (!value) return "";
205
+ const trimmed = String(value).trim();
206
+ if (trimmed === "" || /^(none|off|false|disabled)$/i.test(trimmed)) return "";
207
+ let p = trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
208
+ p = p.replace(/\/+$/, "");
209
+ if (!/^\/[a-z0-9._-]+(\/[a-z0-9._-]+)*$/i.test(p)) {
210
+ fatal(`Invalid --asset-prefix: ${value}\n Use a path like /cms-assets, or "none" to disable single-origin.`);
211
+ }
212
+ return p;
213
+ }
214
+
184
215
  // ─── Pre-flight ──────────────────────────────────────────────────────────────
185
216
 
186
217
  function preflight(flags) {
@@ -223,9 +254,12 @@ function createPrompt() {
223
254
  return { ask, close };
224
255
  }
225
256
 
226
- async function gatherEnv(flags) {
257
+ async function gatherEnv(flags, { singleOrigin }) {
227
258
  let dbUrl = flags.db;
228
- let siteUrl = flags.url ?? "http://localhost:3000";
259
+ // In single-origin mode the frontend is the public entry → default :3001.
260
+ // In separate-origins mode the backend is the public CMS URL → default :3000.
261
+ const defaultSiteUrl = singleOrigin ? "http://localhost:3001" : "http://localhost:3000";
262
+ let siteUrl = flags.url ?? defaultSiteUrl;
229
263
  let locale = flags.locale ?? "en";
230
264
 
231
265
  if (flags.db && !isValidDbUrl(flags.db)) fatal("--db must start with postgresql:// or postgres://");
@@ -321,23 +355,53 @@ function writeRootGitignore(targetDir) {
321
355
  writeFileSync(resolve(targetDir, ".gitignore"), ignore);
322
356
  }
323
357
 
324
- function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret }) {
358
+ function writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl }) {
359
+ const header = assetPrefix
360
+ ? [
361
+ "# Single-origin monorepo dev env",
362
+ "# Public origin = frontend (apps/frontend on :3001). Frontend rewrites /admin/*,",
363
+ "# /api/*, /uploads/*, and the asset prefix to the backend (apps/backend on :3000).",
364
+ ]
365
+ : [
366
+ "# Separate-origins monorepo dev env",
367
+ "# Backend runs at http://localhost:3000, frontend at http://localhost:3001.",
368
+ "# To switch to single-origin: set APOLLO_ASSET_PREFIX, BACKEND_INTERNAL_URL,",
369
+ "# NEXT_PUBLIC_APOLLO_ASSET_PREFIX, and add rewrites() to apps/frontend/next.config.ts.",
370
+ ];
371
+
325
372
  const lines = [
326
- "# Shared dev env — backend reads these via apps/backend/.env.local symlink/copy",
373
+ ...header,
374
+ "",
375
+ "# ── Shared (consumed by both apps) ───────────────────────────────",
327
376
  `DATABASE_URL=${dbUrl}`,
328
377
  `APOLLO_SECRET=${authSecret}`,
329
- `BETTER_AUTH_SECRET=${authSecret}`,
330
378
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
331
379
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
332
380
  "",
333
- "# Frontend",
334
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
335
- "",
336
- ].join("\n");
337
- writeFileSync(resolve(targetDir, ".env.local"), lines);
381
+ ];
382
+
383
+ if (assetPrefix) {
384
+ lines.push(
385
+ "# ── Backend (apps/backend) ───────────────────────────────────────",
386
+ `APOLLO_ASSET_PREFIX=${assetPrefix}`,
387
+ "",
388
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
389
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
390
+ `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
391
+ "",
392
+ );
393
+ } else {
394
+ lines.push(
395
+ "# ── Frontend (apps/frontend) ─────────────────────────────────────",
396
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
397
+ "",
398
+ );
399
+ }
400
+
401
+ writeFileSync(resolve(targetDir, ".env.local"), lines.join("\n"));
338
402
  }
339
403
 
340
- function writeFrontendApp(targetDir, frontendName, siteUrl) {
404
+ function writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl) {
341
405
  const dir = resolve(targetDir, FRONTEND_PATH);
342
406
  mkdirSync(dir, { recursive: true });
343
407
  mkdirSync(resolve(dir, "src/app"), { recursive: true });
@@ -397,15 +461,70 @@ function writeFrontendApp(targetDir, frontendName, siteUrl) {
397
461
  ) + "\n",
398
462
  );
399
463
 
400
- writeFileSync(
401
- resolve(dir, "next.config.ts"),
402
- `import type { NextConfig } from "next";\n\nconst config: NextConfig = {\n reactStrictMode: true,\n};\n\nexport default config;\n`,
403
- );
464
+ // Frontend Next.js config:
465
+ // - Single-origin mode (assetPrefix set): rewrite all backend paths.
466
+ // - Separate-origins mode (assetPrefix empty): no rewrites; the two apps
467
+ // are reachable on their own ports/subdomains.
468
+ const nextConfigContent = assetPrefix
469
+ ? `import type { NextConfig } from "next";
470
+
471
+ const BACKEND = process.env.BACKEND_INTERNAL_URL ?? "http://localhost:3000";
472
+ const ASSET_PREFIX = process.env.NEXT_PUBLIC_APOLLO_ASSET_PREFIX ?? "${assetPrefix}";
473
+
474
+ const config: NextConfig = {
475
+ reactStrictMode: true,
476
+ async rewrites() {
477
+ return [
478
+ // Apollo CMS admin UI
479
+ { source: "/admin/:path*", destination: \`\${BACKEND}/admin/:path*\` },
480
+ // Apollo CMS APIs (REST + auth + cron + email + health + mcp + …)
481
+ { source: "/api/auth/:path*", destination: \`\${BACKEND}/api/auth/:path*\` },
482
+ { source: "/api/v1/:path*", destination: \`\${BACKEND}/api/v1/:path*\` },
483
+ { source: "/api/cron", destination: \`\${BACKEND}/api/cron\` },
484
+ { source: "/api/email/:path*", destination: \`\${BACKEND}/api/email/:path*\` },
485
+ { source: "/api/health", destination: \`\${BACKEND}/api/health\` },
486
+ { source: "/api/mcp", destination: \`\${BACKEND}/api/mcp\` },
487
+ { source: "/api/editing-presence/:path*", destination: \`\${BACKEND}/api/editing-presence/:path*\` },
488
+ { source: "/api/admin/:path*", destination: \`\${BACKEND}/api/admin/:path*\` },
489
+ // Media uploads
490
+ { source: "/uploads/:path*", destination: \`\${BACKEND}/uploads/:path*\` },
491
+ // Sentry tunnel (no-op if SENTRY_DSN isn't set on the backend)
492
+ { source: "/monitoring", destination: \`\${BACKEND}/monitoring\` },
493
+ // Backend's namespaced Next.js chunks (set via APOLLO_ASSET_PREFIX)
494
+ { source: \`\${ASSET_PREFIX}/:path*\`, destination: \`\${BACKEND}\${ASSET_PREFIX}/:path*\` },
495
+ ];
496
+ },
497
+ };
404
498
 
405
- writeFileSync(
406
- resolve(dir, ".env.local.example"),
407
- `NEXT_PUBLIC_BACKEND_URL=${siteUrl}\n`,
408
- );
499
+ export default config;
500
+ `
501
+ : `import type { NextConfig } from "next";
502
+
503
+ // Separate-origins mode: backend runs at http://localhost:3000, frontend here.
504
+ // Switch to single-origin by setting APOLLO_ASSET_PREFIX in apps/backend/.env.local
505
+ // and adding rewrites() — see this monorepo's README for the recipe.
506
+ const config: NextConfig = {
507
+ reactStrictMode: true,
508
+ };
509
+
510
+ export default config;
511
+ `;
512
+ writeFileSync(resolve(dir, "next.config.ts"), nextConfigContent);
513
+
514
+ const envLocalLines = assetPrefix
515
+ ? [
516
+ `# Internal backend URL (used by Next.js rewrites — not exposed to browser)`,
517
+ `BACKEND_INTERNAL_URL=${backendInternalUrl}`,
518
+ `# Mirror of backend's APOLLO_ASSET_PREFIX`,
519
+ `NEXT_PUBLIC_APOLLO_ASSET_PREFIX=${assetPrefix}`,
520
+ "",
521
+ ]
522
+ : [
523
+ `# Public URL of the backend (used by your client code)`,
524
+ `NEXT_PUBLIC_BACKEND_URL=${siteUrl}`,
525
+ "",
526
+ ];
527
+ writeFileSync(resolve(dir, ".env.local.example"), envLocalLines.join("\n"));
409
528
 
410
529
  writeFileSync(
411
530
  resolve(dir, "src/app/layout.tsx"),
@@ -414,7 +533,7 @@ function writeFrontendApp(targetDir, frontendName, siteUrl) {
414
533
 
415
534
  writeFileSync(
416
535
  resolve(dir, "src/app/page.tsx"),
417
- `export default function Page() {\n return (\n <main style={{ padding: 40, fontFamily: "system-ui" }}>\n <h1>Frontend</h1>\n <p>Backend: <code>{process.env.NEXT_PUBLIC_BACKEND_URL}</code></p>\n </main>\n );\n}\n`,
536
+ `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`,
418
537
  );
419
538
 
420
539
  writeFileSync(
@@ -423,7 +542,54 @@ function writeFrontendApp(targetDir, frontendName, siteUrl) {
423
542
  );
424
543
  }
425
544
 
426
- function writeReadme(targetDir, dirName, frontendName) {
545
+ function writeReadme(targetDir, dirName, frontendName, assetPrefix) {
546
+ const singleOrigin = !!assetPrefix;
547
+
548
+ const originSection = singleOrigin
549
+ ? `## Routing model — single origin
550
+
551
+ Both apps share one public origin (the frontend). The frontend rewrites these
552
+ paths to the backend so /_next/* doesn't collide:
553
+
554
+ | Path | Goes to |
555
+ | --------------------------- | ------------------ |
556
+ | \`/\` and other frontend routes | \`apps/frontend\` |
557
+ | \`/admin/*\` | \`apps/backend\` |
558
+ | \`/api/auth/*\`, \`/api/v1/*\`, \`/api/email/*\`, \`/api/cron\`, \`/api/health\`, \`/api/mcp\`, \`/api/admin/*\`, \`/api/editing-presence/*\` | \`apps/backend\` |
559
+ | \`/uploads/*\` | \`apps/backend\` (media) |
560
+ | \`${assetPrefix}/*\` | \`apps/backend\` (chunks via \`APOLLO_ASSET_PREFIX\`) |
561
+
562
+ The frontend MUST NOT define routes at \`/admin\`, \`/api/auth\`, \`/api/v1\`, etc.
563
+ If you need your own API, namespace it under \`/api/internal/*\` or similar.
564
+
565
+ To **disable** single-origin and run the backend on its own subdomain,
566
+ delete \`APOLLO_ASSET_PREFIX\` from \`apps/backend/.env.local\` and remove the
567
+ \`rewrites()\` block from \`apps/frontend/next.config.ts\`.
568
+
569
+ ### Known limitations
570
+
571
+ - The backend's \`/_next/image\` (Next.js Image optimization endpoint) is **not**
572
+ rewritten — only \`/_next/static\` moves under \`APOLLO_ASSET_PREFIX\`. If your
573
+ backend admin uses \`<Image>\` to render \`/uploads/*\` media, those will fall
574
+ back to the frontend's image optimizer and 404. Apollo CMS's stock admin uses
575
+ plain \`<img>\` for uploaded media so this rarely matters; if you customize the
576
+ admin and need optimized images, switch to separate-origins mode or set
577
+ \`unoptimized\` on those \`<Image>\` instances.
578
+ `
579
+ : `## Routing model — separate origins
580
+
581
+ The two apps run on separate origins. Default ports:
582
+
583
+ - Backend (apps/backend): http://localhost:3000
584
+ - Frontend (apps/frontend): http://localhost:3001
585
+
586
+ \`NEXT_PUBLIC_SITE_URL\` should point at whichever origin you treat as the
587
+ public CMS URL (typically the backend). To switch to **single-origin** later,
588
+ re-run the installer with \`--asset-prefix /cms-assets\` or set
589
+ \`APOLLO_ASSET_PREFIX\` in \`apps/backend/.env.local\` and add the matching
590
+ \`rewrites()\` block to \`apps/frontend/next.config.ts\`.
591
+ `;
592
+
427
593
  const readme = `# ${dirName}
428
594
 
429
595
  Monorepo with a custom frontend and Apollo CMS as a git submodule backend.
@@ -447,6 +613,7 @@ pnpm backend:setup # push schema + seed apollo-cms
447
613
  pnpm dev # runs frontend (3001) + backend (3000) in parallel
448
614
  \`\`\`
449
615
 
616
+ ${originSection}
450
617
  ## Updating the backend
451
618
 
452
619
  Apollo CMS is tracked as a git submodule. Pull the latest:
@@ -490,9 +657,13 @@ async function main() {
490
657
 
491
658
  // ── Step 2: Gather env ──
492
659
  step(2, "Configuring environment");
493
- const { dbUrl, siteUrl, locale } = await gatherEnv(flags);
660
+ const assetPrefix = normalizeAssetPrefix(flags.assetPrefix);
661
+ const singleOrigin = !!assetPrefix;
662
+ const { dbUrl, siteUrl, locale } = await gatherEnv(flags, { singleOrigin });
494
663
  const authSecret = randomBytes(48).toString("base64");
664
+ const backendInternalUrl = "http://localhost:3000";
495
665
  success(`Frontend pkg name: ${frontendName}`);
666
+ success(`Asset prefix: ${assetPrefix || "(disabled — separate origins)"}`);
496
667
 
497
668
  // ── Step 3: Scaffold root ──
498
669
  step(3, "Scaffolding monorepo root");
@@ -501,8 +672,8 @@ async function main() {
501
672
  writeRootPackageJson(targetDir, dirName);
502
673
  writePnpmWorkspace(targetDir);
503
674
  writeRootGitignore(targetDir);
504
- writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret });
505
- writeReadme(targetDir, dirName, frontendName);
675
+ writeRootEnv(targetDir, { dbUrl, siteUrl, locale, authSecret, assetPrefix, backendInternalUrl });
676
+ writeReadme(targetDir, dirName, frontendName, assetPrefix);
506
677
  success("package.json, pnpm-workspace.yaml, .gitignore, .env.local, README.md");
507
678
 
508
679
  // ── Step 4: git init ──
@@ -512,7 +683,7 @@ async function main() {
512
683
 
513
684
  // ── Step 5: Frontend skeleton ──
514
685
  step(5, "Creating frontend app");
515
- writeFrontendApp(targetDir, frontendName, siteUrl);
686
+ writeFrontendApp(targetDir, frontendName, siteUrl, assetPrefix, backendInternalUrl);
516
687
  success(`apps/frontend (${frontendName})`);
517
688
 
518
689
  // ── Step 6: Backend submodule ──
@@ -537,15 +708,17 @@ async function main() {
537
708
  }
538
709
 
539
710
  // Mirror env to backend
540
- const backendEnv = [
711
+ const backendEnvLines = [
541
712
  `DATABASE_URL=${dbUrl}`,
542
713
  `APOLLO_SECRET=${authSecret}`,
543
- `BETTER_AUTH_SECRET=${authSecret}`,
544
714
  `NEXT_PUBLIC_SITE_URL=${siteUrl}`,
545
715
  `NEXT_PUBLIC_DEFAULT_LOCALE=${locale}`,
546
- "",
547
- ].join("\n");
548
- writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnv);
716
+ ];
717
+ if (assetPrefix) {
718
+ backendEnvLines.push(`APOLLO_ASSET_PREFIX=${assetPrefix}`);
719
+ }
720
+ backendEnvLines.push("");
721
+ writeFileSync(resolve(targetDir, BACKEND_PATH, ".env.local"), backendEnvLines.join("\n"));
549
722
  success("apps/backend/.env.local");
550
723
  }
551
724
 
package/package.json CHANGED
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "name": "create-apollo-monorepo",
3
- "version": "0.1.0",
4
- "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend",
5
- "bin": "./index.mjs",
3
+ "version": "0.2.0",
4
+ "description": "Scaffold a monorepo with a frontend app and Apollo CMS as a git submodule backend (single-origin via Next.js rewrites + assetPrefix)",
5
+ "bin": {
6
+ "create-apollo-monorepo": "index.mjs"
7
+ },
6
8
  "type": "module",
7
9
  "engines": {
8
10
  "node": ">=20"