@ultimat3/cli 11.2.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 +52 -2
- package/README.md +4 -4
- package/package.json +28 -28
- package/src/app-permissions.ts +0 -0
- package/src/browser-launcher.ts +79 -9
- 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/cmd-shot-island.ts +5 -0
- package/src/cmd-shot.ts +17 -10
- package/src/compile-externals.ts +7 -4
- 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/island-bundle.ts +18 -7
- 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/shot-browser.ts +84 -0
- 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
- package/src/island-solid-production.ts +0 -129
package/src/i18n-registration.ts
CHANGED
|
@@ -4,6 +4,9 @@
|
|
|
4
4
|
// source against files on disk and was green for every string of a shipped app whose catalog
|
|
5
5
|
// module nothing imported (issue #249).
|
|
6
6
|
|
|
7
|
+
// why: Bun ships no path API, so `join` is the only way to reach the app's own i18n module on
|
|
8
|
+
// the host's separator.
|
|
9
|
+
import { join } from 'node:path';
|
|
7
10
|
import type { Catalog, Extraction, ExtractReport, Locale } from '@ultimat3/i18n';
|
|
8
11
|
import {
|
|
9
12
|
auditCatalogs,
|
|
@@ -17,9 +20,10 @@ import {
|
|
|
17
20
|
} from '@ultimat3/i18n';
|
|
18
21
|
import { loadApp } from './app-load';
|
|
19
22
|
import { auditApp } from './i18n-audit';
|
|
23
|
+
import { I18N_INDEX_PATH } from './i18n-index';
|
|
20
24
|
import type { Finding } from './output';
|
|
21
25
|
import { findingFrom } from './output';
|
|
22
|
-
import { catalogPath } from './templates/locales';
|
|
26
|
+
import { CATALOG_ROOT, catalogPath } from './templates/locales';
|
|
23
27
|
|
|
24
28
|
/**
|
|
25
29
|
* What this check needs of a boot. The seam is injected so a fixture can be exactly "the app
|
|
@@ -75,8 +79,10 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
75
79
|
const app = await (input.load ?? loadApp)(input.root);
|
|
76
80
|
|
|
77
81
|
const gaps = catalogRegistrationGaps(input.catalogs);
|
|
82
|
+
const index = await indexSource(input.root);
|
|
78
83
|
const findings: Finding[] = gaps.map((gap) => ({
|
|
79
84
|
...findingFrom(catalogUnregistered(gap)),
|
|
85
|
+
...unregisteredFix(gap.locale, index),
|
|
80
86
|
at: catalogPath(gap.locale),
|
|
81
87
|
}));
|
|
82
88
|
let unregistered = gaps.reduce((sum, gap) => sum + gap.missing.length, 0);
|
|
@@ -105,6 +111,42 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
105
111
|
};
|
|
106
112
|
}
|
|
107
113
|
|
|
114
|
+
/**
|
|
115
|
+
* `packages/i18n/src/index.ts` as text, or `undefined` where the app has no i18n package. Read
|
|
116
|
+
* here and nowhere lower down: `@ultimat3/i18n` states that it never reads a file, and the CLI is
|
|
117
|
+
* the half that knows what an app's directories are.
|
|
118
|
+
*/
|
|
119
|
+
async function indexSource(root: string): Promise<string | undefined> {
|
|
120
|
+
const file = Bun.file(join(root, I18N_INDEX_PATH));
|
|
121
|
+
return (await file.exists()) ? file.text() : undefined;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* `X_CATALOG_UNREGISTERED` is one code over two causes, and until now it printed one fix for both.
|
|
126
|
+
* `@ultimat3/i18n`'s is written for the app whose `defineCatalogs()` call is in a module nothing
|
|
127
|
+
* imports — "move it into packages/i18n/src/index.ts (where `x new` puts it)". For a locale added
|
|
128
|
+
* by `x i18n add` the call is ALREADY there and the locale is simply not in its `locales:` map, so
|
|
129
|
+
* that instruction names an edit with nothing to perform: an agent following it verbatim changes
|
|
130
|
+
* nothing, re-runs, and is red again, on the command whose whole job is adding a locale (#F4).
|
|
131
|
+
*
|
|
132
|
+
* The condition is narrow enough that the finding never has to be argued with — the index exists,
|
|
133
|
+
* and the locale's tag appears nowhere in it — and the replacement is a command that performs the
|
|
134
|
+
* registration rather than describing it.
|
|
135
|
+
*/
|
|
136
|
+
export function unregisteredFix(
|
|
137
|
+
locale: string,
|
|
138
|
+
index: string | undefined,
|
|
139
|
+
): { readonly fix?: string } {
|
|
140
|
+
if (index === undefined) return {};
|
|
141
|
+
// The index is GENERATED (`i18nIndex`), so the one spelling that matters is the import it writes
|
|
142
|
+
// — `catalogs/<tag>.json`. Matching a bare tag instead would read `en` out of the word `key` and
|
|
143
|
+
// report a registered locale as unregistered, which is the direction that costs trust.
|
|
144
|
+
if (index.includes(`${CATALOG_ROOT.split('/').pop() ?? 'catalogs'}/${locale}.json`)) return {};
|
|
145
|
+
return {
|
|
146
|
+
fix: `x i18n sync ${locale} # re-derives ${I18N_INDEX_PATH} from the catalogs on disk`,
|
|
147
|
+
};
|
|
148
|
+
}
|
|
149
|
+
|
|
108
150
|
/**
|
|
109
151
|
* `⟦key⟧` — `@ultimat3/i18n`'s own loud miss, spelled ONCE for the whole CLI. `x i18n sync <default>`
|
|
110
152
|
* writes it and the two checks below refuse it, so a second spelling would be a placeholder one
|
package/src/island-bundle.ts
CHANGED
|
@@ -10,7 +10,6 @@ import { renderThrowable } from '@ultimat3/core';
|
|
|
10
10
|
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
11
11
|
import { contentHash } from '@ultimat3/render/server';
|
|
12
12
|
import { IslandBuildFailedError } from './errors';
|
|
13
|
-
import { solidProductionPlugin } from './island-solid-production';
|
|
14
13
|
import { islandStylesPlugin } from './island-styles';
|
|
15
14
|
import { solidJsxPlugin } from './solid-loader';
|
|
16
15
|
|
|
@@ -84,12 +83,24 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
84
83
|
// classic `React.createElement` — emitted into a browser chunk that imports no React, with
|
|
85
84
|
// `success: true` and no log. Every island shipped that way through five majors.
|
|
86
85
|
//
|
|
87
|
-
// The
|
|
88
|
-
// `success: true`: without
|
|
89
|
-
//
|
|
90
|
-
|
|
91
|
-
//
|
|
92
|
-
|
|
86
|
+
// The second closes the same shape of failure — a wrong answer `Bun.build` reports as
|
|
87
|
+
// `success: true`: without it, Bun's file loader resolves a `.module.scss` to its asset
|
|
88
|
+
// PATH, so `styles['x']` is `undefined` and every element renders unclassed.
|
|
89
|
+
plugins: [solidJsxPlugin, islandStylesPlugin],
|
|
90
|
+
// The third one, and it is a `define` rather than the plugin this used to be: Bun selects
|
|
91
|
+
// the `development`/`production` export condition from the BUILD PROCESS's own `NODE_ENV`,
|
|
92
|
+
// and a defined `process.env.NODE_ENV` overrides it. Measured on 1.4.0, `solid-js` plus
|
|
93
|
+
// `solid-js/web` plus `solid-js/store`: unset → dev build, `test` → dev build, `production`
|
|
94
|
+
// → production build, this line → production build in all three, byte for byte.
|
|
95
|
+
//
|
|
96
|
+
// So without it a chunk built anywhere a container did not run — `x dev`, `x build` on a
|
|
97
|
+
// laptop, `bun test` — ships Solid's development build, and the island's own
|
|
98
|
+
// `process.env.NODE_ENV` reads `"development"` in the file a browser downloads.
|
|
99
|
+
//
|
|
100
|
+
// Pinned rather than inherited, because an island chunk is only ever built to be shipped:
|
|
101
|
+
// `x dev` serves the same chunk the container does, and bytes that depend on the ambient
|
|
102
|
+
// NODE_ENV are a content hash and a byte budget measured on a build nobody ships.
|
|
103
|
+
define: { 'process.env.NODE_ENV': '"production"' },
|
|
93
104
|
});
|
|
94
105
|
} catch (error) {
|
|
95
106
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
package/src/mcp-errors.ts
CHANGED
|
@@ -99,6 +99,18 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
99
99
|
X_STORAGE_UNWRITABLE: 'x doctor --json',
|
|
100
100
|
X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
|
|
101
101
|
X_MANIFEST_STALE: 'x manifest --json',
|
|
102
|
+
// The file itself, absent. One command writes it, and `bin/setup` now runs that command — so
|
|
103
|
+
// the fix here is the same one the gate's finding carries rather than a second phrasing.
|
|
104
|
+
X_MANIFEST_MISSING: 'x manifest --json',
|
|
105
|
+
// `@ultimat3/policy`'s code, and the CLI is the surface an agent reaches it from: `x policy list`
|
|
106
|
+
// is the only thing that prints the set the permission is missing from. The declare-it half is
|
|
107
|
+
// an edit to the app's own `definePermissions([...])`, which no command can perform.
|
|
108
|
+
X_PERMISSION_UNKNOWN:
|
|
109
|
+
'x policy list --json # then add the permission to the app definePermissions([...]) call, or fix the typo',
|
|
110
|
+
// `@ultimat3/db`'s code, reported by `x doctor`'s probe. Both branches of db's own fix are an
|
|
111
|
+
// environment edit, so the runnable half is the probe that says which one is needed.
|
|
112
|
+
X_DB_UNAVAILABLE:
|
|
113
|
+
'x doctor --json # set DATABASE_URL to a reachable Postgres url, or unset it for embedded PGlite',
|
|
102
114
|
// `--target static`, not a bare `x build`: `--target` defaults to `docker`, and only the static
|
|
103
115
|
// target runs `apps/web/prerender.ts` — the one caller of `writeBuildStats`. Without the flag
|
|
104
116
|
// this fix builds an image, writes no `.x/build-stats.json`, and the next `x verify` reports the
|
package/src/messages.ts
CHANGED
|
@@ -140,7 +140,12 @@ const CATALOG = {
|
|
|
140
140
|
// `.env.development.local`, runs `x db gen "initial"` (the scaffold writes no migration, so the
|
|
141
141
|
// drift step is red until it has), migrates and seeds. The four-command line this replaced named
|
|
142
142
|
// `x dev` off a tree where nothing had installed the CLI yet, and skipped the seed entirely.
|
|
143
|
-
|
|
143
|
+
// `bin/dev`, never `x dev`: `bun install` links the binary into `./node_modules/.bin` and
|
|
144
|
+
// nowhere else, so the bare `x` this line printed is not on PATH in the shell it is pasted into
|
|
145
|
+
// (proved with `env -i PATH=… command -v x`). The scaffold's own `bin/` wrappers are the form
|
|
146
|
+
// that works from a fresh clone, and `bin/setup` already uses `bunx x` internally for this
|
|
147
|
+
// reason.
|
|
148
|
+
'cli.new.done': 'created {name} — next: cd {name} && bin/setup && bin/dev',
|
|
144
149
|
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
145
150
|
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
146
151
|
// command is a broken one — the same split `Finding.fix` already makes.
|
package/src/parse.ts
CHANGED
|
@@ -47,6 +47,19 @@ export interface CommandSpec {
|
|
|
47
47
|
* sorted first. A command with no defensible default omits this and the parser refuses instead.
|
|
48
48
|
*/
|
|
49
49
|
readonly defaultSubcommand?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Whether a first word that is NOT a subcommand is an ARGUMENT to `defaultSubcommand` rather
|
|
52
|
+
* than a misspelt one. Declared per command, never inferred, because only some commands can say
|
|
53
|
+
* it truthfully: `x errors X_PERMISSION_UNKNOWN` can only be a code, and `x jobs 4f2a` is
|
|
54
|
+
* genuinely ambiguous with `show`, so an unconditional fallback would turn `x jobs <id>` into a
|
|
55
|
+
* silent `x jobs ls` that ignores the id.
|
|
56
|
+
*
|
|
57
|
+
* `x errors X_PERMISSION_UNKNOWN --json` answered `X_CLI_UNKNOWN_COMMAND … fix: x help`, and
|
|
58
|
+
* `x help` prints `errors an X_* code, explained` — which reads as exactly the form that was
|
|
59
|
+
* refused (#F16). A near miss is still refused with its suggestion, so `x errors explan X_FOO`
|
|
60
|
+
* does not quietly become a lookup of the code `explan`.
|
|
61
|
+
*/
|
|
62
|
+
readonly defaultSubcommandTakesPositional?: boolean;
|
|
50
63
|
/**
|
|
51
64
|
* A closed set the FIRST positional must come from, where the command has one. Declarative only:
|
|
52
65
|
* the parser leaves positionals to the command, because `x test`'s own `readOnlyType` already
|
|
@@ -232,12 +245,16 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
232
245
|
// asking what the usage is, on every command that takes a subcommand. Help is answered by
|
|
233
246
|
// `dispatch`, which needs only the command name.
|
|
234
247
|
const help = flags.get('help') === true;
|
|
235
|
-
const
|
|
248
|
+
const resolved = help ? NO_SUBCOMMAND : readSubcommand(spec, positionals);
|
|
249
|
+
const subcommand = resolved.name;
|
|
236
250
|
if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
|
|
237
251
|
return {
|
|
238
252
|
command: spec.name,
|
|
239
253
|
subcommand,
|
|
240
|
-
|
|
254
|
+
// `consumed`, never `subcommand !== undefined`: a default subcommand the caller did not TYPE
|
|
255
|
+
// leaves its first positional in place, which is what makes `x errors X_DB_DRIFT` the same
|
|
256
|
+
// invocation as `x errors explain X_DB_DRIFT` instead of one with its argument eaten.
|
|
257
|
+
positionals: resolved.consumed ? positionals.slice(1) : positionals,
|
|
241
258
|
flags,
|
|
242
259
|
json: flags.get('json') === true,
|
|
243
260
|
help,
|
|
@@ -278,16 +295,32 @@ function splitInline(raw: string): [string, string | undefined] {
|
|
|
278
295
|
return [raw.slice(0, eq), raw.slice(eq + 1)];
|
|
279
296
|
}
|
|
280
297
|
|
|
281
|
-
|
|
298
|
+
/** Which subcommand ran, and whether the caller's first positional is what named it. */
|
|
299
|
+
interface ResolvedSubcommand {
|
|
300
|
+
readonly name: string | undefined;
|
|
301
|
+
readonly consumed: boolean;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
const NO_SUBCOMMAND: ResolvedSubcommand = { name: undefined, consumed: false };
|
|
305
|
+
|
|
306
|
+
function readSubcommand(spec: CommandSpec, positionals: readonly string[]): ResolvedSubcommand {
|
|
282
307
|
const allowed = spec.subcommands;
|
|
283
|
-
if (allowed === undefined || allowed.length === 0) return
|
|
308
|
+
if (allowed === undefined || allowed.length === 0) return NO_SUBCOMMAND;
|
|
284
309
|
const token = positionals[0];
|
|
285
310
|
if (token === undefined) {
|
|
286
|
-
if (spec.defaultSubcommand !== undefined)
|
|
311
|
+
if (spec.defaultSubcommand !== undefined) {
|
|
312
|
+
return { name: spec.defaultSubcommand, consumed: false };
|
|
313
|
+
}
|
|
287
314
|
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
288
315
|
}
|
|
289
|
-
if (allowed.includes(token)) return token;
|
|
316
|
+
if (allowed.includes(token)) return { name: token, consumed: true };
|
|
290
317
|
const suggestion = nearestName(token, allowed);
|
|
318
|
+
// The declared fallback, and only past the near-miss guard: a word within `nearestName`'s edit
|
|
319
|
+
// budget of a real subcommand is a typo, and reading it as the default subcommand's argument
|
|
320
|
+
// would answer a question nobody asked.
|
|
321
|
+
if (spec.defaultSubcommandTakesPositional === true && suggestion === undefined) {
|
|
322
|
+
return { name: spec.defaultSubcommand, consumed: false };
|
|
323
|
+
}
|
|
291
324
|
throw new UnknownCommandError(
|
|
292
325
|
suggestion === undefined
|
|
293
326
|
? { path: `${spec.name} ${token}`, known: allowed }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Can this process bind that port? One implementation, because two commands ask it and they must
|
|
2
|
+
// not disagree: `x doctor` reports it as a finding, and `startSync` asks it after a bind failure to
|
|
3
|
+
// name the real cause instead of rendering a caught `Error` into a refusal.
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Binds and immediately releases. `Bun.serve({ port: 0 })` ALWAYS succeeds — the kernel picks —
|
|
7
|
+
* so 0 is answered `true` without opening anything: a probe that cannot fail is worse than none,
|
|
8
|
+
* and `x dev --port 0` genuinely has no port to be in use.
|
|
9
|
+
*/
|
|
10
|
+
export async function portFree(port: number): Promise<boolean> {
|
|
11
|
+
if (port === 0) return true;
|
|
12
|
+
try {
|
|
13
|
+
const server = Bun.serve({ port, fetch: () => new Response('') });
|
|
14
|
+
await server.stop(true);
|
|
15
|
+
return true;
|
|
16
|
+
} catch {
|
|
17
|
+
// Deliberately swallowed and never rendered: the caught value is `Bun.serve`'s own
|
|
18
|
+
// `Failed to start server. Is port N in use?`, and interpolating it into a `cause:` is exactly
|
|
19
|
+
// what `scripts/catch-render.ts` refuses. The ANSWER is the boolean; the caller owns the words.
|
|
20
|
+
return false;
|
|
21
|
+
}
|
|
22
|
+
}
|
package/src/serve.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { appManifest } from './app-manifest';
|
|
|
28
28
|
import { assetRoutes } from './dev-assets';
|
|
29
29
|
import { startQueue } from './dev-queue';
|
|
30
30
|
import { appRoutes } from './dev-render';
|
|
31
|
+
import { replicaOverrides } from './dev-replica';
|
|
31
32
|
import type { RunningRoles, WebBinding } from './dev-roles';
|
|
32
33
|
import { startRoles } from './dev-roles';
|
|
33
34
|
import type { RunningServices } from './dev-runtime';
|
|
@@ -317,6 +318,7 @@ async function bootRoles(boot: {
|
|
|
317
318
|
// fixed 9090 would fail the next suite to boot beside it. An environment that names the port
|
|
318
319
|
// still wins — that is the deploy talking.
|
|
319
320
|
const metricsPort = metricsPortFor(options.env, port, options.metricsPort);
|
|
321
|
+
const replicaOverride = replicaOverrides(options.runtime, runtime.services.db, options.env);
|
|
320
322
|
const running = await startRoles({
|
|
321
323
|
roles: [role],
|
|
322
324
|
port,
|
|
@@ -332,7 +334,11 @@ async function bootRoles(boot: {
|
|
|
332
334
|
// process and `x dev` cannot answer a browser differently.
|
|
333
335
|
root: options.root,
|
|
334
336
|
http: CONTAINER_BINDING,
|
|
335
|
-
|
|
337
|
+
// The read-replica scope rides in FRONT of whatever the host supplied, or the host's own value
|
|
338
|
+
// passes through untouched. `DATABASE_REPLICA_URL` was read by no booted process before this:
|
|
339
|
+
// `defaultClient()` is the one composer of a replicated pair and it runs only when an app
|
|
340
|
+
// installed no client, which no framework boot leaves true (`dev-queue.ts`).
|
|
341
|
+
...(replicaOverride === undefined ? {} : { overrides: replicaOverride }),
|
|
336
342
|
});
|
|
337
343
|
acquired.push(() => running.stop());
|
|
338
344
|
return {
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
// Which browser a `x shot` run gets: start one in this container, or ATTACH to one somebody else is
|
|
2
|
+
// running. Three rules over plain inputs and no `ParsedArgs`, so each is testable without a boot —
|
|
3
|
+
// the `cmd-jobs.ts` / `jobs-report.ts` split, repeated for the one decision that is easy to get
|
|
4
|
+
// silently wrong.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
browserBinaryExists,
|
|
8
|
+
cdpUrlFrom,
|
|
9
|
+
cdpUrlProblem,
|
|
10
|
+
executablePathFrom,
|
|
11
|
+
} from './browser-launcher';
|
|
12
|
+
import { BadFlagError } from './errors';
|
|
13
|
+
|
|
14
|
+
/** A runnable example, not a placeholder: every refusal below hands one of these back. */
|
|
15
|
+
const CDP_FIX = 'x shot / --cdp-url wss://cdp.example.com/session/abc';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Exactly one of these is set. `undefined` on both is the ordinary local run where the library
|
|
19
|
+
* finds its own Chrome — which is why neither is required rather than a union of two shapes.
|
|
20
|
+
*/
|
|
21
|
+
export interface ShotBrowserChoice {
|
|
22
|
+
/** Attach here. When set, nothing about a local executable was read. */
|
|
23
|
+
readonly cdpUrl?: string | undefined;
|
|
24
|
+
/** Launch this. Already proved to exist on disk. */
|
|
25
|
+
readonly executablePath?: string | undefined;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export interface ShotBrowserInput {
|
|
29
|
+
/** `--cdp-url` as typed. The env fallback is applied here, not by the caller. */
|
|
30
|
+
readonly cdpFlag?: string | undefined;
|
|
31
|
+
/** `--browser` as typed, before `PUPPETEER_EXECUTABLE_PATH` / `CHROME_PATH`. */
|
|
32
|
+
readonly browserFlag?: string | undefined;
|
|
33
|
+
readonly env: Readonly<Record<string, string | undefined>>;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Decided before anything boots, because a typo must not cost an embedded Postgres — and, on the
|
|
38
|
+
* attach path, a provider session — to report.
|
|
39
|
+
*
|
|
40
|
+
* The three rules, each chosen against a silent failure rather than for symmetry:
|
|
41
|
+
*
|
|
42
|
+
* 1. **Both FLAGS is refused, never ranked.** One names a Chrome to START and the other says the
|
|
43
|
+
* browser is somebody else's, so honouring either ignores what was typed.
|
|
44
|
+
* 2. **An exported `SCRAPE_CDP_URL` loses to `--browser`.** A shell-wide default is not a typed
|
|
45
|
+
* intent. The alternative is a flag that parses, reports nothing and quietly attaches somewhere
|
|
46
|
+
* else — the `--critical` defect class `flag-reads.ts` exists for and cannot see here, because
|
|
47
|
+
* the flag IS read.
|
|
48
|
+
* 3. **On an attach, no executable is read at all.** Checking the filesystem for a binary this run
|
|
49
|
+
* will never execute is how a correct remote capture gets refused on a box with no Chrome.
|
|
50
|
+
*/
|
|
51
|
+
export function shotBrowserChoice(input: ShotBrowserInput): ShotBrowserChoice {
|
|
52
|
+
if (input.cdpFlag !== undefined && input.browserFlag !== undefined) {
|
|
53
|
+
throw new BadFlagError({
|
|
54
|
+
flag: 'cdp-url',
|
|
55
|
+
command: 'shot',
|
|
56
|
+
reason:
|
|
57
|
+
'--browser names a Chrome to launch here and --cdp-url attaches to one already running',
|
|
58
|
+
fix: CDP_FIX,
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
const cdpUrl = input.browserFlag === undefined ? cdpUrlFrom(input.cdpFlag, input.env) : undefined;
|
|
62
|
+
if (cdpUrl !== undefined) {
|
|
63
|
+
const problem = cdpUrlProblem(cdpUrl);
|
|
64
|
+
if (problem !== undefined) {
|
|
65
|
+
throw new BadFlagError({
|
|
66
|
+
flag: 'cdp-url',
|
|
67
|
+
command: 'shot',
|
|
68
|
+
reason: `"${cdpUrl}" ${problem}`,
|
|
69
|
+
fix: CDP_FIX,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
return { cdpUrl };
|
|
73
|
+
}
|
|
74
|
+
const executablePath = executablePathFrom(input.browserFlag, input.env);
|
|
75
|
+
if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
|
|
76
|
+
throw new BadFlagError({
|
|
77
|
+
flag: 'browser',
|
|
78
|
+
command: 'shot',
|
|
79
|
+
reason: `no executable at "${executablePath}"`,
|
|
80
|
+
fix: 'x shot / --browser /usr/bin/chromium',
|
|
81
|
+
});
|
|
82
|
+
}
|
|
83
|
+
return executablePath === undefined ? {} : { executablePath };
|
|
84
|
+
}
|
|
@@ -7,6 +7,7 @@ import type { GeneratedFile, NameSet } from './naming';
|
|
|
7
7
|
import { apiFiles } from './scaffold-api';
|
|
8
8
|
import { authFiles } from './scaffold-auth';
|
|
9
9
|
import { entryFiles } from './scaffold-entries';
|
|
10
|
+
import { httpFiles } from './scaffold-http';
|
|
10
11
|
import { icon } from './scaffold-icon';
|
|
11
12
|
import { rolesFiles } from './scaffold-roles';
|
|
12
13
|
|
|
@@ -401,6 +402,7 @@ export function appFiles(app: NameSet, example: boolean): readonly GeneratedFile
|
|
|
401
402
|
// The app's role map, beside the actor that reads it. `shared/` and not a feature folder:
|
|
402
403
|
// `defineRoles()` merges, so a per-feature call is legal and is how an app ends up with no
|
|
403
404
|
// answer to "which roles exist?" — see `scaffold-roles.ts`.
|
|
405
|
+
...httpFiles(app),
|
|
404
406
|
...rolesFiles(),
|
|
405
407
|
{ path: 'apps/admin/package.json', contents: adminPackage(app) },
|
|
406
408
|
{ path: 'apps/admin/tsconfig.json', contents: tsconfig() },
|
|
@@ -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. */
|