@ultimat3/cli 7.0.0 → 9.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 +24 -4
- package/README.md +8 -3
- package/package.json +26 -25
- package/src/app-boundaries.ts +55 -5
- package/src/app-load.ts +7 -0
- package/src/bin.ts +6 -3
- package/src/ci-log.ts +0 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +43 -6
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +55 -4
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +68 -6
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-verify.ts +47 -6
- package/src/dev-assets.ts +4 -7
- package/src/dev-cache.ts +130 -33
- package/src/dev-lock.ts +124 -12
- package/src/dev-purge.ts +120 -0
- package/src/dev-queue.ts +39 -9
- package/src/dev-render.ts +11 -14
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +137 -6
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/document-styles.ts +2 -1
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +5 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +1 -1
- package/src/island-bundle.ts +2 -6
- package/src/island-styles.ts +1 -1
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +3 -0
- package/src/messages.ts +12 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/prerender.ts +2 -1
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/runtime-overrides.ts +12 -4
- package/src/serve.ts +1 -1
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +27 -4
- package/src/solid-loader.ts +1 -1
- package/src/style-csp.ts +2 -1
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +3 -0
- package/src/templates/island.ts +2 -1
- package/src/templates/route.ts +1 -1
- package/src/templates/scaffold-app.ts +3 -82
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +14 -6
- package/src/templates/scaffold-docs.ts +34 -16
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-repo.ts +40 -7
- package/src/test-select.ts +4 -3
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/write-line.ts +23 -5
package/src/framework-scope.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// directory or they describe different builds.
|
|
5
5
|
|
|
6
6
|
// `node:fs`/`node:path` because Bun ships neither: `dirname` walks a resolved module up to the
|
|
7
|
-
// directory that owns it,
|
|
7
|
+
// directory that owns it, `basename` names the two segments the store layout is recognised by,
|
|
8
|
+
// and `existsSync` is what says which directory is really there.
|
|
8
9
|
import { existsSync } from 'node:fs';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
|
|
@@ -14,6 +15,45 @@ import { dirname, join } from 'node:path';
|
|
|
14
15
|
*/
|
|
15
16
|
const MAX_DEPTH = 6;
|
|
16
17
|
|
|
18
|
+
/** The two segments Bun's isolated layout is recognised by: `node_modules/.bun/<pkg>@<version>/`. */
|
|
19
|
+
const STORE_DIR = '.bun';
|
|
20
|
+
const NODE_MODULES = 'node_modules';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `node_modules/.bun/@ultimat3+core@7.0.0/node_modules/@ultimat3` → `node_modules/@ultimat3`.
|
|
24
|
+
*
|
|
25
|
+
* Bun's **isolated** layout gives every package its own store entry, and a store entry's scope
|
|
26
|
+
* directory holds exactly the one package it was created for. `Bun.resolveSync` follows the
|
|
27
|
+
* install's symlink into that store, so walking up from the resolved entry lands there rather
|
|
28
|
+
* than in the tree an app actually installed: measured on a fixture install, `x docs` saw
|
|
29
|
+
* **1** of 6 packages, and `x errors explain` answered *"nothing in the installed framework raises
|
|
30
|
+
* X_…"* — with `ok: true` — for 400 of 405 codes. A confident wrong answer, from a walk that had
|
|
31
|
+
* never looked at the app's own `node_modules`.
|
|
32
|
+
*
|
|
33
|
+
* The store is recognised by its own shape and never by an app root handed in from outside: the
|
|
34
|
+
* two callers here are a module-scope memo (`error-fixes.ts`) and a command that may run under
|
|
35
|
+
* `--cwd`, so a cwd-derived root would be wrong for one of them and absent for the other.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` — leaving the caller on the resolved answer — whenever this is not a store path or
|
|
38
|
+
* the sibling scope directory is not there. A hoisted install already resolves to
|
|
39
|
+
* `node_modules/@ultimat3/core` and a workspace checkout to `packages/core`, and neither has a
|
|
40
|
+
* `.bun` above it.
|
|
41
|
+
*/
|
|
42
|
+
function installedScopeFor(storeScope: string): string | undefined {
|
|
43
|
+
const scopeName = basename(storeScope);
|
|
44
|
+
let dir = storeScope;
|
|
45
|
+
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return undefined;
|
|
48
|
+
if (basename(dir) === STORE_DIR && basename(parent) === NODE_MODULES) {
|
|
49
|
+
const candidate = join(parent, scopeName);
|
|
50
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
51
|
+
}
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
/**
|
|
18
58
|
* Resolved from the CLI's own dependency on `@ultimat3/core` rather than from the user's cwd:
|
|
19
59
|
* these are the packages this `x` would actually run. Resolution follows the symlink, so a
|
|
@@ -29,18 +69,30 @@ const MAX_DEPTH = 6;
|
|
|
29
69
|
* framework raises the code. Walking up from the entry to the directory that owns its
|
|
30
70
|
* `package.json` depends on nothing but the entry that is already imported.
|
|
31
71
|
*
|
|
72
|
+
* Following the symlink is also what makes the last step necessary rather than optional: under
|
|
73
|
+
* Bun's isolated layout the entry resolves *into the store*, whose scope directory holds one
|
|
74
|
+
* package. `installedScopeFor` is the correction, and it is a shape test on the path — never a
|
|
75
|
+
* `readdir` of the resolved package's parent, which is the read that reported one package as the
|
|
76
|
+
* whole framework.
|
|
77
|
+
*
|
|
78
|
+
* `resolveFrom` exists so a test can point this at a fixture install; nothing passes it in
|
|
79
|
+
* production, where the only defensible base is this module's own directory.
|
|
80
|
+
*
|
|
32
81
|
* `undefined` means the CLI cannot see its own dependency, which is a broken install and not
|
|
33
82
|
* merely an undocumented one; every caller reports that rather than answering emptily.
|
|
34
83
|
*/
|
|
35
|
-
export function frameworkScopeDir(): string | undefined {
|
|
84
|
+
export function frameworkScopeDir(resolveFrom: string = import.meta.dir): string | undefined {
|
|
36
85
|
let dir: string;
|
|
37
86
|
try {
|
|
38
|
-
dir = dirname(Bun.resolveSync('@ultimat3/core',
|
|
87
|
+
dir = dirname(Bun.resolveSync('@ultimat3/core', resolveFrom));
|
|
39
88
|
} catch {
|
|
40
89
|
return undefined;
|
|
41
90
|
}
|
|
42
91
|
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
43
|
-
if (existsSync(join(dir, 'package.json')))
|
|
92
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
93
|
+
const scope = dirname(dir);
|
|
94
|
+
return installedScopeFor(scope) ?? scope;
|
|
95
|
+
}
|
|
44
96
|
const parent = dirname(dir);
|
|
45
97
|
if (parent === dir) return undefined;
|
|
46
98
|
dir = parent;
|
package/src/generate-kinds.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// because "what a generator emits" and "which spelling reaches it" are two jobs — and the file
|
|
3
3
|
// that held both had reached the 500-line ceiling, one generator short of failing its own gate.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import { BadFlagError, MissingPositionalError, UnknownCommandError } from './errors';
|
|
6
7
|
import type { Surface } from './templates';
|
|
7
8
|
|
|
@@ -42,13 +43,30 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The generator a spelling reaches, or a refusal that leads with the one it is nearest.
|
|
48
|
+
*
|
|
49
|
+
* The lead used to be the literal `g resource`, whatever was typed: `x g rout x` — one edit from
|
|
50
|
+
* `route` — answered `fix: x g resource`, the WRONG PRIMITIVE, and one that refuses in turn
|
|
51
|
+
* because it carries no `<name>`. `nearestName` is what `parse.ts` already does with a mistyped
|
|
52
|
+
* command, over this file's own list.
|
|
53
|
+
*
|
|
54
|
+
* Two rules hold the fix line to a command that runs. A near miss is completed with that
|
|
55
|
+
* generator's own `EXAMPLE_NAME`, because `x g route <name>` pasted into a shell is a redirect.
|
|
56
|
+
* A word near NOTHING gets `x help g` rather than an invented lead — the same rule `parse.test.ts`
|
|
57
|
+
* pins for a command that resembles nothing, and the reason `nearestName` is never asked about an
|
|
58
|
+
* ABSENT kind: the empty string is within the cutoff of `job`, so `x g` would "suggest" a
|
|
59
|
+
* generator nobody typed.
|
|
60
|
+
*/
|
|
45
61
|
export function readKind(raw: string | undefined): Generator {
|
|
46
62
|
const kinds: readonly string[] = GENERATORS;
|
|
47
63
|
if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
|
|
64
|
+
const near = raw === undefined ? undefined : nearestName(raw, kinds);
|
|
65
|
+
const suggestion = GENERATORS.find((kind) => kind === near);
|
|
48
66
|
throw new UnknownCommandError({
|
|
49
67
|
path: `g ${raw ?? ''}`.trim(),
|
|
50
68
|
known: GENERATORS,
|
|
51
|
-
suggestion: 'g
|
|
69
|
+
suggestion: suggestion === undefined ? 'help g' : `g ${suggestion} ${EXAMPLE_NAME[suggestion]}`,
|
|
52
70
|
});
|
|
53
71
|
}
|
|
54
72
|
|
package/src/i18n-registration.ts
CHANGED
|
@@ -12,6 +12,7 @@ import {
|
|
|
12
12
|
catalogRegistrationGaps,
|
|
13
13
|
catalogsNeverRegistered,
|
|
14
14
|
catalogUnregistered,
|
|
15
|
+
pluralVariantsOf,
|
|
15
16
|
registeredLocales,
|
|
16
17
|
} from '@ultimat3/i18n';
|
|
17
18
|
import { loadApp } from './app-load';
|
|
@@ -105,9 +106,68 @@ export async function checkRegistration(input: RegistrationInput): Promise<Regis
|
|
|
105
106
|
}
|
|
106
107
|
|
|
107
108
|
/**
|
|
108
|
-
*
|
|
109
|
-
*
|
|
110
|
-
*
|
|
109
|
+
* `⟦key⟧` — `@ultimat3/i18n`'s own loud miss, spelled ONCE for the whole CLI. `x i18n sync <default>`
|
|
110
|
+
* writes it and the two checks below refuse it, so a second spelling would be a placeholder one
|
|
111
|
+
* half of this package writes and the other half cannot see.
|
|
112
|
+
*/
|
|
113
|
+
const MISS_OPEN = '\u27E6';
|
|
114
|
+
const MISS_CLOSE = '\u27E7';
|
|
115
|
+
|
|
116
|
+
/** What `x i18n sync` seeds a key with when there is no catalog above it to copy a value from. */
|
|
117
|
+
export const loudMiss = (key: string): string => `${MISS_OPEN}${key}${MISS_CLOSE}`;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* A value that is a placeholder rather than a translation. Any `⟦…⟧`, not just `⟦<this key>⟧`:
|
|
121
|
+
* an author who renames a key and leaves the old marker behind still ships a placeholder.
|
|
122
|
+
*/
|
|
123
|
+
export const isLoudMiss = (value: string | undefined): boolean =>
|
|
124
|
+
value?.startsWith(MISS_OPEN) === true && value.endsWith(MISS_CLOSE);
|
|
125
|
+
|
|
126
|
+
/**
|
|
127
|
+
* Whether `locale` answers `key` with a placeholder. Every spelling `definesKey` accepts is
|
|
128
|
+
* checked — `pluralVariantsOf` is `@ultimat3/i18n`'s own list, the same one `auditCatalogs` uses,
|
|
129
|
+
* so "defined" and "defined with a real string" can never disagree about which entries count.
|
|
130
|
+
* `some`, not `every`: a plural family with one untranslated category renders `⟦items_many⟧` on
|
|
131
|
+
* exactly the rows that hit it.
|
|
132
|
+
*/
|
|
133
|
+
const answersWithPlaceholder = (catalog: Catalog, key: string): boolean =>
|
|
134
|
+
[key, ...pluralVariantsOf(key)].some(
|
|
135
|
+
(candidate) => Object.hasOwn(catalog, candidate) && isLoudMiss(catalog[candidate]),
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The audit, with every placeholder counted as the missing key it stands in for.
|
|
140
|
+
*
|
|
141
|
+
* `auditCatalogs` asks `Object.hasOwn` and nothing else, so a key present with ANY value is not
|
|
142
|
+
* missing — including `⟦key⟧`, which is the value `x i18n sync <defaultLocale>` writes for every
|
|
143
|
+
* gap it closes. Without this, following `X_CATALOG_MISSING_KEYS`'s own `fix:` turns the `i18n`
|
|
144
|
+
* gate step green over strings no human has ever read: issue #249's ending, reached by running the
|
|
145
|
+
* command the error recommends. The hole predates the seeding — a hand-written `"TODO"` bought the
|
|
146
|
+
* same green — but one command now writes sixteen of them, so it is a hole with a shortcut to it.
|
|
147
|
+
*
|
|
148
|
+
* Applied HERE and not in `auditCatalogs`: `⟦…⟧` is what the CLI writes, and `@ultimat3/i18n`'s
|
|
149
|
+
* audit answering "is this key defined" is a different question from "is this app shippable".
|
|
150
|
+
*/
|
|
151
|
+
export function withPlaceholdersMissing(
|
|
152
|
+
report: ExtractReport,
|
|
153
|
+
catalogs: Readonly<Record<Locale, Catalog>>,
|
|
154
|
+
): ExtractReport {
|
|
155
|
+
const locales = report.locales.map((audit) => {
|
|
156
|
+
const catalog = catalogs[audit.locale] ?? {};
|
|
157
|
+
const known = new Set(audit.missing);
|
|
158
|
+
const placeheld = report.used.filter(
|
|
159
|
+
(key) => !known.has(key) && answersWithPlaceholder(catalog, key),
|
|
160
|
+
);
|
|
161
|
+
if (placeheld.length === 0) return audit;
|
|
162
|
+
return { ...audit, missing: [...audit.missing, ...placeheld].sort() };
|
|
163
|
+
});
|
|
164
|
+
return { ...report, locales, ok: locales.every((audit) => audit.missing.length === 0) };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The file half: a key source uses that a locale's catalog does not define, or defines with a
|
|
169
|
+
* placeholder. Built here rather than in `cmd-i18n.ts` because `x verify`'s `i18n` step reports
|
|
170
|
+
* the same finding, and a second construction of it is two renderers of one fact waiting to drift.
|
|
111
171
|
*/
|
|
112
172
|
export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
113
173
|
return report.locales
|
|
@@ -126,5 +186,8 @@ export function missingKeyFindings(report: ExtractReport): readonly Finding[] {
|
|
|
126
186
|
export async function catalogFindings(root: string): Promise<readonly Finding[]> {
|
|
127
187
|
const { report, catalogs, extraction, ignoreUnused } = await auditApp(root);
|
|
128
188
|
const registration = await checkRegistration({ root, catalogs, extraction, ignoreUnused });
|
|
129
|
-
return [
|
|
189
|
+
return [
|
|
190
|
+
...missingKeyFindings(withPlaceholdersMissing(report, catalogs)),
|
|
191
|
+
...registration.findings,
|
|
192
|
+
];
|
|
130
193
|
}
|
package/src/index.ts
CHANGED
|
@@ -323,4 +323,4 @@ export type { WorkspaceNode, WorkspaceScan } from './workspace-graph';
|
|
|
323
323
|
// nothing to read. `checkWorkspaceDependencies` stays internal — it is reached through
|
|
324
324
|
// `x verify`, which is the one way a rule is enforced here.
|
|
325
325
|
export { readWorkspaceGraph, scanWorkspaces } from './workspace-graph';
|
|
326
|
-
export { writeLine } from './write-line';
|
|
326
|
+
export { writeErrorLine, writeLine } from './write-line';
|
package/src/island-bundle.ts
CHANGED
|
@@ -6,12 +6,8 @@
|
|
|
6
6
|
// Bun ships no path API. `posix` does the specifier arithmetic (an app-relative route file is
|
|
7
7
|
// POSIX by construction), `join`/`basename` the filesystem side.
|
|
8
8
|
import { basename, join, posix, relative, sep } from 'node:path';
|
|
9
|
-
import {
|
|
10
|
-
|
|
11
|
-
ISLAND_EXTENSION,
|
|
12
|
-
IslandInvalidError,
|
|
13
|
-
islandModuleId,
|
|
14
|
-
} from '@ultimat3/render';
|
|
9
|
+
import { ISLAND_EXTENSION, IslandInvalidError, islandModuleId } from '@ultimat3/render';
|
|
10
|
+
import { contentHash } from '@ultimat3/render/server';
|
|
15
11
|
import { IslandBuildFailedError } from './errors';
|
|
16
12
|
import { solidProductionPlugin } from './island-solid-production';
|
|
17
13
|
import { islandStylesPlugin } from './island-styles';
|
package/src/island-styles.ts
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
// element renders unclassed, and `Bun.build` reports `success: true` with no log.
|
|
5
5
|
|
|
6
6
|
import { renderThrowable, UltimateError } from '@ultimat3/core';
|
|
7
|
-
import { loadStylesheet } from '@ultimat3/render';
|
|
7
|
+
import { loadStylesheet } from '@ultimat3/render/server';
|
|
8
8
|
import type { BunPlugin } from 'bun';
|
|
9
9
|
import { IslandBuildFailedError } from './errors';
|
|
10
10
|
|
package/src/jobs-report.ts
CHANGED
|
@@ -18,23 +18,20 @@ import {
|
|
|
18
18
|
inspectJob,
|
|
19
19
|
inspectJobList,
|
|
20
20
|
inspectQueues,
|
|
21
|
+
isJobState,
|
|
22
|
+
JOB_STATES,
|
|
21
23
|
retryFromStep,
|
|
22
24
|
} from '@ultimat3/jobs';
|
|
23
25
|
import { BadFlagError, JobUnknownError } from './errors';
|
|
24
26
|
|
|
25
|
-
/**
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
'dead',
|
|
34
|
-
];
|
|
35
|
-
|
|
36
|
-
const isJobState = (value: string): value is JobState =>
|
|
37
|
-
(JOB_STATES as readonly string[]).includes(value);
|
|
27
|
+
/**
|
|
28
|
+
* The queue's own vocabulary, re-exported rather than restated — this file carried a copy of it,
|
|
29
|
+
* and the copy was one member short. `cancelled` shipped in `@ultimat3/jobs` and never here, so
|
|
30
|
+
* `x jobs cancel` created a state `x jobs ls --state cancelled` then refused to filter on: two
|
|
31
|
+
* commands of one CLI disagreeing about what a job can be. Kept on this module's surface because
|
|
32
|
+
* `index.ts` exports it from here.
|
|
33
|
+
*/
|
|
34
|
+
export { JOB_STATES } from '@ultimat3/jobs';
|
|
38
35
|
|
|
39
36
|
export function parseStateFlag(value: string | undefined): JobState | undefined {
|
|
40
37
|
if (value === undefined) return undefined;
|
package/src/mcp-errors.ts
CHANGED
|
@@ -102,6 +102,9 @@ const CLI_FIXES: Readonly<Record<CliErrorCode, string>> = {
|
|
|
102
102
|
X_PORT_IN_USE: 'x dev --port 3001 --json',
|
|
103
103
|
X_DEV_ALREADY_RUNNING:
|
|
104
104
|
'x dev --json # after stopping the x dev that already owns this checkout',
|
|
105
|
+
// The path, not `x dev`: the boot has already refused and rerunning it refuses again. The
|
|
106
|
+
// error's own `fix:` carries the resolved `.x/dev.lock`; this table cannot know the state dir.
|
|
107
|
+
X_DEV_LOCK_UNREADABLE: 'rm .x/dev.lock # then: x dev --json',
|
|
105
108
|
// Not `x db status`: there is no such subcommand (`x db` is gen, migrate, reset, studio, branch),
|
|
106
109
|
// so the fix answered a failed step with X_CLI_UNKNOWN_COMMAND. `x doctor` is what reports
|
|
107
110
|
// reachability and drift, and is already this table's answer for X_DB_STUDIO_FAILED.
|
package/src/messages.ts
CHANGED
|
@@ -139,6 +139,11 @@ const CATALOG = {
|
|
|
139
139
|
// the app has a schema no migration records and `x verify`'s drift step says so until it runs.
|
|
140
140
|
'cli.new.done':
|
|
141
141
|
'created {name} — next: cd {name} && bun install && x db gen "initial" && x db migrate && x dev',
|
|
142
|
+
// The two prose lines of `x new`'s report. The `run: cd … && git init …` line beneath the second
|
|
143
|
+
// one stays inline in `cmd-new.ts`: it is an instruction to paste verbatim, and a translated
|
|
144
|
+
// command is a broken one — the same split `Finding.fix` already makes.
|
|
145
|
+
'cli.new.wrote': ' {count} files in {dir}',
|
|
146
|
+
'cli.new.noRepository': ' no repository — {problem}',
|
|
142
147
|
'cli.policy.count':
|
|
143
148
|
'{permissions} permission(s), {roles} role(s), {enforced} enforced by a declaration',
|
|
144
149
|
// One row per (declaration, actor) pair, never per role: a permission two declarations enforce
|
|
@@ -178,6 +183,9 @@ const CATALOG = {
|
|
|
178
183
|
'cli.shot.canvasUnreadable': ' canvas unreadable — {bytes} byte(s), not a decodable image',
|
|
179
184
|
'cli.shot.islands': ' islands {booted} of {declared} mounted ({strategies})',
|
|
180
185
|
'cli.shot.islandsUnknown': ' islands not counted — the page answered no probe',
|
|
186
|
+
// The failure a picture cannot show and a console count cannot see: a rejected mount promise
|
|
187
|
+
// calls no console method, so the FIRST one is named here rather than left to verdict.json.
|
|
188
|
+
'cli.shot.islandFailed': '{route}: {failed} island(s) failed to mount — {island}: {message}',
|
|
181
189
|
'cli.shot.network': ' network {requests} request(s), {refused} refused, {dropped} dropped',
|
|
182
190
|
'cli.shot.console': ' console {level}: {text}',
|
|
183
191
|
'cli.shot.threw': '{route}: {thrown} uncaught exception(s) — {first}',
|
|
@@ -228,6 +236,10 @@ const CATALOG = {
|
|
|
228
236
|
'cli.test.sampled': 'sampled {kept} of {total} {type} file(s)',
|
|
229
237
|
'cli.test.type.fail': '{type} — {failed} of {workers} shard(s) failed',
|
|
230
238
|
'cli.test.type.pass': '{type} — {files} test file(s) on {workers} worker(s) passed in {ms}ms',
|
|
239
|
+
// The banner a `--only` run carries, in front of whichever summary above it renders. Rendered
|
|
240
|
+
// output, so it lives here — `data.notAGateRun` is the machine marker, and a reader testing for
|
|
241
|
+
// one narrowed run reads that boolean rather than substring-matching this line.
|
|
242
|
+
'cli.verify.notAGateRun': 'NOT A GATE RUN — {summary}',
|
|
231
243
|
'cli.verify.pass': 'all {count} steps passed in {ms}ms',
|
|
232
244
|
'cli.verify.fail': '{failed} of {count} steps failed',
|
|
233
245
|
// A skipped step is not a passed one, so the two counts never share a sentence — and the skipped
|
package/src/output.ts
CHANGED
|
@@ -52,6 +52,17 @@ export interface CommandResult {
|
|
|
52
52
|
* could show is how the two drift.
|
|
53
53
|
*/
|
|
54
54
|
readonly hold?: () => Promise<void>;
|
|
55
|
+
/**
|
|
56
|
+
* Which fd this result is written to. `stdout` for every command, absent included — and
|
|
57
|
+
* `stderr` for the one case where fd 1 is not the command's to write on: `x mcp serve
|
|
58
|
+
* --transport stdio`, whose stdout carries JSON-RPC frames, and where the `✓ mcp stdio serving
|
|
59
|
+
* 13 tools` line printed after the loop exits is a malformed frame to whatever is reading.
|
|
60
|
+
*
|
|
61
|
+
* Behaviour, not a fact, exactly like `hold` above — so NEITHER renderer carries it. It says
|
|
62
|
+
* where a rendered line goes, and a payload that also claimed it would be a second answer to a
|
|
63
|
+
* question `dispatch` has already answered by choosing the sink.
|
|
64
|
+
*/
|
|
65
|
+
readonly stream?: 'stdout' | 'stderr';
|
|
55
66
|
}
|
|
56
67
|
|
|
57
68
|
export interface UltimateErrorShape {
|
|
@@ -158,13 +169,22 @@ export function renderHuman(result: CommandResult, verbose = false): string {
|
|
|
158
169
|
for (const step of result.steps ?? []) {
|
|
159
170
|
out.push(` ${mark(step)} ${step.name.padEnd(18)} ${step.durationMs}ms${width(step)}`);
|
|
160
171
|
for (const finding of step.findings) out.push(renderFinding(finding, ' '));
|
|
172
|
+
// NOT escaped, and that is the one exception: `output` is this process's own captured
|
|
173
|
+
// subprocess stdout — `bun test`'s colour is the reason a human reads it at all, and it is
|
|
174
|
+
// already split on its real newlines rather than carrying them inside one entry.
|
|
161
175
|
if (step.output !== undefined && step.output.length > 0 && (verbose || !step.ok)) {
|
|
162
176
|
for (const line of step.output.trimEnd().split('\n')) out.push(` | ${line}`);
|
|
163
177
|
}
|
|
164
178
|
}
|
|
165
|
-
|
|
179
|
+
// Every free-text line through the SAME `singleLine` the 3-line format runs, because this is
|
|
180
|
+
// where text the CLI did not write reaches fd 1: a GitHub review body (`x pr`), a CI log tail
|
|
181
|
+
// (`x ci`), a page's own console (`x shot`). It was emitted verbatim, so an ESC byte in a PR
|
|
182
|
+
// comment retitled the window and cleared the screen, and a newline in one entry printed a
|
|
183
|
+
// second line a reader — or the agent this command exists for — takes for the renderer's own.
|
|
184
|
+
for (const line of result.lines ?? []) out.push(singleLine(line));
|
|
166
185
|
for (const finding of result.findings ?? []) out.push(renderFinding(finding, ' '));
|
|
167
|
-
|
|
186
|
+
// The summary is foreign too: `cli.shot.threw` interpolates a page's own error message.
|
|
187
|
+
out.push(`${result.ok ? '✓' : '✗'} ${singleLine(result.summary)}`);
|
|
168
188
|
return out.join('\n');
|
|
169
189
|
}
|
|
170
190
|
|
package/src/parse.ts
CHANGED
|
@@ -2,7 +2,19 @@
|
|
|
2
2
|
// same way and `--json` / `--help` behave identically everywhere. Pure: no I/O, no process
|
|
3
3
|
// access, so the parser is unit-testable and the dispatcher owns all side effects.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import { BadFlagError, MissingSubcommandError, UnknownCommandError } from './errors';
|
|
7
|
+
// `shell-quote.ts` is a leaf — it imports nothing — so the parser stays pure and importable from
|
|
8
|
+
// anywhere while still refusing with a fix line a shell reads as one argument.
|
|
9
|
+
import { quoteArg } from './shell-quote';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* The historical name for `@ultimat3/core`'s `nearestName`, kept because it shipped on this
|
|
13
|
+
* package's exported surface and removing it would be a major for a rename. One implementation
|
|
14
|
+
* behind both — this is a delegation, not the second copy of the algorithm that `@ultimat3/policy`
|
|
15
|
+
* used to carry. New callers import `nearestName` from core.
|
|
16
|
+
*/
|
|
17
|
+
export const nearest = nearestName;
|
|
6
18
|
|
|
7
19
|
export type FlagValue = string | boolean;
|
|
8
20
|
|
|
@@ -12,6 +24,15 @@ export interface FlagSpec {
|
|
|
12
24
|
readonly summary: string;
|
|
13
25
|
readonly short?: string;
|
|
14
26
|
readonly default?: FlagValue;
|
|
27
|
+
/**
|
|
28
|
+
* The subcommands that READ this flag, where it is not command-wide. Absent means every one —
|
|
29
|
+
* opt-in, because most flags really are. Declared from the same fact the summary states, and
|
|
30
|
+
* enforced: `x db gen --dry-run` parsed, ran the generator and WROTE the migration, because the
|
|
31
|
+
* parser validates a flag against the COMMAND and nothing then validates it against the word
|
|
32
|
+
* that decides what runs. A dry run that writes a file is the direction a mistake may never
|
|
33
|
+
* fail in. `parse.test.ts` pins that every entry names a subcommand its command declares.
|
|
34
|
+
*/
|
|
35
|
+
readonly subcommands?: readonly string[];
|
|
15
36
|
}
|
|
16
37
|
|
|
17
38
|
export interface CommandSpec {
|
|
@@ -87,41 +108,11 @@ export const wantsJson = (argv: readonly string[]): boolean =>
|
|
|
87
108
|
const HELP_ALIASES = new Set(['--help', '-h', 'help']);
|
|
88
109
|
const VERSION_ALIASES = new Set(['--version', '-v', '-V']);
|
|
89
110
|
|
|
90
|
-
function distance(a: string, b: string): number {
|
|
91
|
-
const rows = a.length + 1;
|
|
92
|
-
const cols = b.length + 1;
|
|
93
|
-
const grid: number[] = new Array<number>(rows * cols).fill(0);
|
|
94
|
-
const at = (r: number, c: number): number => grid[r * cols + c] ?? 0;
|
|
95
|
-
for (let r = 0; r < rows; r += 1) grid[r * cols] = r;
|
|
96
|
-
for (let c = 0; c < cols; c += 1) grid[c] = c;
|
|
97
|
-
for (let r = 1; r < rows; r += 1) {
|
|
98
|
-
for (let c = 1; c < cols; c += 1) {
|
|
99
|
-
const cost = a[r - 1] === b[c - 1] ? 0 : 1;
|
|
100
|
-
grid[r * cols + c] = Math.min(at(r - 1, c) + 1, at(r, c - 1) + 1, at(r - 1, c - 1) + cost);
|
|
101
|
-
}
|
|
102
|
-
}
|
|
103
|
-
return at(rows - 1, cols - 1);
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/** Nearest known name within an edit distance of 3, so the error can suggest a retry. */
|
|
107
|
-
export function nearest(input: string, candidates: readonly string[]): string | undefined {
|
|
108
|
-
let best: string | undefined;
|
|
109
|
-
let bestScore = 4;
|
|
110
|
-
for (const candidate of candidates) {
|
|
111
|
-
const score = distance(input, candidate);
|
|
112
|
-
if (score < bestScore) {
|
|
113
|
-
best = candidate;
|
|
114
|
-
bestScore = score;
|
|
115
|
-
}
|
|
116
|
-
}
|
|
117
|
-
return best;
|
|
118
|
-
}
|
|
119
|
-
|
|
120
111
|
function resolveCommand(token: string, specs: readonly CommandSpec[]): CommandSpec {
|
|
121
112
|
const found = specs.find((spec) => spec.name === token || (spec.aliases ?? []).includes(token));
|
|
122
113
|
if (found !== undefined) return found;
|
|
123
114
|
const names = specs.map((spec) => spec.name);
|
|
124
|
-
const suggestion =
|
|
115
|
+
const suggestion = nearestName(token, names);
|
|
125
116
|
throw new UnknownCommandError(
|
|
126
117
|
suggestion === undefined
|
|
127
118
|
? { path: token, known: names }
|
|
@@ -168,6 +159,9 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
168
159
|
const spec = resolveCommand(first, specs);
|
|
169
160
|
const flags = defaults(spec);
|
|
170
161
|
const positionals: string[] = [];
|
|
162
|
+
// What argv actually SET, as against what `defaults()` seeded: a default is nobody's request,
|
|
163
|
+
// and refusing a flag the caller never typed would refuse the command itself.
|
|
164
|
+
const given = new Set<string>();
|
|
171
165
|
let index = 1;
|
|
172
166
|
|
|
173
167
|
while (index < tokens.length) {
|
|
@@ -183,7 +177,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
183
177
|
const flag = findFlag(name, spec);
|
|
184
178
|
if (flag === undefined) {
|
|
185
179
|
const known = [...GLOBAL_FLAGS, ...(spec.flags ?? [])].map((entry) => entry.name);
|
|
186
|
-
const suggestion =
|
|
180
|
+
const suggestion = nearestName(name, known);
|
|
187
181
|
throw new BadFlagError({
|
|
188
182
|
flag: name,
|
|
189
183
|
command: spec.name,
|
|
@@ -193,6 +187,7 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
193
187
|
: `unknown flag — did you mean --${suggestion}?`,
|
|
194
188
|
});
|
|
195
189
|
}
|
|
190
|
+
given.add(flag.name);
|
|
196
191
|
if (flag.type === 'boolean') {
|
|
197
192
|
if (inlineValue !== undefined) {
|
|
198
193
|
throw new BadFlagError({
|
|
@@ -204,30 +199,79 @@ export function parseArgs(argv: readonly string[], specs: readonly CommandSpec[]
|
|
|
204
199
|
flags.set(flag.name, !negated);
|
|
205
200
|
continue;
|
|
206
201
|
}
|
|
202
|
+
// `--no-<string flag>` used to fall through to the value read below, so `--no-name feat` set
|
|
203
|
+
// `name` to `feat`: the caller asked for the flag to be OFF and argv's next token became its
|
|
204
|
+
// value. There is nothing a string flag can be negated to, so this is a refusal.
|
|
205
|
+
if (negated) {
|
|
206
|
+
throw new BadFlagError({
|
|
207
|
+
flag: flag.name,
|
|
208
|
+
command: spec.name,
|
|
209
|
+
reason: `--no- negates a boolean flag, and --${flag.name} takes a value`,
|
|
210
|
+
});
|
|
211
|
+
}
|
|
207
212
|
const value = inlineValue ?? tokens[index];
|
|
208
|
-
if (value === undefined
|
|
213
|
+
if (value === undefined) {
|
|
214
|
+
throw new BadFlagError({ flag: flag.name, command: spec.name, reason: 'expects a value' });
|
|
215
|
+
}
|
|
216
|
+
// A value beginning `--` is a flag as far as this loop can tell, and the caller who really
|
|
217
|
+
// meant it as a value has one form available — so the refusal names it rather than leaving
|
|
218
|
+
// `--filter --json` looking like a parser that cannot express the input.
|
|
219
|
+
if (value.startsWith('--')) {
|
|
209
220
|
throw new BadFlagError({
|
|
210
221
|
flag: flag.name,
|
|
211
222
|
command: spec.name,
|
|
212
|
-
reason:
|
|
223
|
+
reason: `expects a value, and "${value}" is a flag — write --${flag.name}=${value} to pass it as the value`,
|
|
213
224
|
});
|
|
214
225
|
}
|
|
215
226
|
if (inlineValue === undefined) index += 1;
|
|
216
227
|
flags.set(flag.name, value);
|
|
217
228
|
}
|
|
218
229
|
|
|
219
|
-
|
|
230
|
+
// Before `readSubcommand`, which THROWS on a missing or unknown one: `x db --help`, `x mcp
|
|
231
|
+
// --help` and `x pr --help` all exited 1 with `X_CLI_BAD_FLAG` — usage refused to the caller
|
|
232
|
+
// asking what the usage is, on every command that takes a subcommand. Help is answered by
|
|
233
|
+
// `dispatch`, which needs only the command name.
|
|
234
|
+
const help = flags.get('help') === true;
|
|
235
|
+
const subcommand = help ? undefined : readSubcommand(spec, positionals);
|
|
236
|
+
if (subcommand !== undefined) assertFlagsApply(spec, subcommand, given, flags);
|
|
220
237
|
return {
|
|
221
238
|
command: spec.name,
|
|
222
239
|
subcommand,
|
|
223
240
|
positionals: subcommand === undefined ? positionals : positionals.slice(1),
|
|
224
241
|
flags,
|
|
225
242
|
json: flags.get('json') === true,
|
|
226
|
-
help
|
|
243
|
+
help,
|
|
227
244
|
passthrough,
|
|
228
245
|
};
|
|
229
246
|
}
|
|
230
247
|
|
|
248
|
+
/**
|
|
249
|
+
* A flag the resolved subcommand does not read is refused, never ignored. Only what argv SET is
|
|
250
|
+
* judged, and only against a flag that declared a scope: an undeclared flag stays command-wide.
|
|
251
|
+
*
|
|
252
|
+
* The fix carries the caller's own value through `quoteArg`, because it is pasted into a shell
|
|
253
|
+
* verbatim — `x db backfill --status 'a b'` runs, `--status a b` runs something else.
|
|
254
|
+
*/
|
|
255
|
+
function assertFlagsApply(
|
|
256
|
+
spec: CommandSpec,
|
|
257
|
+
subcommand: string,
|
|
258
|
+
given: ReadonlySet<string>,
|
|
259
|
+
flags: ReadonlyMap<string, FlagValue>,
|
|
260
|
+
): void {
|
|
261
|
+
for (const flag of spec.flags ?? []) {
|
|
262
|
+
const only = flag.subcommands;
|
|
263
|
+
if (only === undefined || only.includes(subcommand) || !given.has(flag.name)) continue;
|
|
264
|
+
const value = flags.get(flag.name);
|
|
265
|
+
const argument = typeof value === 'string' ? ` ${quoteArg(value)}` : '';
|
|
266
|
+
throw new BadFlagError({
|
|
267
|
+
flag: flag.name,
|
|
268
|
+
command: `${spec.name} ${subcommand}`,
|
|
269
|
+
reason: `read by ${only.map((word) => `x ${spec.name} ${word}`).join(' / ')} only — "${subcommand}" would ignore it`,
|
|
270
|
+
fix: `x ${spec.name} ${only[0]} --${flag.name}${argument}`,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
}
|
|
274
|
+
|
|
231
275
|
function splitInline(raw: string): [string, string | undefined] {
|
|
232
276
|
const eq = raw.indexOf('=');
|
|
233
277
|
if (eq === -1) return [raw, undefined];
|
|
@@ -243,7 +287,7 @@ function readSubcommand(spec: CommandSpec, positionals: readonly string[]): stri
|
|
|
243
287
|
throw new MissingSubcommandError({ command: spec.name, known: allowed });
|
|
244
288
|
}
|
|
245
289
|
if (allowed.includes(token)) return token;
|
|
246
|
-
const suggestion =
|
|
290
|
+
const suggestion = nearestName(token, allowed);
|
|
247
291
|
throw new UnknownCommandError(
|
|
248
292
|
suggestion === undefined
|
|
249
293
|
? { path: `${spec.name} ${token}`, known: allowed }
|
package/src/prerender.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
import { join } from 'node:path';
|
|
7
7
|
import { renderThrowable } from '@ultimat3/core';
|
|
8
8
|
import type { RouteEntry } from '@ultimat3/render';
|
|
9
|
-
import {
|
|
9
|
+
import { routeEntries } from '@ultimat3/render';
|
|
10
|
+
import { renderStatic } from '@ultimat3/render/server';
|
|
10
11
|
import { loadApp } from './app-load';
|
|
11
12
|
import { appManifest } from './app-manifest';
|
|
12
13
|
import type { RouteStats } from './budgets';
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
// The browser island `wiki/Realtime.md` promises: one live hook and nothing else. It is a real
|
|
2
|
+
// module rather than a string a test writes to a temp path, because module resolution is the thing
|
|
3
|
+
// under test — `@ultimat3/realtime`'s client entry must reach neither the bus nor the WAL decoder.
|
|
4
|
+
// Bundled AND imported by `realtime-browser-barrel.test.ts` — the import is what gives it an lcov
|
|
5
|
+
// record, since `Bun.build()` reads this file without evaluating it.
|
|
6
|
+
|
|
7
|
+
import { useLive } from '@ultimat3/realtime';
|
|
8
|
+
|
|
9
|
+
export const probeUseLive = useLive;
|
package/src/runtime-overrides.ts
CHANGED
|
@@ -8,8 +8,8 @@ import type { PurgeDriver } from '@ultimat3/cache';
|
|
|
8
8
|
import type { Middleware, RateLimitStore } from '@ultimat3/http';
|
|
9
9
|
import type { JobDriver } from '@ultimat3/jobs';
|
|
10
10
|
import type { MailDriver } from '@ultimat3/mail';
|
|
11
|
-
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime';
|
|
12
|
-
import type { IsrStore } from '@ultimat3/render';
|
|
11
|
+
import type { SyncAuthenticator, Transport } from '@ultimat3/realtime/server';
|
|
12
|
+
import type { IsrStore } from '@ultimat3/render/server';
|
|
13
13
|
import type { ImageTransformDriver } from '@ultimat3/seo';
|
|
14
14
|
import type { Storage } from '@ultimat3/storage';
|
|
15
15
|
|
|
@@ -45,6 +45,11 @@ export interface RuntimeOverrides {
|
|
|
45
45
|
* Where the HTTP rate limiter keeps its counters. It also DECIDES `rateLimit.scope`: a store
|
|
46
46
|
* that says `'shared'` is a deployment declaring fleet-wide numbers, and `assertRateLimitScope`
|
|
47
47
|
* holds the two halves together rather than a literal in the boot contradicting the store.
|
|
48
|
+
*
|
|
49
|
+
* Omitted, the boot installs `postgresRateLimitStore` over the pool it already opened
|
|
50
|
+
* (`startServices`) — so this replaces a SHARED default, not an absent one. A store whose scope
|
|
51
|
+
* is `'process'` is legal and warned about: it is every declared limit enforced once per
|
|
52
|
+
* replica, and `docker/helm/values.yaml` runs three.
|
|
48
53
|
*/
|
|
49
54
|
readonly rateLimitStore?: RateLimitStore;
|
|
50
55
|
/**
|
|
@@ -59,8 +64,11 @@ export interface RuntimeOverrides {
|
|
|
59
64
|
readonly images?: ImageTransformDriver;
|
|
60
65
|
/**
|
|
61
66
|
* Who is dialling the `sync` node. Omitted, the app's own `configureAuthenticator()` is adapted
|
|
62
|
-
* —
|
|
63
|
-
*
|
|
67
|
+
* — and that adapter now carries `expiresAt` and `refresh` of its own (`SYNC_GRANT_TTL_MS`),
|
|
68
|
+
* re-asking the app's resolver with the upgrade's own `cookie`/`authorization`. So this field is
|
|
69
|
+
* no longer the only way to get re-authorization; it is how a deployment states a window the
|
|
70
|
+
* credential itself declares (a token's `exp`), or resolves identity from a header the adapter
|
|
71
|
+
* deliberately does not retain per socket.
|
|
64
72
|
*/
|
|
65
73
|
readonly syncAuthenticate?: SyncAuthenticator;
|
|
66
74
|
}
|
package/src/serve.ts
CHANGED
|
@@ -20,7 +20,7 @@ import {
|
|
|
20
20
|
migrate,
|
|
21
21
|
} from '@ultimat3/db';
|
|
22
22
|
import type { Route } from '@ultimat3/http';
|
|
23
|
-
import { createIsrController } from '@ultimat3/render';
|
|
23
|
+
import { createIsrController } from '@ultimat3/render/server';
|
|
24
24
|
import { apiRoutes } from './api-routes';
|
|
25
25
|
import { loadSignInPath } from './app-auth';
|
|
26
26
|
import { loadApp } from './app-load';
|