@ultimat3/cli 3.0.0 → 4.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.
Files changed (52) hide show
  1. package/CLAUDE.md +69 -10
  2. package/package.json +24 -24
  3. package/src/budgets.ts +8 -5
  4. package/src/cmd-deploy.ts +42 -14
  5. package/src/cmd-docs.ts +7 -3
  6. package/src/cmd-fix.ts +15 -3
  7. package/src/cmd-generate.ts +29 -4
  8. package/src/cmd-help.ts +25 -4
  9. package/src/cmd-i18n.ts +8 -5
  10. package/src/cmd-jobs.ts +6 -5
  11. package/src/cmd-mcp.ts +16 -12
  12. package/src/cmd-new.ts +9 -13
  13. package/src/cmd-planned.ts +13 -0
  14. package/src/cmd-policy.ts +8 -6
  15. package/src/cmd-registries.ts +7 -6
  16. package/src/cmd-routes.ts +27 -4
  17. package/src/cmd-secrets.ts +6 -6
  18. package/src/cmd-verify.ts +55 -3
  19. package/src/command.ts +10 -2
  20. package/src/dev-cache.ts +9 -9
  21. package/src/dev-render.ts +6 -1
  22. package/src/dev-runtime.ts +2 -2
  23. package/src/dispatch.ts +33 -4
  24. package/src/error-codes.ts +5 -0
  25. package/src/error-contract.ts +31 -4
  26. package/src/fix-command.ts +9 -2
  27. package/src/fix-imports.ts +118 -0
  28. package/src/fix-scan.ts +251 -0
  29. package/src/flag-reads.ts +114 -0
  30. package/src/i18n-audit.ts +2 -1
  31. package/src/index.ts +8 -1
  32. package/src/jobs-drain.ts +6 -1
  33. package/src/mcp-errors.ts +5 -0
  34. package/src/mcp-host.ts +4 -2
  35. package/src/messages.ts +3 -0
  36. package/src/otlp-export.ts +14 -0
  37. package/src/parse.ts +6 -1
  38. package/src/seo-meta.ts +105 -0
  39. package/src/templates/action.ts +39 -7
  40. package/src/templates/backfill.ts +3 -1
  41. package/src/templates/index.ts +10 -1
  42. package/src/templates/job.ts +6 -2
  43. package/src/templates/query.ts +6 -1
  44. package/src/templates/route.ts +18 -9
  45. package/src/templates/scaffold-api.ts +100 -0
  46. package/src/templates/scaffold-app.ts +8 -48
  47. package/src/templates/scaffold-container.ts +44 -9
  48. package/src/templates/scaffold-helm-templates.ts +327 -0
  49. package/src/templates/scaffold-helm.ts +144 -0
  50. package/src/templates/scaffold-repo.ts +25 -8
  51. package/src/ts-scan.ts +12 -174
  52. package/src/verify-step.ts +5 -0
@@ -0,0 +1,105 @@
1
+ // Single responsibility: the app's `site/` routes as `@ultimat3/seo` reads them.
2
+ //
3
+ // The two shapes only meet here. `RouteRecord.meta` is a STATIC object, and `defineRoute({ meta })`
4
+ // is an async function of the route's own data — so somebody has to call one to get the other, and
5
+ // this is the only tier that can see both: `@ultimat3/seo` is tier 1 and may not import the route
6
+ // registry, which is tier 4.
7
+
8
+ import { metaContextFor, type RouteEntry, routeDataFor, routeEntries } from '@ultimat3/render';
9
+ import type { RouteRecord } from '@ultimat3/seo';
10
+ import { loadApp } from './app-load';
11
+ import type { Finding } from './output';
12
+ import { findingFrom } from './output';
13
+
14
+ /**
15
+ * Why a `site/` route's metadata cannot be read without running the app.
16
+ *
17
+ * Neither is a defect — both are routes whose `<head>` is a function of data that does not exist
18
+ * until a request does. They are reported rather than dropped, because a gate that silently checks
19
+ * two of five routes and says "ok" is worse than one that says which three it could not reach.
20
+ */
21
+ export type UnresolvedReason = 'declares-load' | 'dynamic';
22
+
23
+ export interface UnresolvedRoute {
24
+ readonly path: string;
25
+ readonly file: string;
26
+ readonly reason: UnresolvedReason;
27
+ }
28
+
29
+ export interface SiteMetaScan {
30
+ /** Routes whose meta resolved, in the shape `validateMeta` takes. */
31
+ readonly records: readonly RouteRecord[];
32
+ readonly unresolved: readonly UnresolvedRoute[];
33
+ /** A route whose `meta()` THREW — a page that cannot render its own head. */
34
+ readonly findings: readonly Finding[];
35
+ }
36
+
37
+ /**
38
+ * The origin `meta` is called with. Reserved by RFC 6761 and resolvable by nothing, deliberately:
39
+ * an app has no configured base URL (`packages/core/src/config.ts` declares none), so any real
40
+ * origin here would be this file inventing one — and a `canonical` compared against an invented
41
+ * origin is a finding nobody can act on. `validateMeta` is therefore called with no `baseUrl` and
42
+ * skips canonical checks; `absoluteUrl` never sees this string.
43
+ */
44
+ const PROBE_ORIGIN = 'https://verify.invalid';
45
+
46
+ const reasonFor = (entry: RouteEntry): UnresolvedReason | undefined => {
47
+ if (entry.pattern.keys.length > 0) return 'dynamic';
48
+ // A `load` is a database read. Running one inside `x verify` would make the gate need a live
49
+ // database to answer a question about text, and would run app queries nobody asked for.
50
+ if (entry.config.load !== undefined) return 'declares-load';
51
+ return undefined;
52
+ };
53
+
54
+ /**
55
+ * Read every `site/` route's metadata, without rendering and without touching a database.
56
+ *
57
+ * `routeDataFor` hands a no-`load` route its own context back as the data — that is exactly what
58
+ * `defineRoute`'s `LoadRequirement` guarantees — so `meta` gets the same argument here that it gets
59
+ * in `x dev` and in the prerenderer, from the same two builders both of those use.
60
+ */
61
+ export async function scanSiteMeta(root: string): Promise<SiteMetaScan> {
62
+ await loadApp(root);
63
+ return await readSiteMeta();
64
+ }
65
+
66
+ /**
67
+ * The same scan over the registry as it stands, without loading anything.
68
+ *
69
+ * Split from `scanSiteMeta` so the resolution rules are testable against routes registered by hand:
70
+ * the half worth pinning is which routes are reachable and what happens when one throws, and
71
+ * neither of those is a fact about globbing a directory.
72
+ */
73
+ export async function readSiteMeta(): Promise<SiteMetaScan> {
74
+ const records: RouteRecord[] = [];
75
+ const unresolved: UnresolvedRoute[] = [];
76
+ const findings: Finding[] = [];
77
+
78
+ for (const entry of routeEntries()) {
79
+ if (entry.surface !== 'site') continue;
80
+ const reason = reasonFor(entry);
81
+ if (reason !== undefined) {
82
+ unresolved.push({ path: entry.path, file: entry.file, reason });
83
+ continue;
84
+ }
85
+ const ctx = { url: `${PROBE_ORIGIN}${entry.path}`, params: {} };
86
+ try {
87
+ const meta = await entry.config.meta(
88
+ metaContextFor(ctx, await routeDataFor(entry.config, ctx)),
89
+ );
90
+ records.push({
91
+ path: entry.path,
92
+ file: entry.file,
93
+ surface: 'site',
94
+ render: entry.config.render,
95
+ meta,
96
+ });
97
+ } catch (error) {
98
+ // `findingFrom`, not a code of this file's own: a `meta` that throws an `UltimateError` has
99
+ // already said what broke and how to fix it, and a wrapper would bury both. Anything else
100
+ // becomes `X_CLI_UNEXPECTED` through core's total renderer.
101
+ findings.push({ ...findingFrom(error), at: entry.file });
102
+ }
103
+ }
104
+ return { records, unresolved, findings };
105
+ }
@@ -100,14 +100,18 @@ const shapeTest = (name: NameSet, isMutator: boolean): string =>
100
100
  expect(target.describe().name).toBe('${name.camel}');
101
101
  });`;
102
102
 
103
- const actionTest = (
103
+ /**
104
+ * The preamble both generated tests share. `outsider` rides along only where it is USED: the
105
+ * scaffolded app lints with `noUnusedVariables: error`, so an actor declared in the unit half
106
+ * would be a lint failure in code nobody typed.
107
+ */
108
+ const preamble = (
104
109
  name: NameSet,
105
110
  feature: NameSet,
106
111
  isMutator: boolean,
107
- ): string => `// ${name.camel}: its declared shape, the input it refuses, the contract every action owes, and the
108
- // foreign-org actor it denies before the handler runs. One declaration, every surface.
109
- import { testActor } from '@ultimat3/policy';
110
- import { contractTest, expect, unitTest } from '@ultimat3/testing';
112
+ wrappers: string,
113
+ outsider: boolean,
114
+ ): string => `${outsider ? "import { testActor } from '@ultimat3/policy';\n" : ''}import { ${wrappers} } from '@ultimat3/testing';
111
115
  import { ${name.camel} } from './${name.kebab}';
112
116
 
113
117
  const id = '${ID}';
@@ -118,21 +122,41 @@ const input = { id, orgId${isMutator ? ", title: 'a title'" : ''} };
118
122
  // At boot \`registerActions(await import('./actions'))\` stamps the same name onto the same
119
123
  // object, so \`${name.camel}.tool()\` works there with nothing to remember.
120
124
  const target = ${name.camel}.named('${name.camel}');
121
-
125
+ ${
126
+ outsider
127
+ ? `
122
128
  // Holds the grant, wrong org. That is the interesting actor: a denial here is the predicate
123
129
  // deciding, not the permission check, so this test fails if the tenancy rule is ever dropped.
124
130
  const outsider = testActor('outsider', {
125
131
  orgId: '${OTHER_ORG}',
126
132
  permissions: ['${feature.kebab}:write'],
127
133
  }).actor;
134
+ `
135
+ : ''
136
+ }`;
128
137
 
138
+ const actionUnitTest = (
139
+ name: NameSet,
140
+ feature: NameSet,
141
+ isMutator: boolean,
142
+ ): string => `// ${name.camel}: its declared shape and the input it refuses. Both are answered by the declaration
143
+ // alone, so they belong to the \`unit\` step — the contract projections are next door.
144
+ ${preamble(name, feature, isMutator, 'expect, unitTest', false)}
129
145
  ${shapeTest(name, isMutator)}
130
146
 
131
147
  unitTest('${name.camel} rejects input that is not a uuid', async () => {
132
148
  await expect(target.input).toRejectInput({ ...input, id: 'not-a-uuid' });
133
149
  await expect(target.input).toAcceptInput(input);
134
150
  });
151
+ `;
135
152
 
153
+ const actionContractTest = (
154
+ name: NameSet,
155
+ feature: NameSet,
156
+ isMutator: boolean,
157
+ ): string => `// ${name.camel}: the contract every action owes, and the foreign-org actor it denies before the
158
+ // handler runs. One declaration, every surface.
159
+ ${preamble(name, feature, isMutator, 'contractTest, expect', true)}
136
160
  contractTest('${name.camel} passes the action contract', async () => {
137
161
  // Three assertions the framework makes for any action, without knowing what this one does:
138
162
  // garbage input is rejected, an anonymous actor is denied, and the operation reaches the
@@ -173,6 +197,14 @@ export function actionFiles(rawName: string, target: ActionOptions): readonly Ge
173
197
  path: `${dir}/${name.kebab}.ts`,
174
198
  contents: isMutator ? mutatorSource(name, feature) : actionSource(name, feature),
175
199
  },
176
- { path: `${dir}/${name.kebab}.test.ts`, contents: actionTest(name, feature, isMutator) },
200
+ // TWO test files, because the gate types a test by its FILENAME and this declaration owes two
201
+ // suites: the input parse is a `unit` assertion and the three projections are `contract` ones.
202
+ // Emitted as one file, the contract half ran under `unit` and `x test contract` answered
203
+ // X_TEST_NO_FILES; renaming that one file would have put the `unitTest` in the same bind.
204
+ { path: `${dir}/${name.kebab}.test.ts`, contents: actionUnitTest(name, feature, isMutator) },
205
+ {
206
+ path: `${dir}/${name.kebab}.contract.test.ts`,
207
+ contents: actionContractTest(name, feature, isMutator),
208
+ },
177
209
  ];
178
210
  }
@@ -207,6 +207,8 @@ export function backfillFiles(rawName: string, target: FeatureTarget): readonly
207
207
  // `repo.ts` rides along with `entity.ts`: it is that file's only reader.
208
208
  ...sliceFoundation(target, ['entity']),
209
209
  { path: `${dir}/${name.kebab}.ts`, contents: backfillSource(name, feature) },
210
- { path: `${dir}/${name.kebab}.test.ts`, contents: backfillTest(name, feature) },
210
+ // A sweep IS a job (`backfill()` is a job factory), its test is a `jobTest`, and the gate
211
+ // types a test by its filename — so `<name>.test.ts` put it in the `unit` step forever.
212
+ { path: `${dir}/${name.kebab}.job.test.ts`, contents: backfillTest(name, feature) },
211
213
  ];
212
214
  }
@@ -14,7 +14,16 @@ export type { IslandOptions } from './island';
14
14
  export { islandFiles } from './island';
15
15
  export { jobFiles, taskFiles } from './job';
16
16
  export { CATALOG_ROOT, catalogPath, DEFAULT_LOCALES, resolveLocales } from './locales';
17
- export type { GeneratedFile, GeneratedFoundationFile, NameSet } from './naming';
17
+ // All three members of the `GeneratedFile` union, not two: the barrel exported the union and the
18
+ // foundation variant only, so a consumer could hold a `GeneratedFile` and had no name to narrow it
19
+ // to — and `merge` is the discriminant the split exists for.
20
+ export type {
21
+ GeneratedFile,
22
+ GeneratedFoundationFile,
23
+ GeneratedJsonFile,
24
+ GeneratedSourceFile,
25
+ NameSet,
26
+ } from './naming';
18
27
  export { camel, kebab, names, pascal, plural, titleKey } from './naming';
19
28
  export { policyFiles } from './policy';
20
29
  export type { QueryOptions } from './query';
@@ -172,7 +172,10 @@ export function jobFiles(rawName: string, target: FeatureTarget): readonly Gener
172
172
  // file nobody asked for. `x g task` inherits this by composing `jobFiles` below.
173
173
  ...sliceFoundation(target, ['entity']),
174
174
  { path: `${dir}/${name.kebab}.ts`, contents: jobSource(name) },
175
- { path: `${dir}/${name.kebab}.test.ts`, contents: jobTest(name) },
175
+ // `.job.test.ts`, because the gate types a test by its FILENAME: a `jobTest` in a plain
176
+ // `<name>.test.ts` runs under `unit`, and `x test job` answers X_TEST_NO_FILES in an app that
177
+ // is full of them. Same lesson `x g route` already carries for `page.e2e.test.ts`.
178
+ { path: `${dir}/${name.kebab}.job.test.ts`, contents: jobTest(name) },
176
179
  ];
177
180
  }
178
181
 
@@ -182,7 +185,8 @@ export function taskFiles(rawName: string, target: FeatureTarget): readonly Gene
182
185
  const dir = `${target.surfaceDir}/${target.feature}/tasks`;
183
186
  return [
184
187
  { path: `${dir}/${name.kebab}.ts`, contents: taskSource(name, jobName) },
185
- { path: `${dir}/${name.kebab}.test.ts`, contents: taskTest(name, jobName) },
188
+ // A task's test is a `jobTest` too — it drives a queue — so it takes the same suffix.
189
+ { path: `${dir}/${name.kebab}.job.test.ts`, contents: taskTest(name, jobName) },
186
190
  ...jobFiles(`${rawName}-job`, target),
187
191
  ];
188
192
  }
@@ -130,6 +130,11 @@ export function queryFiles(rawName: string, target: QueryOptions): readonly Gene
130
130
  // `--live` changes only which directory this file lands in.
131
131
  ...sliceFoundation(target, ['entity', 'policy']),
132
132
  { path: `${dir}/${name.kebab}.ts`, contents: querySource(name, feature, live) },
133
- { path: `${dir}/${name.kebab}.test.ts`, contents: queryTest(name, feature, live) },
133
+ // The suffix follows the WRAPPER, which follows `--live`: a `liveTest` in a plain
134
+ // `<name>.test.ts` runs under `unit`, so `x test live` had no files in an app full of them.
135
+ {
136
+ path: `${dir}/${name.kebab}${live ? '.live' : ''}.test.ts`,
137
+ contents: queryTest(name, feature, live),
138
+ },
134
139
  ];
135
140
  }
@@ -21,13 +21,21 @@ export type Surface = 'site' | 'app';
21
21
  const RENDER: Record<Surface, string> = { site: 'isr', app: 'ssr' };
22
22
  const HYDRATE: Record<Surface, string> = { site: 'never', app: 'visible' };
23
23
  const OFFLINE: Record<Surface, string> = { site: 'precache', app: 'runtime' };
24
- /** Structured, not a literal string: the route and the test that pins it read the same fact. */
25
- const BUDGET: Record<Surface, { readonly js: string; readonly lcp: number }> = {
26
- site: { js: '0kb', lcp: 1800 },
27
- app: { js: '60kb', lcp: 2500 },
24
+ /**
25
+ * Structured, not a literal string: the route and the test that pins it read the same fact.
26
+ *
27
+ * `lcp` is deliberately NOT here. `RouteBudget.lcp` is accepted by `defineRoute`, and nothing in
28
+ * the framework can produce the `lcpMs` that `checkBudgets` compares it against — `prerender.ts`
29
+ * emits static HTML and there is no browser in the build to observe a paint. A scaffolded `lcp`
30
+ * was therefore a budget that reads as declared, is never weighed, and passes silently the moment
31
+ * a `build-stats.json` row exists: the false green `budgets.ts`'s own header forbids. Scaffolding
32
+ * only what the build measures is the half of "delete it or thread it" that `x new` can act on.
33
+ */
34
+ const BUDGET: Record<Surface, { readonly js: string }> = {
35
+ site: { js: '0kb' },
36
+ app: { js: '60kb' },
28
37
  };
29
- const budgetLiteral = (surface: Surface): string =>
30
- `{ js: '${BUDGET[surface].js}', lcp: ${BUDGET[surface].lcp} }`;
38
+ const budgetLiteral = (surface: Surface): string => `{ js: '${BUDGET[surface].js}' }`;
31
39
  /** `isr` without a trigger is `static` wearing a costume — @ultimat3/render rejects it at boot. */
32
40
  const REVALIDATE: Record<Surface, string> = { site: "\n revalidate: { ttl: '1h' },", app: '' };
33
41
 
@@ -149,13 +157,14 @@ unitTest('/${path} declares metadata', async () => {
149
157
  expect(meta.description ?? '').not.toBe('');
150
158
  });
151
159
 
152
- unitTest('/${path} declares its render, offline and budget', () => {
160
+ unitTest('/${path} declares its render and offline strategy', () => {
153
161
  expect(config.render).toBe('${RENDER[surface]}');
154
162
  expect(config.offline).toBe('${OFFLINE[surface]}');
155
- // budget is always on the descriptor, so pin the number: presence cannot fail.
156
- expect(config.budget.lcp).toBe(${BUDGET[surface].lcp});
157
163
  });
158
164
 
165
+ // The only budget this route declares, because it is the only one the build can weigh: nothing
166
+ // in the framework observes a paint, so an lcp budget would be a number pinned here and never
167
+ // compared against anything.
159
168
  unitTest('/${path} stays inside its byte budget declaration', () => {
160
169
  expect(config.budget.js).toBe('${BUDGET[surface].js}');
161
170
  });
@@ -0,0 +1,100 @@
1
+ // `apps/web/api` — the surface with no rendering behind it: the health action, and the one call
2
+ // that hands every primitive this app declares to the framework by name.
3
+ // Split from scaffold-app.ts because that file is the three SURFACES and this is one of them, and
4
+ // because `index.ts` has to know the example slice's own file layout.
5
+
6
+ import type { GeneratedFile } from './naming';
7
+
8
+ const healthAction =
9
+ (): string => `// api/ holds actions only: no rendering, no components. This one is the readiness probe every
10
+ // role exposes, declared as an action so it appears in OpenAPI and MCP like everything else.
11
+
12
+ import { action, t } from '@ultimat3/action';
13
+ import { allow } from '@ultimat3/policy';
14
+
15
+ export const health = action({
16
+ input: t.object({}),
17
+ output: t.object({ ok: t.boolean, role: t.string }),
18
+ // Public, said out loud. \`can('x:y')\` is the other branch; a missing policy is a build error,
19
+ // so "anyone may call this" has to be a declaration too.
20
+ policy: allow('public'),
21
+ mcp: { expose: true, description: 'Readiness of this process' },
22
+ async handle({ ctx }) {
23
+ return { ok: true, role: ctx.role };
24
+ },
25
+ });
26
+ `;
27
+
28
+ const healthTest =
29
+ (): string => `// The health action's contract, run as the framework generates it: garbage input refused, the
30
+ // operation in the OpenAPI document. The declaration is the source; this only runs it.
31
+ import { contractTest, expect } from '@ultimat3/testing';
32
+ import { health } from './health';
33
+
34
+ // Named here because every projection needs a stable name and this file does not boot the app.
35
+ // At boot \`registerActions\` stamps the same name onto the same object.
36
+ const target = health.named('health');
37
+
38
+ contractTest('health is an action exposed over MCP', () => {
39
+ expect(target.kind).toBe('action');
40
+ expect(target.mcp?.expose).toBe(true);
41
+ });
42
+
43
+ contractTest('health projects one MCP tool and one OpenAPI operation', () => {
44
+ // Same policy object on both surfaces — a public action says so once, not once per surface.
45
+ expect(target.tool().policy).toBe(target.policy);
46
+ expect(target.openapi().operationId).toBe('health');
47
+ });
48
+ `;
49
+
50
+ /** The imports and the lists that differ between `x new` and `x new --no-example`. */
51
+ const exampleSlice = {
52
+ imports: `import * as archivePost from '../app/post/actions/archive-post';
53
+ import * as createPost from '../app/post/actions/create-post';
54
+ import * as reindexPost from '../app/post/jobs/reindex-post';
55
+ import * as postList from '../app/post/live/post-list';
56
+ `,
57
+ actions: ', createPost, archivePost',
58
+ extra: `
59
+ queries: [postList],
60
+ jobs: [reindexPost],`,
61
+ };
62
+
63
+ const apiIndex = (example: boolean): string => {
64
+ const slice = example ? exampleSlice : { imports: '', actions: '', extra: '' };
65
+ return `/**
66
+ * The API surface: every action, mutator, query, job and task this app exposes, registered in one
67
+ * call. Nothing else lives here — no rendering, no logic, no request handling. From this list the
68
+ * framework projects HTTP routes, \`openapi.json\`, the typed client, job handles and MCP tools.
69
+ *
70
+ * \`defineApi\` takes whole MODULES, so the export name IS the primitive's name: there is no second
71
+ * list of strings to keep in step with the declarations, and adding an action to a feature is one
72
+ * edit rather than two. Two features exporting one name collide with X_ACTION_DUPLICATE.
73
+ *
74
+ * That is why the jobs are handed over here too, and it is the half an app cannot skip: the
75
+ * framework's module scan registers actions and queries on its own, and registers NO job — so a
76
+ * job module nothing lists keeps the positional name \`job()\` gave it, \`anonymous-job-2\`, on the
77
+ * queue row, in \`x.manifest.json\` and in every dead-letter trace.
78
+ *
79
+ * Importing this module IS the registration: the call below runs on import, and the importer is
80
+ * the framework's own scan of \`apps/*\`, which is what backs \`x manifest\`, \`x routes\`, \`x dev\`,
81
+ * \`x verify\` and \`apps/web/server.ts\` alike.
82
+ */
83
+
84
+ import { defineApi } from '@ultimat3/action';
85
+ ${slice.imports}import * as health from './health';
86
+
87
+ export const api = defineApi({
88
+ actions: [health${slice.actions}],${slice.extra}
89
+ });
90
+ `;
91
+ };
92
+
93
+ /** `apps/web/api` for a new app: the readiness action, its contract test, and the registration. */
94
+ export function apiFiles(example: boolean): readonly GeneratedFile[] {
95
+ return [
96
+ { path: 'apps/web/api/health.ts', contents: healthAction() },
97
+ { path: 'apps/web/api/health.contract.test.ts', contents: healthTest() },
98
+ { path: 'apps/web/api/index.ts', contents: apiIndex(example) },
99
+ ];
100
+ }
@@ -3,6 +3,7 @@
3
3
  // a restructure. Every file here is real, typed and covered — no placeholder that fails to boot.
4
4
 
5
5
  import type { GeneratedFile, NameSet } from './naming';
6
+ import { apiFiles } from './scaffold-api';
6
7
  import { icon } from './scaffold-icon';
7
8
  import { rolesFiles } from './scaffold-roles';
8
9
 
@@ -41,7 +42,7 @@ export const config = defineRoute({
41
42
  render: 'static',
42
43
  hydrate: 'never',
43
44
  offline: 'precache',
44
- budget: { js: '0kb', lcp: 1500 },
45
+ budget: { js: '0kb' },
45
46
  meta: () => ({
46
47
  title: t('site.home.title'),
47
48
  description: t('site.home.description'),
@@ -121,7 +122,7 @@ export const config = defineRoute({
121
122
  offline: 'runtime',
122
123
  // Auth is a policy, never a route-local flag: one authz system, evaluated everywhere.
123
124
  policy: { permission: 'dashboard:read' },
124
- budget: { js: '60kb', lcp: 2500 },
125
+ budget: { js: '60kb' },
125
126
  meta: () => ({ title: t('app.dashboard.title'), description: t('app.dashboard.description') }),
126
127
  });
127
128
 
@@ -184,48 +185,6 @@ const offlineStyle = (): string => `@use '@ultimat3/ui/tokens' as tokens;
184
185
  }
185
186
  `;
186
187
 
187
- const apiAction =
188
- (): string => `// api/ holds actions only: no rendering, no components. This one is the readiness probe every
189
- // role exposes, declared as an action so it appears in OpenAPI and MCP like everything else.
190
-
191
- import { action, t } from '@ultimat3/action';
192
- import { allow } from '@ultimat3/policy';
193
-
194
- export const health = action({
195
- input: t.object({}),
196
- output: t.object({ ok: t.boolean, role: t.string }),
197
- // Public, said out loud. \`can('x:y')\` is the other branch; a missing policy is a build error,
198
- // so "anyone may call this" has to be a declaration too.
199
- policy: allow('public'),
200
- mcp: { expose: true, description: 'Readiness of this process' },
201
- async handle({ ctx }) {
202
- return { ok: true, role: ctx.role };
203
- },
204
- });
205
- `;
206
-
207
- const apiTest =
208
- (): string => `// The health action's contract, run as the framework generates it: garbage input refused, the
209
- // operation in the OpenAPI document. The declaration is the source; this only runs it.
210
- import { contractTest, expect } from '@ultimat3/testing';
211
- import { health } from './health';
212
-
213
- // Named here because every projection needs a stable name and this file does not boot the app.
214
- // At boot \`registerActions\` stamps the same name onto the same object.
215
- const target = health.named('health');
216
-
217
- contractTest('health is an action exposed over MCP', () => {
218
- expect(target.kind).toBe('action');
219
- expect(target.mcp?.expose).toBe(true);
220
- });
221
-
222
- contractTest('health projects one MCP tool and one OpenAPI operation', () => {
223
- // Same policy object on both surfaces — a public action says so once, not once per surface.
224
- expect(target.tool().policy).toBe(target.policy);
225
- expect(target.openapi().operationId).toBe('health');
226
- });
227
- `;
228
-
229
188
  const sharedTokens =
230
189
  (): string => `// This app's authoring layer for stylesheets: \`@use '../../shared/tokens' as t;\` in a
231
190
  // \`*.module.scss\` and reach for \`t.role(…)\`. Forwards @ultimat3/ui's token layer verbatim and is
@@ -336,7 +295,7 @@ export const config = defineRoute({
336
295
  offline: 'network-only',
337
296
  // A spa renders no data, so the shell itself must be gated — @ultimat3/render requires it.
338
297
  policy: { permission: 'admin:read' },
339
- budget: { js: '120kb', lcp: 3000 },
298
+ budget: { js: '120kb' },
340
299
  meta: () => ({ title: t('admin.home.title'), description: t('admin.home.description') }),
341
300
  });
342
301
 
@@ -432,7 +391,8 @@ restructure.
432
391
  | Start | \`x new ${app.kebab}-${surface}\` inside this directory, or wire it by hand |
433
392
  `;
434
393
 
435
- export function appFiles(app: NameSet): readonly GeneratedFile[] {
394
+ /** `example` reaches only `apps/web/api/index.ts`: the slice it registers is written elsewhere. */
395
+ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile[] {
436
396
  return [
437
397
  { path: 'apps/web/package.json', contents: webPackage(app) },
438
398
  { path: 'apps/web/tsconfig.json', contents: tsconfig() },
@@ -447,8 +407,8 @@ export function appFiles(app: NameSet): readonly GeneratedFile[] {
447
407
  { path: 'apps/web/app/dashboard/page.test.ts', contents: dashboardTest() },
448
408
  { path: 'apps/web/app/offline.tsx', contents: offlineFallback() },
449
409
  { path: 'apps/web/app/offline.module.scss', contents: offlineStyle() },
450
- { path: 'apps/web/api/health.ts', contents: apiAction() },
451
- { path: 'apps/web/api/health.test.ts', contents: apiTest() },
410
+ // The third surface, and the one call that registers what the app declares — `scaffold-api.ts`.
411
+ ...apiFiles(example),
452
412
  { path: 'apps/web/shared/tokens.scss', contents: sharedTokens() },
453
413
  { path: 'apps/web/shared/global.scss', contents: sharedGlobalStyle() },
454
414
  { path: 'apps/web/shared/global.ts', contents: sharedGlobalModule() },
@@ -7,6 +7,7 @@
7
7
  // buildpack, a Render blueprint or a fly.toml would be the primitive that never ships.
8
8
 
9
9
  import type { GeneratedFile, NameSet } from './naming';
10
+ import { helmFiles } from './scaffold-helm';
10
11
 
11
12
  const dockerfile = (
12
13
  app: NameSet,
@@ -71,15 +72,27 @@ ENTRYPOINT ["bun", "apps/web/server.ts"]
71
72
  * BuildKit prefers `<dockerfile>.dockerignore` over the context root's, so this sits beside the
72
73
  * Dockerfile. Without it `COPY . .` ships `node_modules` and `.x/` — a stale host `node_modules`
73
74
  * would shadow the `--production` install the deps stage just made.
75
+ *
76
+ * The `.env` block carries a recursive prefix for a reason measured against a real `docker build`.
77
+ * An ignore pattern is anchored at the context root and crosses no directory on its own, so `.env`
78
+ * plus `.env.*.local` matched NEITHER `.env.production` — the file `docker-compose.prod.yml` below
79
+ * tells the operator to create, in its own `env_file:` — nor `.env.development`, and both landed in
80
+ * an image layer that `cache-to=mode=max` then pushes to a shared cache. Same four lines the
81
+ * framework's own `docker/Dockerfile.dockerignore` carries, and for the same reason.
74
82
  */
75
- const dockerignore = (): string => `node_modules
83
+ const dockerignore = (): string => `**/.env
84
+ **/.env.*
85
+ !**/.env.example
86
+ # Same shape, different file: an .npmrc carries a registry auth token, so any that exists in a
87
+ # build context is somebody's local credential and has no business in a layer.
88
+ **/.npmrc
89
+
90
+ node_modules
76
91
  **/node_modules
77
92
  **/.x
78
93
  **/dist
79
94
  **/*.tsbuildinfo
80
95
  .git
81
- .env
82
- .env.*.local
83
96
  coverage
84
97
  **/test-results
85
98
  **/playwright-report
@@ -93,9 +106,10 @@ const composeProd = (
93
106
  # IMAGE=ghcr.io/you/${app.kebab}:1.2.3 x deploy --image ghcr.io/you/${app.kebab}:1.2.3
94
107
  #
95
108
  # A published host port has exactly one binder, so \`web\` and \`sync\` run at 1 here. Compose is one
96
- # box; horizontal scaling of those two belongs to an orchestrator (copy \`docker/helm\` from the
97
- # framework repo). To scale them on one box anyway, drop \`ports:\` and put your own proxy on this
98
- # network — the service name resolves to every replica over the compose DNS round robin.
109
+ # box; horizontal scaling of those two belongs to an orchestrator — \`docker/helm\`, beside this
110
+ # file, is the chart \`x deploy --method helm\` installs. To scale them on one box anyway, drop
111
+ # \`ports:\` and put your own proxy on this network — the service name resolves to every replica
112
+ # over the compose DNS round robin.
99
113
  name: ${app.kebab}
100
114
 
101
115
  x-image: &image
@@ -292,9 +306,26 @@ a one-off command sets \`command:\` (the entrypoint) as well as \`args:\`, for t
292
306
 
293
307
  ## Kubernetes
294
308
 
295
- \`x deploy --method helm\` expects a chart at \`docker/helm\`. \`x new\` does not write one — a chart is
296
- a topology decision, not a scaffold default. Copy \`docker/helm\` from the framework repository, or
297
- stay on \`--method compose\`.
309
+ \`\`\`sh
310
+ x deploy --method helm --image ghcr.io/you/${app.kebab}:1.2.3 --dry-run --json # the plan
311
+ x deploy --method helm --image ghcr.io/you/${app.kebab}:1.2.3 # run it
312
+ \`\`\`
313
+
314
+ One \`helm upgrade --install\` against \`docker/helm\`, which is scaffolded beside this file for the
315
+ same reason \`docker-compose.prod.yml\` is: a deploy method whose topology file only exists in
316
+ somebody else's repository is a command that cannot run. \`--image\` sets \`image.repository\` and
317
+ \`image.tag\`; everything else is \`docker/helm/values.yaml\`, and it is yours to edit.
318
+
319
+ | Object | Per | Note |
320
+ |---|---|---|
321
+ | Deployment + Service | enabled role | one image, \`ROLE\` and the port are the only difference |
322
+ | Job | release | \`ROLE=migrate\`, a \`pre-install,pre-upgrade\` hook — it runs to completion first |
323
+ | Ingress | release | off by default; routes \`/_x/sync\` to sync and \`/\` to web |
324
+ | HorizontalPodAutoscaler | role, opt-in | rps, websocket connections, queue depth — never CPU |
325
+
326
+ No ServiceMonitor and no PodDisruptionBudget: the first needs a CRD \`helm install\` fails on in a
327
+ cluster with no Prometheus operator, and both are cluster policy rather than this app's topology.
328
+ Every role already answers \`/metrics\` on \`metricsPort\`.
298
329
  `;
299
330
 
300
331
  /** The container files for a new app, in the order a reader meets them. */
@@ -304,5 +335,9 @@ export function containerFiles(app: NameSet): readonly GeneratedFile[] {
304
335
  { path: 'docker/Dockerfile.dockerignore', contents: dockerignore() },
305
336
  { path: 'docker/docker-compose.prod.yml', contents: composeProd(app) },
306
337
  { path: 'docker/README.md', contents: readme(app) },
338
+ // The other deploy method's topology, on the same terms as the compose file above: `x deploy`
339
+ // is one `helm upgrade --install` against this directory, and a chart that shipped in no npm
340
+ // tarball made that command's only failure mode "clone the framework repository".
341
+ ...helmFiles(app),
307
342
  ];
308
343
  }