@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.
- package/CLAUDE.md +50 -1
- package/README.md +4 -4
- package/package.json +29 -28
- package/src/app-permissions.ts +0 -0
- 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/dev-queue.ts +51 -37
- 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-catalog.ts +1 -0
- package/src/error-codes.ts +21 -0
- package/src/framework-schema.ts +152 -0
- package/src/i18n-index.ts +40 -0
- package/src/i18n-registration.ts +43 -1
- package/src/index.ts +10 -0
- package/src/mcp-errors.ts +15 -0
- package/src/messages.ts +6 -1
- package/src/parse.ts +39 -6
- package/src/port-probe.ts +22 -0
- package/src/schema-errors.ts +28 -0
- package/src/serve.ts +7 -1
- 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
|
@@ -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
|
+
}
|
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/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
|
-
|
|
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
|
+
}
|
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
+
}
|