@ultimat3/cli 10.0.0 → 11.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 +71 -0
- package/package.json +28 -26
- package/src/budgets.ts +6 -0
- package/src/cmd-db-backfill.ts +14 -1
- package/src/cmd-dev.ts +3 -0
- package/src/cmd-new.ts +34 -4
- package/src/command.ts +12 -0
- package/src/dev-assets.ts +6 -0
- package/src/dev-hooks.ts +8 -0
- package/src/dev-purge.ts +8 -2
- package/src/dev-render.ts +5 -2
- package/src/dev-roles.ts +26 -2
- package/src/dispatch.ts +8 -0
- package/src/error-catalog.ts +11 -1
- package/src/error-codes.ts +10 -0
- package/src/error-contract.ts +25 -2
- package/src/error-pages.ts +79 -0
- package/src/errors.ts +25 -0
- package/src/favicon.ts +113 -0
- package/src/fix-path.ts +104 -0
- package/src/hold.ts +73 -7
- package/src/index.ts +14 -0
- package/src/live-routes.ts +181 -0
- package/src/mcp-errors.ts +5 -0
- package/src/messages.ts +6 -4
- package/src/prerender.ts +32 -3
- package/src/script-csp.ts +17 -0
- package/src/serve.ts +12 -1
- package/src/templates/admin-page.ts +11 -7
- package/src/templates/imports.ts +26 -0
- package/src/templates/route.ts +2 -2
- package/src/templates/scaffold-app.ts +18 -6
- package/src/templates/scaffold-auth.ts +151 -0
- package/src/templates/scaffold-mcp-package.ts +6 -3
- package/src/templates/scaffold-repo.ts +1 -0
- package/src/verify-checks.ts +7 -1
package/CLAUDE.md
CHANGED
|
@@ -193,6 +193,7 @@ cast: a `null` where an id was expected would otherwise become a mutation agains
|
|
|
193
193
|
| `fix-imports.ts` | which of those factories a file can call that it did not declare — one relative specifier, one file read |
|
|
194
194
|
| `error-contract.ts` | the rules, the two checks that turn them into findings, and `collectDeclaredCodes` |
|
|
195
195
|
| `fix-command.ts` | resolving an `x <command>` a `fix:` cites against the registry |
|
|
196
|
+
| `fix-path.ts` | resolving a PATH or a glob a `fix:` cites against the root the gate is running in |
|
|
196
197
|
| `source-files.ts` | which files are shipped source — shared with `filesize`, never a second list |
|
|
197
198
|
|
|
198
199
|
**A `fix:` may not cite a command this build does not ship.** Six shipped fix lines named
|
|
@@ -201,6 +202,26 @@ one passed — the text rule checks that a fix NAMES a command, never that the r
|
|
|
201
202
|
`fix-command.ts` resolves the citation, and a PLANNED command fails too: `x logs` parses, `x help`
|
|
202
203
|
lists it, and running it hands the reader `X_NOT_IMPLEMENTED` instead of the fix.
|
|
203
204
|
|
|
205
|
+
**A `fix:` may not cite a file this repo does not have, either.** That was the other half, and
|
|
206
|
+
nothing resolved it: `X_UI_RUNTIME_MISSING` told its reader to paste a line no generator ever wrote,
|
|
207
|
+
through every gate since it shipped (#274, #246). A file token is one of the four things that make a
|
|
208
|
+
fix an instruction at all (`COMMAND_TOKENS`), so `fix-path.ts` is built from the SAME extension list
|
|
209
|
+
— a token that satisfies the instruction rule is exactly the token this one has to resolve, and two
|
|
210
|
+
lists would be a citation the second rule cannot see. `X_ERROR_FIX_PATH_MISSING` is its own code:
|
|
211
|
+
`X_ERROR_FIX_INVALID` means the fix is not an instruction, this one means it is one and points at
|
|
212
|
+
nothing, and the repairs differ.
|
|
213
|
+
|
|
214
|
+
It is narrow so a finding never has to be argued with — three shapes are not judged at all, because
|
|
215
|
+
each resolves against something other than the root the gate is running in: a scoped specifier
|
|
216
|
+
(`@ultimat3/ui/global.scss`, which resolves through `node_modules`), a dot-relative path
|
|
217
|
+
(`./global.scss`, which resolves against the reader's own file) and any path whose **parent
|
|
218
|
+
directory** this root does not have (`src/errors.ts`, `apps/web/server.ts`,
|
|
219
|
+
`packages/i18n/catalogs/en.json` — all three name a directory a generated app has and this repo does
|
|
220
|
+
not). What is left is the citation a root really can answer: a directory that exists, named as
|
|
221
|
+
holding a file it does not hold. A glob must match at least one file. Measured over all three roots
|
|
222
|
+
the gate runs in — the framework, `examples/dummy`, `dummy/social-media-clone` — **117 path citations
|
|
223
|
+
read, 0 findings**, so it enforces outright with no pin table.
|
|
224
|
+
|
|
204
225
|
The rule is **conditional, and that is load-bearing**: *if* a fix cites `x <command>`, it must
|
|
205
226
|
resolve. It does not require every fix to name one — `set OTEL_EXPORTER_OTLP_ENDPOINT=…` and
|
|
206
227
|
`counter('orders_total', { maxSeries: 4000 })` are executable and correctly cite nothing, and a
|
|
@@ -542,7 +563,9 @@ hand-written layout and `readMigrations` skips it — read as a migration it sor
|
|
|
542
563
|
| `otlp-export.ts` | the exporters `OTEL_EXPORTER_OTLP_ENDPOINT` switches on, and their drain hooks |
|
|
543
564
|
| `dev-render.ts` | one HTTP route per registered `route`, through render's own mode function |
|
|
544
565
|
| `style-csp.ts` | the `style-src` sha256 of every inline `<style>` the web role serves |
|
|
566
|
+
| `script-csp.ts` | the `script-src` sha256 of every inline `<script>` it serves — the hydration runtime, from `@ultimat3/render`'s own `HYDRATE_RUNTIME_BODIES` |
|
|
545
567
|
| `dev-assets.ts` | the image pipeline's only HTTP surface: `/icons/*` and `/media/*` |
|
|
568
|
+
| `favicon.ts` | `/favicon.ico`: the app's own file, and the bytes the framework answers with when there is none |
|
|
546
569
|
| `dev-hooks.ts` | the pipeline's `authorize` seam, decided from the app's own `Policy` objects |
|
|
547
570
|
| `dev-roles.ts` | `--role` selection plus start/stop for `web`, `sync`, `worker`, `scheduler` |
|
|
548
571
|
| `dev-dashboard.ts` | the `DevSources` hooks only this process can answer, and the two CLI panels |
|
|
@@ -654,6 +677,23 @@ missing.
|
|
|
654
677
|
| `prerender.ts` | build first, write the chunks into the export, then measure |
|
|
655
678
|
| `budgets.ts` | `measureDocumentJs` weighs `data-x-entry` as well as `<script src>` |
|
|
656
679
|
|
|
680
|
+
**A stats row is keyed by the route's DECLARED path, and holds its heaviest page**
|
|
681
|
+
(`As of 2026-08-23`). `checkBudgets` looks a route up by `route.url` off the manifest, which is the
|
|
682
|
+
pattern (`/blog/:slug`), and `prerenderSite` pushed `artifact.path` — the filled one
|
|
683
|
+
(`/blog/hello`). So no dynamic static route had ever been weighed: each was `X_BUDGET_UNMEASURED`
|
|
684
|
+
and `X_BUDGET_EXCEEDED` could not fire for the whole class. The heaviest page and not the first,
|
|
685
|
+
because a budget is a ceiling and the page that breaks it is the one the route answers for; the
|
|
686
|
+
report's `emitted` list still names every filled path.
|
|
687
|
+
|
|
688
|
+
**Both halves of the CSP are computed at boot, `As of 2026-08-23`.** `style-csp.ts` was alone, and
|
|
689
|
+
the hydration runtime is emitted INLINE in every document carrying an island — so with
|
|
690
|
+
`script-src 'self' 'wasm-unsafe-eval'` no island booted in any container. `x dev` sends the policy
|
|
691
|
+
report-only (`dev: true`), which is exactly why nobody saw it: the page hydrated on a laptop and
|
|
692
|
+
never in the image. `startWeb` extends both directives; the property is pinned end to end by
|
|
693
|
+
`dev-roles-script-csp.test.ts`, which parses a served document, hashes every executable inline
|
|
694
|
+
script in it and asserts the response's own `script-src` names each one — with `dev: false`, the
|
|
695
|
+
only mode in which the policy can block anything.
|
|
696
|
+
|
|
657
697
|
**One `Bun.build` per island, never one call with N entry points**, and `splitting: false`. The
|
|
658
698
|
island's `src` is a string, so no import edge reaches it and the page's graph stays the page's
|
|
659
699
|
(axiom 6) — a shared chunk would put that number back behind a graph walk, and the budget compares
|
|
@@ -719,6 +759,21 @@ width is not one of the eight. Anything outside it is still served; only the `pu
|
|
|
719
759
|
no caller gains a new 4xx. `?q=` is deliberately still unbounded here — the closed set for quality
|
|
720
760
|
is `@ultimat3/seo`'s to declare, not this file's.
|
|
721
761
|
|
|
762
|
+
**`/favicon.ico` is a mechanism, not a scaffolded file.** Every browser requests it unprompted, the
|
|
763
|
+
scaffold wrote none and neither served surface mounted a route, so a permanent 404 sat in the console
|
|
764
|
+
of every app the framework produces — noise that trains the reader to ignore console errors, which is
|
|
765
|
+
the opposite of what `--json` and an executable `fix:` are for (#272). Two rungs and one path: the
|
|
766
|
+
app's own `apps/web/site/favicon.ico` wins, and `favicon.ts` answers a 32x32 PNG encoded through
|
|
767
|
+
`@ultimat3/core`'s own pipeline when there is none — the same encoder `x new`'s icon goes through, so
|
|
768
|
+
there is no second image format in the tree and no base64 blob nobody can verify. It is deliberately
|
|
769
|
+
NOT derived from `ICON_SOURCE`: resizing the install icon needs `@ultimat3/pwa`'s pipeline and would
|
|
770
|
+
make the answer depend on a file that may be absent, which is a third rung under a mechanism that has
|
|
771
|
+
exactly two. The file is read per REQUEST, so dropping one into a running `x dev` takes effect
|
|
772
|
+
without a restart. It mounts through `assetRoutes`, which is the one route set `serve.ts` and
|
|
773
|
+
`cmd-dev.ts` both compose — a favicon added to one of them alone is a 404 that comes back in
|
|
774
|
+
production only — and `prerenderSite` writes the same bytes into the static export, because an
|
|
775
|
+
artifact served with no process behind it has to carry every byte the browser will ask for.
|
|
776
|
+
|
|
722
777
|
`ICON_SOURCE` lives here, not in `cmd-doctor.ts`, because this is the module that reads it: the
|
|
723
778
|
diagnostic checks what `x dev` serves, so one constant cannot pass the check and serve nothing.
|
|
724
779
|
It is a **PNG** — core decodes PNG and JPEG only, and the SVG this used to name could never
|
|
@@ -745,6 +800,22 @@ watcher — *after* the drain, so an in-flight request still has the database it
|
|
|
745
800
|
Ctrl-C is therefore the same three phases production runs, not a kill that leaves `.x/pgdata`
|
|
746
801
|
locked by a process that no longer exists.
|
|
747
802
|
|
|
803
|
+
**The release runs INSIDE the drain's own deadline, `As of 2026-08-23`, and it is the same
|
|
804
|
+
deadline.** `drain()` ABANDONS a hook that overruns `ShutdownReason.deadlineAt` — the process is
|
|
805
|
+
meant to exit without it — and `release` re-enters the very same teardown one call later:
|
|
806
|
+
`app.stop()` → `startRoles().stop()` → `worker.stop()`, memoised in the package that owns it, so
|
|
807
|
+
awaiting it is awaiting the promise the drain just walked away from. Unbounded, that hung past
|
|
808
|
+
`terminationGracePeriodSeconds` and the kubelet SIGKILLed a process that had already drained
|
|
809
|
+
cleanly. The budget is the hook's own `reason.deadlineAt`, not a stopwatch of ours, so there is one
|
|
810
|
+
number and not two; an overrun is logged as `X_SHUTDOWN_TIMEOUT` and a REJECTION still rejects,
|
|
811
|
+
because `dispatch` awaits the hold inside its own `try`.
|
|
812
|
+
|
|
813
|
+
**`options.exit` has exactly one caller: `runRole` in `serve.ts`.** `bin.ts` ends in
|
|
814
|
+
`process.exit(code)`, so `x dev` and `x mcp` need nothing; `apps/web/server.ts` — which is what a
|
|
815
|
+
container runs — has no such line, and one non-unref'd interval anywhere in the app then holds an
|
|
816
|
+
event loop with nothing left to do. A function rather than a boolean because `process.exit` inside
|
|
817
|
+
a library is untestable, and the caller is the one that knows.
|
|
818
|
+
|
|
748
819
|
Commands: `bun test`, `bunx tsc --noEmit -p tsconfig.json`.
|
|
749
820
|
|
|
750
821
|
## A declared flag with no reader is a promise `x help` makes and nothing keeps
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ultimat3/cli",
|
|
3
|
-
"version": "
|
|
3
|
+
"version": "11.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",
|
|
@@ -37,31 +37,33 @@
|
|
|
37
37
|
},
|
|
38
38
|
"dependencies": {
|
|
39
39
|
"@babel/core": "^7.28.4",
|
|
40
|
-
"@ultimat3/action": "
|
|
41
|
-
"@ultimat3/admin": "
|
|
42
|
-
"@ultimat3/ai": "
|
|
43
|
-
"@ultimat3/auth": "
|
|
44
|
-
"@ultimat3/cache": "
|
|
45
|
-
"@ultimat3/core": "
|
|
46
|
-
"@ultimat3/db": "
|
|
47
|
-
"@ultimat3/entity": "
|
|
48
|
-
"@ultimat3/
|
|
49
|
-
"@ultimat3/
|
|
50
|
-
"@ultimat3/
|
|
51
|
-
"@ultimat3/
|
|
52
|
-
"@ultimat3/
|
|
53
|
-
"@ultimat3/
|
|
54
|
-
"@ultimat3/
|
|
55
|
-
"@ultimat3/
|
|
56
|
-
"@ultimat3/
|
|
57
|
-
"@ultimat3/
|
|
58
|
-
"@ultimat3/
|
|
59
|
-
"@ultimat3/
|
|
60
|
-
"@ultimat3/
|
|
61
|
-
"@ultimat3/
|
|
62
|
-
"@ultimat3/
|
|
63
|
-
"@ultimat3/
|
|
64
|
-
"@ultimat3/
|
|
40
|
+
"@ultimat3/action": "11.0.0",
|
|
41
|
+
"@ultimat3/admin": "11.0.0",
|
|
42
|
+
"@ultimat3/ai": "11.0.0",
|
|
43
|
+
"@ultimat3/auth": "11.0.0",
|
|
44
|
+
"@ultimat3/cache": "11.0.0",
|
|
45
|
+
"@ultimat3/core": "11.0.0",
|
|
46
|
+
"@ultimat3/db": "11.0.0",
|
|
47
|
+
"@ultimat3/entity": "11.0.0",
|
|
48
|
+
"@ultimat3/flags": "11.0.0",
|
|
49
|
+
"@ultimat3/http": "11.0.0",
|
|
50
|
+
"@ultimat3/i18n": "11.0.0",
|
|
51
|
+
"@ultimat3/jobs": "11.0.0",
|
|
52
|
+
"@ultimat3/mail": "11.0.0",
|
|
53
|
+
"@ultimat3/manifest": "11.0.0",
|
|
54
|
+
"@ultimat3/mcp": "11.0.0",
|
|
55
|
+
"@ultimat3/money": "11.0.0",
|
|
56
|
+
"@ultimat3/policy": "11.0.0",
|
|
57
|
+
"@ultimat3/pwa": "11.0.0",
|
|
58
|
+
"@ultimat3/query": "11.0.0",
|
|
59
|
+
"@ultimat3/realtime": "11.0.0",
|
|
60
|
+
"@ultimat3/render": "11.0.0",
|
|
61
|
+
"@ultimat3/schema": "11.0.0",
|
|
62
|
+
"@ultimat3/scraping": "11.0.0",
|
|
63
|
+
"@ultimat3/seo": "11.0.0",
|
|
64
|
+
"@ultimat3/storage": "11.0.0",
|
|
65
|
+
"@ultimat3/testing": "11.0.0",
|
|
66
|
+
"@ultimat3/time": "11.0.0",
|
|
65
67
|
"babel-preset-solid": "^1.9.15"
|
|
66
68
|
}
|
|
67
69
|
}
|
package/src/budgets.ts
CHANGED
|
@@ -16,6 +16,12 @@ import type { Finding } from './output';
|
|
|
16
16
|
export const BUILD_STATS_FILE = join('.x', 'build-stats.json');
|
|
17
17
|
|
|
18
18
|
export interface RouteStats {
|
|
19
|
+
/**
|
|
20
|
+
* The route's DECLARED path — `/blog/:slug`, never `/blog/hello`. `checkBudgets` looks a row up
|
|
21
|
+
* by `route.url` off the manifest, which is the pattern, so a row keyed by a filled path is a
|
|
22
|
+
* row nothing can find: every dynamic static route read as `X_BUDGET_UNMEASURED`. A route that
|
|
23
|
+
* prerenders many pages contributes ONE row, holding its heaviest.
|
|
24
|
+
*/
|
|
19
25
|
readonly path: string;
|
|
20
26
|
readonly jsBytes: number;
|
|
21
27
|
/**
|
package/src/cmd-db-backfill.ts
CHANGED
|
@@ -27,6 +27,7 @@ import type { CommandResult } from './output';
|
|
|
27
27
|
import { findingFrom } from './output';
|
|
28
28
|
import type { ParsedArgs } from './parse';
|
|
29
29
|
import { flagBool, flagString } from './parse';
|
|
30
|
+
import { quoteArg } from './shell-quote';
|
|
30
31
|
|
|
31
32
|
/** Which of the four questions an invocation asked. `pass` is `<name>` and `--all` both. */
|
|
32
33
|
type BackfillShape = 'list' | 'pending' | 'pass';
|
|
@@ -73,11 +74,23 @@ function readShape(args: ParsedArgs): { readonly shape: BackfillShape; readonly
|
|
|
73
74
|
// `--name` is a FILTER under `--list` and a target everywhere else, so it selects a shape only
|
|
74
75
|
// where `--list` is absent. Two spellings of one target are still two, and are refused.
|
|
75
76
|
const target = list ? undefined : (positional ?? named);
|
|
77
|
+
// Under `--list` the positional is DROPPED by the line above, and until 2026-08 nothing said so:
|
|
78
|
+
// `x db backfill cleanup --list` printed the whole ledger and reported `ok: true` while the
|
|
79
|
+
// operator had named one sweep. `--name` is the filter's one spelling here, so this is a refusal
|
|
80
|
+
// and not a second reading of the argument — the same rule every other shape in this function
|
|
81
|
+
// follows, and the same argv one flag over (`--pending cleanup`) has always been refused.
|
|
82
|
+
if (list && positional !== undefined) {
|
|
83
|
+
return refuseShape(
|
|
84
|
+
'name',
|
|
85
|
+
`x db backfill --list filters the ledger with --name, so the positional "${positional}" selects nothing — it is a pass target in every other shape`,
|
|
86
|
+
`x db backfill --list --name ${quoteArg(positional)} --json`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
76
89
|
if (!list && positional !== undefined && named !== undefined) {
|
|
77
90
|
return refuseShape(
|
|
78
91
|
'name',
|
|
79
92
|
`x db backfill names two backfills ("${positional}" and "${named}") — a pass sweeps the positional or --name, never both`,
|
|
80
|
-
`x db backfill ${positional} --write --json`,
|
|
93
|
+
`x db backfill ${quoteArg(positional)} --write --json`,
|
|
81
94
|
);
|
|
82
95
|
}
|
|
83
96
|
const asked = [
|
package/src/cmd-dev.ts
CHANGED
|
@@ -199,6 +199,9 @@ export async function startDev(options: StartDevOptions): Promise<DevServer> {
|
|
|
199
199
|
// declaration, and `x dev` and `serve.ts` must not be able to disagree about where the app's
|
|
200
200
|
// sign-in page is.
|
|
201
201
|
signInPath: await loadSignInPath(options.root),
|
|
202
|
+
// The same seam `serve.ts` passes: the app's own error page is a FILE, so the root is what
|
|
203
|
+
// `startWeb` needs to find one.
|
|
204
|
+
root: options.root,
|
|
202
205
|
// The one document this process serves that the app did not write; `startRoles` covers the
|
|
203
206
|
// app's own surfaces itself. `x dev` sends the policy report-only, so an uncovered `<style>`
|
|
204
207
|
// here is a console report rather than a blank page — which is how this reached production.
|
package/src/cmd-new.ts
CHANGED
|
@@ -8,7 +8,8 @@ import { isAbsolute, join, resolve } from 'node:path';
|
|
|
8
8
|
import { ERROR_DOCS_URL, renderThrowable } from '@ultimat3/core';
|
|
9
9
|
import { dedupe } from './cmd-generate';
|
|
10
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
-
import {
|
|
11
|
+
import { invocationOf } from './command';
|
|
12
|
+
import { AppNameIsPathError, MissingPositionalError } from './errors';
|
|
12
13
|
import type { Runner } from './exec';
|
|
13
14
|
import { msg } from './messages';
|
|
14
15
|
import type { CommandResult } from './output';
|
|
@@ -116,7 +117,8 @@ export interface WrittenApp {
|
|
|
116
117
|
* a migration whose snapshot never existed is what made the app's first two database commands
|
|
117
118
|
* refuse each other — `x db migrate` naming `x db gen`, and `x db gen` refusing a sidecar version
|
|
118
119
|
* control never had. The consequence is deliberate: `x verify`'s `drift` step is red on a pristine
|
|
119
|
-
* scaffold until `x db gen "initial"` runs
|
|
120
|
+
* scaffold until `x db gen "initial"` runs — which `bin/setup` does, and which is what
|
|
121
|
+
* `cli.new.done` tells the author to run.
|
|
120
122
|
*/
|
|
121
123
|
export async function writeNewApp(target: string, options: NewAppOptions): Promise<WrittenApp> {
|
|
122
124
|
const files = planNewApp(options);
|
|
@@ -130,6 +132,24 @@ function parentDir(cwd: string, dirFlag: string | undefined): string {
|
|
|
130
132
|
return isAbsolute(dirFlag) ? dirFlag : join(cwd, dirFlag);
|
|
131
133
|
}
|
|
132
134
|
|
|
135
|
+
/**
|
|
136
|
+
* The `--dir`/name split of a positional that is really a path, or `undefined` when it is a name.
|
|
137
|
+
*
|
|
138
|
+
* Separator-agnostic: a Windows path pasted into a shell here is the same mistake, and `\` is not
|
|
139
|
+
* a character any app name may hold either.
|
|
140
|
+
*/
|
|
141
|
+
export function appNamePath(raw: string): { parent: string; base: string } | undefined {
|
|
142
|
+
if (!/[\\/]/.test(raw)) return undefined;
|
|
143
|
+
const trimmed = raw.replace(/[\\/]+$/, '');
|
|
144
|
+
const segments = trimmed.split(/[\\/]+/).filter((segment) => segment.length > 0);
|
|
145
|
+
const base = segments.at(-1) ?? 'myapp';
|
|
146
|
+
const parent = trimmed.slice(0, trimmed.lastIndexOf(base)).replace(/[\\/]+$/, '');
|
|
147
|
+
// `x new ./shop` and `x new shop/` both name the directory the caller is already in; `/shop`
|
|
148
|
+
// names the root, which is a directory and not "here".
|
|
149
|
+
if (parent !== '' && parent !== '.') return { parent, base };
|
|
150
|
+
return { parent: /^[\\/]/.test(trimmed) ? '/' : '.', base };
|
|
151
|
+
}
|
|
152
|
+
|
|
133
153
|
export const newCommand: CliCommand = {
|
|
134
154
|
spec: {
|
|
135
155
|
name: 'new',
|
|
@@ -143,7 +163,7 @@ export const newCommand: CliCommand = {
|
|
|
143
163
|
{
|
|
144
164
|
// The summary carries the default and the negation because the page has to answer "which
|
|
145
165
|
// one do I get if I type neither": the usage line offered `--no-example`, this table said
|
|
146
|
-
// `--example`, and `default: true` is a field only `--json` renders.
|
|
166
|
+
// `--example`, and `default: true` is a field only `--json` renders. 136 files against 109.
|
|
147
167
|
name: 'example',
|
|
148
168
|
type: 'boolean',
|
|
149
169
|
summary: 'include the example feature slice (default: on; --no-example for an empty app/)',
|
|
@@ -168,9 +188,19 @@ export const newCommand: CliCommand = {
|
|
|
168
188
|
throw new MissingPositionalError({
|
|
169
189
|
command: 'new',
|
|
170
190
|
positional: 'name',
|
|
171
|
-
|
|
191
|
+
// Never the literal `x new`: this command is also the whole of `bunx create-ultimate`,
|
|
192
|
+
// which runs BEFORE `x` exists — so the one instruction the reader was given named a
|
|
193
|
+
// binary they had not installed yet.
|
|
194
|
+
example: `${invocationOf(ctx, 'new')} myapp`,
|
|
172
195
|
});
|
|
173
196
|
}
|
|
197
|
+
// Before `names()`, which is what makes this reachable at all: it slugifies a path into one
|
|
198
|
+
// kebab-case directory name, so `/srv/apps/shop` becomes `srv-apps-shop` inside the cwd and
|
|
199
|
+
// the scaffold lands somewhere nobody asked for. `--dir` is the flag that takes a path.
|
|
200
|
+
const path = appNamePath(raw);
|
|
201
|
+
if (path !== undefined) {
|
|
202
|
+
throw new AppNameIsPathError({ name: raw, invocation: invocationOf(ctx, 'new'), ...path });
|
|
203
|
+
}
|
|
174
204
|
const app = names(raw);
|
|
175
205
|
const target = resolve(parentDir(ctx.cwd, flagString(ctx.args, 'dir')), app.kebab);
|
|
176
206
|
const options: NewAppOptions = { name: raw, example: ctx.args.flags.get('example') !== false };
|
package/src/command.ts
CHANGED
|
@@ -13,8 +13,20 @@ export interface CommandContext {
|
|
|
13
13
|
readonly runner: Runner;
|
|
14
14
|
readonly env: Readonly<Record<string, string | undefined>>;
|
|
15
15
|
readonly bunVersion: string;
|
|
16
|
+
/**
|
|
17
|
+
* How this process was invoked, up to and including the subcommand — `x new`, or
|
|
18
|
+
* `bunx create-ultimate` when `create-ultimate` is the entry point. Read by a `fix:` line, which
|
|
19
|
+
* is a command the reader is meant to RUN: `create-ultimate`'s whole reason to exist is running
|
|
20
|
+
* before `x` is installed, so `x new myapp` was an instruction nobody in that process could
|
|
21
|
+
* follow. Absent means the default below — a caller that builds a context by hand owes nothing.
|
|
22
|
+
*/
|
|
23
|
+
readonly invocation?: string;
|
|
16
24
|
}
|
|
17
25
|
|
|
26
|
+
/** What `ctx.invocation` means when nobody said: the binary, then the subcommand they typed. */
|
|
27
|
+
export const invocationOf = (ctx: CommandContext, command: string): string =>
|
|
28
|
+
ctx.invocation ?? `x ${command}`;
|
|
29
|
+
|
|
18
30
|
export interface CliCommand {
|
|
19
31
|
readonly spec: CommandSpec;
|
|
20
32
|
run(ctx: CommandContext): Promise<CommandResult>;
|
package/src/dev-assets.ts
CHANGED
|
@@ -23,6 +23,7 @@ import {
|
|
|
23
23
|
authorizeStorageRead,
|
|
24
24
|
STORAGE_READ_PERMISSION,
|
|
25
25
|
} from './dev-storage';
|
|
26
|
+
import { faviconRoute } from './favicon';
|
|
26
27
|
|
|
27
28
|
/**
|
|
28
29
|
* The one source image every generated icon derives from. `x new` scaffolds it, `x doctor` checks
|
|
@@ -254,6 +255,11 @@ export function assetRoutes(options: AssetRoutesOptions): readonly Route[] {
|
|
|
254
255
|
handler: async (request: UltimateRequest, ctx: RequestContext): Promise<Response> =>
|
|
255
256
|
mediaResponse(request, ctx, options.storage, options.images),
|
|
256
257
|
});
|
|
258
|
+
// Mounted here rather than in `serve.ts` and `cmd-dev.ts` separately: this is the one route set
|
|
259
|
+
// both served surfaces already compose, and a favicon that answers on a laptop and 404s in the
|
|
260
|
+
// container is the dev/prod difference this package's own rule forbids. `favicon.ts` owns what
|
|
261
|
+
// the answer IS — this file only says the app's asset surface is where it hangs.
|
|
262
|
+
routes.push(faviconRoute(options.root));
|
|
257
263
|
|
|
258
264
|
return routes;
|
|
259
265
|
}
|
package/src/dev-hooks.ts
CHANGED
|
@@ -41,14 +41,22 @@ export interface DevHookOptions {
|
|
|
41
41
|
* that starts a web role — `serve.ts` included — carry a diagnostic only one of them installs.
|
|
42
42
|
*/
|
|
43
43
|
readonly devNotices?: ServerHooks['devNotices'];
|
|
44
|
+
/**
|
|
45
|
+
* The app's own error page for a status, read off its disk. Passed for `devNotices`' reason: a
|
|
46
|
+
* hook that reached for the app root itself would make every host that starts a web role carry a
|
|
47
|
+
* path only the one that knows the root can supply.
|
|
48
|
+
*/
|
|
49
|
+
readonly errorPage?: ServerHooks['errorPage'];
|
|
44
50
|
}
|
|
45
51
|
|
|
46
52
|
export function devHooks(options: DevHookOptions = {}): ServerHooks {
|
|
47
53
|
const authenticate = configuredAuthenticator();
|
|
48
54
|
const devNotices = options.devNotices;
|
|
55
|
+
const errorPage = options.errorPage;
|
|
49
56
|
return {
|
|
50
57
|
...(authenticate === undefined ? {} : { authenticate }),
|
|
51
58
|
...(devNotices === undefined ? {} : { devNotices }),
|
|
59
|
+
...(errorPage === undefined ? {} : { errorPage }),
|
|
52
60
|
authorize: (route, _request, ctx): AuthzDecision => {
|
|
53
61
|
// An action route never arrives here: it carries `enforcedBy: 'handler'`, so the pipeline
|
|
54
62
|
// never asks. `invoke` is its one evaluation, and the only one holding the row a row-level
|
package/src/dev-purge.ts
CHANGED
|
@@ -112,9 +112,15 @@ function declareSweep(): void {
|
|
|
112
112
|
* background work at all, and this is one more thing it does not do.
|
|
113
113
|
*/
|
|
114
114
|
export function installRetentionSweep(stores: RetentionStores): () => void {
|
|
115
|
-
|
|
115
|
+
const mine = retentionTargets(stores);
|
|
116
|
+
installed = mine;
|
|
116
117
|
declareSweep();
|
|
117
118
|
return () => {
|
|
118
|
-
|
|
119
|
+
// Only if the slot is still OURS. Two runtimes can share one process — a test harness, or
|
|
120
|
+
// `x shot`'s scratch server beside the one it photographs — and a boot that installed after
|
|
121
|
+
// this one owns the slot now: emptying it there would leave the LIVE boot's hourly sweep
|
|
122
|
+
// deleting nothing, forever, with no error anywhere. Same rule, and the same comment, as
|
|
123
|
+
// `installedRevalidator` in `@ultimat3/render`'s ISR controller.
|
|
124
|
+
if (installed === mine) installed = [];
|
|
119
125
|
};
|
|
120
126
|
}
|
package/src/dev-render.ts
CHANGED
|
@@ -183,11 +183,14 @@ async function resultFor(
|
|
|
183
183
|
return { status: 200, headers: staticHeaders(contentHash(body), options.buildId), body };
|
|
184
184
|
}
|
|
185
185
|
case 'isr': {
|
|
186
|
-
// `isrKey(url)`, never `url.pathname`: the query is part of what was rendered — this
|
|
186
|
+
// `isrKey(url, locale)`, never `url.pathname`: the query is part of what was rendered — this
|
|
187
187
|
// route's own `meta` reads `data.url` — so two URLs differing only in their query are two
|
|
188
188
|
// documents. Keyed on the pathname alone, the first render answered every later query
|
|
189
189
|
// string (#171). Render owns the derivation so no second caller can invent another.
|
|
190
|
-
|
|
190
|
+
// The locale is the second dimension and it is `ctx.locale`, the answer the `locale` stage
|
|
191
|
+
// already negotiated for THIS request — never `currentLocale()`, which would read the same
|
|
192
|
+
// value through an ambient store the key does not need.
|
|
193
|
+
const served = await isr.serve(isrKey(url, ctx.locale), () =>
|
|
191
194
|
documentFrom(entry, request, data, options),
|
|
192
195
|
);
|
|
193
196
|
return served.result;
|
package/src/dev-roles.ts
CHANGED
|
@@ -26,9 +26,11 @@ import { startReplicator } from './dev-replicator';
|
|
|
26
26
|
import type { RunningServices } from './dev-runtime';
|
|
27
27
|
import type { Env } from './dev-services';
|
|
28
28
|
import { startSync } from './dev-sync';
|
|
29
|
+
import { errorPageHook } from './error-pages';
|
|
29
30
|
import { BadFlagError, PortInvalidError, RuntimeDriverSplitError } from './errors';
|
|
30
31
|
import { DEFAULT_METRICS_PORT, startMetricsEndpoint } from './metrics-endpoint';
|
|
31
32
|
import type { RuntimeOverrides } from './runtime-overrides';
|
|
33
|
+
import { inlineScriptSources } from './script-csp';
|
|
32
34
|
import { inlineStyleSources } from './style-csp';
|
|
33
35
|
|
|
34
36
|
/** The roles `x dev` starts when `--role` names none, in boot order. */
|
|
@@ -63,6 +65,16 @@ export interface StartRolesOptions {
|
|
|
63
65
|
* `startRoles` takes plain values — a test starts a web role with no `app.config.ts` at all.
|
|
64
66
|
*/
|
|
65
67
|
readonly signInPath?: string | null;
|
|
68
|
+
/**
|
|
69
|
+
* The app root, for the one seam that is a FILE and not a value: `apps/web/site/errors/404.html`
|
|
70
|
+
* and its siblings. Bound HERE rather than passed by each caller, because `x dev` and `serve.ts`
|
|
71
|
+
* both boot through this function and an override wired at one of them alone is a page that
|
|
72
|
+
* appears in dev and not in production — `/favicon.ico`'s rule, one seam over.
|
|
73
|
+
*
|
|
74
|
+
* Optional for the reason `signInPath` is: `startRoles` takes plain values, and a test starts a
|
|
75
|
+
* web role with no app on disk at all. Absent, every error page is the framework's.
|
|
76
|
+
*/
|
|
77
|
+
readonly root?: string;
|
|
66
78
|
/**
|
|
67
79
|
* Inline `<style>` bodies this process serves that the app's own surfaces do not account for —
|
|
68
80
|
* `/_x`'s shell. The surfaces themselves are read from the stylesheet registry here rather than
|
|
@@ -235,7 +247,10 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
235
247
|
return createServer({
|
|
236
248
|
routes: options.routes,
|
|
237
249
|
role: 'web',
|
|
238
|
-
hooks: devHooks(
|
|
250
|
+
hooks: devHooks({
|
|
251
|
+
...(options.devNotices === undefined ? {} : { devNotices: options.devNotices }),
|
|
252
|
+
...(options.root === undefined ? {} : { errorPage: errorPageHook(options.root) }),
|
|
253
|
+
}),
|
|
239
254
|
// Both seams `createServer` already had and `startRoles` passed neither of, so an app's own
|
|
240
255
|
// middleware could not reach the pipeline any process the framework boots actually runs.
|
|
241
256
|
...(options.overrides?.middleware === undefined
|
|
@@ -260,8 +275,17 @@ function startWeb(options: StartRolesOptions): ServerHandle {
|
|
|
260
275
|
// Hashes, never `'unsafe-inline'`: a `render: 'static'` page is a file on disk, so
|
|
261
276
|
// nothing can stamp a per-response nonce into it, but its body is fixed and a hash is a
|
|
262
277
|
// function of that body. Read after `loadApp` — importing the app IS what registered them.
|
|
278
|
+
// BOTH directives, and the script half is the one that was missing: the hydration runtime
|
|
279
|
+
// is emitted inline in every document that carries an island, so `script-src 'self'` meant
|
|
280
|
+
// no island booted anywhere the policy is enforced — which is every container, and never
|
|
281
|
+
// `x dev`, where it is report-only.
|
|
263
282
|
security: {
|
|
264
|
-
csp: {
|
|
283
|
+
csp: {
|
|
284
|
+
extend: {
|
|
285
|
+
'style-src': inlineStyleSources(options.inlineStyles ?? []),
|
|
286
|
+
'script-src': inlineScriptSources(),
|
|
287
|
+
},
|
|
288
|
+
},
|
|
265
289
|
},
|
|
266
290
|
}),
|
|
267
291
|
}).start();
|
package/src/dispatch.ts
CHANGED
|
@@ -31,6 +31,13 @@ export interface DispatchOptions {
|
|
|
31
31
|
* it cannot produce. `bin.ts` passes the real fd 2.
|
|
32
32
|
*/
|
|
33
33
|
readonly writeError?: (line: string) => void;
|
|
34
|
+
/**
|
|
35
|
+
* How the caller invoked this process, up to and including the subcommand — passed straight to
|
|
36
|
+
* `CommandContext.invocation` and read by the `fix:` lines that quote a whole command back.
|
|
37
|
+
* `create-ultimate` is the one caller that supplies it, because it is the one entry point whose
|
|
38
|
+
* name is not `x`.
|
|
39
|
+
*/
|
|
40
|
+
readonly invocation?: string;
|
|
34
41
|
}
|
|
35
42
|
|
|
36
43
|
/**
|
|
@@ -122,6 +129,7 @@ export async function dispatch(options: DispatchOptions): Promise<number> {
|
|
|
122
129
|
runner: options.runner ?? exec,
|
|
123
130
|
env: options.env,
|
|
124
131
|
bunVersion: options.bunVersion,
|
|
132
|
+
...(options.invocation === undefined ? {} : { invocation: options.invocation }),
|
|
125
133
|
};
|
|
126
134
|
|
|
127
135
|
try {
|
package/src/error-catalog.ts
CHANGED
|
@@ -44,12 +44,22 @@ export const CATALOG_PACKAGES = [
|
|
|
44
44
|
'@ultimat3/ui',
|
|
45
45
|
] as const;
|
|
46
46
|
|
|
47
|
+
/**
|
|
48
|
+
* The two packages the catalog may import WITHOUT `@ultimat3/cli` declaring them: they reach for a
|
|
49
|
+
* JSX runtime an app has and a bare CLI process does not, so a hard dependency would make the CLI
|
|
50
|
+
* uninstallable where the codes are merely absent today. Every other entry above is a real runtime
|
|
51
|
+
* import and must be a declared dependency — `error-catalog.test.ts` holds the list to exactly that,
|
|
52
|
+
* because an undeclared one resolves through workspace symlinks here and through nothing in an
|
|
53
|
+
* installed app, where `x errors explain X_FLAG_EXPIRED` then refuses a code the wiki promises.
|
|
54
|
+
*/
|
|
55
|
+
export const CATALOG_OPTIONAL_HOSTS: readonly string[] = ['@ultimat3/admin', '@ultimat3/ui'];
|
|
56
|
+
|
|
47
57
|
export interface ErrorCatalog {
|
|
48
58
|
/** Packages whose codes are now registered. */
|
|
49
59
|
readonly loaded: readonly string[];
|
|
50
60
|
/**
|
|
51
61
|
* Packages this process could not *resolve*, so their codes are absent from the answer. The one
|
|
52
|
-
* tolerated case is the optional host:
|
|
62
|
+
* tolerated case is the optional host: `CATALOG_OPTIONAL_HOSTS` reach for a JSX
|
|
53
63
|
* runtime an app has and a bare CLI process does not, and a list silently missing their codes is
|
|
54
64
|
* worse than one that says which packages are missing. A package that resolved and then threw is
|
|
55
65
|
* a defect, not a host gap, and goes to `failed`.
|
package/src/error-codes.ts
CHANGED
|
@@ -21,6 +21,9 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
21
21
|
'X_JOB_UNKNOWN',
|
|
22
22
|
'X_FIX_TARGET_UNKNOWN',
|
|
23
23
|
'X_ERROR_FIX_INVALID',
|
|
24
|
+
// The second half of the same contract: X_ERROR_FIX_INVALID means the fix is not an instruction,
|
|
25
|
+
// this one means it is one and cites a file that is not there. Two conditions, two repairs.
|
|
26
|
+
'X_ERROR_FIX_PATH_MISSING',
|
|
24
27
|
'X_ERROR_CODE_UNDOCUMENTED',
|
|
25
28
|
'X_ERROR_CODE_UNREGISTERED',
|
|
26
29
|
// Reported as `Finding`s rather than thrown, and unregistered until now because of it — so
|
|
@@ -46,6 +49,11 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
46
49
|
'X_STORAGE_SECRET_DEV',
|
|
47
50
|
'X_MANIFEST_STALE',
|
|
48
51
|
'X_BUDGET_UNMEASURED',
|
|
52
|
+
// The other half of #271, and the half no runtime can raise: a route reads a live hook and boots
|
|
53
|
+
// no module in a browser, so its rows have nowhere to arrive and the page renders its loading
|
|
54
|
+
// branch forever, at 200. Only this package can see it — `@ultimat3/realtime` cannot see a route
|
|
55
|
+
// and `@ultimat3/render` may not import realtime.
|
|
56
|
+
'X_LIVE_ROUTE_NO_ISLAND',
|
|
49
57
|
'X_BUILD_FAILED',
|
|
50
58
|
'X_BUILD_ENTRY_MISSING',
|
|
51
59
|
'X_DEPLOY_FAILED',
|
|
@@ -160,6 +168,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
160
168
|
X_JOB_UNKNOWN: 'the queue holds no job with this id',
|
|
161
169
|
X_FIX_TARGET_UNKNOWN: 'the named file is not one of the app source files',
|
|
162
170
|
X_ERROR_FIX_INVALID: "an error's fix line is not a runnable instruction",
|
|
171
|
+
X_ERROR_FIX_PATH_MISSING: "an error's fix line cites a file this repository does not have",
|
|
163
172
|
X_ERROR_CODE_UNDOCUMENTED: 'a shipped error code has no row in the error reference',
|
|
164
173
|
X_ERROR_CODE_UNREGISTERED: 'the error reference documents a code no package registers',
|
|
165
174
|
X_STORAGE_UNWRITABLE: 'the storage disk this process needs cannot be written to',
|
|
@@ -175,6 +184,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
175
184
|
X_RELEASE_VERSION_SKEW: 'a workspace is not at the lockstep version',
|
|
176
185
|
X_MANIFEST_STALE: 'openapi.json is stale',
|
|
177
186
|
X_BUDGET_UNMEASURED: 'a route declares a budget the build never measured',
|
|
187
|
+
X_LIVE_ROUTE_NO_ISLAND: 'a route reads live rows and boots nothing that could receive them',
|
|
178
188
|
X_BUILD_FAILED: 'x build failed',
|
|
179
189
|
X_BUILD_ENTRY_MISSING: "the build target's entry file is not in the app",
|
|
180
190
|
X_DEPLOY_FAILED: 'a deploy step failed',
|
package/src/error-contract.ts
CHANGED
|
@@ -9,6 +9,7 @@ import { join } from 'node:path';
|
|
|
9
9
|
import { ERROR_DOCS_URL } from '@ultimat3/core';
|
|
10
10
|
import { citedCommandProblem, loadCommandCatalog } from './fix-command';
|
|
11
11
|
import { createHelperResolver } from './fix-imports';
|
|
12
|
+
import { citedPathProblem, FILE_TOKEN_PATTERN } from './fix-path';
|
|
12
13
|
import { scanFixSites } from './fix-scan';
|
|
13
14
|
import type { Finding } from './output';
|
|
14
15
|
import { eachSourceFile, isGenerated, isTest } from './source-files';
|
|
@@ -38,7 +39,9 @@ export const COMMAND_TOKENS: readonly RegExp[] = [
|
|
|
38
39
|
/(?:^|[\s;|&("'`])x\s+[a-z][a-z-]*/,
|
|
39
40
|
/\b(?:bun|bunx|npm|npx|node|git|docker|kubectl|helm|psql|curl|openssl|biome|tsc)\b/,
|
|
40
41
|
/\b[A-Za-z_$][\w$]*\(/,
|
|
41
|
-
|
|
42
|
+
// Built from `fix-path.ts`'s extension list, because the token that makes a fix count as an
|
|
43
|
+
// instruction is exactly the token `citedPathProblem` then has to resolve.
|
|
44
|
+
new RegExp(FILE_TOKEN_PATTERN),
|
|
42
45
|
/\b(?:app\.config\.ts|package\.json|tsconfig\.json|bunfig\.toml|\.env(?:\.[\w.-]+)?)\b/,
|
|
43
46
|
];
|
|
44
47
|
|
|
@@ -67,6 +70,19 @@ const fixFinding = (site: FixSite, problem: string): Finding => ({
|
|
|
67
70
|
at: `${site.at}:${site.line}`,
|
|
68
71
|
});
|
|
69
72
|
|
|
73
|
+
/**
|
|
74
|
+
* Its own code, not `X_ERROR_FIX_INVALID`: that one means the fix is not an instruction, and this
|
|
75
|
+
* one means it IS one and points at nothing. The repairs are different — rewrite the sentence
|
|
76
|
+
* versus correct the path — and one code for both would hand two readers the same wrong edit.
|
|
77
|
+
*/
|
|
78
|
+
const pathFinding = (site: FixSite, problem: string): Finding => ({
|
|
79
|
+
code: 'X_ERROR_FIX_PATH_MISSING',
|
|
80
|
+
cause: `the fix at ${site.at}:${site.line} ${problem}`,
|
|
81
|
+
fix: `correct the path in the fix at ${site.at}:${site.line} to one this repo holds, or create the file it names`,
|
|
82
|
+
docs: ERROR_DOCS_URL,
|
|
83
|
+
at: `${site.at}:${site.line}`,
|
|
84
|
+
});
|
|
85
|
+
|
|
70
86
|
/**
|
|
71
87
|
* Every `fix:` an agent can be handed, read out of shipped source and held to BOTH rules: it must
|
|
72
88
|
* be an instruction, and any `x <command>` it cites must be one this build ships.
|
|
@@ -110,7 +126,14 @@ export async function checkErrorFixReport(root: string): Promise<ErrorFixReport>
|
|
|
110
126
|
// resolve, and reading `<value>` as one would be a finding nobody can act on.
|
|
111
127
|
const fix = staticFix(site.fix);
|
|
112
128
|
const problem = fixProblem(site.fix) ?? citedCommandProblem(fix, catalog);
|
|
113
|
-
if (problem !== undefined)
|
|
129
|
+
if (problem !== undefined) {
|
|
130
|
+
findings.push(fixFinding(site, problem));
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
// Third rule, and the one nothing resolved: a fix that names a file this repo does not have.
|
|
134
|
+
// Reported only where the first two hold — a line already being rewritten needs one finding.
|
|
135
|
+
const missing = await citedPathProblem(fix, root);
|
|
136
|
+
if (missing !== undefined) findings.push(pathFinding(site, missing));
|
|
114
137
|
}
|
|
115
138
|
}
|
|
116
139
|
return { findings, checked, unreadable };
|