@eventmodelers/cli 2.0.1 → 2.0.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +27 -9
- package/cli.js +369 -36
- package/lib/build-kits.js +23 -0
- package/lib/scaffold-model.js +71 -0
- package/lib/scaffold.js +224 -0
- package/lib/supervise.js +60 -0
- package/lib/ui-baseline.js +123 -0
- package/package.json +1 -1
- package/shared/build-kit/lib/build-log-share.js +149 -0
- package/shared/build-kit/lib/events-view.js +18 -3
- package/shared/build-kit/lib/ralph.js +27 -16
- package/shared/build-kit/lib/slice-events.js +42 -2
- package/shared/build-kit/lib/slice-info.js +52 -0
- package/shared/build-kit/lib/slice-run.js +2 -2
- package/shared/build-kit/lib/slice-state.js +6 -4
- package/shared/scaffold/LICENSE-em-scaffold +21 -0
- package/shared/scaffold/supabase/generate.mjs +290 -0
- package/shared/scaffold/supabase/template/scripts/create-user.mjs.tmpl +29 -0
- package/shared/scaffold/supabase/template/scripts/dev.mjs.tmpl +145 -0
- package/shared/scaffold/supabase/template/scripts/write-env.mjs.tmpl +44 -0
- package/shared/scaffold/supabase/template/server.ts.tmpl +168 -0
- package/shared/scaffold/supabase/template/src/common/extraEndpoints.ts.tmpl +5 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.test.ts.tmpl +58 -0
- package/shared/scaffold/supabase/template/src/common/notImplemented.ts.tmpl +29 -0
- package/shared/scaffold/supabase/template/supabase/migrations/_V0__supabase_stubs.sql.tmpl +34 -0
- package/shared/scaffold/supabase/template/web/src/App.tsx.tmpl +38 -0
- package/shared/scaffold/supabase/template/web/src/app/Layout.tsx.tmpl +48 -0
- package/shared/scaffold/supabase/template/web/src/app/NotFound.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/app/SignIn.tsx.tmpl +42 -0
- package/shared/scaffold/supabase/template/web/src/app/TimelinePage.tsx.tmpl +73 -0
- package/shared/scaffold/supabase/template/web/src/app/pages.ts.tmpl +26 -0
- package/shared/scaffold/supabase/template/web/src/app/skeleton.css.tmpl +25 -0
- package/shared/scaffold/supabase/template/web/src/lib/NotBuilt.tsx.tmpl +12 -0
- package/shared/scaffold/supabase/template/web/src/lib/api.ts.tmpl +226 -0
- package/shared/scaffold/supabase/template/web/src/main.tsx.tmpl +13 -0
- package/shared/skills/learn-eventmodelers-api/SKILL.md +4 -4
- package/shared/skills/ui-contract/SKILL.md +6 -6
- package/shared/skills/update-slice-status/SKILL.md +1 -1
- package/stacks/axon/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/axon/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/axon/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/blank/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/blank/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/blank/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/classic/templates/build-kit/CLAUDE.md +6 -5
- package/stacks/classic/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/classic/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/kurrent/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/kurrent/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/kurrent/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/modeling-kit/templates/.claude/skills/analyze-existing-model/SKILL.md +1 -1
- package/stacks/modeling-kit/templates/.claude/skills/prototype/SKILL.md +2 -0
- package/stacks/modeling-kit/templates/.claude/skills/prototype/assets/prototype.mjs +7 -5
- package/stacks/modeling-kit/templates/kit/CLAUDE-STANDALONE.md +1 -1
- package/stacks/modeling-kit/templates/kit/CLAUDE.md +11 -5
- package/stacks/node/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/node/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/node/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/opencqrs/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/opencqrs/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/opencqrs/templates/build-kit/lib/prompt.md +24 -0
- package/stacks/react/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/react/templates/build-kit/lib/prompt.md +39 -2
- package/stacks/supabase/templates/.claude/skills/build-state-view/SKILL.md +15 -0
- package/stacks/supabase/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/supabase/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/supabase/templates/build-kit/lib/prompt.md +10 -0
- package/stacks/supabase-react/templates/.claude/skills/build-state-change/SKILL.md +78 -6
- package/stacks/supabase-react/templates/.claude/skills/build-state-view/SKILL.md +64 -11
- package/stacks/supabase-react/templates/build-kit/CLAUDE.md +5 -4
- package/stacks/supabase-react/templates/build-kit/lib/AGENT.md +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/backend-prompt.md +17 -7
- package/stacks/supabase-react/templates/build-kit/lib/kit.json +1 -1
- package/stacks/supabase-react/templates/build-kit/lib/prompt.md +40 -3
- package/stacks/umadb/templates/build-kit/CLAUDE.md +4 -3
- package/stacks/umadb/templates/build-kit/lib/backend-prompt.md +11 -1
- package/stacks/umadb/templates/build-kit/lib/prompt.md +24 -0
|
@@ -0,0 +1,290 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Writes the skeleton overlay into a project that already has the eventmodelers `supabase` kit at its
|
|
3
|
+
// root and the `supabase-react` kit in web/. Idempotent: skeleton-owned files are rewritten on every run,
|
|
4
|
+
// page files (web/src/pages/*.tsx) and src/common/extraEndpoints.ts are only created when absent.
|
|
5
|
+
//
|
|
6
|
+
// Usage: node generate.mjs --model <model.json> --target <dir> --api-port <n> --web-port <n> --db-port-prefix <nnn> [--templates <dir>]
|
|
7
|
+
// --templates: a folder whose *.tmpl files replace the built-in template of the same path, or add files (same tokens).
|
|
8
|
+
// model.json is written by "eventmodelers scaffold" from the board (lib/scaffold-model.js).
|
|
9
|
+
import {existsSync, mkdirSync, readdirSync, readFileSync, statSync, writeFileSync, appendFileSync} from 'node:fs';
|
|
10
|
+
import {createHash} from 'node:crypto';
|
|
11
|
+
import {basename, dirname, join, relative, resolve, sep} from 'node:path';
|
|
12
|
+
import {fileURLToPath} from 'node:url';
|
|
13
|
+
|
|
14
|
+
const here = dirname(fileURLToPath(import.meta.url));
|
|
15
|
+
const templateDir = join(here, 'template');
|
|
16
|
+
|
|
17
|
+
const args = Object.fromEntries(
|
|
18
|
+
process.argv.slice(2).reduce((acc, a, i, all) => (a.startsWith('--') ? [...acc, [a.slice(2), all[i + 1]]] : acc), []),
|
|
19
|
+
);
|
|
20
|
+
for (const required of ['model', 'target', 'api-port', 'web-port', 'db-port-prefix']) {
|
|
21
|
+
if (!args[required]) throw new Error(`missing --${required}`);
|
|
22
|
+
}
|
|
23
|
+
const target = args.target;
|
|
24
|
+
const apiPort = Number(args['api-port']);
|
|
25
|
+
const webPort = Number(args['web-port']);
|
|
26
|
+
const dbPrefix = String(args['db-port-prefix']);
|
|
27
|
+
if (!/^\d{3}$/.test(dbPrefix) || Number(dbPrefix) > 654) throw new Error('--db-port-prefix must be 3 digits (ports become <prefix>20..<prefix>29), at most 654');
|
|
28
|
+
for (const p of ['package.json', 'server.ts', 'supabase/config.toml', 'web/package.json', 'web/src/lib/api.ts']) {
|
|
29
|
+
if (!existsSync(join(target, p))) throw new Error(`${p} not found in ${target}: install the kits first (eventmodelers scaffold does this)`);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const model = JSON.parse(readFileSync(args.model, 'utf8'));
|
|
33
|
+
const css = ['bulma', 'none'].includes(model.css) ? model.css : 'none';
|
|
34
|
+
const appTitle = model.appTitle || model.boardName || 'App';
|
|
35
|
+
|
|
36
|
+
// ---------- names (kit conventions) ----------
|
|
37
|
+
// Backend route name: slice title lowercased, whitespace removed (build-state-change/-view `{slicename}`).
|
|
38
|
+
const endpointName = (title) => title.toLowerCase().replace(/\s+/g, '');
|
|
39
|
+
// Page file/export name: the screen title in PascalCase (web kit: `src/pages/<ScreenTitle>.tsx`).
|
|
40
|
+
const pageName = (title) => {
|
|
41
|
+
const name = title.split(/[^A-Za-z0-9]+/).filter(Boolean).map((w) => w[0].toUpperCase() + w.slice(1)).join('');
|
|
42
|
+
return /^[A-Za-z]/.test(name) ? name : `Page${name}`;
|
|
43
|
+
};
|
|
44
|
+
const slug = (s) => s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '') || 'x';
|
|
45
|
+
|
|
46
|
+
// ---------- timelines ----------
|
|
47
|
+
const slices = (model.slices ?? []).filter((s) => s && s.title);
|
|
48
|
+
const chapters = [...(model.timelines ?? [])].sort((a, b) => (a.y ?? 0) - (b.y ?? 0) || (a.x ?? 0) - (b.x ?? 0));
|
|
49
|
+
const timelineTitles = chapters.map((c) => c.title);
|
|
50
|
+
// Slices whose chapter is not a known timeline still get a nav entry, named after their chapter/context.
|
|
51
|
+
for (const s of slices) {
|
|
52
|
+
const t = s.chapter || s.context || 'Unassigned';
|
|
53
|
+
s.chapter = t;
|
|
54
|
+
if (!timelineTitles.includes(t)) timelineTitles.push(t);
|
|
55
|
+
}
|
|
56
|
+
const usedSlugs = new Set();
|
|
57
|
+
const uniqueSlug = (base) => {
|
|
58
|
+
let s = base, n = 2;
|
|
59
|
+
while (usedSlugs.has(s)) s = `${base}-${n++}`;
|
|
60
|
+
usedSlugs.add(s);
|
|
61
|
+
return s;
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
// ---------- pages (one per distinct screen title) ----------
|
|
65
|
+
const screenInfo = Object.fromEntries((model.screens ?? []).map((s) => [s.title, s]));
|
|
66
|
+
const pages = new Map(); // name -> page
|
|
67
|
+
const usedRoutes = new Set(['/', '/timelines']);
|
|
68
|
+
const warnings = [];
|
|
69
|
+
for (const s of slices) {
|
|
70
|
+
for (const title of s.screens ?? []) {
|
|
71
|
+
const name = pageName(title);
|
|
72
|
+
if (!pages.has(name)) {
|
|
73
|
+
let route = screenInfo[title]?.route;
|
|
74
|
+
if (route && (!route.startsWith('/') || usedRoutes.has(route))) {
|
|
75
|
+
warnings.push(`screen "${title}": route ${route} unusable (not absolute or already taken), using a generated one`);
|
|
76
|
+
route = undefined;
|
|
77
|
+
}
|
|
78
|
+
if (!route) {
|
|
79
|
+
let base = `/${slug(title.replace(/\s+page$/i, ''))}`, r = base, n = 2;
|
|
80
|
+
while (usedRoutes.has(r)) r = `${base}-${n++}`;
|
|
81
|
+
route = r;
|
|
82
|
+
}
|
|
83
|
+
usedRoutes.add(route);
|
|
84
|
+
pages.set(name, {name, title, route, hasParams: route.includes('/:'), home: !!screenInfo[title]?.home, slices: [], timelines: []});
|
|
85
|
+
}
|
|
86
|
+
const page = pages.get(name);
|
|
87
|
+
if (!page.slices.includes(s.title)) page.slices.push(s.title);
|
|
88
|
+
if (!page.timelines.includes(s.chapter)) page.timelines.push(s.chapter);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const timelines = timelineTitles.map((title) => {
|
|
93
|
+
const own = slices.filter((s) => s.chapter === title);
|
|
94
|
+
return {
|
|
95
|
+
title,
|
|
96
|
+
slug: uniqueSlug(slug(title)),
|
|
97
|
+
pages: [...pages.values()].filter((p) => p.timelines.includes(title)).map((p) => p.name),
|
|
98
|
+
slices: own.map((s) => ({
|
|
99
|
+
title: s.title,
|
|
100
|
+
type: s.sliceType,
|
|
101
|
+
...(s.sliceType === 'STATE_CHANGE' ? {endpoint: `POST /api/${endpointName(s.title)}/:id`} : {}),
|
|
102
|
+
...(s.sliceType === 'STATE_VIEW' ? {endpoint: `GET /api/query/${endpointName(s.title)}-collection`} : {}),
|
|
103
|
+
})),
|
|
104
|
+
};
|
|
105
|
+
});
|
|
106
|
+
const homePage = [...pages.values()].find((p) => p.home && !p.hasParams);
|
|
107
|
+
const homeRoute = homePage ? homePage.route : timelines.length ? `/timelines/${timelines[0].slug}` : '/timelines';
|
|
108
|
+
|
|
109
|
+
// ---------- helpers ----------
|
|
110
|
+
const written = [];
|
|
111
|
+
const write = (rel, content, {onlyIfAbsent = false} = {}) => {
|
|
112
|
+
const file = join(target, rel);
|
|
113
|
+
if (onlyIfAbsent && existsSync(file)) return;
|
|
114
|
+
mkdirSync(dirname(file), {recursive: true});
|
|
115
|
+
writeFileSync(file, content);
|
|
116
|
+
written.push(rel);
|
|
117
|
+
};
|
|
118
|
+
const tokens = {
|
|
119
|
+
__APP_TITLE__: appTitle,
|
|
120
|
+
__API_PORT__: String(apiPort),
|
|
121
|
+
__WEB_PORT__: String(webPort),
|
|
122
|
+
__DB_PORT__: `${dbPrefix}22`,
|
|
123
|
+
__CSS_IMPORT__: css === 'bulma' ? `import 'bulma/css/bulma.min.css'` : `import './app/skeleton.css'`,
|
|
124
|
+
};
|
|
125
|
+
const fill = (text) => Object.entries(tokens).reduce((t, [k, v]) => t.split(k).join(v), text);
|
|
126
|
+
|
|
127
|
+
// ---------- 1. template overlay (skeleton-owned files, rewritten every run) ----------
|
|
128
|
+
const walk = (dir) => readdirSync(dir).flatMap((n) => (statSync(join(dir, n)).isDirectory() ? walk(join(dir, n)) : [join(dir, n)]));
|
|
129
|
+
// Template path relative to its folder, always with '/', without the .tmpl suffix.
|
|
130
|
+
const relOf = (dir, file) => relative(dir, file).split(sep).join('/').replace(/\.tmpl$/, '');
|
|
131
|
+
const templates = new Map(walk(templateDir).map((file) => [relOf(templateDir, file), file]));
|
|
132
|
+
// A project's own templates win over the built-in ones. Only *.tmpl files count, so nothing in there is
|
|
133
|
+
// ever compiled as project code.
|
|
134
|
+
const overridden = [];
|
|
135
|
+
const added = [];
|
|
136
|
+
if (args.templates) {
|
|
137
|
+
if (!existsSync(args.templates)) throw new Error(`--templates ${args.templates} does not exist`);
|
|
138
|
+
for (const file of walk(args.templates).filter((f) => f.endsWith('.tmpl'))) {
|
|
139
|
+
const rel = relOf(args.templates, file);
|
|
140
|
+
(templates.has(rel) ? overridden : added).push(rel);
|
|
141
|
+
templates.set(rel, file);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
for (const [rel, file] of templates) {
|
|
145
|
+
if (rel === 'web/src/app/skeleton.css' && css !== 'none') continue;
|
|
146
|
+
const onlyIfAbsent = ['src/common/extraEndpoints.ts', 'supabase/migrations/_V0__supabase_stubs.sql'].includes(rel);
|
|
147
|
+
write(rel, fill(readFileSync(file, 'utf8')), {onlyIfAbsent});
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// ---------- 2. generated from the model ----------
|
|
151
|
+
const endpoints = slices
|
|
152
|
+
.filter((s) => s.sliceType === 'STATE_CHANGE' || s.sliceType === 'STATE_VIEW')
|
|
153
|
+
.map((s) => (s.sliceType === 'STATE_CHANGE' ? `POST ${endpointName(s.title)}` : `GET query/${endpointName(s.title)}-collection`));
|
|
154
|
+
write('src/common/modeledEndpoints.ts', `// GENERATED by "eventmodelers scaffold" from the event model. Do not edit by hand: re-run the command.
|
|
155
|
+
// Every endpoint the model describes (commands and read models). Used by notImplemented.ts to answer
|
|
156
|
+
// 501 for modeled endpoints whose slice is not built yet. Endpoints outside the naming convention go in
|
|
157
|
+
// extraEndpoints.ts.
|
|
158
|
+
export const MODELED_ENDPOINTS: string[] = [
|
|
159
|
+
${[...new Set(endpoints)].sort().map((e) => ` '${e}',`).join('\n')}
|
|
160
|
+
];
|
|
161
|
+
`);
|
|
162
|
+
|
|
163
|
+
const pageList = [...pages.values()].map(({name, title, route, hasParams, slices: s}) => ({name, title, route, hasParams, slices: s}));
|
|
164
|
+
write('web/src/app/model.ts', `// GENERATED by "eventmodelers scaffold" from the event model. Do not edit by hand: re-run the command.
|
|
165
|
+
// Navigation has one entry per timeline (chapter). Pages are one per distinct screen title (web kit convention).
|
|
166
|
+
|
|
167
|
+
export type SliceInfo = { title: string; type?: string; endpoint?: string };
|
|
168
|
+
export type TimelineInfo = { title: string; slug: string; pages: string[]; slices: SliceInfo[] };
|
|
169
|
+
export type PageInfo = { name: string; title: string; route: string; hasParams: boolean; slices: string[] };
|
|
170
|
+
|
|
171
|
+
export const APP_TITLE = ${JSON.stringify(appTitle)};
|
|
172
|
+
export const HOME_ROUTE = ${JSON.stringify(homeRoute)};
|
|
173
|
+
|
|
174
|
+
export const TIMELINES: TimelineInfo[] = ${JSON.stringify(timelines, null, 2)};
|
|
175
|
+
|
|
176
|
+
export const PAGES: PageInfo[] = ${JSON.stringify(pageList, null, 2)};
|
|
177
|
+
`);
|
|
178
|
+
|
|
179
|
+
for (const p of pages.values()) {
|
|
180
|
+
const params = p.hasParams;
|
|
181
|
+
const tl = p.timelines.join(', ');
|
|
182
|
+
write(`web/src/pages/${p.name}.tsx`, `${params ? "import { useParams } from 'react-router-dom';\n" : ''}import { NotBuilt } from '../lib/NotBuilt';
|
|
183
|
+
|
|
184
|
+
// Page shell for the screen "${p.title}" (timeline: ${tl}), route ${p.route}, generated by "eventmodelers scaffold".
|
|
185
|
+
// Slices on this screen: ${p.slices.join(', ')}.
|
|
186
|
+
// Compose their components here as they are built (build-state-change/-view Step 5). Keep the export name:
|
|
187
|
+
// the router finds this page by it.
|
|
188
|
+
export function ${p.name}() {
|
|
189
|
+
${params ? ' const params = useParams();\n' : ''} return (
|
|
190
|
+
<>
|
|
191
|
+
<h1 className="title">${p.title.replace(/[{}<>]/g, '')}</h1>
|
|
192
|
+
${params ? ` <p className="subtitle is-6">{Object.entries(params).map(([k, v]) => \`\${k}: \${v}\`).join(', ')}</p>\n` : ''} <NotBuilt detail={${JSON.stringify(`Slices on this page: ${p.slices.join(', ')}.`)}} />
|
|
193
|
+
</>
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
`, {onlyIfAbsent: true});
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// ---------- 3. patches to kit-installed files ----------
|
|
200
|
+
const pkgFile = join(target, 'package.json');
|
|
201
|
+
const pkg = JSON.parse(readFileSync(pkgFile, 'utf8'));
|
|
202
|
+
pkg.scripts = {
|
|
203
|
+
...pkg.scripts,
|
|
204
|
+
// tsx watch restarts the API when any source file or migration changes, including new slice folders.
|
|
205
|
+
dev: "tsx watch --clear-screen=false --env-file=.env --include 'src/**/*.ts' --include 'supabase/migrations/*.sql' --exclude 'src/**/*.test.ts' server.ts",
|
|
206
|
+
'create-user': 'node scripts/create-user.mjs',
|
|
207
|
+
// Local run without extra tools: scripts/dev.mjs (see its header).
|
|
208
|
+
up: 'node scripts/dev.mjs up',
|
|
209
|
+
down: 'node scripts/dev.mjs down',
|
|
210
|
+
'db:start': 'node scripts/dev.mjs db:start',
|
|
211
|
+
'db:stop': 'node scripts/dev.mjs db:stop',
|
|
212
|
+
env: 'node scripts/dev.mjs env',
|
|
213
|
+
user: 'node scripts/create-user.mjs',
|
|
214
|
+
check: 'node scripts/dev.mjs check',
|
|
215
|
+
};
|
|
216
|
+
write('package.json', JSON.stringify(pkg, null, 2) + '\n');
|
|
217
|
+
|
|
218
|
+
// The root tsc (and the kit's tsc-build check) must not compile the React app or the dist output.
|
|
219
|
+
const tsconfigFile = join(target, 'tsconfig.json');
|
|
220
|
+
const tsconfig = JSON.parse(readFileSync(tsconfigFile, 'utf8'));
|
|
221
|
+
tsconfig.exclude = [...new Set([...(tsconfig.exclude ?? []), 'node_modules', 'web', 'dist'])];
|
|
222
|
+
write('tsconfig.json', JSON.stringify(tsconfig, null, 2) + '\n');
|
|
223
|
+
|
|
224
|
+
const webPkgFile = join(target, 'web/package.json');
|
|
225
|
+
const webPkg = JSON.parse(readFileSync(webPkgFile, 'utf8'));
|
|
226
|
+
webPkg.dependencies = {...webPkg.dependencies, 'react-router-dom': '^7.18.4', ...(css === 'bulma' ? {bulma: '^1.0.4'} : {})};
|
|
227
|
+
webPkg.scripts = {...webPkg.scripts, dev: `vite --port ${webPort} --strictPort`};
|
|
228
|
+
write('web/package.json', JSON.stringify(webPkg, null, 2) + '\n');
|
|
229
|
+
|
|
230
|
+
const indexHtml = join(target, 'web/index.html');
|
|
231
|
+
if (existsSync(indexHtml)) {
|
|
232
|
+
const html = readFileSync(indexHtml, 'utf8').replace(/<title>.*<\/title>/, `<title>${appTitle.replace(/[<>&]/g, '')}</title>`);
|
|
233
|
+
write('web/index.html', html);
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
// Supabase: own project id and ports, so the stack can run beside other local stacks. The project id names the
|
|
237
|
+
// containers, so two apps in folders called "target" must not share it: `supabase start` in the second would find
|
|
238
|
+
// the first one's containers, report them as its own and leave its ports with nothing listening. The hash of the
|
|
239
|
+
// absolute path keeps it unique per folder and stable across refreshes.
|
|
240
|
+
const projectId = args['project-id']
|
|
241
|
+
|| `${slug(basename(resolve(target)))}-${createHash('sha1').update(resolve(target)).digest('hex').slice(0, 6)}`;
|
|
242
|
+
let toml = readFileSync(join(target, 'supabase/config.toml'), 'utf8');
|
|
243
|
+
toml = toml
|
|
244
|
+
.replace(/^project_id = ".*"$/m, `project_id = "${projectId}"`)
|
|
245
|
+
.replace(/^(\s*(?:shadow_)?port = )\d{3}(\d{2})$/gm, `$1${dbPrefix}$2`)
|
|
246
|
+
.replace(/^(\s*#?\s*(?:smtp|pop3)_port = )\d{3}(\d{2})$/gm, `$1${dbPrefix}$2`)
|
|
247
|
+
.replace(/^site_url = ".*"$/m, `site_url = "http://localhost:${webPort}"`)
|
|
248
|
+
.replace(/^additional_redirect_urls = .*$/m, `additional_redirect_urls = ["http://127.0.0.1:${webPort}"]`);
|
|
249
|
+
write('supabase/config.toml', toml);
|
|
250
|
+
|
|
251
|
+
const gitignore = join(target, '.gitignore');
|
|
252
|
+
const ignoreLines = ['.env', 'web/.env', 'node_modules/', 'dist/', 'web/dist/', 'supabase/.temp/', 'supabase/.branches/', '.dev.pid'];
|
|
253
|
+
const existing = existsSync(gitignore) ? readFileSync(gitignore, 'utf8').split('\n') : [];
|
|
254
|
+
const missing = ignoreLines.filter((l) => !existing.includes(l));
|
|
255
|
+
if (missing.length) appendFileSync(gitignore, (existing.length && existing.at(-1) !== '' ? '\n' : '') + missing.join('\n') + '\n');
|
|
256
|
+
|
|
257
|
+
// Notes for the build agents. Both kits read .build-kit/AGENTS.md at session start.
|
|
258
|
+
const MARK = '<!-- em-scaffold skeleton -->';
|
|
259
|
+
const notes = {
|
|
260
|
+
'.build-kit/AGENTS.md': `${MARK}
|
|
261
|
+
## App skeleton (em-scaffold)
|
|
262
|
+
- Unbuilt endpoints answer 501 from \`notImplemented\` in \`server.ts\`, registered after every real route, so a slice's own \`routes.ts\` always wins. Never add stub routes or touch \`server.ts\`.
|
|
263
|
+
- Name routes by the kit convention (\`POST /api/<slicename>[/:id]\`, \`GET /api/query/<slicename>-collection\`). A route the model needs outside that convention goes in \`src/common/extraEndpoints.ts\`, or \`src/common/notImplemented.test.ts\` fails.
|
|
264
|
+
- Register every projection in \`src/common/loadPostgresEventstore.ts\`: slice tests pass without it, the running app does not.
|
|
265
|
+
- There is no \`src/core/readmodel\` (build-state-view Step 6 imports it). Read the projection's table in the route with \`createServiceClient()\` from \`src/supabase/api.ts\`, as this stack's existing routes do.
|
|
266
|
+
- Projection tests need \`supabase/migrations/_V0__supabase_stubs.sql\` (Supabase roles and auth stubs for plain Postgres). The skeleton ships it; never delete it. The leading underscore keeps Flyway from running it.
|
|
267
|
+
- Commit the slice cache (\`.build-kit/.slices/\`) in its own \`chore:\` commit before the slice commit: the commit-scope check rejects it as out of scope otherwise.
|
|
268
|
+
- \`npm run dev\` (via \`npm run up\`) reloads the API on any change under \`src/\` or \`supabase/migrations/\`, and runs Flyway on each start. Do not restart servers.
|
|
269
|
+
`,
|
|
270
|
+
'web/.build-kit/AGENTS.md': `${MARK}
|
|
271
|
+
## App skeleton (em-scaffold)
|
|
272
|
+
- Every distinct screen title already has a page at \`src/pages/<ScreenTitle>.tsx\` (PascalCase, export of the same name), routed from \`src/app/model.ts\`. Compose slice components into that page, even when only one slice uses the screen. Never create a second page for the same screen and never edit \`App.tsx\`, \`src/app/*\` or the router: a new \`src/pages/*.tsx\` file is routed automatically.
|
|
273
|
+
- A 501 from the backend means "slice not built yet". \`src/lib/api.ts\` turns it into \`NOT_BUILT_MESSAGE\`; a missing read-model table gives the same 501. Render it with \`<NotBuilt />\` from \`src/lib/NotBuilt.tsx\` (\`isNotBuilt(err)\` from \`src/lib/api.ts\`), not as a failure.
|
|
274
|
+
- A read model is read straight from its table with \`queryReadModel\` (never \`supabase.from\` in a component, never the backend \`GET\` route). An empty list from a table that has rows means the migration lacks the RLS select policy: fix the migration (build-state-view Step 2), do not switch the page to \`getReadModel\`.
|
|
275
|
+
- A page that shows one record of a list (the screen's id field is mapped from a list read model) receives that id from the list: the list row links to \`/<route>?<idParam>=<id>\`, and the page selects that record from the table with \`queryReadModel\` filtered on the id, never by loading the whole list and searching it.
|
|
276
|
+
`,
|
|
277
|
+
};
|
|
278
|
+
for (const [rel, text] of Object.entries(notes)) {
|
|
279
|
+
const file = join(target, rel);
|
|
280
|
+
const current = existsSync(file) ? readFileSync(file, 'utf8') : '';
|
|
281
|
+
if (!current.includes(MARK)) {
|
|
282
|
+
mkdirSync(dirname(file), {recursive: true});
|
|
283
|
+
appendFileSync(file, (current && !current.endsWith('\n') ? '\n' : '') + (current ? '\n' : '') + text);
|
|
284
|
+
written.push(rel);
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
write('skeleton.json', JSON.stringify({apiPort, webPort, dbPortPrefix: dbPrefix, css, homeRoute, timelines: timelines.length, pages: pages.size, endpoints: endpoints.length}, null, 2) + '\n');
|
|
289
|
+
|
|
290
|
+
console.log(JSON.stringify({written: written.length, templatesOverridden: overridden, templatesAdded: added, timelines: timelines.map((t) => t.title), pages: pages.size, endpoints: endpoints.length, homeRoute, css, warnings}, null, 2));
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// Creates (or resets the password of) a confirmed Supabase auth user to sign in with. There is no sign-up screen.
|
|
2
|
+
// Usage: npm run create-user -- <email> <password>
|
|
3
|
+
import {readFileSync} from 'node:fs';
|
|
4
|
+
import {createClient} from '@supabase/supabase-js';
|
|
5
|
+
|
|
6
|
+
const [email, password] = process.argv.slice(2);
|
|
7
|
+
if (!email || !password) {
|
|
8
|
+
console.error('usage: npm run create-user -- <email> <password>');
|
|
9
|
+
process.exit(1);
|
|
10
|
+
}
|
|
11
|
+
const env = Object.fromEntries(readFileSync('.env', 'utf8').split('\n').map((l) => l.match(/^([A-Z0-9_]+)=(.*)$/)).filter(Boolean).map((m) => [m[1], m[2]]));
|
|
12
|
+
const admin = createClient(env.SUPABASE_URL, env.SUPABASE_SECRET_KEY, {auth: {persistSession: false}}).auth.admin;
|
|
13
|
+
|
|
14
|
+
const {error} = await admin.createUser({email, password, email_confirm: true});
|
|
15
|
+
if (error?.name === 'AuthRetryableFetchError') {
|
|
16
|
+
console.error(`Cannot reach Supabase at ${env.SUPABASE_URL}. Start it with npm run db:start (or npm run up), then try again.`);
|
|
17
|
+
process.exit(1);
|
|
18
|
+
}
|
|
19
|
+
if (error && /already been registered|already exists/i.test(error.message)) {
|
|
20
|
+
const {data} = await admin.listUsers();
|
|
21
|
+
const user = data.users.find((u) => u.email === email);
|
|
22
|
+
const {error: updateError} = await admin.updateUserById(user.id, {password});
|
|
23
|
+
if (updateError) throw updateError;
|
|
24
|
+
console.log(`password reset for ${email}`);
|
|
25
|
+
} else if (error) {
|
|
26
|
+
throw error;
|
|
27
|
+
} else {
|
|
28
|
+
console.log(`created ${email}`);
|
|
29
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
// Runs the app locally with Node only: no extra tools, no extra dependencies.
|
|
2
|
+
// Usage: npm run up | down | db:start | db:stop | env | check
|
|
3
|
+
// up start Supabase if needed, write the .env files, then run the API (tsx watch) and the web (vite) with hot reload.
|
|
4
|
+
// Ctrl+c stops API and web; Supabase keeps running.
|
|
5
|
+
// down stop an `npm run up` from another terminal.
|
|
6
|
+
// db:start start the local Supabase stack (ports in supabase/config.toml).
|
|
7
|
+
// db:stop stop the local Supabase stack.
|
|
8
|
+
// env write .env and web/.env from the running Supabase stack and skeleton.json.
|
|
9
|
+
// check backend tests (Docker), then the web build and lint.
|
|
10
|
+
import {spawn, spawnSync} from 'node:child_process';
|
|
11
|
+
import {existsSync, readFileSync, rmSync, writeFileSync} from 'node:fs';
|
|
12
|
+
import net from 'node:net';
|
|
13
|
+
|
|
14
|
+
const PID_FILE = '.dev.pid';
|
|
15
|
+
const WIN = process.platform === 'win32';
|
|
16
|
+
// npm, npx and the Supabase CLI are .cmd shims on Windows, which only start through a shell.
|
|
17
|
+
const launch = (cmd, args) => (WIN ? [[cmd, ...args].join(' '), [], {shell: true}] : [cmd, args, {}]);
|
|
18
|
+
const run = (cmd, args, opts = {}) => {
|
|
19
|
+
const [file, fileArgs, extra] = launch(cmd, args);
|
|
20
|
+
return spawnSync(file, fileArgs, {stdio: 'inherit', ...extra, ...opts});
|
|
21
|
+
};
|
|
22
|
+
// Stops a process and everything it started (npm, tsx watch, vite). POSIX: the child leads its own process
|
|
23
|
+
// group, so one signal reaches all of them. Windows has no groups: taskkill walks the tree.
|
|
24
|
+
const killTree = (pid, signal = 'SIGINT') => {
|
|
25
|
+
if (WIN) spawnSync('taskkill', ['/pid', String(pid), '/T', '/F'], {stdio: 'ignore'});
|
|
26
|
+
else try { process.kill(-pid, signal); } catch {}
|
|
27
|
+
};
|
|
28
|
+
const must = (result, what) => {
|
|
29
|
+
if (result.error?.code === 'ENOENT') {
|
|
30
|
+
console.error(`${what}: command not found. Install it and try again.`);
|
|
31
|
+
process.exit(1);
|
|
32
|
+
}
|
|
33
|
+
if (result.status !== 0) process.exit(result.status ?? 1);
|
|
34
|
+
};
|
|
35
|
+
const supabaseRunning = () => run('supabase', ['status'], {stdio: 'ignore'}).status === 0;
|
|
36
|
+
const alive = (pid) => { try { process.kill(pid, 0); return true; } catch { return false; } };
|
|
37
|
+
// `supabase status` reads the ports from config.toml, not from the containers: after a port change in
|
|
38
|
+
// config.toml it still reports "running" while the old stack listens on the old ports.
|
|
39
|
+
const dbReachable = () => {
|
|
40
|
+
let port;
|
|
41
|
+
try {
|
|
42
|
+
const status = JSON.parse(spawnSync(...launch('supabase', ['status', '-o', 'json']), {encoding: 'utf8'}).stdout);
|
|
43
|
+
port = Number(new URL(status.DB_URL).port);
|
|
44
|
+
} catch {
|
|
45
|
+
return Promise.resolve(false);
|
|
46
|
+
}
|
|
47
|
+
return new Promise((resolve) => {
|
|
48
|
+
net.connect(port, '127.0.0.1').once('connect', function () { this.destroy(); resolve(true); }).once('error', () => resolve(false));
|
|
49
|
+
});
|
|
50
|
+
};
|
|
51
|
+
const free = (port) => new Promise((resolve) => {
|
|
52
|
+
const s = net.createServer().once('error', () => resolve(false)).once('listening', () => s.close(() => resolve(true))).listen(port);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
const commands = {
|
|
56
|
+
async 'db:start'() {
|
|
57
|
+
if (supabaseRunning() && (await dbReachable())) return;
|
|
58
|
+
// Not running, or running on stale ports: a stop is harmless in the first case and fixes the second.
|
|
59
|
+
run('supabase', ['stop'], {stdio: 'ignore'});
|
|
60
|
+
must(run('supabase', ['start']), 'supabase');
|
|
61
|
+
},
|
|
62
|
+
'db:stop'() {
|
|
63
|
+
must(run('supabase', ['stop']), 'supabase');
|
|
64
|
+
},
|
|
65
|
+
env() {
|
|
66
|
+
must(run(process.execPath, ['scripts/write-env.mjs']), 'node');
|
|
67
|
+
},
|
|
68
|
+
check() {
|
|
69
|
+
for (const [cmd, args] of [['npm', ['test']], ['npm', ['--prefix', 'web', 'run', 'build']], ['npm', ['--prefix', 'web', 'run', 'lint']]]) {
|
|
70
|
+
must(run(cmd, args), cmd);
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
down() {
|
|
74
|
+
const pid = existsSync(PID_FILE) ? Number(readFileSync(PID_FILE, 'utf8')) : 0;
|
|
75
|
+
if (pid && alive(pid)) {
|
|
76
|
+
if (WIN) killTree(pid);
|
|
77
|
+
else process.kill(pid, 'SIGINT');
|
|
78
|
+
console.log('stopping API and web (Supabase keeps running; npm run db:stop stops it)');
|
|
79
|
+
} else {
|
|
80
|
+
console.log('nothing running (no live npm run up found)');
|
|
81
|
+
}
|
|
82
|
+
rmSync(PID_FILE, {force: true});
|
|
83
|
+
},
|
|
84
|
+
async up() {
|
|
85
|
+
const previous = existsSync(PID_FILE) ? Number(readFileSync(PID_FILE, 'utf8')) : 0;
|
|
86
|
+
if (previous && alive(previous)) {
|
|
87
|
+
console.error(`Already running (pid ${previous}). Stop it with: npm run down`);
|
|
88
|
+
process.exit(1);
|
|
89
|
+
}
|
|
90
|
+
for (const dir of ['.', 'web']) {
|
|
91
|
+
if (!existsSync(`${dir}/node_modules`)) must(run('npm', ['install'], {cwd: dir}), 'npm');
|
|
92
|
+
}
|
|
93
|
+
await commands['db:start']();
|
|
94
|
+
commands.env();
|
|
95
|
+
|
|
96
|
+
const {apiPort, webPort} = JSON.parse(readFileSync('skeleton.json', 'utf8'));
|
|
97
|
+
const busy = [];
|
|
98
|
+
for (const [name, port] of [['API', apiPort], ['web', webPort]]) if (!(await free(port))) busy.push(`${name} port ${port}`);
|
|
99
|
+
if (busy.length) {
|
|
100
|
+
console.error(`In use: ${busy.join(', ')}. Is the app already running (npm run down)? Otherwise find what uses it (macOS/Linux: lsof -nP -iTCP -sTCP:LISTEN; Windows: netstat -ano), or change the ports in skeleton.json and run npm run env.`);
|
|
101
|
+
process.exit(1);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
writeFileSync(PID_FILE, String(process.pid));
|
|
105
|
+
// POSIX: each child leads its own process group (see killTree). Windows: no detach, it would open a console window.
|
|
106
|
+
const children = [['api', 'npm', ['run', 'dev']], ['web', 'npm', ['--prefix', 'web', 'run', 'dev']]].map(([name, cmd, args]) => {
|
|
107
|
+
const [file, fileArgs, extra] = launch(cmd, args);
|
|
108
|
+
const child = spawn(file, fileArgs, {detached: !WIN, stdio: ['ignore', 'pipe', 'pipe'], ...extra});
|
|
109
|
+
for (const out of [child.stdout, child.stderr]) {
|
|
110
|
+
let rest = '';
|
|
111
|
+
out.on('data', (chunk) => {
|
|
112
|
+
const lines = (rest + chunk).split('\n');
|
|
113
|
+
rest = lines.pop();
|
|
114
|
+
for (const line of lines) console.log(`${name.padEnd(4)} | ${line}`);
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return {name, child};
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
let stopping = false;
|
|
121
|
+
const stop = (code = 0) => {
|
|
122
|
+
if (stopping) return;
|
|
123
|
+
stopping = true;
|
|
124
|
+
for (const {child} of children) killTree(child.pid);
|
|
125
|
+
rmSync(PID_FILE, {force: true});
|
|
126
|
+
setTimeout(() => process.exit(code), 1500).unref();
|
|
127
|
+
};
|
|
128
|
+
process.on('SIGINT', () => stop());
|
|
129
|
+
process.on('SIGTERM', () => stop());
|
|
130
|
+
for (const {name, child} of children) {
|
|
131
|
+
child.on('exit', (code) => {
|
|
132
|
+
if (!stopping) console.error(`${name} exited (${code}); stopping the rest`);
|
|
133
|
+
stop(code ?? 0);
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
console.log(`API http://localhost:${apiPort} web http://localhost:${webPort} (Ctrl+c to stop)`);
|
|
137
|
+
},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
const name = process.argv[2];
|
|
141
|
+
if (!commands[name]) {
|
|
142
|
+
console.error(`usage: node scripts/dev.mjs <${Object.keys(commands).join('|')}>`);
|
|
143
|
+
process.exit(1);
|
|
144
|
+
}
|
|
145
|
+
await commands[name]();
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// Writes .env and web/.env from the running local Supabase stack (`supabase status`) and the ports in
|
|
2
|
+
// skeleton.json. Keys it does not manage are kept. Run by `npm run up`; safe to run any time.
|
|
3
|
+
import {execFileSync} from 'node:child_process';
|
|
4
|
+
import {existsSync, readFileSync, writeFileSync} from 'node:fs';
|
|
5
|
+
|
|
6
|
+
const {apiPort, webPort} = JSON.parse(readFileSync('skeleton.json', 'utf8'));
|
|
7
|
+
const status = JSON.parse(execFileSync('supabase', ['status', '-o', 'json'], {encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore']}));
|
|
8
|
+
const dbPort = new URL(status.DB_URL).port;
|
|
9
|
+
|
|
10
|
+
function upsert(file, values) {
|
|
11
|
+
const lines = existsSync(file) ? readFileSync(file, 'utf8').split('\n') : [];
|
|
12
|
+
const seen = new Set();
|
|
13
|
+
const out = lines.map((line) => {
|
|
14
|
+
const key = line.match(/^([A-Z0-9_]+)=/)?.[1];
|
|
15
|
+
if (key && key in values) {
|
|
16
|
+
seen.add(key);
|
|
17
|
+
return `${key}=${values[key]}`;
|
|
18
|
+
}
|
|
19
|
+
return line;
|
|
20
|
+
});
|
|
21
|
+
for (const [k, v] of Object.entries(values)) if (!seen.has(k)) out.push(`${k}=${v}`);
|
|
22
|
+
writeFileSync(file, out.join('\n').replace(/\n*$/, '\n'));
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
upsert('.env', {
|
|
26
|
+
SUPABASE_URL: status.API_URL,
|
|
27
|
+
SUPABASE_PUBLISHABLE_KEY: status.ANON_KEY,
|
|
28
|
+
SUPABASE_SECRET_KEY: status.SERVICE_ROLE_KEY,
|
|
29
|
+
SUPABASE_DB_URL: status.DB_URL,
|
|
30
|
+
FLYWAY_URL: `jdbc:postgresql://127.0.0.1:${dbPort}/postgres`,
|
|
31
|
+
FLYWAY_USER: 'postgres',
|
|
32
|
+
FLYWAY_PASSWORD: 'postgres',
|
|
33
|
+
PORT: apiPort,
|
|
34
|
+
API_URL: `http://localhost:${apiPort}`,
|
|
35
|
+
BACKEND_URL: `http://localhost:${apiPort}`,
|
|
36
|
+
CORS_ORIGINS: `http://localhost:${webPort},http://127.0.0.1:${webPort}`,
|
|
37
|
+
AUTO_MIGRATE: 'true',
|
|
38
|
+
});
|
|
39
|
+
upsert('web/.env', {
|
|
40
|
+
VITE_SUPABASE_URL: status.API_URL,
|
|
41
|
+
VITE_SUPABASE_ANON_KEY: status.ANON_KEY,
|
|
42
|
+
VITE_API_BASE: `http://localhost:${apiPort}`,
|
|
43
|
+
});
|
|
44
|
+
console.log(`.env and web/.env written (Supabase ${status.API_URL}, API :${apiPort}, web :${webPort})`);
|