@ultimat3/cli 1.2.0 → 3.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 +761 -0
- package/README.md +42 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +134 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +219 -0
- package/src/cmd-db.ts +458 -153
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +92 -18
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +74 -10
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +14 -8
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +29 -24
- package/src/cmd-verify.ts +197 -25
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +269 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +144 -0
- package/src/db-seed.ts +294 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +108 -23
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +167 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +247 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +37 -7
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +78 -10
- package/src/error-catalog.ts +8 -18
- package/src/error-codes.ts +192 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +201 -138
- package/src/exec.ts +42 -8
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +67 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +92 -15
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +128 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +93 -2
- package/src/metrics-endpoint.ts +64 -16
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +185 -13
- package/src/shell-quote.ts +15 -0
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +20 -11
- package/src/test-workers.ts +50 -0
- package/src/ts-scan.ts +284 -15
- package/src/tsconfig-references.ts +103 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/README.md
CHANGED
|
@@ -11,17 +11,23 @@ Commands and the `x verify` step count, `As of 2026-08`:
|
|
|
11
11
|
| `x new <name>` | scaffolds the monorepo | interactive-free; auth, seeded DB, example route |
|
|
12
12
|
| `x dev` | every role in one process | embedded Postgres/events/storage, `/_x` mounted |
|
|
13
13
|
| `x build --target docker\|binary\|static` | one artifact | `ROLE` selects behaviour at start |
|
|
14
|
-
| `x verify` | **the gate** |
|
|
14
|
+
| `x verify` | **the gate** | 17 named steps, each with pass/fail + duration |
|
|
15
15
|
| `x g <primitive> <name>` | scaffolds a primitive **with a passing test** | never a TODO stub |
|
|
16
|
-
| `x db gen\|migrate\|reset\|
|
|
16
|
+
| `x db gen\|migrate\|reset\|branch\|backfill` | everything DB | `branch` = copy-on-write clone + preview URL; `backfill` dry-runs unless `--write`. `x db studio` is **planned** — it parses, and exits `X_NOT_IMPLEMENTED` naming `/_x`'s db panel |
|
|
17
17
|
| `x mcp serve` | `@ultimat3/mcp`'s 13 dev tools, over stdio or HTTP | one catalog, one scope set, both transports |
|
|
18
18
|
| `x doctor` | environment, ports, drift, PWA prerequisites | every finding carries a fix command |
|
|
19
19
|
| `x deploy` | container deploy plan | compose or helm; zero platform primitives |
|
|
20
20
|
| `x manifest` / `x routes` | generated facts | `x.manifest.json`, `openapi.json`, route table |
|
|
21
21
|
| `x actions` / `x queries` / `x entities` | the declaration registries | `list` and `describe <name>`, straight off the registries |
|
|
22
|
-
| `x
|
|
22
|
+
| `x tasks list\|show` | cron tasks | timezone and next run, off `registeredTasks()` |
|
|
23
|
+
| `x jobs ls\|show\|retry\|cancel\|drain` | the queue | depth, dead letters, step traces, `retry --from-step`, `cancel --reason`, `drain --to` |
|
|
23
24
|
| `x test [type]` | one of the six test types, or all | same type rule as the gate; `--filter`, `--sample N` |
|
|
24
|
-
| `x
|
|
25
|
+
| `x env check\|example` | the typed environment `envSchema` declares | and the `.env.example` rendered from it |
|
|
26
|
+
| `x secrets show\|init\|edit\|set\|rotate` | the committed encrypted secrets | decrypted into the `envSchema` variables of the same names |
|
|
27
|
+
| `x policy list\|explain <subject>` | which clause decided a permission, and why | five packages print `x policy explain` as a denial's `fix:` |
|
|
28
|
+
| `x i18n check\|add\|sync` | catalogs: gaps, a new locale, key sync | all three of i18n's own error fixes name it |
|
|
29
|
+
| `x errors explain <CODE>` / `list` | the error table, programmatically | refuses an unregistered code instead of inventing one |
|
|
30
|
+
| `x docs "<question>"` | the framework docs, offline | answered from the installed packages, never the network |
|
|
25
31
|
| `x fix boundary <file>` | the minimal cut for a crossed surface boundary | prints the plan and the `git mv`; never rewrites a file |
|
|
26
32
|
|
|
27
33
|
Everything in [CLI reference](../../wiki/CLI-Reference.md)'s planned table is also in the registry
|
|
@@ -41,28 +47,46 @@ X_DB_DRIFT: schema differs from migrations
|
|
|
41
47
|
|
|
42
48
|
```sh
|
|
43
49
|
x verify --json
|
|
44
|
-
# {"ok":false,"command":"verify","summary":"1 of
|
|
50
|
+
# {"ok":false,"command":"verify","summary":"1 of 17 steps failed","steps":[...]}
|
|
45
51
|
```
|
|
46
52
|
|
|
47
53
|
## `x verify` steps
|
|
48
54
|
|
|
49
55
|
`typecheck lint boundaries filesize package-shape errors unit contract live job e2e eval drift
|
|
50
|
-
contract-diff budgets manifest`
|
|
56
|
+
contract-diff budgets manifest roadmap`
|
|
51
57
|
|
|
52
|
-
|
|
53
|
-
|
|
58
|
+
Seventeen, in cost order, defined once as `VERIFY_STEP_NAMES` (`verify-step.ts`) — the summary
|
|
59
|
+
count above is projected from that list, and the framework repo's own gate (`bun run verify`)
|
|
60
|
+
runs exactly it. A step with nothing to check here reports as skipped, never as
|
|
54
61
|
passed. Never bails early: an agent fixing three things needs all three findings from one run.
|
|
55
62
|
There is no `--only` and no `--skip`; the exit code is non-zero if any step fails.
|
|
56
63
|
|
|
64
|
+
A committed `x.verify.json` is the floor, `As of 2026-08`: it names the steps this repo has already
|
|
65
|
+
proved it can run, and a step it names that reports nothing is `X_VERIFY_SUITE_VANISHED` rather
|
|
66
|
+
than a skip.
|
|
67
|
+
"Nothing" is both ways a suite disappears — no files at all, and every test in the files it found
|
|
68
|
+
skipping itself, which is read back out of `bun test`'s own summary. `x new` writes one.
|
|
69
|
+
|
|
70
|
+
An app extends the gate with its own conventions, never with its own step: a file in `guards/`
|
|
71
|
+
exports a `guard` whose `check(root)` returns `Finding[]`, and the `boundaries` step runs every one
|
|
72
|
+
of them. Nothing registers a guard — the directory is the registration — and what a guard returns
|
|
73
|
+
is held to the same error contract shipped source is (`X_GUARD_INVALID`, `X_GUARD_FAILED`,
|
|
74
|
+
`X_GUARD_FINDING_INVALID`). `x g guard <name>` scaffolds one with its test.
|
|
75
|
+
|
|
57
76
|
## Layout
|
|
58
77
|
|
|
59
78
|
| File | Responsibility |
|
|
60
79
|
|---|---|
|
|
61
80
|
| `bin.ts` | argv, stdout, exit code — nothing else |
|
|
81
|
+
| `write-line.ts` | the synchronous fd-1 write both published entry points use (`create-ultimate`'s too) |
|
|
62
82
|
| `dispatch.ts` | parse → run → render → exit; the only I/O boundary |
|
|
63
83
|
| `parse.ts` | flags, subcommands, `--json`, `--help`, suggestions |
|
|
84
|
+
| `flag-number.ts` | the one integer-flag reader — `--port`, `--workers`, `--shard` |
|
|
85
|
+
| `shell-quote.ts` | the one POSIX quoter for a value pasted into a `fix:` or a reproduce line |
|
|
64
86
|
| `output.ts` | one data shape, two renderers, the 3-line error format |
|
|
65
87
|
| `registry.ts` | the one command list |
|
|
88
|
+
| `generate-kinds.ts` | which generators exist, and how a command line names one |
|
|
89
|
+
| `guards.ts` | the app's own conventions: `guards/` discovered, run, and held to the error contract |
|
|
66
90
|
| `cmd-*.ts` | one command group each |
|
|
67
91
|
| `templates/` | scaffolding as typed string modules, not copied fixtures |
|
|
68
92
|
| `app-load.ts` | import an app's modules so the framework registries hold it |
|
|
@@ -70,7 +94,13 @@ There is no `--only` and no `--skip`; the exit code is non-zero if any step fail
|
|
|
70
94
|
| `app-openapi.ts` | `openapi.json`, projected by `@ultimat3/action` |
|
|
71
95
|
| `app-boundaries.ts` | app import boundaries, over `@ultimat3/render`'s surface check |
|
|
72
96
|
| `app-agents-md.ts` | `AGENTS.md` exists and stays short, over `@ultimat3/manifest`'s check |
|
|
97
|
+
| `serve.ts` | **what a container starts** — `runRole(options)`, the same boot `x dev` runs minus the watcher, `/_x` and `dev: true`. `x new`'s `apps/web/server.ts` is three lines that call it |
|
|
98
|
+
| `prerender.ts` | `x build --target static`: which `site/` routes qualify, and where the bytes land |
|
|
99
|
+
| `metrics-endpoint.ts` | the `METRICS_PATH` scrape listener every role opens, on `METRICS_PORT` |
|
|
100
|
+
| `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
|
|
73
101
|
| `dev-*.ts` | what `x dev` boots: services, runtime, routes, hooks, roles, the `/_x` mount |
|
|
102
|
+
| `island-bundle.ts` | every `*.island.tsx` built as its own entry point, content-hashed |
|
|
103
|
+
| `island-routes.ts` | the one route those chunks are served from, in dev and in the container |
|
|
74
104
|
| `mcp-host.ts` | the shell-side half of `@ultimat3/mcp`'s dev server — db, tests, logs, verify |
|
|
75
105
|
| `verify-step.ts` | the step shape, the step names, the host-check hook |
|
|
76
106
|
| `verify-tests.ts` | one `bun test` invocation per test type |
|
|
@@ -90,6 +120,9 @@ no second OpenAPI builder and no second surface-boundary walk anywhere in this p
|
|
|
90
120
|
apps/web/app/<feature>/{entity,repo,service,policy,errors,ui}.ts
|
|
91
121
|
apps/web/app/<feature>/{actions,queries,live,jobs,tasks}/<name>.ts
|
|
92
122
|
apps/web/{site,app}/<path>/page.tsx
|
|
123
|
+
apps/web/{site,app}/<path>/<name>.island.tsx # x g island <name> --at <dir>
|
|
124
|
+
apps/admin/src/pages/<name>.tsx # x g admin:page <name> --permission p
|
|
125
|
+
guards/<name>.ts # x g guard <name>
|
|
93
126
|
```
|
|
94
127
|
|
|
95
128
|
Every emitted source has a `<file>.test.ts` beside it that passes on the first run.
|
|
@@ -97,4 +130,4 @@ Every emitted source has a `<file>.test.ts` beside it that passes on the first r
|
|
|
97
130
|
## Errors
|
|
98
131
|
|
|
99
132
|
`X_CLI_UNKNOWN_COMMAND` `X_CLI_BAD_FLAG` `X_VERIFY_FAILED` `X_NOT_IN_APP` `X_BUN_VERSION`
|
|
100
|
-
`X_NOT_IMPLEMENTED`
|
|
133
|
+
`X_NOT_IMPLEMENTED` `X_GUARD_INVALID` `X_GUARD_FAILED` `X_GUARD_FINDING_INVALID`
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "3.0.0",
|
|
4
4
|
"description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -22,6 +22,7 @@
|
|
|
22
22
|
"files": [
|
|
23
23
|
"src",
|
|
24
24
|
"!src/**/*.test.ts",
|
|
25
|
+
"CLAUDE.md",
|
|
25
26
|
"README.md",
|
|
26
27
|
"LICENSE"
|
|
27
28
|
],
|
|
@@ -34,27 +35,28 @@
|
|
|
34
35
|
"dev": "bun run src/bin.ts dev"
|
|
35
36
|
},
|
|
36
37
|
"dependencies": {
|
|
37
|
-
"@ultimat3/action": "
|
|
38
|
-
"@ultimat3/admin": "
|
|
39
|
-
"@ultimat3/ai": "
|
|
40
|
-
"@ultimat3/cache": "
|
|
41
|
-
"@ultimat3/core": "
|
|
42
|
-
"@ultimat3/db": "
|
|
43
|
-
"@ultimat3/entity": "
|
|
44
|
-
"@ultimat3/http": "
|
|
45
|
-
"@ultimat3/i18n": "
|
|
46
|
-
"@ultimat3/jobs": "
|
|
47
|
-
"@ultimat3/mail": "
|
|
48
|
-
"@ultimat3/manifest": "
|
|
49
|
-
"@ultimat3/mcp": "
|
|
50
|
-
"@ultimat3/policy": "
|
|
51
|
-
"@ultimat3/pwa": "
|
|
52
|
-
"@ultimat3/query": "
|
|
53
|
-
"@ultimat3/realtime": "
|
|
54
|
-
"@ultimat3/render": "
|
|
55
|
-
"@ultimat3/
|
|
56
|
-
"@ultimat3/
|
|
57
|
-
"@ultimat3/
|
|
58
|
-
"@ultimat3/
|
|
38
|
+
"@ultimat3/action": "3.0.0",
|
|
39
|
+
"@ultimat3/admin": "3.0.0",
|
|
40
|
+
"@ultimat3/ai": "3.0.0",
|
|
41
|
+
"@ultimat3/cache": "3.0.0",
|
|
42
|
+
"@ultimat3/core": "3.0.0",
|
|
43
|
+
"@ultimat3/db": "3.0.0",
|
|
44
|
+
"@ultimat3/entity": "3.0.0",
|
|
45
|
+
"@ultimat3/http": "3.0.0",
|
|
46
|
+
"@ultimat3/i18n": "3.0.0",
|
|
47
|
+
"@ultimat3/jobs": "3.0.0",
|
|
48
|
+
"@ultimat3/mail": "3.0.0",
|
|
49
|
+
"@ultimat3/manifest": "3.0.0",
|
|
50
|
+
"@ultimat3/mcp": "3.0.0",
|
|
51
|
+
"@ultimat3/policy": "3.0.0",
|
|
52
|
+
"@ultimat3/pwa": "3.0.0",
|
|
53
|
+
"@ultimat3/query": "3.0.0",
|
|
54
|
+
"@ultimat3/realtime": "3.0.0",
|
|
55
|
+
"@ultimat3/render": "3.0.0",
|
|
56
|
+
"@ultimat3/schema": "3.0.0",
|
|
57
|
+
"@ultimat3/seo": "3.0.0",
|
|
58
|
+
"@ultimat3/storage": "3.0.0",
|
|
59
|
+
"@ultimat3/testing": "3.0.0",
|
|
60
|
+
"@ultimat3/time": "3.0.0"
|
|
59
61
|
}
|
|
60
62
|
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// The app's API over HTTP, composed once: the write half `@ultimat3/action` projects and the read
|
|
2
|
+
// half `@ultimat3/query` projects. `x dev` and `serve.ts` both mount THIS rather than each listing
|
|
3
|
+
// the registries themselves — two lists is how `query.client()` shipped deriving `/_x/query/<kebab>`
|
|
4
|
+
// against a route neither file mounted, compiling everywhere and 404ing everywhere.
|
|
5
|
+
|
|
6
|
+
import { listActions, toRoute } from '@ultimat3/action';
|
|
7
|
+
import type { Route } from '@ultimat3/http';
|
|
8
|
+
import { listQueries, toQueryRoute } from '@ultimat3/query';
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Whatever loading the app's modules put in the two registries, as routes. Read at call time,
|
|
12
|
+
* never at import: importing the app IS the registration, and it happens after this module loads.
|
|
13
|
+
*/
|
|
14
|
+
export function apiRoutes(): readonly Route[] {
|
|
15
|
+
return [...listActions().map(toRoute), ...listQueries().map(toQueryRoute)];
|
|
16
|
+
}
|
package/src/app-auth.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
// The one fact `x dev` and `serve.ts` need out of `app.config.ts` before a listener binds: where
|
|
2
|
+
// this app's sign-in page is. Sibling of `app-env.ts`'s `loadEnvSchema`, and imports the config
|
|
3
|
+
// for the same reason — a regex over the app's source is the pattern `app-load.ts` refuses.
|
|
4
|
+
|
|
5
|
+
import { existsSync } from 'node:fs';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
8
|
+
|
|
9
|
+
/** The export every app declares. Named, never default — the CLI and the runtime both import it. */
|
|
10
|
+
export const APP_CONFIG_EXPORT = 'config';
|
|
11
|
+
|
|
12
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
13
|
+
typeof value === 'object' && value !== null;
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `auth.signInPath`, or `null` when the app declares none.
|
|
17
|
+
*
|
|
18
|
+
* Structural, not `instanceof`: `defineConfig` returns a plain object, and a config that resolved
|
|
19
|
+
* through an older version of core simply has no `auth` section. `null` is the safe answer either
|
|
20
|
+
* way — it is what turns the browser redirect off and leaves the problem document in place.
|
|
21
|
+
*/
|
|
22
|
+
export async function loadSignInPath(root: string): Promise<string | null> {
|
|
23
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
24
|
+
if (!existsSync(configPath)) return null;
|
|
25
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
26
|
+
const config = module[APP_CONFIG_EXPORT];
|
|
27
|
+
if (!isRecord(config)) return null;
|
|
28
|
+
const auth = config['auth'];
|
|
29
|
+
if (!isRecord(auth)) return null;
|
|
30
|
+
const path = auth['signInPath'];
|
|
31
|
+
return typeof path === 'string' && path.startsWith('/') ? path : null;
|
|
32
|
+
}
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
// How many entities an app declares, answered the way this package answers every question about a
|
|
2
|
+
// primitive: load the app, then project the registry — never a scan of the source. An entity may be
|
|
3
|
+
// declared anywhere `loadApp` reaches, so a text rule over `packages/db/src` would miss the ones
|
|
4
|
+
// that are not there and mistake a type-only import for a declaration.
|
|
5
|
+
|
|
6
|
+
import { describeEntities } from '@ultimat3/entity';
|
|
7
|
+
import { loadApp } from './app-load';
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A module that will not import registers nothing, so a broken app answers with a SHORT count and
|
|
11
|
+
* never a throw — `loadApp` collects import failures as findings rather than raising them. Callers
|
|
12
|
+
* must therefore read a zero as "nothing is declared *that this process could load*", which is why
|
|
13
|
+
* the one caller uses it to go quiet rather than to accuse.
|
|
14
|
+
*/
|
|
15
|
+
export async function countDeclaredEntities(root: string): Promise<number> {
|
|
16
|
+
await loadApp(root);
|
|
17
|
+
return describeEntities().length;
|
|
18
|
+
}
|
package/src/app-env.ts
ADDED
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// The app's typed environment as the CLI sees it: the `defineEnv` declaration read back out of
|
|
2
|
+
// `app.config.ts`, the `.env.example` projected from it, and the drift between the two. One
|
|
3
|
+
// declaration, both files (axiom 2) — nothing here holds a second list of variable names.
|
|
4
|
+
|
|
5
|
+
// Bun ships no equivalent: `existsSync` answers whether this root is an app, `join` builds the
|
|
6
|
+
// host-separator path to the two files this module reads.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { join } from 'node:path';
|
|
9
|
+
import type { EnvSchema, EnvVarDecl } from '@ultimat3/core';
|
|
10
|
+
import { checkEnvExample, ENV_EXAMPLE_PATH, renderEnvExample } from '@ultimat3/core';
|
|
11
|
+
import { APP_CONFIG_FILE } from './app-root';
|
|
12
|
+
import type { Finding } from './output';
|
|
13
|
+
import { findingFrom } from './output';
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The one export name the CLI looks for. `defineEnv()` returns the resolved VALUES, so the
|
|
17
|
+
* declaration it validated is unreachable from its result — an app that wants `.env.example`,
|
|
18
|
+
* `x env check` and the drift gate names the record it passed in:
|
|
19
|
+
*
|
|
20
|
+
* ```ts
|
|
21
|
+
* export const envSchema = { DATABASE_URL: { type: 'url', … } } satisfies EnvSchema;
|
|
22
|
+
* export const env = defineEnv(envSchema);
|
|
23
|
+
* ```
|
|
24
|
+
*
|
|
25
|
+
* An app that exports no `envSchema` declares no environment, so there is nothing to project and
|
|
26
|
+
* nothing to drift — every check here reports nothing rather than inventing a requirement.
|
|
27
|
+
*/
|
|
28
|
+
export const ENV_SCHEMA_EXPORT = 'envSchema';
|
|
29
|
+
|
|
30
|
+
const ENV_TYPES = new Set(['string', 'url', 'number', 'integer', 'port', 'boolean', 'enum']);
|
|
31
|
+
|
|
32
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
33
|
+
typeof value === 'object' && value !== null;
|
|
34
|
+
|
|
35
|
+
const isDecl = (value: unknown): value is EnvVarDecl =>
|
|
36
|
+
isRecord(value) && typeof value['type'] === 'string' && ENV_TYPES.has(value['type']);
|
|
37
|
+
|
|
38
|
+
/** Structural, not `instanceof`: the schema is a plain record the app authored, never a class. */
|
|
39
|
+
export const isEnvSchema = (value: unknown): value is EnvSchema =>
|
|
40
|
+
isRecord(value) && Object.values(value).every(isDecl);
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Import `app.config.ts` and hand back the declaration it exports. Importing is the only honest
|
|
44
|
+
* way to read it — the alternative is a regex over the app's source, which is the pattern
|
|
45
|
+
* `app-load.ts` exists to refuse. `undefined` means "this root declares no environment"; a config
|
|
46
|
+
* that will not import throws, and the caller turns that into the finding.
|
|
47
|
+
*/
|
|
48
|
+
export async function loadEnvSchema(root: string): Promise<EnvSchema | undefined> {
|
|
49
|
+
const configPath = join(root, APP_CONFIG_FILE);
|
|
50
|
+
if (!existsSync(configPath)) return undefined;
|
|
51
|
+
const module = (await import(configPath)) as Record<string, unknown>;
|
|
52
|
+
const declared = module[ENV_SCHEMA_EXPORT];
|
|
53
|
+
if (declared === undefined) return undefined;
|
|
54
|
+
return isEnvSchema(declared) ? declared : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The bytes `.env.example` must hold. Deterministic, so a rewrite that changes nothing diffs to nothing. */
|
|
58
|
+
export const envExampleFor = (schema: EnvSchema): string => renderEnvExample(schema);
|
|
59
|
+
|
|
60
|
+
const driftFinding = (cause: string): Finding => ({
|
|
61
|
+
code: 'X_ENV_EXAMPLE_DRIFT',
|
|
62
|
+
cause,
|
|
63
|
+
// The generator, not the assertion: `assertEnvExample`'s own fix is a `Bun.write(…)` call for
|
|
64
|
+
// an app that has a schema object in scope, and a gate reader has a shell.
|
|
65
|
+
fix: 'x env example',
|
|
66
|
+
docs: 'https://ultimate.dev/errors/X_ENV_EXAMPLE_DRIFT',
|
|
67
|
+
at: ENV_EXAMPLE_PATH,
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* The gate half. Byte-exact against the projection, not just "every key is present somewhere":
|
|
72
|
+
* the example carries each variable's description, whether it is required and its default, and a
|
|
73
|
+
* key-only rule would let all three rot while the file still passed. Missing keys are still called
|
|
74
|
+
* out by name first, because that is the failure a reader can act on without diffing.
|
|
75
|
+
*/
|
|
76
|
+
export async function envExampleFindings(root: string): Promise<readonly Finding[]> {
|
|
77
|
+
let schema: EnvSchema | undefined;
|
|
78
|
+
try {
|
|
79
|
+
schema = await loadEnvSchema(root);
|
|
80
|
+
} catch (error) {
|
|
81
|
+
return [{ ...findingFrom(error), at: APP_CONFIG_FILE }];
|
|
82
|
+
}
|
|
83
|
+
if (schema === undefined) return [];
|
|
84
|
+
const expected = envExampleFor(schema);
|
|
85
|
+
const file = Bun.file(join(root, ENV_EXAMPLE_PATH));
|
|
86
|
+
if (!(await file.exists())) {
|
|
87
|
+
return [
|
|
88
|
+
driftFinding(
|
|
89
|
+
`${ENV_EXAMPLE_PATH} does not exist and ${ENV_SCHEMA_EXPORT} declares ${Object.keys(schema).length} variable(s)`,
|
|
90
|
+
),
|
|
91
|
+
];
|
|
92
|
+
}
|
|
93
|
+
const text = await file.text();
|
|
94
|
+
if (text === expected) return [];
|
|
95
|
+
const report = checkEnvExample(schema, text);
|
|
96
|
+
return [
|
|
97
|
+
driftFinding(
|
|
98
|
+
report.missing.length > 0
|
|
99
|
+
? `${ENV_EXAMPLE_PATH} does not declare ${report.missing.join(', ')}, declared by ${ENV_SCHEMA_EXPORT} in ${APP_CONFIG_FILE}`
|
|
100
|
+
: `${ENV_EXAMPLE_PATH} is no longer the projection of ${ENV_SCHEMA_EXPORT} — a description, a default or the required flag has moved`,
|
|
101
|
+
),
|
|
102
|
+
];
|
|
103
|
+
}
|
package/src/app-load.ts
CHANGED
|
@@ -10,7 +10,7 @@ import { registerActions } from '@ultimat3/action';
|
|
|
10
10
|
import { localeConfig } from '@ultimat3/i18n';
|
|
11
11
|
import type { ErrorCodeFact } from '@ultimat3/manifest';
|
|
12
12
|
import { registerQueries } from '@ultimat3/query';
|
|
13
|
-
import { isRouteConfig, registerRoute } from '@ultimat3/render';
|
|
13
|
+
import { isRouteConfig, pageComponentOf, registerRoute } from '@ultimat3/render';
|
|
14
14
|
import { collectDeclaredCodes } from './error-contract';
|
|
15
15
|
import type { Finding } from './output';
|
|
16
16
|
import { findingFrom } from './output';
|
|
@@ -31,6 +31,14 @@ const APP_GLOBS = [
|
|
|
31
31
|
*/
|
|
32
32
|
const ENTRY_POINT = /^apps\/[^/]+\/(?:server|prerender)\.tsx?$/;
|
|
33
33
|
|
|
34
|
+
/**
|
|
35
|
+
* A `*.island.tsx` is a CLIENT entry point and is deliberately not imported here. It registers no
|
|
36
|
+
* primitive — a page names it by specifier, never by import — and importing it would put the one
|
|
37
|
+
* module the framework guarantees is outside the server's graph inside this process's, where a
|
|
38
|
+
* top-level `document` reference takes the whole scan down (axiom 6).
|
|
39
|
+
*/
|
|
40
|
+
const CLIENT_ENTRY_POINT = /\.island\.tsx$/;
|
|
41
|
+
|
|
34
42
|
export interface LoadedApp {
|
|
35
43
|
readonly root: string;
|
|
36
44
|
/** App-root-relative POSIX paths of every module that imported, sorted. */
|
|
@@ -73,7 +81,7 @@ export async function loadApp(root: string): Promise<LoadedApp> {
|
|
|
73
81
|
for await (const absolute of new Bun.Glob(pattern).scan({ cwd: root, absolute: true })) {
|
|
74
82
|
if (absolute.includes('node_modules') || absolute.includes('.test.')) continue;
|
|
75
83
|
const file = relative(root, absolute).split(sep).join('/');
|
|
76
|
-
if (ENTRY_POINT.test(file)) continue;
|
|
84
|
+
if (ENTRY_POINT.test(file) || CLIENT_ENTRY_POINT.test(file)) continue;
|
|
77
85
|
let module: Record<string, unknown>;
|
|
78
86
|
try {
|
|
79
87
|
module = (await import(absolute)) as Record<string, unknown>;
|
|
@@ -114,7 +122,16 @@ async function register(
|
|
|
114
122
|
// The build counts boundaries from the compiled JSX; before a build there is only the
|
|
115
123
|
// source, and `render: 'stream'` is rejected without one — so count them in the text.
|
|
116
124
|
const source = await Bun.file(absolute).text();
|
|
117
|
-
|
|
125
|
+
// The page component comes from the same module as its config, resolved by render's own
|
|
126
|
+
// rule — the CLI does not decide which export is a page any more than it decides what a
|
|
127
|
+
// route is. A module with no component registers without one, and renders a bare shell.
|
|
128
|
+
const component = pageComponentOf(module);
|
|
129
|
+
registerRoute({
|
|
130
|
+
file,
|
|
131
|
+
config,
|
|
132
|
+
suspenseBoundaries: countSuspense(source),
|
|
133
|
+
...(component === undefined ? {} : { component }),
|
|
134
|
+
});
|
|
118
135
|
}
|
|
119
136
|
registerActions(module);
|
|
120
137
|
registerQueries(module);
|
package/src/bin.ts
CHANGED
|
@@ -3,15 +3,16 @@
|
|
|
3
3
|
// dispatch.ts, so the whole CLI is testable without spawning a process.
|
|
4
4
|
|
|
5
5
|
import { dispatch } from './dispatch';
|
|
6
|
+
// The write itself is `write-line.ts`: `create-ultimate`'s entry point needs the identical one,
|
|
7
|
+
// and a second copy of a note about pipe truncation is a second copy that drifts.
|
|
8
|
+
import { writeLine } from './write-line';
|
|
6
9
|
|
|
7
10
|
const code = await dispatch({
|
|
8
11
|
argv: Bun.argv.slice(2),
|
|
9
12
|
cwd: process.cwd(),
|
|
10
13
|
env: Bun.env,
|
|
11
14
|
bunVersion: Bun.version,
|
|
12
|
-
write:
|
|
13
|
-
process.stdout.write(`${line}\n`);
|
|
14
|
-
},
|
|
15
|
+
write: writeLine,
|
|
15
16
|
});
|
|
16
17
|
|
|
17
18
|
process.exit(code);
|
package/src/budgets.ts
CHANGED
|
@@ -44,8 +44,44 @@ function declaredBudgets(js: number | null, lcp: number | undefined): string {
|
|
|
44
44
|
* finding, never a pass: a route that clears the gate without ever being weighed is exactly the
|
|
45
45
|
* false green axiom 5 exists to prevent. Only a route that declares nothing is skipped.
|
|
46
46
|
*/
|
|
47
|
-
|
|
48
|
-
|
|
47
|
+
/**
|
|
48
|
+
* Two ways a budget goes unweighed, and they are not one instruction.
|
|
49
|
+
*
|
|
50
|
+
* `undefined` stats is "no build has ever run in this repo" — one command closes every route at
|
|
51
|
+
* once, and `.x/` is gitignored, so this is the state a fresh clone and a fresh scaffold are in.
|
|
52
|
+
* A stats file that exists and has no row for this route is the other thing entirely: a build DID
|
|
53
|
+
* run and could not weigh this one, which is `PrerenderReport.unmeasured`'s question and not a
|
|
54
|
+
* second build's. Reporting the first as the second is what sends a reader to re-run a build that
|
|
55
|
+
* already did everything it was going to do.
|
|
56
|
+
*/
|
|
57
|
+
function unmeasuredFinding(url: string, declared: string, built: boolean): Finding {
|
|
58
|
+
return {
|
|
59
|
+
code: 'X_BUDGET_UNMEASURED',
|
|
60
|
+
cause: built
|
|
61
|
+
? `${url} declares a ${declared} budget and ${BUILD_STATS_FILE} has no row for it, so the build ran and could not weigh it`
|
|
62
|
+
: `${url} declares a ${declared} budget and no build has written ${BUILD_STATS_FILE} in this repo`,
|
|
63
|
+
// `--target static` is load-bearing and `x build` alone was a fix that changes nothing: the
|
|
64
|
+
// flag defaults to `docker`, and only the static target runs `apps/web/prerender.ts`, which is
|
|
65
|
+
// the one caller of `writeBuildStats`. When a build already ran, the second half is where the
|
|
66
|
+
// answer is — the report names every route it could not weigh, and why.
|
|
67
|
+
fix: built
|
|
68
|
+
? `x build --target static --json # its "unmeasured" list says why ${url} could not be weighed`
|
|
69
|
+
: 'x build --target static --json && x verify --json',
|
|
70
|
+
docs: 'https://ultimate.dev/errors/X_BUDGET_UNMEASURED',
|
|
71
|
+
at: url,
|
|
72
|
+
};
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* `undefined` stats means no build has run; `{ routes: [] }` means one ran and emitted nothing.
|
|
77
|
+
* The parameter is widened rather than defaulted, because collapsing the two here is exactly the
|
|
78
|
+
* distinction the finding above exists to make.
|
|
79
|
+
*/
|
|
80
|
+
export function checkBudgets(
|
|
81
|
+
manifest: Manifest,
|
|
82
|
+
stats: BuildStats | undefined,
|
|
83
|
+
): readonly Finding[] {
|
|
84
|
+
const byPath = new Map((stats?.routes ?? []).map((route) => [route.path, route]));
|
|
49
85
|
const findings: Finding[] = [];
|
|
50
86
|
for (const route of manifest.routes) {
|
|
51
87
|
const measured = byPath.get(route.url);
|
|
@@ -53,13 +89,7 @@ export function checkBudgets(manifest: Manifest, stats: BuildStats): readonly Fi
|
|
|
53
89
|
const lcp = route.budget?.lcp;
|
|
54
90
|
if (measured === undefined) {
|
|
55
91
|
if (js !== null || lcp !== undefined) {
|
|
56
|
-
findings.push(
|
|
57
|
-
code: 'X_BUDGET_UNMEASURED',
|
|
58
|
-
cause: `${route.url} declares a ${declaredBudgets(js, lcp)} budget but ${BUILD_STATS_FILE} has no entry for it`,
|
|
59
|
-
fix: 'x build && x verify',
|
|
60
|
-
docs: 'https://ultimate.dev/errors/X_BUDGET_UNMEASURED',
|
|
61
|
-
at: route.url,
|
|
62
|
-
});
|
|
92
|
+
findings.push(unmeasuredFinding(route.url, declaredBudgets(js, lcp), stats !== undefined));
|
|
63
93
|
}
|
|
64
94
|
continue;
|
|
65
95
|
}
|
|
@@ -90,3 +120,98 @@ export async function readBuildStats(root: string): Promise<BuildStats | undefin
|
|
|
90
120
|
if (!existsSync(path)) return undefined;
|
|
91
121
|
return (await Bun.file(path).json()) as BuildStats;
|
|
92
122
|
}
|
|
123
|
+
|
|
124
|
+
const SCRIPT_TAG = /<script(?<attrs>[^>]*)>(?<body>[\s\S]*?)<\/script>/g;
|
|
125
|
+
const SRC_ATTR = /\ssrc="(?<src>[^"]*)"/;
|
|
126
|
+
const TYPE_ATTR = /\stype="(?<type>[^"]*)"/;
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* `application/ld+json`, `application/json`, any `…+json`: the body is data, not code — the rule
|
|
130
|
+
* `@ultimat3/render`'s `head.ts` already states, restated because its `carriesJson` reads a
|
|
131
|
+
* `HeadTag` and is not exported, and this side has an attribute string off the emitted document.
|
|
132
|
+
* Without it a page shipping only `meta.ld` structured data and island props measured 8kb of JS
|
|
133
|
+
* and failed a 2kb budget with a `fix:` naming an import chain that does not exist.
|
|
134
|
+
*/
|
|
135
|
+
const carriesJson = (attrs: string): boolean => {
|
|
136
|
+
// Everything from the first `;` is a MIME PARAMETER and not the type: a real document writes
|
|
137
|
+
// `type="application/ld+json; charset=utf-8"`, which does not END with `json`, so the suffix
|
|
138
|
+
// test alone charged an SEO structured-data block as executable JavaScript all over again.
|
|
139
|
+
const [type = ''] = (TYPE_ATTR.exec(attrs)?.groups?.['type'] ?? '').split(';');
|
|
140
|
+
return type.trim().toLowerCase().endsWith('json');
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
/**
|
|
144
|
+
* An island's chunk is reached by `import()` from inside the hydration runtime, so it never appears
|
|
145
|
+
* as a `<script src>` — and a document weighed by script tags alone was charged for the runtime and
|
|
146
|
+
* never for the code that runtime boots. The entry attribute is that module URL, so it is read as
|
|
147
|
+
* exactly what it is: a file the browser will execute.
|
|
148
|
+
*/
|
|
149
|
+
const ENTRY_ATTR = /\sdata-x-entry="(?<url>[^"]*)"/g;
|
|
150
|
+
|
|
151
|
+
/** One executable module the document names, and what it weighs on disk. */
|
|
152
|
+
export interface MeasuredEntry {
|
|
153
|
+
readonly url: string;
|
|
154
|
+
readonly bytes: number;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface MeasuredJs {
|
|
158
|
+
readonly jsBytes: number;
|
|
159
|
+
/** Every `src=`/`data-x-entry=` module, so a finding can name the heaviest by file. */
|
|
160
|
+
readonly entries: readonly MeasuredEntry[];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* What a rendered document actually makes the browser execute: the bytes of every inline script
|
|
165
|
+
* the parser will run, the size of every file a `src` points at, and the size of every island
|
|
166
|
+
* chunk it boots. A JSON-typed script is skipped — it is data the parser never runs. Measured
|
|
167
|
+
* from the emitted HTML rather than from the declared graph, because the graph is what a route
|
|
168
|
+
* *says* it ships and this gate exists to catch the case where those two disagree.
|
|
169
|
+
*/
|
|
170
|
+
export async function measureDocumentJs(html: string, out: string): Promise<MeasuredJs> {
|
|
171
|
+
let jsBytes = 0;
|
|
172
|
+
const entries: MeasuredEntry[] = [];
|
|
173
|
+
const weigh = async (url: string): Promise<void> => {
|
|
174
|
+
// Only a path inside the artifact can be weighed; a cross-origin script is not this build's.
|
|
175
|
+
if (!url.startsWith('/')) return;
|
|
176
|
+
const file = Bun.file(join(out, url.slice(1)));
|
|
177
|
+
const bytes = (await file.exists()) ? file.size : 0;
|
|
178
|
+
entries.push({ url, bytes });
|
|
179
|
+
jsBytes += bytes;
|
|
180
|
+
};
|
|
181
|
+
|
|
182
|
+
for (const match of html.matchAll(SCRIPT_TAG)) {
|
|
183
|
+
const attrs = match.groups?.['attrs'] ?? '';
|
|
184
|
+
if (carriesJson(attrs)) continue;
|
|
185
|
+
const src = SRC_ATTR.exec(attrs)?.groups?.['src'];
|
|
186
|
+
if (src === undefined) {
|
|
187
|
+
jsBytes += Buffer.byteLength(match.groups?.['body'] ?? '', 'utf8');
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
await weigh(src);
|
|
191
|
+
}
|
|
192
|
+
// Deduped: two instances of one island are two wrappers and one chunk, and a browser that
|
|
193
|
+
// imports the same module twice fetches and executes it once.
|
|
194
|
+
const booted = new Set<string>();
|
|
195
|
+
for (const match of html.matchAll(ENTRY_ATTR)) {
|
|
196
|
+
const url = match.groups?.['url'];
|
|
197
|
+
if (url === undefined || booted.has(url)) continue;
|
|
198
|
+
booted.add(url);
|
|
199
|
+
await weigh(url);
|
|
200
|
+
}
|
|
201
|
+
return { jsBytes, entries };
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/** The total alone, for a caller with nothing to say about which module was the heavy one. */
|
|
205
|
+
export async function measureJsBytes(html: string, out: string): Promise<number> {
|
|
206
|
+
return (await measureDocumentJs(html, out)).jsBytes;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* The file `checkBudgets` reads. Written by the build and by nothing else — a stats file produced
|
|
211
|
+
* anywhere but from real output is the false green this gate exists to prevent.
|
|
212
|
+
*/
|
|
213
|
+
export async function writeBuildStats(root: string, stats: BuildStats): Promise<string> {
|
|
214
|
+
const path = join(root, BUILD_STATS_FILE);
|
|
215
|
+
await Bun.write(path, `${JSON.stringify(stats, null, 2)}\n`);
|
|
216
|
+
return path;
|
|
217
|
+
}
|