create-forgeon 0.3.14 → 0.3.16

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 (41) hide show
  1. package/package.json +4 -2
  2. package/src/core/docs.test.mjs +79 -40
  3. package/src/core/scaffold.test.mjs +99 -0
  4. package/src/modules/db-prisma.mjs +23 -55
  5. package/src/modules/executor.test.mjs +2575 -2419
  6. package/src/modules/files-access.mjs +27 -98
  7. package/src/modules/files-image.mjs +26 -100
  8. package/src/modules/files-quotas.mjs +67 -87
  9. package/src/modules/files.mjs +35 -104
  10. package/src/modules/i18n.mjs +17 -121
  11. package/src/modules/idempotency.test.mjs +174 -0
  12. package/src/modules/jwt-auth.mjs +90 -209
  13. package/src/modules/logger.mjs +0 -9
  14. package/src/modules/probes.test.mjs +202 -0
  15. package/src/modules/queue.mjs +325 -412
  16. package/src/modules/rate-limit.mjs +22 -66
  17. package/src/modules/rbac.mjs +27 -67
  18. package/src/modules/scheduler.mjs +44 -167
  19. package/src/modules/shared/nest-runtime-wiring.mjs +110 -0
  20. package/src/modules/shared/probes.mjs +235 -0
  21. package/src/modules/sync-integrations.mjs +54 -21
  22. package/src/modules/sync-integrations.test.mjs +220 -0
  23. package/src/run-add-module.test.mjs +153 -0
  24. package/templates/base/README.md +7 -55
  25. package/templates/base/apps/web/src/App.tsx +70 -42
  26. package/templates/base/apps/web/src/probes.ts +61 -0
  27. package/templates/base/apps/web/src/styles.css +86 -25
  28. package/templates/base/package.json +21 -15
  29. package/templates/base/scripts/forgeon-sync-integrations.mjs +55 -11
  30. package/templates/module-presets/files-quotas/packages/files-quotas/src/forgeon-files-quotas.module.ts +12 -4
  31. package/templates/module-presets/i18n/apps/web/src/App.tsx +68 -41
  32. package/templates/module-presets/logger/packages/logger/src/index.ts +0 -1
  33. package/templates/base/docs/AI/ARCHITECTURE.md +0 -85
  34. package/templates/base/docs/AI/MODULE_CHECKS.md +0 -28
  35. package/templates/base/docs/AI/MODULE_SPEC.md +0 -77
  36. package/templates/base/docs/AI/PROJECT.md +0 -43
  37. package/templates/base/docs/AI/ROADMAP.md +0 -171
  38. package/templates/base/docs/AI/TASKS.md +0 -60
  39. package/templates/base/docs/AI/VALIDATION.md +0 -31
  40. package/templates/base/docs/README.md +0 -18
  41. package/templates/module-presets/logger/packages/logger/src/http-logging.interceptor.ts +0 -94
@@ -0,0 +1,153 @@
1
+ import { describe, it } from 'node:test';
2
+ import assert from 'node:assert/strict';
3
+ import fs from 'node:fs';
4
+ import os from 'node:os';
5
+ import path from 'node:path';
6
+ import { fileURLToPath } from 'node:url';
7
+ import { scaffoldProject } from './core/scaffold.mjs';
8
+ import { runAddModule } from './run-add-module.mjs';
9
+
10
+ function makeTempDir(prefix) {
11
+ return fs.mkdtempSync(path.join(os.tmpdir(), prefix));
12
+ }
13
+
14
+ function readFile(filePath) {
15
+ return fs.readFileSync(filePath, 'utf8');
16
+ }
17
+
18
+ function writeJson(filePath, value) {
19
+ fs.writeFileSync(filePath, `${JSON.stringify(value, null, 2)}\n`, 'utf8');
20
+ }
21
+
22
+ function scaffoldBaseProject({ packageRoot, targetRoot, projectName, proxy }) {
23
+ const templateRoot = path.join(packageRoot, 'templates', 'base');
24
+ scaffoldProject({
25
+ templateRoot,
26
+ packageRoot,
27
+ targetRoot,
28
+ projectName,
29
+ frontend: 'react',
30
+ db: 'prisma',
31
+ dbPrismaEnabled: false,
32
+ i18nEnabled: false,
33
+ proxy,
34
+ });
35
+ }
36
+
37
+ function stripSyncTooling(targetRoot) {
38
+ const packagePath = path.join(targetRoot, 'package.json');
39
+ const packageJson = JSON.parse(readFile(packagePath));
40
+ if (packageJson.scripts) {
41
+ delete packageJson.scripts['forgeon:sync-integrations'];
42
+ }
43
+ writeJson(packagePath, packageJson);
44
+
45
+ fs.rmSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs'), { force: true });
46
+ }
47
+
48
+ async function captureLogs(work) {
49
+ const lines = [];
50
+ const originalLog = console.log;
51
+ console.log = (...args) => {
52
+ lines.push(args.join(' '));
53
+ };
54
+
55
+ try {
56
+ await work(lines);
57
+ } finally {
58
+ console.log = originalLog;
59
+ }
60
+
61
+ return lines.join('\n');
62
+ }
63
+
64
+ describe('runAddModule', () => {
65
+ const thisDir = path.dirname(fileURLToPath(import.meta.url));
66
+ const packageRoot = path.resolve(thisDir, '..');
67
+
68
+ it('installs files stack non-interactively with provider selection and restores sync tooling', async () => {
69
+ const tempRoot = makeTempDir('forgeon-run-add-files-');
70
+ const targetRoot = path.join(tempRoot, 'demo-run-add-files');
71
+
72
+ try {
73
+ scaffoldBaseProject({
74
+ packageRoot,
75
+ targetRoot,
76
+ projectName: 'demo-run-add-files',
77
+ proxy: 'nginx',
78
+ });
79
+ stripSyncTooling(targetRoot);
80
+
81
+ const output = await captureLogs(async () => {
82
+ await runAddModule([
83
+ 'files',
84
+ '--project',
85
+ targetRoot,
86
+ '--with-required',
87
+ '--provider',
88
+ 'files-storage-adapter=files-local',
89
+ ]);
90
+ });
91
+
92
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'db-prisma', 'package.json')), true);
93
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files-local', 'package.json')), true);
94
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'files', 'package.json')), true);
95
+ assert.equal(fs.existsSync(path.join(targetRoot, 'scripts', 'forgeon-sync-integrations.mjs')), true);
96
+
97
+ const packageJson = JSON.parse(readFile(path.join(targetRoot, 'package.json')));
98
+ assert.equal(packageJson.scripts['forgeon:sync-integrations'], 'node scripts/forgeon-sync-integrations.mjs');
99
+
100
+ const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
101
+ assert.match(compose, /^\s{2}nginx:\s*$/m);
102
+ assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
103
+
104
+ const apiEnv = readFile(path.join(targetRoot, 'apps', 'api', '.env.example'));
105
+ assert.match(apiEnv, /DATABASE_URL=postgresql:\/\/postgres:postgres@localhost:5432\/app\?schema=public/);
106
+ assert.match(apiEnv, /FILES_STORAGE_DRIVER=local/);
107
+
108
+ const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
109
+ assert.match(healthController, /@Post\('files'\)/);
110
+
111
+ assert.match(output, /Recommended companion modules are available:/);
112
+ assert.match(output, /No integration groups found\./);
113
+ assert.match(output, /Next: run pnpm install/);
114
+ } finally {
115
+ fs.rmSync(tempRoot, { recursive: true, force: true });
116
+ }
117
+ });
118
+
119
+ it('installs scheduler with required queue on proxy=none scaffold', async () => {
120
+ const tempRoot = makeTempDir('forgeon-run-add-scheduler-');
121
+ const targetRoot = path.join(tempRoot, 'demo-run-add-scheduler');
122
+
123
+ try {
124
+ scaffoldBaseProject({
125
+ packageRoot,
126
+ targetRoot,
127
+ projectName: 'demo-run-add-scheduler',
128
+ proxy: 'none',
129
+ });
130
+
131
+ const output = await captureLogs(async () => {
132
+ await runAddModule(['scheduler', '--project', targetRoot, '--with-required']);
133
+ });
134
+
135
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'queue', 'package.json')), true);
136
+ assert.equal(fs.existsSync(path.join(targetRoot, 'packages', 'scheduler', 'package.json')), true);
137
+
138
+ const compose = readFile(path.join(targetRoot, 'infra', 'docker', 'compose.yml'));
139
+ assert.match(compose, /^\s{2}redis:\s*$/m);
140
+ assert.doesNotMatch(compose, /^\s{2}caddy:\s*$/m);
141
+ assert.doesNotMatch(compose, /^\s{2}nginx:\s*$/m);
142
+
143
+ const healthController = readFile(path.join(targetRoot, 'apps', 'api', 'src', 'health', 'health.controller.ts'));
144
+ assert.match(healthController, /@Get\('queue'\)/);
145
+ assert.match(healthController, /@Get\('scheduler'\)/);
146
+
147
+ assert.match(output, /No integration groups found\./);
148
+ assert.match(output, /Next: run pnpm install/);
149
+ } finally {
150
+ fs.rmSync(tempRoot, { recursive: true, force: true });
151
+ }
152
+ });
153
+ });
@@ -1,58 +1,10 @@
1
- # Forgeon Fullstack Scaffold
2
-
3
- Canonical monorepo scaffold for NestJS + frontend with shared packages, built-in docs, optional i18n (enabled by default), and default DB stack Prisma + Postgres.
4
-
5
- ## Quick Start (Dev)
6
-
7
- 1. Install dependencies:
8
- ```bash
9
- pnpm install
10
- ```
11
- 2. Start local Postgres (Docker):
12
- ```bash
13
- docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up db -d
14
- ```
15
- 3. Run API + web in dev mode:
16
- ```bash
17
- pnpm dev
18
- ```
19
- 4. Open:
20
- - Web: `http://localhost:5173`
21
- - API health: `http://localhost:3000/api/health`
22
-
23
- ## Quick Start (Docker)
24
-
25
- ```bash
26
- docker compose --env-file infra/docker/.env.example -f infra/docker/compose.yml up --build
27
- ```
28
-
29
- Open `http://localhost:8080`.
1
+ # Forgeon Base Template
30
2
 
31
- ## Integration Sync
3
+ Internal scaffold substrate copied before presets and generated docs are applied.
32
4
 
33
- Use integration sync to reconcile module cross-wiring when modules are installed in any order.
5
+ Notes for Forgeon development:
34
6
 
35
- ```bash
36
- pnpm forgeon:sync-integrations
37
- ```
38
-
39
- Current sync coverage:
40
- - `jwt-auth + rbac`: extends demo auth tokens with the `health.rbac` permission.
41
- - `jwt-auth + db-adapter` (current provider: `db-prisma`): wires persistent refresh-token storage for auth.
42
-
43
- `create-forgeon add <module>` scans for relevant integration groups and can apply them immediately.
44
-
45
- ## i18n Configuration
46
-
47
- Set in env (when i18n module is installed):
48
- - `I18N_DEFAULT_LANG=en`
49
- - `I18N_FALLBACK_LANG=en`
50
-
51
- ## Prisma In Docker Start
52
-
53
- API container starts with:
54
- 1. `prisma migrate deploy`
55
- 2. `node apps/api/dist/main.js`
56
-
57
- This keeps container startup production-like while still simple.
58
-
7
+ - the final generated project `README.md` is produced from `templates/docs-fragments/README/*`
8
+ - module notes are produced under `modules/<module-id>/README.md` from `templates/module-fragments/*`
9
+ - this file is a temporary template placeholder and is normally overwritten during scaffold
10
+ - generated-project commands such as `pnpm forgeon:sync-integrations` belong to scaffolded output, not to the root development repo
@@ -1,21 +1,29 @@
1
- import { useState } from 'react';
1
+ import { useState } from 'react';
2
+ import { probeDefinitions, type ProbeDefinition, type ProbeResult } from './probes';
2
3
  import './styles.css';
3
4
 
4
- type ProbeResult = {
5
- statusCode: number;
6
- body: unknown;
5
+ type ProbeState = {
6
+ result: ProbeResult | null;
7
+ error: string | null;
8
+ loading: boolean;
9
+ };
10
+
11
+ const emptyProbeState: ProbeState = {
12
+ result: null,
13
+ error: null,
14
+ loading: false,
7
15
  };
8
16
 
9
17
  export default function App() {
10
- const [healthResult, setHealthResult] = useState<ProbeResult | null>(null);
11
- const [errorProbeResult, setErrorProbeResult] = useState<ProbeResult | null>(null);
12
- const [validationProbeResult, setValidationProbeResult] = useState<ProbeResult | null>(null);
13
- const [networkError, setNetworkError] = useState<string | null>(null);
18
+ const [probeState, setProbeState] = useState<Record<string, ProbeState>>({});
14
19
 
15
- const requestProbe = async (url: string, init?: RequestInit): Promise<ProbeResult> => {
16
- const response = await fetch(url, {
17
- ...(init ?? {}),
20
+ const requestProbe = async (probe: ProbeDefinition): Promise<ProbeResult> => {
21
+ const response = await fetch(`/api${probe.path}`, {
22
+ ...(probe.request ?? {}),
18
23
  cache: 'no-store',
24
+ headers: {
25
+ ...(probe.request?.headers ?? {}),
26
+ },
19
27
  });
20
28
  let body: unknown = null;
21
29
 
@@ -31,46 +39,66 @@ export default function App() {
31
39
  };
32
40
  };
33
41
 
34
- const runProbe = async (
35
- setter: (value: ProbeResult | null) => void,
36
- url: string,
37
- init?: RequestInit,
38
- ) => {
39
- setNetworkError(null);
42
+ const runProbe = async (probe: ProbeDefinition) => {
43
+ setProbeState((current) => ({
44
+ ...current,
45
+ [probe.id]: {
46
+ ...(current[probe.id] ?? emptyProbeState),
47
+ error: null,
48
+ loading: true,
49
+ },
50
+ }));
51
+
40
52
  try {
41
- const result = await requestProbe(url, init);
42
- setter(result);
53
+ const result = await requestProbe(probe);
54
+ setProbeState((current) => ({
55
+ ...current,
56
+ [probe.id]: {
57
+ result,
58
+ error: null,
59
+ loading: false,
60
+ },
61
+ }));
43
62
  } catch (err) {
44
- setNetworkError(err instanceof Error ? err.message : 'Unknown error');
63
+ setProbeState((current) => ({
64
+ ...current,
65
+ [probe.id]: {
66
+ result: current[probe.id]?.result ?? null,
67
+ error: err instanceof Error ? err.message : 'Unknown error',
68
+ loading: false,
69
+ },
70
+ }));
45
71
  }
46
72
  };
47
73
 
48
- const renderResult = (title: string, result: ProbeResult | null) => (
49
- <section>
50
- <h3>{title}</h3>
51
- {result ? <pre>{JSON.stringify(result, null, 2)}</pre> : null}
52
- </section>
53
- );
54
-
55
74
  return (
56
75
  <main className="page">
57
76
  <h1>Forgeon Fullstack Scaffold</h1>
58
77
  <p>Default frontend preset: React + Vite + TypeScript.</p>
59
- <div className="actions">
60
- <button onClick={() => runProbe(setHealthResult, '/api/health')}>Check API health</button>
61
- <button onClick={() => runProbe(setErrorProbeResult, '/api/health/error')}>
62
- Check error envelope
63
- </button>
64
- <button onClick={() => runProbe(setValidationProbeResult, '/api/health/validation')}>
65
- Check validation (expect 400)
66
- </button>
78
+ <div id="probes" className="probes">
79
+ {probeDefinitions.map((probe) => {
80
+ const current = probeState[probe.id] ?? emptyProbeState;
81
+
82
+ return (
83
+ <section key={probe.id} className="probe">
84
+ <div className="probe-header">
85
+ <h2>{probe.title}</h2>
86
+ <button type="button" onClick={() => runProbe(probe)} disabled={current.loading}>
87
+ {current.loading ? 'Running...' : probe.buttonLabel}
88
+ </button>
89
+ </div>
90
+ <div className="probe-output">
91
+ <h3>{probe.resultTitle}</h3>
92
+ {current.error ? <p className="error">{current.error}</p> : null}
93
+ {current.result ? <pre>{JSON.stringify(current.result, null, 2)}</pre> : null}
94
+ {!current.error && !current.result ? (
95
+ <p className="placeholder">No probe result yet.</p>
96
+ ) : null}
97
+ </div>
98
+ </section>
99
+ );
100
+ })}
67
101
  </div>
68
- {renderResult('Health response', healthResult)}
69
- {renderResult('Error probe response', errorProbeResult)}
70
- {renderResult('Validation probe response', validationProbeResult)}
71
- {networkError ? <p className="error">{networkError}</p> : null}
72
102
  </main>
73
103
  );
74
- }
75
-
76
-
104
+ }
@@ -0,0 +1,61 @@
1
+ export type ProbeResult = {
2
+ statusCode: number;
3
+ body: unknown;
4
+ };
5
+
6
+ export type ProbeRequest = {
7
+ method?: 'GET' | 'POST';
8
+ headers?: Record<string, string>;
9
+ };
10
+
11
+ export type ProbeDefinition = {
12
+ id: string;
13
+ order: number;
14
+ title: string;
15
+ buttonLabel: string;
16
+ resultTitle: string;
17
+ path: string;
18
+ request?: ProbeRequest;
19
+ };
20
+
21
+ const baseProbeDefinitions: ProbeDefinition[] = [
22
+ {
23
+ id: 'health',
24
+ order: 10,
25
+ title: 'API Health',
26
+ buttonLabel: 'Check API health',
27
+ resultTitle: 'Health response',
28
+ path: '/health',
29
+ },
30
+ {
31
+ id: 'error',
32
+ order: 20,
33
+ title: 'Error Envelope',
34
+ buttonLabel: 'Check error envelope',
35
+ resultTitle: 'Error probe response',
36
+ path: '/health/error',
37
+ },
38
+ {
39
+ id: 'validation',
40
+ order: 30,
41
+ title: 'Validation',
42
+ buttonLabel: 'Check validation (expect 400)',
43
+ resultTitle: 'Validation probe response',
44
+ path: '/health/validation',
45
+ },
46
+ ];
47
+
48
+ const moduleProbeDefinitions: ProbeDefinition[] = [
49
+ // forgeon:module-probes:start
50
+ // forgeon:module-probes:end
51
+ ];
52
+
53
+ function compareProbeOrder(left: ProbeDefinition, right: ProbeDefinition): number {
54
+ if (left.order !== right.order) {
55
+ return left.order - right.order;
56
+ }
57
+
58
+ return left.id.localeCompare(right.id);
59
+ }
60
+
61
+ export const probeDefinitions = [...baseProbeDefinitions, ...moduleProbeDefinitions].sort(compareProbeOrder);
@@ -1,45 +1,106 @@
1
- :root {
2
- font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
3
- }
4
-
5
- body {
6
- margin: 0;
7
- background: #f8fafc;
8
- color: #0f172a;
9
- }
10
-
1
+ :root {
2
+ font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
3
+ }
4
+
5
+ body {
6
+ margin: 0;
7
+ background: #f8fafc;
8
+ color: #0f172a;
9
+ }
10
+
11
11
  .page {
12
- max-width: 720px;
12
+ max-width: 1120px;
13
13
  margin: 3rem auto;
14
- padding: 0 1rem;
14
+ padding: 0 1rem 4rem;
15
+ }
16
+
17
+ .probes {
18
+ display: grid;
19
+ grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
20
+ gap: 1rem;
21
+ margin-top: 1.5rem;
22
+ }
23
+
24
+ .probe {
25
+ display: grid;
26
+ gap: 0.9rem;
27
+ padding: 1rem;
28
+ border: 1px solid #cbd5e1;
29
+ border-radius: 0.9rem;
30
+ background: #ffffff;
31
+ box-shadow: 0 14px 30px rgba(15, 23, 42, 0.06);
32
+ }
33
+
34
+ .probe-header {
35
+ display: grid;
36
+ gap: 0.75rem;
37
+ }
38
+
39
+ .probe-header h2 {
40
+ margin: 0;
41
+ font-size: 1rem;
15
42
  }
16
43
 
17
- .actions {
44
+ .probe-output {
18
45
  display: grid;
19
- grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
20
- gap: 0.6rem;
21
- margin: 1rem 0 1.25rem;
46
+ gap: 0.5rem;
22
47
  }
23
48
 
24
49
  button {
25
- padding: 0.6rem 1rem;
50
+ padding: 0.7rem 1rem;
26
51
  border: 0;
27
- border-radius: 0.5rem;
52
+ border-radius: 0.65rem;
28
53
  cursor: pointer;
54
+ background: #0f172a;
55
+ color: #f8fafc;
56
+ font-weight: 600;
57
+ }
58
+
59
+ button:disabled {
60
+ opacity: 0.7;
61
+ cursor: progress;
29
62
  }
30
63
 
31
64
  h3 {
32
- margin: 1rem 0 0.5rem;
33
- font-size: 1rem;
65
+ margin: 0;
66
+ font-size: 0.95rem;
34
67
  }
35
68
 
36
69
  pre {
70
+ margin: 0;
37
71
  background: #e2e8f0;
38
72
  padding: 1rem;
73
+ border-radius: 0.65rem;
74
+ overflow: auto;
75
+ }
76
+
77
+ label {
78
+ display: inline-block;
79
+ margin-top: 1rem;
80
+ margin-right: 0.5rem;
81
+ }
82
+
83
+ select {
84
+ min-width: 140px;
85
+ padding: 0.55rem 0.7rem;
39
86
  border-radius: 0.5rem;
40
- overflow: auto;
41
- }
42
-
43
- .error {
44
- color: #b91c1c;
87
+ border: 1px solid #cbd5e1;
88
+ background: #ffffff;
89
+ }
90
+
91
+ .error {
92
+ margin: 0;
93
+ color: #b91c1c;
94
+ }
95
+
96
+ .placeholder {
97
+ margin: 0;
98
+ color: #475569;
99
+ }
100
+
101
+ @media (min-width: 640px) {
102
+ .probe-header {
103
+ grid-template-columns: 1fr auto;
104
+ align-items: start;
105
+ }
45
106
  }
@@ -1,8 +1,8 @@
1
- {
2
- "name": "forgeon",
3
- "version": "0.1.0",
4
- "private": true,
5
- "packageManager": "pnpm@10.0.0",
1
+ {
2
+ "name": "forgeon",
3
+ "version": "0.1.0",
4
+ "private": true,
5
+ "packageManager": "pnpm@10.0.0",
6
6
  "scripts": {
7
7
  "dev": "pnpm --parallel --filter @forgeon/api --filter @forgeon/web dev",
8
8
  "build": "pnpm -r build",
@@ -12,13 +12,19 @@
12
12
  "docker:down": "docker compose -f infra/docker/compose.yml down -v"
13
13
  },
14
14
  "pnpm": {
15
- "onlyBuiltDependencies": [
16
- "@nestjs/core",
17
- "@prisma/client",
18
- "@prisma/engines",
19
- "esbuild",
20
- "prisma"
21
- ]
22
- }
23
- }
24
-
15
+ "onlyBuiltDependencies": [
16
+ "@nestjs/core",
17
+ "@prisma/client",
18
+ "@prisma/engines",
19
+ "esbuild",
20
+ "prisma"
21
+ ]
22
+ },
23
+ "forgeon": {
24
+ "diagnostics": {
25
+ "probes": {
26
+ "enabled": true
27
+ }
28
+ }
29
+ }
30
+ }
@@ -83,6 +83,34 @@ function ensureLineAfter(content, anchorLine, lineToInsert) {
83
83
  return `${content.slice(0, insertAt)}\n${lineToInsert}${content.slice(insertAt)}`;
84
84
  }
85
85
 
86
+ const JWT_AUTH_PERSISTENCE_MARKERS = {
87
+ start: '<!-- forgeon:jwt-auth:persistence:start -->',
88
+ end: '<!-- forgeon:jwt-auth:persistence:end -->',
89
+ };
90
+
91
+ const JWT_AUTH_RBAC_MARKERS = {
92
+ start: '<!-- forgeon:jwt-auth:rbac:start -->',
93
+ end: '<!-- forgeon:jwt-auth:rbac:end -->',
94
+ };
95
+
96
+ const JWT_AUTH_DB_PRISMA_PERSISTENCE_BLOCK = [
97
+ '- refresh token persistence: enabled through the `db-adapter` capability (current provider: `db-prisma`)',
98
+ '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`',
99
+ ].join('\n');
100
+
101
+ const JWT_AUTH_RBAC_ENABLED_BLOCK = '- RBAC integration: demo auth tokens include `health.rbac` permission';
102
+
103
+ function escapeRegExp(value) {
104
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
105
+ }
106
+
107
+ function replaceReadmeManagedBlock(content, startMarker, endMarker, nextBody) {
108
+ const pattern = new RegExp(`${escapeRegExp(startMarker)}\\n[\\s\\S]*?\\n${escapeRegExp(endMarker)}`);
109
+ if (!pattern.test(content)) {
110
+ return content;
111
+ }
112
+ return content.replace(pattern, `${startMarker}\n${nextBody}\n${endMarker}`);
113
+ }
86
114
  function resolveAuthPersistenceStrategy(detected) {
87
115
  const matched = AUTH_PERSISTENCE_STRATEGIES.filter((strategy) => strategy.isDetected(detected));
88
116
  if (matched.length === 0) {
@@ -181,14 +209,24 @@ function syncJwtDbPrisma({ rootDir, changedFiles }) {
181
209
  if (fs.existsSync(readmePath)) {
182
210
  let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
183
211
  const originalReadme = readme;
184
- readme = readme.replace(
185
- '- refresh token persistence: disabled by default (stateless mode; enable it later through a `db-adapter` provider + integration sync)',
186
- '- refresh token persistence: enabled through the `db-adapter` capability (current provider: `db-prisma`)',
187
- );
188
- readme = readme.replace(
189
- /- to enable persistence later:[\s\S]*?2\. run `pnpm forgeon:sync-integrations` to wire auth persistence to the active DB adapter implementation\./m,
190
- '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`',
212
+ const managedReadme = replaceReadmeManagedBlock(
213
+ readme,
214
+ JWT_AUTH_PERSISTENCE_MARKERS.start,
215
+ JWT_AUTH_PERSISTENCE_MARKERS.end,
216
+ JWT_AUTH_DB_PRISMA_PERSISTENCE_BLOCK,
191
217
  );
218
+ if (managedReadme !== readme) {
219
+ readme = managedReadme;
220
+ } else {
221
+ readme = readme.replace(
222
+ '- refresh token persistence: disabled by default (stateless mode; enable it later through a `db-adapter` provider + integration sync)',
223
+ '- refresh token persistence: enabled through the `db-adapter` capability (current provider: `db-prisma`)',
224
+ );
225
+ readme = readme.replace(
226
+ /- to enable persistence later:[\s\S]*?2\. run `pnpm forgeon:sync-integrations` to wire auth persistence to the active DB adapter implementation\./m,
227
+ '- migration: `apps/api/prisma/migrations/0002_auth_refresh_token_hash`',
228
+ );
229
+ }
192
230
  if (readme !== originalReadme) {
193
231
  fs.writeFileSync(readmePath, `${readme.trimEnd()}\n`, 'utf8');
194
232
  changedFiles.add(readmePath);
@@ -281,14 +319,20 @@ function syncJwtRbacClaims({ rootDir, changedFiles }) {
281
319
  if (fs.existsSync(readmePath)) {
282
320
  let readme = fs.readFileSync(readmePath, 'utf8').replace(/\r\n/g, '\n');
283
321
  const originalReadme = readme;
284
- if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
322
+ const managedReadme = replaceReadmeManagedBlock(
323
+ readme,
324
+ JWT_AUTH_RBAC_MARKERS.start,
325
+ JWT_AUTH_RBAC_MARKERS.end,
326
+ JWT_AUTH_RBAC_ENABLED_BLOCK,
327
+ );
328
+ if (managedReadme !== readme) {
329
+ readme = managedReadme;
330
+ } else if (!readme.includes('- RBAC integration: demo auth tokens include `health.rbac` permission')) {
285
331
  const marker = 'Default demo credentials:';
286
332
  if (readme.includes(marker)) {
287
333
  readme = readme.replace(
288
334
  marker,
289
- `- RBAC integration: demo auth tokens include \`health.rbac\` permission
290
-
291
- Default demo credentials:`,
335
+ '- RBAC integration: demo auth tokens include `health.rbac` permission\n\nDefault demo credentials:',
292
336
  );
293
337
  }
294
338
  }