@ultimat3/cli 5.0.1 → 6.0.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.
- package/CLAUDE.md +25 -2
- package/README.md +2 -2
- package/package.json +27 -24
- package/src/cmd-build.ts +7 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +2 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/island-bundle.ts +7 -0
- package/src/mcp-errors.ts +2 -0
- package/src/messages.ts +3 -0
- package/src/solid-loader.ts +127 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/resource.ts +39 -9
- package/src/templates/route.ts +31 -5
- package/src/templates/scaffold-app.ts +55 -18
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +56 -38
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/verify-checks.ts +339 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/types/babel-modules.d.ts +31 -0
|
@@ -58,12 +58,29 @@ unitTest('${feature.pascal}NotFoundError carries a code, a cause and a fix', ()
|
|
|
58
58
|
});
|
|
59
59
|
`;
|
|
60
60
|
|
|
61
|
+
/**
|
|
62
|
+
* The generated component reaches strings through the APP's catalog module — the one that calls
|
|
63
|
+
* `defineCatalogs()` — so a component that renders a string depends on the module that registers
|
|
64
|
+
* them. `t` from `@ultimat3/i18n` renders while depending on nothing, which is how a shipped app
|
|
65
|
+
* served every string as a loud miss with a green gate (issue #249). An app with no catalog module
|
|
66
|
+
* keeps the framework import: emitting one that cannot resolve is worse than the wrong idiom.
|
|
67
|
+
*/
|
|
68
|
+
const catalogImport = (module: string | undefined): string =>
|
|
69
|
+
module === undefined
|
|
70
|
+
? "import { t } from '@ultimat3/i18n';"
|
|
71
|
+
: `import { useT } from '${module}';`;
|
|
72
|
+
|
|
73
|
+
/** `useT()` is per render, so each component binds it in its own body. */
|
|
74
|
+
const translatorBinding = (module: string | undefined): string =>
|
|
75
|
+
module === undefined ? '' : '\n const t = useT();\n';
|
|
76
|
+
|
|
61
77
|
const uiSource = (
|
|
62
78
|
feature: NameSet,
|
|
79
|
+
module: string | undefined,
|
|
63
80
|
): string => `// Presentation only. No fetching, no business logic: the list arrives as a prop from the route,
|
|
64
81
|
// which got it from the live query.
|
|
65
82
|
|
|
66
|
-
|
|
83
|
+
${catalogImport(module)}
|
|
67
84
|
import { For } from 'solid-js';
|
|
68
85
|
import type { ${feature.pascal} } from './entity';
|
|
69
86
|
import styles from './ui.module.scss';
|
|
@@ -72,7 +89,7 @@ export interface ${feature.pascal}ListProps {
|
|
|
72
89
|
readonly rows: readonly ${feature.pascal}[];
|
|
73
90
|
}
|
|
74
91
|
|
|
75
|
-
export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {
|
|
92
|
+
export function ${feature.pascal}List(props: ${feature.pascal}ListProps) {${translatorBinding(module)}
|
|
76
93
|
return (
|
|
77
94
|
<ul class={styles.list}>
|
|
78
95
|
<For each={props.rows} fallback={<li>{t('app.${feature.kebab}.empty')}</li>}>
|
|
@@ -103,10 +120,11 @@ const uiStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
|
|
|
103
120
|
|
|
104
121
|
const cardSource = (
|
|
105
122
|
feature: NameSet,
|
|
123
|
+
module: string | undefined,
|
|
106
124
|
): string => `// One ${feature.camel} rendered on its own — the list's \`item\` shown outside a list, so a
|
|
107
125
|
// detail route and a search result render the identical markup.
|
|
108
126
|
|
|
109
|
-
|
|
127
|
+
${catalogImport(module)}
|
|
110
128
|
import type { ${feature.pascal} } from '../entity';
|
|
111
129
|
import styles from '../ui.module.scss';
|
|
112
130
|
|
|
@@ -114,7 +132,7 @@ export interface ${feature.pascal}CardProps {
|
|
|
114
132
|
readonly row: ${feature.pascal};
|
|
115
133
|
}
|
|
116
134
|
|
|
117
|
-
export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
|
|
135
|
+
export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {${translatorBinding(module)}
|
|
118
136
|
return (
|
|
119
137
|
<article class={styles.item}>
|
|
120
138
|
<h3>{props.row.title}</h3>
|
|
@@ -126,10 +144,11 @@ export function ${feature.pascal}Card(props: ${feature.pascal}CardProps) {
|
|
|
126
144
|
|
|
127
145
|
const formSource = (
|
|
128
146
|
feature: NameSet,
|
|
147
|
+
module: string | undefined,
|
|
129
148
|
): string => `// Presentation only: the mutator this submits to owns validation server-side, so this form
|
|
130
149
|
// never re-implements the invariant — a blank title fails at the boundary, not in the DOM.
|
|
131
150
|
|
|
132
|
-
|
|
151
|
+
${catalogImport(module)}
|
|
133
152
|
import { createSignal } from 'solid-js';
|
|
134
153
|
import styles from '../ui.module.scss';
|
|
135
154
|
|
|
@@ -138,7 +157,7 @@ export interface ${feature.pascal}FormProps {
|
|
|
138
157
|
}
|
|
139
158
|
|
|
140
159
|
export function ${feature.pascal}Form(props: ${feature.pascal}FormProps) {
|
|
141
|
-
const [title, setTitle] = createSignal('')
|
|
160
|
+
const [title, setTitle] = createSignal('');${translatorBinding(module)}
|
|
142
161
|
return (
|
|
143
162
|
<form
|
|
144
163
|
class={styles.item}
|
|
@@ -174,6 +193,11 @@ export interface ResourceOptions extends FeatureTarget {
|
|
|
174
193
|
readonly admin?: boolean;
|
|
175
194
|
/** Every locale the feature's catalog ships for. Defaults to `['en']`. */
|
|
176
195
|
readonly locales?: readonly string[];
|
|
196
|
+
/**
|
|
197
|
+
* The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
|
|
198
|
+
* `resolveCatalogModule`. Absent only for an app that ships no such package.
|
|
199
|
+
*/
|
|
200
|
+
readonly catalogModule?: string;
|
|
177
201
|
}
|
|
178
202
|
|
|
179
203
|
export function resourceFiles(rawName: string, target: ResourceOptions): readonly GeneratedFile[] {
|
|
@@ -190,10 +214,16 @@ export function resourceFiles(rawName: string, target: ResourceOptions): readonl
|
|
|
190
214
|
...jobFiles(`reindex-${feature.kebab}`, slice),
|
|
191
215
|
{ path: `${dir}/service.ts`, contents: serviceSource(feature) },
|
|
192
216
|
{ path: `${dir}/service.test.ts`, contents: serviceTest(feature) },
|
|
193
|
-
{ path: `${dir}/ui.tsx`, contents: uiSource(feature) },
|
|
217
|
+
{ path: `${dir}/ui.tsx`, contents: uiSource(feature, target.catalogModule) },
|
|
194
218
|
{ path: `${dir}/ui.module.scss`, contents: uiStyle() },
|
|
195
|
-
{
|
|
196
|
-
|
|
219
|
+
{
|
|
220
|
+
path: `${dir}/ui/${feature.kebab}-card.tsx`,
|
|
221
|
+
contents: cardSource(feature, target.catalogModule),
|
|
222
|
+
},
|
|
223
|
+
{
|
|
224
|
+
path: `${dir}/ui/${feature.kebab}-form.tsx`,
|
|
225
|
+
contents: formSource(feature, target.catalogModule),
|
|
226
|
+
},
|
|
197
227
|
...locales.map((locale) => ({
|
|
198
228
|
path: catalogPath(locale),
|
|
199
229
|
contents: catalogSource(feature),
|
package/src/templates/route.ts
CHANGED
|
@@ -69,7 +69,27 @@ export const routeParams = (path: string): readonly string[] =>
|
|
|
69
69
|
const routeDir = (surface: Surface, path: string): string =>
|
|
70
70
|
`apps/web/${surface}/${segmentsOf(path).join('/')}`;
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
/**
|
|
73
|
+
* How the generated page reaches a string, and it is the whole reason this generator takes an app
|
|
74
|
+
* module at all. `useT()` comes from the app's own catalog module — the one that calls
|
|
75
|
+
* `defineCatalogs()` — so a page that renders a string DEPENDS on the module that registers them.
|
|
76
|
+
* Reaching straight for `t` in `@ultimat3/i18n` renders strings while depending on nothing, which
|
|
77
|
+
* is how a shipped app served `⟦app.play.title⟧` on every page with a green gate (issue #249), and
|
|
78
|
+
* this generator is where that idiom came from.
|
|
79
|
+
*
|
|
80
|
+
* An app with no catalog module keeps the framework import: emitting one that cannot resolve would
|
|
81
|
+
* trade a wrong idiom for a file that does not compile.
|
|
82
|
+
*/
|
|
83
|
+
const catalogImport = (module: string | undefined): string =>
|
|
84
|
+
module === undefined
|
|
85
|
+
? "import { t } from '@ultimat3/i18n';"
|
|
86
|
+
: `import { useT } from '${module}';`;
|
|
87
|
+
|
|
88
|
+
/** `useT()` is per render, so the body binds it; `meta` takes the router's own `t`. */
|
|
89
|
+
const translatorBinding = (module: string | undefined): string =>
|
|
90
|
+
module === undefined ? '' : '\n const t = useT();\n';
|
|
91
|
+
|
|
92
|
+
const pageSource = (surface: Surface, path: string, module: string | undefined): string => {
|
|
73
93
|
const name = pascal(
|
|
74
94
|
path
|
|
75
95
|
.split('/')
|
|
@@ -79,7 +99,7 @@ const pageSource = (surface: Surface, path: string): string => {
|
|
|
79
99
|
return `// Route: /${path} on the ${surface} surface. Config first: render mode, offline
|
|
80
100
|
// strategy and budget are declarations, not runtime choices.
|
|
81
101
|
|
|
82
|
-
|
|
102
|
+
${catalogImport(module)}
|
|
83
103
|
import { defineRoute } from '@ultimat3/render';
|
|
84
104
|
import styles from './page.module.scss';
|
|
85
105
|
|
|
@@ -88,13 +108,13 @@ export const config = defineRoute({
|
|
|
88
108
|
hydrate: '${HYDRATE[surface]}',
|
|
89
109
|
offline: '${OFFLINE[surface]}',
|
|
90
110
|
budget: ${budgetLiteral(surface)},
|
|
91
|
-
meta: () => ({
|
|
111
|
+
meta: ({ t }) => ({
|
|
92
112
|
title: t('${titleKey(path)}'),
|
|
93
113
|
description: t('${titleKey(path).replace('.title', '.description')}'),
|
|
94
114
|
}),
|
|
95
115
|
});
|
|
96
116
|
|
|
97
|
-
export function ${name}Page() {
|
|
117
|
+
export function ${name}Page() {${translatorBinding(module)}
|
|
98
118
|
return (
|
|
99
119
|
<main class={styles.page}>
|
|
100
120
|
<h1>{t('${titleKey(path)}')}</h1>
|
|
@@ -202,6 +222,12 @@ export interface RouteOptions {
|
|
|
202
222
|
readonly surface: Surface;
|
|
203
223
|
/** Every locale the catalog entry ships for. Defaults to `['en']` — an app narrows or grows it. */
|
|
204
224
|
readonly locales?: readonly string[];
|
|
225
|
+
/**
|
|
226
|
+
* The app's own catalog module — `@<app>/i18n`, read off `packages/i18n/package.json` by
|
|
227
|
+
* `resolveCatalogModule`. Absent for an app that ships no such package, and only then does the
|
|
228
|
+
* generated page fall back to importing `t` from `@ultimat3/i18n`.
|
|
229
|
+
*/
|
|
230
|
+
readonly catalogModule?: string;
|
|
205
231
|
}
|
|
206
232
|
|
|
207
233
|
export function routeFiles(rawPath: string, options: RouteOptions): readonly GeneratedFile[] {
|
|
@@ -209,7 +235,7 @@ export function routeFiles(rawPath: string, options: RouteOptions): readonly Gen
|
|
|
209
235
|
const dir = routeDir(options.surface, path);
|
|
210
236
|
const locales = resolveLocales(options.locales);
|
|
211
237
|
return [
|
|
212
|
-
{ path: `${dir}/page.tsx`, contents: pageSource(options.surface, path) },
|
|
238
|
+
{ path: `${dir}/page.tsx`, contents: pageSource(options.surface, path, options.catalogModule) },
|
|
213
239
|
{ path: `${dir}/page.module.scss`, contents: styleSource() },
|
|
214
240
|
{ path: `${dir}/page.test.ts`, contents: routeTest(options.surface, path) },
|
|
215
241
|
{ path: `${dir}/page.e2e.test.ts`, contents: routeE2eTest(path) },
|
|
@@ -7,6 +7,10 @@ import { apiFiles } from './scaffold-api';
|
|
|
7
7
|
import { icon } from './scaffold-icon';
|
|
8
8
|
import { rolesFiles } from './scaffold-roles';
|
|
9
9
|
|
|
10
|
+
// The one dependency this manifest names, and it is not decoration: every page below reads its
|
|
11
|
+
// strings through `@<app>/i18n`'s `useT()`, so the surface that renders a string DEPENDS on the
|
|
12
|
+
// module that registers the catalogs. An undeclared workspace dependency resolves through the root
|
|
13
|
+
// symlink and then breaks the day the app is built anywhere else.
|
|
10
14
|
const webPackage = (app: NameSet): string => `{
|
|
11
15
|
"name": "@${app.kebab}/web",
|
|
12
16
|
"version": "0.0.0",
|
|
@@ -18,6 +22,9 @@ const webPackage = (app: NameSet): string => `{
|
|
|
18
22
|
},
|
|
19
23
|
"scripts": {
|
|
20
24
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@${app.kebab}/i18n": "0.0.0"
|
|
21
28
|
}
|
|
22
29
|
}
|
|
23
30
|
`;
|
|
@@ -34,7 +41,12 @@ const tsconfig = (): string => `{
|
|
|
34
41
|
const sitePage = (
|
|
35
42
|
app: NameSet,
|
|
36
43
|
): string => `// The landing page. site/ is 0kb JS: static render, hydrate never, no framework script tag.
|
|
37
|
-
|
|
44
|
+
//
|
|
45
|
+
// Strings come from \`useT()\` — this app's own catalog module — and never from
|
|
46
|
+
// \`t\` in @ultimat3/i18n. That import is what puts the module holding \`defineCatalogs()\` in
|
|
47
|
+
// this page's graph, so rendering a string is what registers the catalogs. A page that reached
|
|
48
|
+
// past it shipped every string as \`\u27e6key\u27e7\` with \`x verify\` green (issue #249).
|
|
49
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
38
50
|
import { defineRoute } from '@ultimat3/render';
|
|
39
51
|
import styles from './page.module.scss';
|
|
40
52
|
|
|
@@ -43,13 +55,17 @@ export const config = defineRoute({
|
|
|
43
55
|
hydrate: 'never',
|
|
44
56
|
offline: 'precache',
|
|
45
57
|
budget: { js: '0kb' },
|
|
46
|
-
meta
|
|
58
|
+
// \`t\` is handed to \`meta\` by the router — one translator per render, resolved against the
|
|
59
|
+
// request's locale before the head is built.
|
|
60
|
+
meta: ({ t }) => ({
|
|
47
61
|
title: t('site.home.title'),
|
|
48
62
|
description: t('site.home.description'),
|
|
49
63
|
}),
|
|
50
64
|
});
|
|
51
65
|
|
|
52
66
|
export function HomePage() {
|
|
67
|
+
const t = useT();
|
|
68
|
+
|
|
53
69
|
return (
|
|
54
70
|
<main class={styles.hero}>
|
|
55
71
|
<h1>{t('site.home.title')}</h1>
|
|
@@ -103,11 +119,13 @@ unitTest('the landing page ships zero JS and declares metadata', async () => {
|
|
|
103
119
|
});
|
|
104
120
|
`;
|
|
105
121
|
|
|
106
|
-
const dashboardPage =
|
|
107
|
-
|
|
122
|
+
const dashboardPage = (
|
|
123
|
+
app: NameSet,
|
|
124
|
+
): string => `// The authed dashboard. app/ streams: a static shell is flushed instantly and the holes arrive
|
|
108
125
|
// as their data resolves.
|
|
109
126
|
|
|
110
|
-
|
|
127
|
+
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
128
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
111
129
|
import { defineRoute } from '@ultimat3/render';
|
|
112
130
|
import styles from './page.module.scss';
|
|
113
131
|
|
|
@@ -123,10 +141,15 @@ export const config = defineRoute({
|
|
|
123
141
|
// Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
|
|
124
142
|
policy: { permission: 'dashboard:read' },
|
|
125
143
|
budget: { js: '60kb' },
|
|
126
|
-
meta: (
|
|
144
|
+
meta: ({ t }) => ({
|
|
145
|
+
title: t('app.dashboard.title'),
|
|
146
|
+
description: t('app.dashboard.description'),
|
|
147
|
+
}),
|
|
127
148
|
});
|
|
128
149
|
|
|
129
150
|
export function DashboardPage() {
|
|
151
|
+
const t = useT();
|
|
152
|
+
|
|
130
153
|
return (
|
|
131
154
|
<section class={styles.panel}>
|
|
132
155
|
<h1>{t('app.dashboard.title')}</h1>
|
|
@@ -157,14 +180,18 @@ unitTest('the dashboard renders on the server, is gated, and has an offline stra
|
|
|
157
180
|
});
|
|
158
181
|
`;
|
|
159
182
|
|
|
160
|
-
const offlineFallback =
|
|
161
|
-
|
|
183
|
+
const offlineFallback = (
|
|
184
|
+
app: NameSet,
|
|
185
|
+
): string => `// The offline fallback. Every app/ route with offline: 'runtime' falls back here, so a train
|
|
162
186
|
// tunnel shows the product's own shell instead of the browser's error page.
|
|
163
187
|
|
|
164
|
-
|
|
188
|
+
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
189
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
165
190
|
import styles from './offline.module.scss';
|
|
166
191
|
|
|
167
192
|
export function OfflineFallback() {
|
|
193
|
+
const t = useT();
|
|
194
|
+
|
|
168
195
|
return (
|
|
169
196
|
<main class={styles.offline}>
|
|
170
197
|
<h1>{t('app.offline.title')}</h1>
|
|
@@ -267,6 +294,8 @@ unitTest('holds answers from the role map, and an anonymous actor holds nothing'
|
|
|
267
294
|
});
|
|
268
295
|
`;
|
|
269
296
|
|
|
297
|
+
// Same one dependency as `apps/web`, and for the same reason: `app/admin/page.tsx` reads its
|
|
298
|
+
// strings through `@<app>/i18n`'s `useT()`.
|
|
270
299
|
const adminPackage = (app: NameSet): string => `{
|
|
271
300
|
"name": "@${app.kebab}/admin",
|
|
272
301
|
"version": "0.0.0",
|
|
@@ -278,28 +307,36 @@ const adminPackage = (app: NameSet): string => `{
|
|
|
278
307
|
},
|
|
279
308
|
"scripts": {
|
|
280
309
|
"typecheck": "tsc --noEmit -p tsconfig.json"
|
|
310
|
+
},
|
|
311
|
+
"dependencies": {
|
|
312
|
+
"@${app.kebab}/i18n": "0.0.0"
|
|
281
313
|
}
|
|
282
314
|
}
|
|
283
315
|
`;
|
|
284
316
|
|
|
285
|
-
const adminPage =
|
|
286
|
-
|
|
317
|
+
const adminPage = (
|
|
318
|
+
app: NameSet,
|
|
319
|
+
): string => `// The generated admin dashboard. It ships an MCP surface over the app's own actions, so the
|
|
287
320
|
// user's agents can drive the user's product with the user's permissions.
|
|
288
321
|
|
|
289
|
-
|
|
322
|
+
// \`useT()\`, not \`t\` from @ultimat3/i18n — see apps/web/site/page.tsx for why.
|
|
323
|
+
import { useT } from '@${app.kebab}/i18n';
|
|
290
324
|
import { defineRoute } from '@ultimat3/render';
|
|
291
325
|
|
|
292
326
|
export const config = defineRoute({
|
|
293
|
-
render: '
|
|
327
|
+
render: 'ssr',
|
|
294
328
|
hydrate: 'idle',
|
|
295
329
|
offline: 'network-only',
|
|
296
|
-
//
|
|
330
|
+
// Behind auth, and \`ssr\` is the one mode that can be: it renders per request, so the guard runs
|
|
331
|
+
// on the server before the page does. \`static\` and \`isr\` refuse a policy outright.
|
|
297
332
|
policy: { permission: 'admin:read' },
|
|
298
333
|
budget: { js: '120kb' },
|
|
299
|
-
meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
|
|
334
|
+
meta: ({ t }) => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
|
|
300
335
|
});
|
|
301
336
|
|
|
302
337
|
export function AdminHome() {
|
|
338
|
+
const t = useT();
|
|
339
|
+
|
|
303
340
|
return <h1>{t('admin.home.title')}</h1>;
|
|
304
341
|
}
|
|
305
342
|
`;
|
|
@@ -402,10 +439,10 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
402
439
|
{ path: 'apps/web/site/page.tsx', contents: sitePage(app) },
|
|
403
440
|
{ path: 'apps/web/site/page.module.scss', contents: siteStyle() },
|
|
404
441
|
{ path: 'apps/web/site/page.test.ts', contents: sitePageTest() },
|
|
405
|
-
{ path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage() },
|
|
442
|
+
{ path: 'apps/web/app/dashboard/page.tsx', contents: dashboardPage(app) },
|
|
406
443
|
{ path: 'apps/web/app/dashboard/page.module.scss', contents: dashboardStyle() },
|
|
407
444
|
{ path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
|
|
408
|
-
{ path: 'apps/web/app/offline.tsx', contents: offlineFallback() },
|
|
445
|
+
{ path: 'apps/web/app/offline.tsx', contents: offlineFallback(app) },
|
|
409
446
|
{ path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
|
|
410
447
|
// The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
|
|
411
448
|
...apiFiles(example),
|
|
@@ -425,7 +462,7 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
425
462
|
// `apps/web/site/page.tsx` — `x dev` loads both surfaces into one route table and the
|
|
426
463
|
// scaffolded app failed its own `x routes` with X_ROUTE_DUPLICATE. `/admin` also matches
|
|
427
464
|
// @ultimat3/admin's own `basePath` default, so the two agree instead of merely not clashing.
|
|
428
|
-
{ path: 'apps/admin/app/admin/page.tsx', contents: adminPage() },
|
|
465
|
+
{ path: 'apps/admin/app/admin/page.tsx', contents: adminPage(app) },
|
|
429
466
|
{ path: 'apps/mobile/README.md', contents: placeholder('mobile', app) },
|
|
430
467
|
{ path: 'apps/desktop/README.md', contents: placeholder('desktop', app) },
|
|
431
468
|
];
|
|
@@ -21,7 +21,7 @@ const dockerfile = (
|
|
|
21
21
|
# syntax=docker/dockerfile:1
|
|
22
22
|
|
|
23
23
|
# ---------- deps: runtime dependencies only, cached on the workspace manifests ----------
|
|
24
|
-
FROM oven/bun:1.
|
|
24
|
+
FROM oven/bun:1.4-alpine AS deps
|
|
25
25
|
WORKDIR /app
|
|
26
26
|
COPY package.json bun.lock ./
|
|
27
27
|
# The workspace members' manifests are what \`bun install\` resolves against; their sources are not.
|
|
@@ -34,7 +34,7 @@ RUN bun install --frozen-lockfile --production
|
|
|
34
34
|
# before it ever calls \`docker build\`. Re-running typecheck and lint here would need the
|
|
35
35
|
# devDependencies the \`--production\` install above deliberately leaves out — which is exactly how
|
|
36
36
|
# a build stage came to run \`tsc\` and \`biome\` against a tree that had neither.
|
|
37
|
-
FROM oven/bun:1.
|
|
37
|
+
FROM oven/bun:1.4-alpine AS runtime
|
|
38
38
|
WORKDIR /app
|
|
39
39
|
COPY --from=deps /app/node_modules ./node_modules
|
|
40
40
|
COPY . .
|
|
@@ -28,20 +28,6 @@ export * as schema from './schema';
|
|
|
28
28
|
const SCHEMA_HEADER = `// Every entity the app declares, re-exported here. This list is what the migration generator
|
|
29
29
|
// reads, so an entity that is not exported here does not exist as far as the database is concerned.`;
|
|
30
30
|
|
|
31
|
-
/**
|
|
32
|
-
* `bun run db:seed`'s entry point. Identical either way — only the rows differ. Interpolated, not
|
|
33
|
-
* nested, so it carries exactly the escaping a single template literal needs.
|
|
34
|
-
*/
|
|
35
|
-
const SEED_MAIN = `
|
|
36
|
-
|
|
37
|
-
if (import.meta.main) {
|
|
38
|
-
const count = await seed();
|
|
39
|
-
// Bun's stdout, not process.stdout: one runtime, one API. Awaited because the write resolves
|
|
40
|
-
// asynchronously, and this JSON line is the whole output of \`bun run db:seed\`.
|
|
41
|
-
await Bun.stdout.write(\`\${JSON.stringify({ ok: true, seeded: count })}\\n\`);
|
|
42
|
-
}
|
|
43
|
-
`;
|
|
44
|
-
|
|
45
31
|
const dbSchema = (app: NameSet, example: boolean): string =>
|
|
46
32
|
example
|
|
47
33
|
? `${SCHEMA_HEADER}
|
|
@@ -52,34 +38,66 @@ export { post } from '@${app.kebab}/web/app/post/entity';
|
|
|
52
38
|
export {};
|
|
53
39
|
`;
|
|
54
40
|
|
|
41
|
+
/**
|
|
42
|
+
* The seed, as a `defineSeed()` — which is what `x db seed` discovers and what the framework has
|
|
43
|
+
* meant by "a seed" since 2.0.0.
|
|
44
|
+
*
|
|
45
|
+
* It used to be a plain `export async function seed()` with an `import.meta.main` block, run by a
|
|
46
|
+
* `bun run db:seed` npm script, and that shipped two defects at once. `x db seed` discovers
|
|
47
|
+
* every `seed*.ts` under a package's `src` and looks for an exported `Seed`, so the scaffold's
|
|
48
|
+
* own seed was invisible to its own command — `x db seed` on a fresh app answered "no seed
|
|
49
|
+
* matched".
|
|
50
|
+
* And `bun run db:seed` reaches the database through `@ultimat3/db`'s `db()`, which reads
|
|
51
|
+
* `DATABASE_URL` and speaks `postgres:` only, so on a clone with no Postgres it cannot see the
|
|
52
|
+
* embedded PGlite that `x db migrate` had just migrated in process. `bin/setup` therefore printed
|
|
53
|
+
* `✓ migrations applied` and then died on `X_DB_UNAVAILABLE`, whose `fix:` says "run `x dev` to use
|
|
54
|
+
* the embedded PGlite" — naming the mechanism that had just worked one line above.
|
|
55
|
+
*
|
|
56
|
+
* `x db seed` owns the connection, the tier and the per-seed transaction. One runner, one answer.
|
|
57
|
+
*/
|
|
55
58
|
const dbSeed = (app: NameSet, example: boolean): string =>
|
|
56
59
|
example
|
|
57
|
-
? `// Deterministic
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
60
|
+
? `// Deterministic fixtures: the same rows every time, so a test, a demo and a branch database
|
|
61
|
+
// all see the same content.
|
|
62
|
+
//
|
|
63
|
+
// \`x db seed\` is the runner — it discovers every exported \`defineSeed()\` in a package's
|
|
64
|
+
// \`src/seed*.ts\`, opens the database exactly as \`x db migrate\` does (embedded PGlite
|
|
65
|
+
// included), and wraps each seed in its own transaction. Never a plain \`bun run\` script: that
|
|
66
|
+
// reaches the database through \`db()\`, which needs a \`postgres:\` \`DATABASE_URL\` and so cannot
|
|
67
|
+
// see the embedded database at all.
|
|
68
|
+
import { defineSeed } from '@ultimat3/entity';
|
|
69
|
+
import { post } from './schema';
|
|
61
70
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
{
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
71
|
+
/** Stable across runs: \`id('post:hello')\` is a UUID v5 of the label, not a random one. */
|
|
72
|
+
export const ${app.camel}Seed = defineSeed('${app.kebab}', async ({ insert, id }) => {
|
|
73
|
+
await insert(post, [
|
|
74
|
+
{
|
|
75
|
+
id: id('post:hello'),
|
|
76
|
+
orgId: id('org:demo'),
|
|
77
|
+
title: 'Hello ${app.pascal}',
|
|
78
|
+
price: { minor: 0, currency: 'USD' },
|
|
79
|
+
},
|
|
80
|
+
{
|
|
81
|
+
id: id('post:second'),
|
|
82
|
+
orgId: id('org:demo'),
|
|
83
|
+
title: 'Second post',
|
|
84
|
+
price: { minor: 1900, currency: 'USD' },
|
|
85
|
+
},
|
|
86
|
+
]);
|
|
87
|
+
});
|
|
88
|
+
`
|
|
89
|
+
: `// Deterministic fixtures, run by \`x db seed\`. No entity is declared yet, so there is nothing
|
|
90
|
+
// to insert — the shape stays so the first \`x g entity\` has one obvious place to seed from.
|
|
91
|
+
//
|
|
92
|
+
// \`x db seed\` discovers every exported \`defineSeed()\` in a package's \`src/seed*.ts\` and opens
|
|
93
|
+
// the database the way \`x db migrate\` does, embedded PGlite included. Never a plain \`bun run\`
|
|
94
|
+
// script: that needs a \`postgres:\` \`DATABASE_URL\` and cannot see the embedded database.
|
|
95
|
+
import { defineSeed } from '@ultimat3/entity';
|
|
79
96
|
|
|
80
|
-
export
|
|
81
|
-
|
|
82
|
-
}
|
|
97
|
+
export const ${app.camel}Seed = defineSeed('${app.kebab}', async () => {
|
|
98
|
+
// \`await insert(<entity>, [...])\` once an entity exists.
|
|
99
|
+
});
|
|
100
|
+
`;
|
|
83
101
|
|
|
84
102
|
/** Every file the `packages/db` workspace ships, in the order `x new` writes them. */
|
|
85
103
|
export const dbPackageFiles = (app: NameSet, example: boolean): readonly GeneratedFile[] => [
|
|
@@ -88,7 +88,11 @@ bun install
|
|
|
88
88
|
# this script is documented idempotent, and the guard is what makes that true here.
|
|
89
89
|
ls packages/db/migrations/*.sql >/dev/null 2>&1 || bunx x db gen "initial"
|
|
90
90
|
bunx x db migrate "$@"
|
|
91
|
-
bun run
|
|
91
|
+
# \`x db seed\`, never \`bun run\`: the CLI owns the connection, so this reaches the same embedded
|
|
92
|
+
# PGlite the migration above just wrote to. A plain script goes through \`db()\`, which needs a
|
|
93
|
+
# \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres — one line after reporting a
|
|
94
|
+
# successful migration.
|
|
95
|
+
bunx x db seed
|
|
92
96
|
echo "setup complete — next: x dev"
|
|
93
97
|
`;
|
|
94
98
|
|
|
@@ -103,6 +107,19 @@ const binCheck = (): string => `#!/usr/bin/env bash
|
|
|
103
107
|
# The gate. Same steps as CI, because a check that lives only in CI cannot be run locally.
|
|
104
108
|
set -euo pipefail
|
|
105
109
|
cd "$(dirname "$0")/.."
|
|
110
|
+
# The build FIRST, and not as a convenience: \`x verify\`'s budgets step compares declared limits
|
|
111
|
+
# against measured bytes in .x/build-stats.json, so with no build it reports X_BUDGET_UNMEASURED and
|
|
112
|
+
# the very first gate anyone runs on a brand-new app is red for a reason that has nothing to do with
|
|
113
|
+
# their code. Cheap on a warm tree, and it makes "green" reachable from a fresh clone.
|
|
114
|
+
#
|
|
115
|
+
# \`--json\` is forwarded to BOTH, or the contract breaks: \`bin/check --json\` would otherwise print
|
|
116
|
+
# the build's human renderer to stdout and then the gate's JSON, and a machine consumer reading one
|
|
117
|
+
# document off stdout gets neither. Both commands emit one object; a reader takes the last line.
|
|
118
|
+
build_flags=""
|
|
119
|
+
for arg in "$@"; do
|
|
120
|
+
case "$arg" in --json|-j) build_flags="--json" ;; esac
|
|
121
|
+
done
|
|
122
|
+
bunx x build --target static $build_flags
|
|
106
123
|
exec bunx x verify "$@"
|
|
107
124
|
`;
|
|
108
125
|
|
|
@@ -87,8 +87,15 @@ export type AppCatalog = typeof en;
|
|
|
87
87
|
export type TranslationKey = KeyOf<AppCatalog>;
|
|
88
88
|
|
|
89
89
|
/**
|
|
90
|
-
*
|
|
91
|
-
*
|
|
90
|
+
* The app's ONE way to read a string. Never \`useI18n()\` directly and never \`t\` from
|
|
91
|
+
* \`@ultimat3/i18n\`, for two independent reasons:
|
|
92
|
+
*
|
|
93
|
+
* 1. the type parameter makes an unknown key a compile error instead of a \`⟦key⟧\` someone
|
|
94
|
+
* notices in production;
|
|
95
|
+
* 2. importing THIS module is what registers the catalogs — \`defineCatalogs()\` above runs on
|
|
96
|
+
* import and nowhere else. A page that reached past it rendered every string as \`⟦key⟧\`
|
|
97
|
+
* with \`x verify\` green, because nothing in the app depended on the module that registers
|
|
98
|
+
* (issue #249). \`x i18n check\` now refuses that app; this import is why it never happens.
|
|
92
99
|
*/
|
|
93
100
|
export const useT = (): Translator<AppCatalog> => useI18n<AppCatalog>();
|
|
94
101
|
`;
|
|
@@ -45,12 +45,12 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
45
45
|
"lint": "biome check .",
|
|
46
46
|
"test": "bun test",
|
|
47
47
|
"db:migrate": "x db migrate",
|
|
48
|
-
"db:seed": "
|
|
48
|
+
"db:seed": "x db seed"
|
|
49
49
|
},
|
|
50
50
|
"devDependencies": {
|
|
51
51
|
"@biomejs/biome": "${BIOME_VERSION}",
|
|
52
52
|
"@electric-sql/pglite": "^0.5.4",
|
|
53
|
-
"@types/bun": "^1.
|
|
53
|
+
"@types/bun": "^1.4.0",
|
|
54
54
|
"@ultimat3/testing": "^${version}",
|
|
55
55
|
"typescript": "^6.0.3"
|
|
56
56
|
},
|