@ultimat3/cli 3.0.0 → 4.1.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 +69 -10
- package/package.json +24 -24
- package/src/budgets.ts +8 -5
- package/src/cmd-deploy.ts +42 -14
- package/src/cmd-docs.ts +7 -3
- package/src/cmd-errors.ts +5 -4
- package/src/cmd-fix.ts +15 -3
- package/src/cmd-generate.ts +29 -4
- package/src/cmd-help.ts +25 -4
- package/src/cmd-i18n.ts +8 -5
- package/src/cmd-jobs.ts +6 -5
- package/src/cmd-mcp.ts +16 -12
- package/src/cmd-new.ts +9 -13
- package/src/cmd-planned.ts +13 -0
- package/src/cmd-policy.ts +8 -6
- package/src/cmd-registries.ts +7 -6
- package/src/cmd-routes.ts +27 -4
- package/src/cmd-secrets.ts +6 -6
- package/src/cmd-verify.ts +55 -3
- package/src/command.ts +10 -2
- package/src/dev-cache.ts +9 -9
- package/src/dev-render.ts +6 -1
- package/src/dev-runtime.ts +2 -2
- package/src/dispatch.ts +33 -4
- package/src/error-codes.ts +5 -0
- package/src/error-contract.ts +31 -4
- package/src/fix-command.ts +9 -2
- package/src/fix-imports.ts +118 -0
- package/src/fix-scan.ts +251 -0
- package/src/flag-reads.ts +114 -0
- package/src/i18n-audit.ts +2 -1
- package/src/index.ts +8 -1
- package/src/jobs-drain.ts +6 -1
- package/src/mcp-errors.ts +5 -0
- package/src/mcp-host.ts +4 -2
- package/src/messages.ts +3 -0
- package/src/otlp-export.ts +14 -0
- package/src/output.ts +8 -5
- package/src/parse.ts +6 -1
- package/src/seo-meta.ts +105 -0
- package/src/templates/action.ts +39 -7
- package/src/templates/backfill.ts +3 -1
- package/src/templates/index.ts +10 -1
- package/src/templates/job.ts +6 -2
- package/src/templates/query.ts +6 -1
- package/src/templates/route.ts +18 -9
- package/src/templates/scaffold-api.ts +100 -0
- package/src/templates/scaffold-app.ts +8 -48
- package/src/templates/scaffold-container.ts +44 -9
- package/src/templates/scaffold-helm-templates.ts +327 -0
- package/src/templates/scaffold-helm.ts +144 -0
- package/src/templates/scaffold-repo.ts +25 -8
- package/src/ts-scan.ts +12 -174
- package/src/verify-step.ts +5 -0
package/src/parse.ts
CHANGED
|
@@ -47,7 +47,12 @@ export interface CommandSpec {
|
|
|
47
47
|
*/
|
|
48
48
|
readonly subcommandPositionals?: Readonly<Record<string, readonly string[]>>;
|
|
49
49
|
readonly flags?: readonly FlagSpec[];
|
|
50
|
-
/**
|
|
50
|
+
/**
|
|
51
|
+
* Command needs an app root (`app.config.ts`). The dispatcher enforces it — `dispatch.ts`, before
|
|
52
|
+
* `target.run` — and until 2026-08 nothing read this field at all: the guarantee was kept only by
|
|
53
|
+
* each of the 17 declaring commands remembering to call `requireAppRoot` itself, so a new command
|
|
54
|
+
* that declared it and forgot the call ran outside an app with no refusal.
|
|
55
|
+
*/
|
|
51
56
|
readonly requiresApp?: boolean;
|
|
52
57
|
}
|
|
53
58
|
|
package/src/seo-meta.ts
ADDED
|
@@ -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
|
+
}
|
package/src/templates/action.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/templates/index.ts
CHANGED
|
@@ -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
|
-
|
|
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';
|
package/src/templates/job.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/templates/query.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
}
|
package/src/templates/route.ts
CHANGED
|
@@ -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
|
-
/**
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
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
|
|
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'
|
|
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'
|
|
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'
|
|
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
|
-
|
|
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
|
-
|
|
451
|
-
|
|
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 =>
|
|
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
|
|
97
|
-
#
|
|
98
|
-
# network — the service name resolves to every replica
|
|
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
|
-
|
|
296
|
-
|
|
297
|
-
|
|
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
|
}
|