@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.
@@ -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
@@ -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 other two close the same shape of failure — a wrong answer `Bun.build` reports as
88
- // `success: true`: without the second, `target: 'browser'` resolves the `development`
89
- // export condition and the chunk carries Solid's dev build; without the third, Bun's file
90
- // loader resolves a `.module.scss` to its asset PATH, so `styles['x']` is `undefined` and
91
- // every element renders unclassed.
92
- plugins: [solidJsxPlugin, solidProductionPlugin, islandStylesPlugin],
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
- 'cli.new.done': 'created {name} — next: cd {name} && bin/setup && x dev',
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 subcommand = help ? undefined : readSubcommand(spec, positionals);
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
- positionals: subcommand === undefined ? positionals : positionals.slice(1),
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
- function readSubcommand(spec: CommandSpec, positionals: readonly string[]): string | undefined {
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 undefined;
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) return spec.defaultSubcommand;
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
- ...(options.runtime === undefined ? {} : { overrides: options.runtime }),
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
- x dev # all roles in one process, embedded Postgres, /_x mounted
68
- x verify # the gate: typecheck, lint, boundaries, tests, drift, budgets
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
- echo "setup complete — next: x dev"
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. */