@ultimat3/cli 11.3.0 → 12.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 +50 -1
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/cmd-dev.ts +6 -0
- package/src/cmd-doctor.ts +74 -16
- package/src/cmd-errors.ts +6 -0
- package/src/cmd-generate.ts +2 -27
- package/src/cmd-i18n.ts +16 -1
- package/src/dev-queue.ts +49 -9
- package/src/dev-replica.ts +99 -0
- package/src/dev-roles-fixture.ts +7 -0
- package/src/dev-roles.ts +44 -30
- package/src/dev-sync.ts +45 -1
- package/src/error-codes.ts +16 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/serve.ts +7 -1
- package/src/templates/scaffold-app.ts +2 -0
- package/src/templates/scaffold-docs.ts +10 -4
- package/src/templates/scaffold-http.ts +84 -0
- package/src/templates/scaffold-repo.ts +20 -0
- package/src/templates/scaffold-roles.ts +31 -3
- package/src/verify-checks.ts +36 -0
- package/src/verify-step.ts +8 -0
|
@@ -63,9 +63,9 @@ Built with [Ultimate](https://github.com/developerz-ai/ultimate). Bun-only, Post
|
|
|
63
63
|
## 🚀 Start
|
|
64
64
|
|
|
65
65
|
\`\`\`sh
|
|
66
|
-
bin/setup # prerequisites, deps, env, the first migration, migrate, seed
|
|
67
|
-
|
|
68
|
-
|
|
66
|
+
bin/setup # prerequisites, deps, env, the first migration, migrate, seed, the manifest
|
|
67
|
+
bin/dev # all roles in one process, embedded Postgres, /_x mounted
|
|
68
|
+
bin/check # the gate: typecheck, lint, boundaries, tests, drift, budgets
|
|
69
69
|
\`\`\`
|
|
70
70
|
|
|
71
71
|
\`packages/db/migrations\` starts empty and \`x db gen\` is its only writer — \`bin/setup\` runs
|
|
@@ -104,7 +104,13 @@ bunx x db migrate "$@"
|
|
|
104
104
|
# \`postgres:\` DATABASE_URL and so dies on a clone with no Postgres — one line after reporting a
|
|
105
105
|
# successful migration.
|
|
106
106
|
bunx x db seed
|
|
107
|
-
|
|
107
|
+
# The file \`AGENTS.md\` line 3 tells an agent facts live in, and \`x dev\` prints the path of. It
|
|
108
|
+
# is a projection of the loaded app, so \`x new\` cannot write it — node_modules does not exist
|
|
109
|
+
# yet — and nothing else ever ran the command: after \`x new\`, \`bin/setup\` and all 13
|
|
110
|
+
# generators, \`find . -name '*.manifest.json'\` returned nothing while \`x verify\` reported
|
|
111
|
+
# \`\u2713 manifest\`. \`x verify\`'s manifest step now refuses its absence (X_MANIFEST_MISSING).
|
|
112
|
+
bunx x manifest
|
|
113
|
+
echo "setup complete — next: bin/dev"
|
|
108
114
|
`;
|
|
109
115
|
|
|
110
116
|
const binDev = (): string => `#!/usr/bin/env bash
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// The scaffold's answer to "how does this server bind, and what does it admit?", which it did not
|
|
2
|
+
// have. `configureHttp()` is the one registration site an app has for CORS origins, the body
|
|
3
|
+
// limit, the request deadline, the in-flight ceiling and the rate-limit buckets — and until it
|
|
4
|
+
// shipped, the only `HttpConfig` any process built was a fixed literal inside `@ultimat3/cli`.
|
|
5
|
+
//
|
|
6
|
+
// `DEFAULT_CORS.origins` is `[]`, so a scaffolded app refuses every cross-origin browser call. The
|
|
7
|
+
// most common homework-scale need — a Vite front end on `localhost:5173` calling the app — was
|
|
8
|
+
// inexpressible; now it is one uncommented line, and this file is where an agent finds it.
|
|
9
|
+
|
|
10
|
+
import type { GeneratedFile, NameSet } from './naming';
|
|
11
|
+
|
|
12
|
+
const httpConfig = (
|
|
13
|
+
app: NameSet,
|
|
14
|
+
): string => `// What this app declares about HTTP. Module scope IS the wiring: the boot scan imports every
|
|
15
|
+
// module under \`apps/*\` before a listener binds, and \`x dev\` and the container both read the
|
|
16
|
+
// configured value back at start — the same seam \`app/auth/dev-actor.ts\` installs through.
|
|
17
|
+
//
|
|
18
|
+
// The BOOT lays its own facts over whatever this says: \`port\`, \`hostname\`, \`dev\`, \`buildId\`,
|
|
19
|
+
// \`signInPath\`, \`trustProxy\`, \`trustedProxyHops\` and \`rateLimit.scope\` are all facts about the
|
|
20
|
+
// PROCESS, so writing one here is a type error rather than a value silently overwritten at the
|
|
21
|
+
// next boot.
|
|
22
|
+
|
|
23
|
+
import { configureHttp } from '@ultimat3/http';
|
|
24
|
+
|
|
25
|
+
configureHttp({
|
|
26
|
+
// EMPTY by default, and that is a refusal rather than an oversight: an origin list is a list of
|
|
27
|
+
// sites allowed to make credentialed calls with this app's cookies, and a framework may not
|
|
28
|
+
// guess one. A browser app served from another origin — a Vite dev server, a separate marketing
|
|
29
|
+
// site — goes here, exactly spelled, scheme and port included:
|
|
30
|
+
//
|
|
31
|
+
// cors: { origins: ['http://localhost:5173'] },
|
|
32
|
+
//
|
|
33
|
+
// \`credentials\` is \`true\` by default, and \`'*'\` with credentials is the one combination a
|
|
34
|
+
// browser refuses — \`@ultimat3/http\` refuses it here instead, at the moment you can act on it.
|
|
35
|
+
cors: { origins: [] },
|
|
36
|
+
// The two bounds a request is measured against. Both are the framework's defaults spelled out,
|
|
37
|
+
// so raising one for an endpoint that really does take a 4 MB CSV or five minutes is an edit to
|
|
38
|
+
// a number that is already in front of you rather than a search for the knob.
|
|
39
|
+
bodyLimitBytes: 1024 * 1024,
|
|
40
|
+
requestTimeoutMs: 30_000,
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
/** Named so the module has an export; importing it for the side effect alone is the wiring. */
|
|
44
|
+
export const ${app.camel}Http = 'configured';
|
|
45
|
+
`;
|
|
46
|
+
|
|
47
|
+
const httpConfigTest =
|
|
48
|
+
(): string => `// The declaration reached the registry. \`configureHttp()\` is a module-scope side effect, so the
|
|
49
|
+
// only thing that can go wrong is nobody importing the module — which is exactly how a shipped app
|
|
50
|
+
// rendered every string as ⟦key⟧ for a whole release (issue #249), one seam along.
|
|
51
|
+
import { configuredHttp, resetHttpConfig } from '@ultimat3/http';
|
|
52
|
+
import { expect, unitTest } from '@ultimat3/testing';
|
|
53
|
+
import './http';
|
|
54
|
+
|
|
55
|
+
unitTest('importing the module IS the registration', () => {
|
|
56
|
+
const declared = configuredHttp();
|
|
57
|
+
expect(declared).toBeDefined();
|
|
58
|
+
// The list is empty on a fresh scaffold and that is the shipped default; what is asserted is
|
|
59
|
+
// that the KEY reaches the boot, so adding an origin to it takes effect.
|
|
60
|
+
expect(declared?.cors?.origins).toEqual([]);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
unitTest('the boot-owned keys are absent — the boot measures them, an app can only guess', () => {
|
|
64
|
+
const declared = configuredHttp() ?? {};
|
|
65
|
+
for (const key of ['port', 'hostname', 'dev', 'buildId', 'signInPath']) {
|
|
66
|
+
expect({ key, declared: Object.hasOwn(declared, key) }).toEqual({ key, declared: false });
|
|
67
|
+
}
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
// The registration is process-global, so a suite that left it set would hand the next file this
|
|
71
|
+
// app's config. \`resetHttpConfig()\` is the seam; this is the one place it is called.
|
|
72
|
+
unitTest('and it is resettable, so no test file inherits the server config of another', () => {
|
|
73
|
+
resetHttpConfig();
|
|
74
|
+
expect(configuredHttp()).toBeUndefined();
|
|
75
|
+
});
|
|
76
|
+
`;
|
|
77
|
+
|
|
78
|
+
/** `apps/web/app/http.ts` and its test. Written by `x new`, with or without the example slice. */
|
|
79
|
+
export function httpFiles(app: NameSet): readonly GeneratedFile[] {
|
|
80
|
+
return [
|
|
81
|
+
{ path: 'apps/web/app/http.ts', contents: httpConfig(app) },
|
|
82
|
+
{ path: 'apps/web/app/http.test.ts', contents: httpConfigTest() },
|
|
83
|
+
];
|
|
84
|
+
}
|
|
@@ -82,6 +82,21 @@ const rootPackage = (app: NameSet, version: string): string => `{
|
|
|
82
82
|
}
|
|
83
83
|
`;
|
|
84
84
|
|
|
85
|
+
/**
|
|
86
|
+
* `"incremental": true` is ONE line and it is the difference between a 4.9s typecheck and a 92s
|
|
87
|
+
* one. `x verify`'s first step is `tsc -b`, and `-b` decides "up to date?" by comparing emitted
|
|
88
|
+
* OUTPUTS against inputs — with `noEmit` and no `composite`/`references`, the output it looks for
|
|
89
|
+
* is an `app.config.js` that will never exist (`Project 'tsconfig.json' is out of date because
|
|
90
|
+
* output file 'app.config.js' does not exist`), so every run rebuilt the whole program from
|
|
91
|
+
* scratch, forever. Measured on a 166-file scaffold with no source change between runs: 92s wall
|
|
92
|
+
* / 43s user CPU without it, 4.9s / 8.8s warm with it, and the whole gate at 12s rather than
|
|
93
|
+
* 24-71s. This was the only tree in the framework without incremental typechecking — the repo
|
|
94
|
+
* root has 32 `references` and `examples/dummy/tsconfig.json` sets `composite`.
|
|
95
|
+
*
|
|
96
|
+
* The note lives HERE and not in the emitted file, for the reason `biome.json` below gives: an
|
|
97
|
+
* app author has no use for eight lines of framework archaeology in their own tsconfig, and
|
|
98
|
+
* `*.tsbuildinfo` is already in the scaffold's `.gitignore`.
|
|
99
|
+
*/
|
|
85
100
|
const rootTsconfig = (app: NameSet): string => `{
|
|
86
101
|
"compilerOptions": {
|
|
87
102
|
"target": "ES2023",
|
|
@@ -101,6 +116,7 @@ const rootTsconfig = (app: NameSet): string => `{
|
|
|
101
116
|
"isolatedModules": true,
|
|
102
117
|
"skipLibCheck": true,
|
|
103
118
|
"noEmit": true,
|
|
119
|
+
"incremental": true,
|
|
104
120
|
"resolveJsonModule": true,
|
|
105
121
|
"jsx": "preserve",
|
|
106
122
|
"jsxImportSource": "solid-js"
|
|
@@ -258,6 +274,10 @@ const SCAFFOLD_FLOOR: readonly VerifyStepName[] = [
|
|
|
258
274
|
'eval',
|
|
259
275
|
'drift',
|
|
260
276
|
'budgets',
|
|
277
|
+
// Always applicable to a scaffolded app — it has an `app.config.ts`, roles and two guarded
|
|
278
|
+
// routes — so a run that reports it skipped is a gate that lost the step, not an app with
|
|
279
|
+
// nothing to check. It is the step that would have caught the 500 `x new` used to ship.
|
|
280
|
+
'policy',
|
|
261
281
|
'manifest',
|
|
262
282
|
];
|
|
263
283
|
|
|
@@ -19,7 +19,15 @@ const rolesSource =
|
|
|
19
19
|
// \`x g policy <feature>\` declares \`<feature>:read\` and \`<feature>:write\`. Granting them is this
|
|
20
20
|
// file's job — a permission no role holds is one no actor can ever exercise.
|
|
21
21
|
|
|
22
|
-
import { defineRoles } from '@ultimat3/policy';
|
|
22
|
+
import { definePermissions, defineRoles } from '@ultimat3/policy';
|
|
23
|
+
|
|
24
|
+
// DECLARED before it is granted, and that order is the whole point. \`can()\` calls
|
|
25
|
+
// \`assertPermission\`, which refuses a name no \`definePermissions()\` call registered
|
|
26
|
+
// (X_PERMISSION_UNKNOWN) — and \`defineRoles()\` does NOT: it took \`grants: ['dashboard:read']\`
|
|
27
|
+
// in silence while nothing declared it, so every scaffolded app answered HTTP 500 on /dashboard
|
|
28
|
+
// and /admin from its first \`x dev\`, under a green gate. A permission a role grants and a
|
|
29
|
+
// permission a route requires both belong here.
|
|
30
|
+
export const appPermissions = definePermissions(['admin:read', 'dashboard:read']);
|
|
23
31
|
|
|
24
32
|
export const roles = defineRoles({
|
|
25
33
|
member: {
|
|
@@ -37,9 +45,9 @@ export const roles = defineRoles({
|
|
|
37
45
|
const rolesTest =
|
|
38
46
|
(): string => `// The app's role map, expanded: what each role grants once inheritance is flattened, and which
|
|
39
47
|
// roles hold a given permission. An undeclared role must grant nothing at all.
|
|
40
|
-
import { expandRoles, rolesGranting } from '@ultimat3/policy';
|
|
48
|
+
import { expandRoles, isKnownPermission, rolesGranting } from '@ultimat3/policy';
|
|
41
49
|
import { expect, unitTest } from '@ultimat3/testing';
|
|
42
|
-
import { roles } from './roles';
|
|
50
|
+
import { appPermissions, roles } from './roles';
|
|
43
51
|
|
|
44
52
|
// The map is passed explicitly rather than read off the module-global one: a test that depended on
|
|
45
53
|
// which module imported first would pass alone and fail inside a suite.
|
|
@@ -57,6 +65,26 @@ unitTest('every permission the app enforces is held by some role', () => {
|
|
|
57
65
|
expect(rolesGranting('dashboard:read', roles)).toEqual(['admin', 'member']);
|
|
58
66
|
expect(rolesGranting('admin:read', roles)).toEqual(['admin']);
|
|
59
67
|
});
|
|
68
|
+
|
|
69
|
+
// The assertion whose absence shipped a 500. Expansion above proves the MAP is right and says
|
|
70
|
+
// nothing about the registry \`can()\` actually consults: a grant naming a permission no
|
|
71
|
+
// \`definePermissions()\` declared expands perfectly and then throws X_PERMISSION_UNKNOWN on the
|
|
72
|
+
// first request to the route that requires it.
|
|
73
|
+
unitTest('every granted permission is in the registry can() asks', () => {
|
|
74
|
+
for (const permission of new Set(Object.values(roles).flatMap((role) => role.grants))) {
|
|
75
|
+
expect({ permission, known: isKnownPermission(permission) }).toEqual({
|
|
76
|
+
permission,
|
|
77
|
+
known: true,
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
unitTest('the routes this app ships require permissions this app declares', () => {
|
|
83
|
+
// The two \`defineRoute({ policy: { permission } })\` values \`x new\` writes. \`RouteGuard\`
|
|
84
|
+
// keeps a bare string, so nothing but this holds them to the declared set.
|
|
85
|
+
expect(appPermissions.has('dashboard:read')).toBe(true);
|
|
86
|
+
expect(appPermissions.has('admin:read')).toBe(true);
|
|
87
|
+
});
|
|
60
88
|
`;
|
|
61
89
|
|
|
62
90
|
/** `apps/web/shared/roles.ts` and its test. Written by `x new`, with or without the example slice. */
|
package/src/verify-checks.ts
CHANGED
|
@@ -19,6 +19,7 @@ import { checkAppBoundaries } from './app-boundaries';
|
|
|
19
19
|
import { envExampleFindings } from './app-env';
|
|
20
20
|
import { appManifest, readAppManifest } from './app-manifest';
|
|
21
21
|
import { OPENAPI_FILE, openApiJson } from './app-openapi';
|
|
22
|
+
import { policyFindings } from './app-permissions';
|
|
22
23
|
import { APP_CONFIG_FILE } from './app-root';
|
|
23
24
|
import { checkBudgets, readBuildStats } from './budgets';
|
|
24
25
|
import { checkDestructiveMigrations } from './db-destructive';
|
|
@@ -265,6 +266,15 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
265
266
|
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
266
267
|
run: async (ctx) => fromFindings(await catalogFindings(ctx.root)),
|
|
267
268
|
},
|
|
269
|
+
{
|
|
270
|
+
name: 'policy',
|
|
271
|
+
summary: 'every permission this app grants or requires is one it declares',
|
|
272
|
+
// A repo with no `app.config.ts` is the framework monorepo, which declares no roles and
|
|
273
|
+
// registers no routes — SKIPPED there, never passed, for the reason `i18n` gives: a step that
|
|
274
|
+
// answers `ok` about nothing is the vacuous green these checks exist to refuse.
|
|
275
|
+
applies: async (ctx) => existsSync(join(ctx.root, APP_CONFIG_FILE)),
|
|
276
|
+
run: async (ctx) => fromFindings(await policyFindings(ctx.root)),
|
|
277
|
+
},
|
|
268
278
|
{
|
|
269
279
|
name: 'manifest',
|
|
270
280
|
summary: 'the files an agent reads: generated facts, hand-written conventions, the env example',
|
|
@@ -283,6 +293,7 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
283
293
|
async run(ctx) {
|
|
284
294
|
const agents = await checkAgentsMd(ctx.root);
|
|
285
295
|
const findings = [
|
|
296
|
+
...manifestMissingFindings(ctx.root),
|
|
286
297
|
...(await driftFindings(ctx.root)),
|
|
287
298
|
...(await envExampleFindings(ctx.root)),
|
|
288
299
|
...floorProblemFindings(await readVerifyFloor(ctx.root)),
|
|
@@ -312,6 +323,31 @@ export const VERIFY_STEPS: readonly VerifyStep[] = [
|
|
|
312
323
|
},
|
|
313
324
|
];
|
|
314
325
|
|
|
326
|
+
/**
|
|
327
|
+
* The half that had never been asked: is the file there at all? `driftFindings` returns nothing
|
|
328
|
+
* when it is absent — correctly, it has nothing to compare — and `AGENTS.md` line 3 tells an agent
|
|
329
|
+
* that facts live in `x.manifest.json`, `x dev` prints its path, and `x manifest` is the only
|
|
330
|
+
* thing that writes it. Nothing ran it: after `x new`, `bin/setup` and all thirteen generators,
|
|
331
|
+
* `find . -name '*.manifest.json'` returned nothing while this step reported green (#F7).
|
|
332
|
+
*
|
|
333
|
+
* An app root, never this repo: the framework monorepo emits `framework.manifest.json` and has no
|
|
334
|
+
* `x.manifest.json` to be missing, so the `app.config.ts` is what decides — the same discriminator
|
|
335
|
+
* `drift`, `budgets`, `seo`, `i18n` and `policy` each use.
|
|
336
|
+
*/
|
|
337
|
+
function manifestMissingFindings(root: string): readonly Finding[] {
|
|
338
|
+
if (!existsSync(join(root, APP_CONFIG_FILE))) return [];
|
|
339
|
+
if (existsSync(join(root, MANIFEST_FILENAME))) return [];
|
|
340
|
+
return [
|
|
341
|
+
{
|
|
342
|
+
code: 'X_MANIFEST_MISSING',
|
|
343
|
+
cause: `${MANIFEST_FILENAME} does not exist, so every fact an agent reads about this app — route table, action schemas, policies, error codes — is unavailable`,
|
|
344
|
+
fix: 'x manifest',
|
|
345
|
+
docs: ERROR_DOCS_URL,
|
|
346
|
+
at: MANIFEST_FILENAME,
|
|
347
|
+
},
|
|
348
|
+
];
|
|
349
|
+
}
|
|
350
|
+
|
|
315
351
|
/** `assertNoDrift` throws `X_MANIFEST_DRIFT`; a step reports, so the error becomes a finding. */
|
|
316
352
|
async function driftFindings(root: string): Promise<readonly Finding[]> {
|
|
317
353
|
const path = join(root, MANIFEST_FILENAME);
|
package/src/verify-step.ts
CHANGED
|
@@ -41,6 +41,14 @@ export const VERIFY_STEP_NAMES = [
|
|
|
41
41
|
// registries that load filled. Until it existed, an app could ship every user-facing string as
|
|
42
42
|
// `⟦key⟧` with `x verify` green, because nothing in the gate ever asked (issue #249).
|
|
43
43
|
'i18n',
|
|
44
|
+
// Twentieth, by the same test `seo` and `i18n` each passed: a rider must ask the SAME question
|
|
45
|
+
// off the same data, and "was this import legal?" is not "does the permission this app grants
|
|
46
|
+
// and requires exist?". Reported under `budgets` it would hand the reader a byte budget for an
|
|
47
|
+
// authz defect (axiom 4). Until it existed, `x new` shipped an app that answered HTTP 500 with
|
|
48
|
+
// X_PERMISSION_UNKNOWN on two of its three routes under a green gate: `defineRoles()` accepts an
|
|
49
|
+
// undeclared grant in silence and `RouteGuard.permission` is a bare string. It costs no second
|
|
50
|
+
// app load — `budgets` already imported every module, and this reads the registries that filled.
|
|
51
|
+
'policy',
|
|
44
52
|
'manifest',
|
|
45
53
|
'roadmap',
|
|
46
54
|
] as const;
|