@ultimat3/cli 11.3.0 → 13.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.
@@ -0,0 +1,152 @@
1
+ // The framework's OWN tables, as one table: which package declares each, which relations it
2
+ // creates, and the DDL. One list, read by the one applier, so "installed in dev and not in
3
+ // production" is not a state this framework can be in — `startQueue` is on every boot path there
4
+ // is, `ROLE=migrate` included.
5
+ //
6
+ // A framework table an app has to install by hand is a table that will be missing in production on
7
+ // the one code path that needs it, and it surfaces as a Postgres `42P01` from inside a worker.
8
+
9
+ import { SQL_AUDIT_TABLE, SQL_IDEMPOTENCY_TABLE } from '@ultimat3/action';
10
+ import { AUTH_TABLE_NAMES, AUTH_TABLES, SQL_AUTH_LIMIT_TABLES } from '@ultimat3/auth';
11
+ import { SQL_RATE_LIMIT_TABLE } from '@ultimat3/http';
12
+ import { SQL_JOBS_TABLE } from '@ultimat3/jobs';
13
+ import { SQL_NOTIFY_DELIVERIES_TABLE, SQL_NOTIFY_INBOX_TABLE } from '@ultimat3/notify';
14
+ import { FrameworkSchemaFailedError } from './schema-errors';
15
+
16
+ export interface FrameworkSchema {
17
+ /** The package whose source declares the DDL — where to look when a column is wrong. */
18
+ readonly pkg: string;
19
+ /**
20
+ * Every relation this entry creates. Read by the refusal below, so an operator learns which
21
+ * tables were being installed rather than only which statement failed — and pinned against the
22
+ * DDL text by `framework-schema.test.ts`, so a row cannot claim a table its SQL never creates.
23
+ */
24
+ readonly tables: readonly string[];
25
+ /** One or more statements each; `;` separates. */
26
+ readonly ddl: readonly string[];
27
+ }
28
+
29
+ /**
30
+ * Applied unconditionally, whether or not this boot installs a store behind it.
31
+ *
32
+ * `create table if not exists` on an unused table costs one round trip at boot. The alternative
33
+ * costs a request: a store installed later must never be the thing that discovers the schema was
34
+ * never applied, and several of these are installed AFTER this runs — `defineAuth` builds its
35
+ * limiter when the app's modules import, and `setNotifyStores` is an app's boot line.
36
+ *
37
+ * Ordered so a foreign key never precedes its target. Only `AUTH_TABLES` has any, and they are
38
+ * internal to that entry, which is why it ships as an ordered list rather than one string.
39
+ */
40
+ export const FRAMEWORK_SCHEMA: readonly FrameworkSchema[] = Object.freeze([
41
+ Object.freeze({
42
+ pkg: '@ultimat3/jobs',
43
+ tables: Object.freeze([
44
+ 'x_jobs',
45
+ 'x_job_steps',
46
+ 'x_backfills',
47
+ 'x_outbox',
48
+ 'x_scheduler_state',
49
+ 'x_scheduler_leader',
50
+ 'x_job_leases',
51
+ 'x_job_events',
52
+ ]),
53
+ ddl: Object.freeze([SQL_JOBS_TABLE]),
54
+ }),
55
+ Object.freeze({
56
+ pkg: '@ultimat3/action',
57
+ tables: Object.freeze(['x_idempotency']),
58
+ ddl: Object.freeze([SQL_IDEMPOTENCY_TABLE]),
59
+ }),
60
+ // The DDL only, and deliberately NO `setAuditSink`: there is no default audit sink on purpose,
61
+ // so `X_AUDIT_SINK_MISSING` keeps firing at boot for an app that declares `audit: true` and
62
+ // installs none.
63
+ Object.freeze({
64
+ pkg: '@ultimat3/action',
65
+ tables: Object.freeze(['x_audit']),
66
+ ddl: Object.freeze([SQL_AUDIT_TABLE]),
67
+ }),
68
+ Object.freeze({
69
+ pkg: '@ultimat3/http',
70
+ tables: Object.freeze(['x_rate_limit']),
71
+ ddl: Object.freeze([SQL_RATE_LIMIT_TABLE]),
72
+ }),
73
+ Object.freeze({
74
+ pkg: '@ultimat3/auth',
75
+ tables: Object.freeze(['x_auth_failures', 'x_auth_lockouts']),
76
+ ddl: Object.freeze([SQL_AUTH_LIMIT_TABLES]),
77
+ }),
78
+ /**
79
+ * The five tables `BuiltinAdapter` reads, and the oldest hole in this list.
80
+ *
81
+ * `packages/auth/src/tables.ts` exports them "so an app can paste them into a migration", and
82
+ * nothing in the framework has ever applied them — while `x db gen` diffs `describeEntities()`
83
+ * and these are not `entity()` declarations, so neither half was a file anybody could
84
+ * hand-write. `examples/dummy/CLAUDE.md` records the consequence in its own words: nobody can
85
+ * hold a session in the reference app. Applied here on exactly the rule the rate-limit and audit
86
+ * rows already follow.
87
+ *
88
+ * `AUTH_TABLE_NAMES` rather than five literals: @ultimat3/auth already publishes the list, and a
89
+ * second copy is a second thing to keep right when a table is added.
90
+ */
91
+ Object.freeze({
92
+ pkg: '@ultimat3/auth',
93
+ tables: AUTH_TABLE_NAMES,
94
+ ddl: AUTH_TABLES,
95
+ }),
96
+ /**
97
+ * The delivery ledger is what stops a replayed notifier job sending twice, and it is the entry
98
+ * whose absence is least visible: without the table the ledger's first `claim` raises `42P01`
99
+ * from inside a worker, which reads as a dead-lettered notification rather than as a missing
100
+ * schema. Installed whether or not this boot calls `setNotifyStores`, for the same reason as
101
+ * every row above it — that call is an APP's boot line and runs after this one.
102
+ */
103
+ Object.freeze({
104
+ pkg: '@ultimat3/notify',
105
+ tables: Object.freeze(['x_notify_deliveries']),
106
+ ddl: Object.freeze([SQL_NOTIFY_DELIVERIES_TABLE]),
107
+ }),
108
+ Object.freeze({
109
+ pkg: '@ultimat3/notify',
110
+ tables: Object.freeze(['x_notify_inbox']),
111
+ ddl: Object.freeze([SQL_NOTIFY_INBOX_TABLE]),
112
+ }),
113
+ ]);
114
+
115
+ /** Every relation this boot creates, flattened. */
116
+ export const frameworkTableNames = (): readonly string[] =>
117
+ FRAMEWORK_SCHEMA.flatMap((entry) => [...entry.tables]);
118
+
119
+ /**
120
+ * PGlite speaks the extended protocol, which carries one statement per round trip, so the DDL is
121
+ * applied statement by statement. Safe to split on `;`: every constant is fixed, with no semicolon
122
+ * inside a literal, and each package's own SQL test is where that stays true.
123
+ */
124
+ export const schemaStatements = (ddl: readonly string[]): readonly string[] =>
125
+ ddl.flatMap((text) => text.split(';')).filter((statement) => statement.trim().length > 0);
126
+
127
+ /** One statement, executed. The caller owns the connection; this file owns no database import. */
128
+ export type SchemaExecutor = (statement: string) => Promise<unknown>;
129
+
130
+ /**
131
+ * Apply every entry, in order, and answer what was created.
132
+ *
133
+ * The refusal is the point of the `pkg`/`tables` columns: a raw `permission denied for schema
134
+ * public` names neither the framework table it was creating nor the package that wants it, and a
135
+ * boot failure is read by an operator who has no source tree open.
136
+ */
137
+ export async function applyFrameworkSchema(execute: SchemaExecutor): Promise<readonly string[]> {
138
+ for (const entry of FRAMEWORK_SCHEMA) {
139
+ for (const statement of schemaStatements(entry.ddl)) {
140
+ try {
141
+ await execute(statement);
142
+ } catch (error) {
143
+ throw new FrameworkSchemaFailedError({
144
+ pkg: entry.pkg,
145
+ tables: entry.tables,
146
+ cause: error,
147
+ });
148
+ }
149
+ }
150
+ }
151
+ return frameworkTableNames();
152
+ }
@@ -0,0 +1,40 @@
1
+ // The one writer of `packages/i18n/src/index.ts`, shared by `x g` and `x i18n add|sync`.
2
+ //
3
+ // A catalog file existing on disk and the app being able to SELECT that locale are two different
4
+ // facts, and only this closes the gap: the index hardcodes `locales: { en }`, so a locale whose
5
+ // catalog nothing registered renders `⟦key⟧` — which the gate's `i18n` step refuses outright
6
+ // (`X_CATALOG_UNREGISTERED`). `x i18n add fr` wrote the file, touched nothing else, and left
7
+ // `x verify` red with a fix line that named an edit nobody could perform (#F4).
8
+
9
+ // why: Bun has no synchronous existence check — `Bun.file(p).exists()` is async, and this decides
10
+ // whether to write at all, before any await the caller could interleave with.
11
+ import { existsSync } from 'node:fs';
12
+ import { containedPath } from './generate-write';
13
+ import { CATALOG_ROOT, i18nIndex } from './templates';
14
+
15
+ export const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
16
+
17
+ /** Every locale with a catalog on disk, sorted — the file names are the tags. */
18
+ export async function catalogLocales(root: string): Promise<readonly string[]> {
19
+ const catalogDir = containedPath(root, CATALOG_ROOT);
20
+ if (!existsSync(catalogDir)) return [];
21
+ const locales: string[] = [];
22
+ for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
23
+ locales.push(entry.replace(/\.json$/, ''));
24
+ }
25
+ return locales.sort();
26
+ }
27
+
28
+ /**
29
+ * Re-derives the FULL locale set from `packages/i18n/catalogs/` — never just the locale one
30
+ * invocation asked for — and rewrites the index to match. It bypasses `writeFiles` on purpose:
31
+ * this file is a projection of the catalog directory, never app-authored content a conflict check
32
+ * should protect. An app with no i18n package (deleted, or never scaffolded) is left alone, and
33
+ * that is what `written` reports.
34
+ */
35
+ export async function syncI18nIndex(root: string): Promise<boolean> {
36
+ const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
37
+ if (!existsSync(indexAbsolute)) return false;
38
+ await Bun.write(indexAbsolute, i18nIndex(await catalogLocales(root)));
39
+ return true;
40
+ }
@@ -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/index.ts CHANGED
@@ -196,6 +196,15 @@ export type { FixHelper, FixScan } from './fix-scan';
196
196
  export { scanFixes, scanFixHelpers, scanFixSites } from './fix-scan';
197
197
  export type { DeclaredFlag } from './flag-reads';
198
198
  export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
199
+ export type { FrameworkSchema, SchemaExecutor } from './framework-schema';
200
+ // The framework's own tables, as data. Exported so `scripts/` can read the applier's list without
201
+ // re-deriving it — the shape a ratchet over declared-but-never-applied DDL needs.
202
+ export {
203
+ applyFrameworkSchema,
204
+ FRAMEWORK_SCHEMA,
205
+ frameworkTableNames,
206
+ schemaStatements,
207
+ } from './framework-schema';
199
208
  export type { Guard } from './guards';
200
209
  export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
201
210
  // The island bundler, and only its entry point. An island is the one module Ultimate ships to a
@@ -239,6 +248,7 @@ export { flagBool, flagList, flagString, GLOBAL_FLAGS, nearest, parseArgs } from
239
248
  export type { PrerenderedPage, PrerenderOptions, PrerenderReport } from './prerender';
240
249
  export { DEFAULT_ORIGIN, isPrerenderable, prerenderSite } from './prerender';
241
250
  export { COMMANDS, cliVersion, commandFor, SPECS } from './registry';
251
+ export { FrameworkSchemaFailedError } from './schema-errors';
242
252
  export type { MigratedApp, ServedApp, ServeOptions, StartedApp } from './serve';
243
253
  export {
244
254
  CONTAINER_BINDING,
package/src/mcp-errors.ts CHANGED
@@ -96,9 +96,24 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
96
96
  X_RELEASE_VERSION_SKEW: 'bun run scripts/release.ts --bump patch --dry-run --json',
97
97
  // Two real remedies and the command cannot know which one this deployment wants, so it names
98
98
  // the one that inspects the binding rather than guessing between a volume and a bucket.
99
+ // `x db migrate --json` and not `x doctor`: this fires from inside `startQueue`, so the command
100
+ // that re-runs exactly the failing step is the migrate role, and it reports what it applied.
101
+ X_FRAMEWORK_SCHEMA_FAILED: 'x db migrate --json',
99
102
  X_STORAGE_UNWRITABLE: 'x doctor --json',
100
103
  X_STORAGE_SECRET_DEV: 'export STORAGE_SIGNING_SECRET="$(openssl rand -hex 32)"',
101
104
  X_MANIFEST_STALE: 'x manifest --json',
105
+ // The file itself, absent. One command writes it, and `bin/setup` now runs that command — so
106
+ // the fix here is the same one the gate's finding carries rather than a second phrasing.
107
+ X_MANIFEST_MISSING: 'x manifest --json',
108
+ // `@ultimat3/policy`'s code, and the CLI is the surface an agent reaches it from: `x policy list`
109
+ // is the only thing that prints the set the permission is missing from. The declare-it half is
110
+ // an edit to the app's own `definePermissions([...])`, which no command can perform.
111
+ X_PERMISSION_UNKNOWN:
112
+ 'x policy list --json # then add the permission to the app definePermissions([...]) call, or fix the typo',
113
+ // `@ultimat3/db`'s code, reported by `x doctor`'s probe. Both branches of db's own fix are an
114
+ // environment edit, so the runnable half is the probe that says which one is needed.
115
+ X_DB_UNAVAILABLE:
116
+ 'x doctor --json # set DATABASE_URL to a reachable Postgres url, or unset it for embedded PGlite',
102
117
  // `--target static`, not a bare `x build`: `--target` defaults to `docker`, and only the static
103
118
  // target runs `apps/web/prerender.ts` — the one caller of `writeBuildStats`. Without the flag
104
119
  // 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
+ }
@@ -0,0 +1,28 @@
1
+ // The one code raised while installing the framework's own tables. Apart from `errors.ts` for the
2
+ // reason `packages/jobs/src/backfill-errors.ts` is apart from that package's: one file, one job,
3
+ // and `errors.ts` is at 486 lines against the 500 the `filesize` step enforces. The code, its
4
+ // title and its registration stay in `error-codes.ts`, where every other CLI code lives.
5
+
6
+ import { renderThrowable, UltimateError } from '@ultimat3/core';
7
+
8
+ /**
9
+ * A statement in `FRAMEWORK_SCHEMA` did not apply. Raised in place of the driver's own rejection,
10
+ * which names neither the framework table being created nor the package that wants it — and a boot
11
+ * failure is read by an operator with no source tree open.
12
+ *
13
+ * `renderThrowable` and never `${cause}`: a `catch` binding is annotated by nobody, and a pool
14
+ * rejection is routinely an object whose `toString` throws.
15
+ */
16
+ export class FrameworkSchemaFailedError extends UltimateError {
17
+ constructor(input: { pkg: string; tables: readonly string[]; cause: unknown }) {
18
+ super({
19
+ code: 'X_FRAMEWORK_SCHEMA_FAILED',
20
+ cause: `${input.pkg} could not create ${input.tables.join(', ')}: ${renderThrowable(input.cause)}`,
21
+ // An EDIT plus the command that CONFIRMS it, which is the house shape for a repair the gate
22
+ // cannot perform: the two real causes are a role without `create`, and a relation of that
23
+ // name already present with an incompatible shape.
24
+ fix: `grant create on schema public to the role in DATABASE_URL, then re-run: x db migrate`,
25
+ meta: { pkg: input.pkg, tables: [...input.tables] },
26
+ });
27
+ }
28
+ }
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 {
@@ -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
+ }