@ultimat3/cli 1.1.0 → 2.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 +724 -0
- package/README.md +41 -9
- package/package.json +25 -23
- package/src/api-routes.ts +16 -0
- package/src/app-auth.ts +32 -0
- package/src/app-entities.ts +18 -0
- package/src/app-env.ts +103 -0
- package/src/app-load.ts +20 -3
- package/src/bin.ts +4 -3
- package/src/budgets.ts +114 -9
- package/src/cmd-build.ts +69 -21
- package/src/cmd-db-branch.ts +215 -0
- package/src/cmd-db.ts +332 -155
- package/src/cmd-deploy.ts +59 -6
- package/src/cmd-dev.ts +87 -17
- package/src/cmd-docs.ts +167 -0
- package/src/cmd-doctor.ts +64 -9
- package/src/cmd-env.ts +95 -0
- package/src/cmd-errors.ts +33 -13
- package/src/cmd-fix.ts +5 -1
- package/src/cmd-generate.ts +146 -111
- package/src/cmd-help.ts +16 -5
- package/src/cmd-i18n.ts +2 -0
- package/src/cmd-jobs.ts +47 -33
- package/src/cmd-mcp.ts +11 -2
- package/src/cmd-new.ts +13 -7
- package/src/cmd-planned.ts +55 -10
- package/src/cmd-policy.ts +1 -0
- package/src/cmd-registries.ts +3 -0
- package/src/cmd-secrets.ts +368 -0
- package/src/cmd-tasks.ts +1 -0
- package/src/cmd-test.ts +17 -23
- package/src/cmd-verify.ts +177 -23
- package/src/db-backfill.ts +401 -0
- package/src/db-branch.ts +251 -0
- package/src/db-destructive.ts +29 -0
- package/src/db-finding.ts +28 -0
- package/src/db-generate.ts +112 -0
- package/src/db-snapshot.ts +24 -0
- package/src/dev-assets.ts +86 -20
- package/src/dev-cache.ts +122 -0
- package/src/dev-dashboard.ts +19 -4
- package/src/dev-hooks.ts +27 -2
- package/src/dev-n-plus-one.ts +191 -0
- package/src/dev-queue.ts +105 -19
- package/src/dev-render.ts +158 -26
- package/src/dev-roles-fixture.ts +67 -0
- package/src/dev-roles.ts +186 -78
- package/src/dev-runtime.ts +117 -40
- package/src/dev-services.ts +15 -0
- package/src/dev-storage.ts +245 -0
- package/src/dev-sync.ts +107 -0
- package/src/dev-traces.ts +11 -3
- package/src/dispatch.ts +4 -2
- package/src/document-styles.ts +54 -0
- package/src/drift.ts +37 -9
- package/src/error-catalog.ts +7 -18
- package/src/error-codes.ts +186 -0
- package/src/error-contract.ts +29 -7
- package/src/error-fixes.ts +114 -0
- package/src/errors.ts +205 -140
- package/src/fix-command.ts +268 -0
- package/src/flag-number.ts +56 -0
- package/src/framework-scope.ts +49 -0
- package/src/generate-kinds.ts +97 -0
- package/src/guards.ts +186 -0
- package/src/index.ts +87 -14
- package/src/island-bundle.ts +166 -0
- package/src/island-routes.ts +50 -0
- package/src/jobs-driver.ts +33 -0
- package/src/jobs-json.ts +24 -0
- package/src/jobs-report.ts +17 -4
- package/src/mcp-db-target.ts +52 -27
- package/src/mcp-errors.ts +120 -19
- package/src/mcp-host.ts +44 -25
- package/src/messages.ts +81 -2
- package/src/metrics-endpoint.ts +73 -0
- package/src/migrations.ts +37 -4
- package/src/otlp-export.ts +64 -0
- package/src/output.ts +46 -16
- package/src/parse.ts +41 -3
- package/src/policy-facts.ts +38 -6
- package/src/policy-fixture.ts +14 -7
- package/src/prerender.ts +111 -2
- package/src/registry.ts +21 -3
- package/src/runtime-overrides.ts +66 -0
- package/src/safe-url-label.ts +24 -0
- package/src/scaffold-fixture.ts +10 -0
- package/src/scaffold-typecheck.ts +16 -38
- package/src/serve.ts +202 -18
- package/src/source-files.ts +4 -0
- package/src/statement-loop.ts +74 -0
- package/src/style-csp.ts +18 -0
- package/src/sync-authenticator.ts +59 -0
- package/src/templates/action.ts +15 -30
- package/src/templates/admin-page.ts +103 -0
- package/src/templates/admin.ts +11 -7
- package/src/templates/backfill.ts +212 -0
- package/src/templates/entity.ts +72 -31
- package/src/templates/guard.ts +143 -0
- package/src/templates/index.ts +12 -1
- package/src/templates/island.ts +67 -0
- package/src/templates/job.ts +53 -13
- package/src/templates/naming.ts +17 -1
- package/src/templates/policy.ts +35 -28
- package/src/templates/query.ts +24 -5
- package/src/templates/resource.ts +19 -11
- package/src/templates/route.ts +90 -15
- package/src/templates/scaffold-app.ts +142 -45
- package/src/templates/scaffold-claude-agents.ts +149 -0
- package/src/templates/scaffold-claude-commands.ts +221 -0
- package/src/templates/scaffold-claude.ts +134 -0
- package/src/templates/scaffold-container.ts +46 -2
- package/src/templates/scaffold-db-package.ts +91 -0
- package/src/templates/scaffold-docs.ts +24 -5
- package/src/templates/scaffold-domain-package.ts +90 -0
- package/src/templates/scaffold-env.ts +87 -0
- package/src/templates/scaffold-i18n.ts +4 -1
- package/src/templates/scaffold-mcp-package.ts +49 -0
- package/src/templates/scaffold-package-shape.ts +25 -4
- package/src/templates/scaffold-repo.ts +116 -257
- package/src/templates/scaffold-roles.ts +68 -0
- package/src/templates/scaffold-ui-package.ts +56 -0
- package/src/templates/slice-foundation.ts +88 -0
- package/src/templates/wrap.ts +95 -0
- package/src/test-counts.ts +35 -0
- package/src/test-select.ts +30 -15
- package/src/test-shards.ts +21 -3
- package/src/test-workers.ts +47 -0
- package/src/ts-scan.ts +271 -13
- package/src/tsconfig-references.ts +78 -0
- package/src/verify-floor.ts +133 -0
- package/src/verify-step.ts +19 -0
- package/src/verify-test-run.ts +72 -0
- package/src/verify-tests.ts +160 -71
- package/src/version-loader.ts +20 -3
- package/src/workspace-checks.ts +87 -16
- package/src/write-line.ts +34 -0
package/src/cmd-errors.ts
CHANGED
|
@@ -7,7 +7,8 @@ import type { ErrorExplanation } from '@ultimat3/mcp';
|
|
|
7
7
|
import type { CliCommand, CommandContext } from './command';
|
|
8
8
|
import type { ErrorCatalog } from './error-catalog';
|
|
9
9
|
import { loadErrorCatalog } from './error-catalog';
|
|
10
|
-
import {
|
|
10
|
+
import { codeFixes, loadCodeFixes } from './error-fixes';
|
|
11
|
+
import { ErrorCodeUnknownError, MissingPositionalError } from './errors';
|
|
11
12
|
import { explainErrorCode, explainEveryErrorCode } from './mcp-errors';
|
|
12
13
|
import { msg } from './messages';
|
|
13
14
|
import type { CommandResult, JsonValue } from './output';
|
|
@@ -15,12 +16,22 @@ import { nearest } from './parse';
|
|
|
15
16
|
|
|
16
17
|
export const ERRORS_SUBCOMMANDS = ['explain', 'list'] as const;
|
|
17
18
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
19
|
+
/**
|
|
20
|
+
* `site` is the throw site as DATA, and it is why the `fix:` for a code whose fix is built at run
|
|
21
|
+
* time does not have to pretend to be a command. For those codes the location IS the answer, and a
|
|
22
|
+
* caller that has to regex `packages/x/src/y.ts:80` back out of an English sentence is a caller the
|
|
23
|
+
* machine-readable surface failed. `null` when the installed framework raises the code nowhere.
|
|
24
|
+
*/
|
|
25
|
+
const asJson = (explanation: ErrorExplanation): JsonValue => {
|
|
26
|
+
const site = codeFixes().get(explanation.code)?.[0];
|
|
27
|
+
return {
|
|
28
|
+
code: explanation.code,
|
|
29
|
+
cause: explanation.cause,
|
|
30
|
+
fix: explanation.fix,
|
|
31
|
+
docs: explanation.docs,
|
|
32
|
+
site: site === undefined ? null : { at: site.at, line: site.line },
|
|
33
|
+
};
|
|
34
|
+
};
|
|
24
35
|
|
|
25
36
|
/** The 3-line contract format, minus the leading blank code line `renderFinding` would add. */
|
|
26
37
|
const detailLines = (explanation: ErrorExplanation): readonly string[] => [
|
|
@@ -79,19 +90,28 @@ export const errorsCommand: CliCommand = {
|
|
|
79
90
|
summary: 'an X_* code, explained: cause, runnable fix, docs URL',
|
|
80
91
|
usage: 'x errors [explain <CODE>|list] [--json]',
|
|
81
92
|
subcommands: ERRORS_SUBCOMMANDS,
|
|
93
|
+
// `explain`, deliberately: the bare `x errors` then answers with `MissingPositionalError`,
|
|
94
|
+
// which names `<CODE>` and hands back a real invocation. `list` would silently print 200 rows
|
|
95
|
+
// to a caller who meant to explain one — see `MissingPositionalError`'s own note.
|
|
96
|
+
defaultSubcommand: 'explain',
|
|
82
97
|
},
|
|
83
98
|
// `async` is load-bearing: a synchronous throw would escape every caller that awaits the
|
|
84
99
|
// promise this signature promises, including the dispatcher's own error path.
|
|
85
100
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
86
|
-
|
|
101
|
+
// Both loads, always, and before either subcommand branches: the catalog is which codes exist
|
|
102
|
+
// and the fix index is what each one instructs. `x errors list` answering 397 codes with the
|
|
103
|
+
// fallback line would be a complete-looking table of shrugs.
|
|
104
|
+
const [catalog] = await Promise.all([loadErrorCatalog(), loadCodeFixes()]);
|
|
87
105
|
if (ctx.args.subcommand === 'list') return listAll(catalog);
|
|
88
106
|
const code = ctx.args.positionals[0];
|
|
89
107
|
if (code === undefined) {
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
108
|
+
// Not a `BadFlagError`: naming `--code` invented a flag that does not exist, so an agent
|
|
109
|
+
// reading the cause literally tried `x errors --code X_DB_DRIFT` and got a SECOND
|
|
110
|
+
// X_CLI_BAD_FLAG for an unknown flag.
|
|
111
|
+
throw new MissingPositionalError({
|
|
112
|
+
command: 'errors explain',
|
|
113
|
+
positional: 'CODE',
|
|
114
|
+
example: 'x errors list --json',
|
|
95
115
|
});
|
|
96
116
|
}
|
|
97
117
|
return explainOne(code);
|
package/src/cmd-fix.ts
CHANGED
|
@@ -92,10 +92,14 @@ const editCount = (cuts: readonly BoundaryCut[]): number =>
|
|
|
92
92
|
export const fixCommand: CliCommand = {
|
|
93
93
|
spec: {
|
|
94
94
|
name: 'fix',
|
|
95
|
-
|
|
95
|
+
// Says "plan" in the one line `x help` prints. The name is kept — five packages' `fix:` lines
|
|
96
|
+
// cite `x fix boundary <file>` and renaming a shipped command breaks every one of them — so
|
|
97
|
+
// the honest move is to stop the summary from promising a repair the command never performs.
|
|
98
|
+
summary: 'plan the minimal cut for an import that crossed a surface boundary (never rewrites)',
|
|
96
99
|
usage: 'x fix boundary <file> [--json]',
|
|
97
100
|
requiresApp: true,
|
|
98
101
|
subcommands: FIX_SUBCOMMANDS,
|
|
102
|
+
defaultSubcommand: 'boundary',
|
|
99
103
|
},
|
|
100
104
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
101
105
|
const root = requireAppRoot('fix', ctx.cwd).dir;
|
package/src/cmd-generate.ts
CHANGED
|
@@ -11,12 +11,21 @@ import { appManifest, writeAppManifest } from './app-manifest';
|
|
|
11
11
|
import { requireAppRoot } from './app-root';
|
|
12
12
|
import type { CliCommand, CommandContext } from './command';
|
|
13
13
|
import {
|
|
14
|
-
BadFlagError,
|
|
15
14
|
CliNotImplementedError,
|
|
16
15
|
GenerateJsonInvalidError,
|
|
17
16
|
ScaffoldPathEscapeError,
|
|
18
|
-
UnknownCommandError,
|
|
19
17
|
} from './errors';
|
|
18
|
+
// Re-exported below: `GENERATORS` and `Generator` are imported from this module by the tests, the
|
|
19
|
+
// scaffold fixture and `src/index.ts`, and moving where they are declared must not move where they
|
|
20
|
+
// are read from.
|
|
21
|
+
import type { Generator } from './generate-kinds';
|
|
22
|
+
import {
|
|
23
|
+
assertSurfaceSupported,
|
|
24
|
+
GENERATORS,
|
|
25
|
+
readKind,
|
|
26
|
+
readName,
|
|
27
|
+
readSurface,
|
|
28
|
+
} from './generate-kinds';
|
|
20
29
|
import { mergeJsonDeep } from './json-merge';
|
|
21
30
|
import { msg } from './messages';
|
|
22
31
|
import type { CommandResult, Finding } from './output';
|
|
@@ -24,10 +33,15 @@ import { flagBool, flagList, flagString } from './parse';
|
|
|
24
33
|
import type { GeneratedFile, Surface } from './templates';
|
|
25
34
|
import {
|
|
26
35
|
actionFiles,
|
|
36
|
+
adminPageFiles,
|
|
37
|
+
backfillFiles,
|
|
27
38
|
CATALOG_ROOT,
|
|
28
39
|
entityFiles,
|
|
40
|
+
guardFiles,
|
|
29
41
|
i18nIndex,
|
|
42
|
+
islandFiles,
|
|
30
43
|
jobFiles,
|
|
44
|
+
kebab,
|
|
31
45
|
policyFiles,
|
|
32
46
|
queryFiles,
|
|
33
47
|
resolveLocales,
|
|
@@ -36,19 +50,8 @@ import {
|
|
|
36
50
|
taskFiles,
|
|
37
51
|
} from './templates';
|
|
38
52
|
|
|
39
|
-
export
|
|
40
|
-
|
|
41
|
-
'action',
|
|
42
|
-
'mutator',
|
|
43
|
-
'job',
|
|
44
|
-
'route',
|
|
45
|
-
'policy',
|
|
46
|
-
'entity',
|
|
47
|
-
'query',
|
|
48
|
-
'task',
|
|
49
|
-
] as const;
|
|
50
|
-
|
|
51
|
-
export type Generator = (typeof GENERATORS)[number];
|
|
53
|
+
export type { Generator } from './generate-kinds';
|
|
54
|
+
export { GENERATORS } from './generate-kinds';
|
|
52
55
|
|
|
53
56
|
export interface GenerateOptions {
|
|
54
57
|
readonly kind: Generator;
|
|
@@ -60,6 +63,14 @@ export interface GenerateOptions {
|
|
|
60
63
|
readonly admin?: boolean;
|
|
61
64
|
/** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
|
|
62
65
|
readonly locales?: readonly string[];
|
|
66
|
+
/**
|
|
67
|
+
* `island` and `admin:page`: the directory the generated files land in. Named rather than
|
|
68
|
+
* derived, because neither destination is derivable — `X_ISLAND_INVALID`'s cause already holds
|
|
69
|
+
* the path a page's `src` resolved to, and an app's admin is wherever its `defineAdmin` is.
|
|
70
|
+
*/
|
|
71
|
+
readonly at?: string;
|
|
72
|
+
/** `admin:page` only: the permission the page's own work needs, on top of `admin:read`. */
|
|
73
|
+
readonly permission?: string;
|
|
63
74
|
}
|
|
64
75
|
|
|
65
76
|
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
@@ -127,30 +138,13 @@ export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[
|
|
|
127
138
|
return [...seen.values()];
|
|
128
139
|
}
|
|
129
140
|
|
|
130
|
-
/**
|
|
131
|
-
* A resource slice is app-surface by construction: it ships a live query, a form with a signal,
|
|
132
|
-
* two actions and a policy, and `site/` is the 0kb, never-hydrated surface that may not import
|
|
133
|
-
* `app/`. The documented shape is the slice in `app/` plus a separate public route
|
|
134
|
-
* (`docs/architecture/15-adding-a-feature.md`), so the flag is refused before anything is written
|
|
135
|
-
* rather than emitting a slice that fails the app's own budget and boundary gates.
|
|
136
|
-
*/
|
|
137
|
-
function assertSurfaceSupported(kind: Generator, surface: Surface): void {
|
|
138
|
-
if (kind !== 'resource' || surface !== 'site') return;
|
|
139
|
-
throw new BadFlagError({
|
|
140
|
-
flag: 'surface',
|
|
141
|
-
command: 'g resource',
|
|
142
|
-
reason: 'a resource slice is app-surface — site/ ships 0kb JS and may not import app/',
|
|
143
|
-
fix: 'x g resource <name> && x g route <name> --surface site',
|
|
144
|
-
});
|
|
145
|
-
}
|
|
146
|
-
|
|
147
141
|
/**
|
|
148
142
|
* Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
|
|
149
143
|
* on them, and nothing has to run a filesystem to review what a generator produces.
|
|
150
144
|
*/
|
|
151
145
|
export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
152
146
|
const surface: Surface = options.surface ?? 'app';
|
|
153
|
-
assertSurfaceSupported(options.kind, surface);
|
|
147
|
+
assertSurfaceSupported(options.kind, surface, options.name);
|
|
154
148
|
const surfaceDir = DEFAULT_SURFACE_DIR[surface];
|
|
155
149
|
const feature = options.feature ?? options.name;
|
|
156
150
|
const target = { surfaceDir, feature };
|
|
@@ -167,6 +161,8 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
167
161
|
return dedupe(actionFiles(options.name, target));
|
|
168
162
|
case 'mutator':
|
|
169
163
|
return dedupe(actionFiles(options.name, { ...target, mutator: true }));
|
|
164
|
+
case 'backfill':
|
|
165
|
+
return dedupe(backfillFiles(options.name, target));
|
|
170
166
|
case 'entity':
|
|
171
167
|
return dedupe(entityFiles(options.name, target));
|
|
172
168
|
case 'policy':
|
|
@@ -177,6 +173,22 @@ export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
|
177
173
|
return dedupe(jobFiles(options.name, target));
|
|
178
174
|
case 'task':
|
|
179
175
|
return dedupe(taskFiles(options.name, target));
|
|
176
|
+
case 'island':
|
|
177
|
+
return dedupe(islandFiles(options.name, { dir: options.at ?? `${surfaceDir}/${feature}` }));
|
|
178
|
+
// No `--at`, no surface, no feature: `guards/` is the one directory the gate discovers, and a
|
|
179
|
+
// guard that lived anywhere else would need an app-side registration to be found.
|
|
180
|
+
case 'guard':
|
|
181
|
+
return dedupe(guardFiles(options.name));
|
|
182
|
+
case 'admin:page':
|
|
183
|
+
// A default permission, never none: an empty list is `X_ADMIN_PAGE_UNGUARDED` on sight.
|
|
184
|
+
return dedupe(
|
|
185
|
+
adminPageFiles(options.name, {
|
|
186
|
+
permission: options.permission ?? `${kebab(options.name)}:read`,
|
|
187
|
+
// The same `--at` `island` takes: an app's admin is wherever its `defineAdmin` is.
|
|
188
|
+
...(options.at === undefined ? {} : { dir: options.at }),
|
|
189
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
190
|
+
}),
|
|
191
|
+
);
|
|
180
192
|
case 'route':
|
|
181
193
|
// `--locales` reaches the route generator too: its catalog entry is the route's title and
|
|
182
194
|
// description, and a locale asked for on the command line is a locale that gets a file.
|
|
@@ -213,7 +225,9 @@ export function containedPath(root: string, path: string): string {
|
|
|
213
225
|
throw new ScaffoldPathEscapeError({
|
|
214
226
|
path,
|
|
215
227
|
dir: base,
|
|
216
|
-
|
|
228
|
+
// Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
|
|
229
|
+
// rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
|
|
230
|
+
fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
|
|
217
231
|
});
|
|
218
232
|
return target;
|
|
219
233
|
}
|
|
@@ -230,20 +244,18 @@ export function containedPath(root: string, path: string): string {
|
|
|
230
244
|
* reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
|
|
231
245
|
* its one call site already applies.
|
|
232
246
|
*/
|
|
233
|
-
async function
|
|
247
|
+
async function planJsonMerge(
|
|
234
248
|
file: Extract<GeneratedFile, { merge: 'json' }>,
|
|
235
249
|
absolute: string,
|
|
236
|
-
): Promise<
|
|
250
|
+
): Promise<WritePlan> {
|
|
237
251
|
const generated = parseJsonObject(file.contents) ?? {};
|
|
238
|
-
if (!existsSync(absolute))
|
|
239
|
-
|
|
240
|
-
return { written: true };
|
|
241
|
-
}
|
|
252
|
+
if (!existsSync(absolute))
|
|
253
|
+
return { kind: 'write', file, absolute, contents: prettyJson(generated) };
|
|
242
254
|
const existing = parseJsonObject(await Bun.file(absolute).text());
|
|
243
255
|
if (existing === undefined) {
|
|
244
256
|
return {
|
|
245
|
-
|
|
246
|
-
|
|
257
|
+
kind: 'conflict',
|
|
258
|
+
finding: {
|
|
247
259
|
code: 'X_GENERATE_CONFLICT',
|
|
248
260
|
cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
|
|
249
261
|
fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
|
|
@@ -256,44 +268,82 @@ async function mergeJsonFile(
|
|
|
256
268
|
// Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
|
|
257
269
|
const { merged, gained } = mergeJsonDeep(existing, generated);
|
|
258
270
|
// Every key the generator wants is already there — leave the file untouched and unclaimed.
|
|
259
|
-
if (!gained) return {
|
|
260
|
-
|
|
261
|
-
return { written: true };
|
|
271
|
+
if (!gained) return { kind: 'skip' };
|
|
272
|
+
return { kind: 'write', file, absolute, contents: prettyJson(merged) };
|
|
262
273
|
}
|
|
263
274
|
|
|
264
|
-
/**
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
const targets = files.map((file) => ({ file, absolute: containedPath(root, file.path) }));
|
|
275
|
-
for (const { file, absolute } of targets) {
|
|
276
|
-
if (file.merge === 'json') {
|
|
277
|
-
const result = await mergeJsonFile(file, absolute);
|
|
278
|
-
if (result.written) written.push(file.path);
|
|
279
|
-
if (result.conflict !== undefined) conflicts.push(result.conflict);
|
|
280
|
-
continue;
|
|
275
|
+
/**
|
|
276
|
+
* What one generated file would do, decided without doing it. The merge case computes its own
|
|
277
|
+
* bytes here rather than at the write, so the two passes below cannot disagree about a file.
|
|
278
|
+
*/
|
|
279
|
+
type WritePlan =
|
|
280
|
+
| {
|
|
281
|
+
readonly kind: 'write';
|
|
282
|
+
readonly file: GeneratedFile;
|
|
283
|
+
readonly absolute: string;
|
|
284
|
+
readonly contents: string | Uint8Array;
|
|
281
285
|
}
|
|
282
|
-
|
|
283
|
-
|
|
286
|
+
| { readonly kind: 'skip' }
|
|
287
|
+
| { readonly kind: 'conflict'; readonly finding: Finding };
|
|
288
|
+
|
|
289
|
+
function planFile(file: GeneratedFile, absolute: string, force: boolean): WritePlan {
|
|
290
|
+
// A foundation file belongs to the slice, not to the generator that needs it: several generators
|
|
291
|
+
// emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
|
|
292
|
+
// overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
|
|
293
|
+
// `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
|
|
294
|
+
// module is `x g entity|policy`.
|
|
295
|
+
if (file.merge === 'if-absent') {
|
|
296
|
+
return existsSync(absolute)
|
|
297
|
+
? { kind: 'skip' }
|
|
298
|
+
: { kind: 'write', file, absolute, contents: file.contents };
|
|
299
|
+
}
|
|
300
|
+
if (!force && existsSync(absolute)) {
|
|
301
|
+
return {
|
|
302
|
+
kind: 'conflict',
|
|
303
|
+
finding: {
|
|
284
304
|
code: 'X_GENERATE_CONFLICT',
|
|
285
305
|
cause: `${file.path} already exists`,
|
|
286
306
|
fix: `x g --force to overwrite, or pass a different name`,
|
|
287
307
|
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
288
308
|
at: file.path,
|
|
289
|
-
}
|
|
290
|
-
|
|
291
|
-
|
|
309
|
+
},
|
|
310
|
+
};
|
|
311
|
+
}
|
|
312
|
+
return { kind: 'write', file, absolute, contents: file.contents };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
|
|
317
|
+
* twice; a generator that lands four of seven files and then reports a conflict is worse, because
|
|
318
|
+
* the next run conflicts on the files the failed one wrote.
|
|
319
|
+
*
|
|
320
|
+
* Two passes, and the split is the point: the first decides — containment, existence, whether a
|
|
321
|
+
* catalog can be merged into — and touches nothing, the second writes only when the first found
|
|
322
|
+
* no conflict at all. Containment was already proven up front and the rest was not.
|
|
323
|
+
*/
|
|
324
|
+
export async function writeFiles(
|
|
325
|
+
root: string,
|
|
326
|
+
files: readonly GeneratedFile[],
|
|
327
|
+
force: boolean,
|
|
328
|
+
): Promise<WriteReport> {
|
|
329
|
+
const plans: WritePlan[] = [];
|
|
330
|
+
for (const file of files) {
|
|
331
|
+
const absolute = containedPath(root, file.path);
|
|
332
|
+
plans.push(
|
|
333
|
+
file.merge === 'json' ? await planJsonMerge(file, absolute) : planFile(file, absolute, force),
|
|
334
|
+
);
|
|
335
|
+
}
|
|
336
|
+
const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
|
|
337
|
+
if (conflicts.length > 0) return { written: [], conflicts };
|
|
338
|
+
|
|
339
|
+
const written: string[] = [];
|
|
340
|
+
for (const plan of plans) {
|
|
341
|
+
if (plan.kind !== 'write') continue;
|
|
292
342
|
// Bun.write creates missing parent directories, so a generator never needs an mkdir step.
|
|
293
|
-
await Bun.write(absolute,
|
|
294
|
-
written.push(file.path);
|
|
343
|
+
await Bun.write(plan.absolute, plan.contents);
|
|
344
|
+
written.push(plan.file.path);
|
|
295
345
|
}
|
|
296
|
-
return { written, conflicts };
|
|
346
|
+
return { written, conflicts: [] };
|
|
297
347
|
}
|
|
298
348
|
|
|
299
349
|
const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
|
|
@@ -322,34 +372,15 @@ async function syncI18nIndex(root: string): Promise<void> {
|
|
|
322
372
|
await Bun.write(indexAbsolute, i18nIndex(locales));
|
|
323
373
|
}
|
|
324
374
|
|
|
325
|
-
function readKind(raw: string | undefined): Generator {
|
|
326
|
-
const kinds: readonly string[] = GENERATORS;
|
|
327
|
-
if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
|
|
328
|
-
throw new UnknownCommandError({
|
|
329
|
-
path: `g ${raw ?? ''}`.trim(),
|
|
330
|
-
known: GENERATORS,
|
|
331
|
-
suggestion: 'g resource',
|
|
332
|
-
});
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
/** Two surfaces, spelled exactly. A typo that fell through to `app` would scaffold the wrong one. */
|
|
336
|
-
function readSurface(raw: string | undefined, kind: Generator): Surface {
|
|
337
|
-
if (raw === undefined || raw === 'app') return 'app';
|
|
338
|
-
if (raw === 'site') return 'site';
|
|
339
|
-
throw new BadFlagError({
|
|
340
|
-
flag: 'surface',
|
|
341
|
-
command: 'g',
|
|
342
|
-
reason: `"${raw}" is not a surface (site, app)`,
|
|
343
|
-
fix: `x g ${kind} <name> --surface app`,
|
|
344
|
-
});
|
|
345
|
-
}
|
|
346
|
-
|
|
347
375
|
export const generateCommand: CliCommand = {
|
|
348
376
|
spec: {
|
|
349
377
|
name: 'g',
|
|
350
378
|
aliases: ['generate'],
|
|
351
379
|
summary: 'scaffold a primitive with its passing test',
|
|
352
|
-
|
|
380
|
+
// Projected from `GENERATORS`, never restated: the literal that used to live here had already
|
|
381
|
+
// drifted — it omitted `backfill` — and a usage line that can disagree with the list it
|
|
382
|
+
// describes is exactly the second source of truth axiom 2 forbids.
|
|
383
|
+
usage: `x g ${GENERATORS.join('|')} <name> [--feature f]`,
|
|
353
384
|
requiresApp: true,
|
|
354
385
|
flags: [
|
|
355
386
|
{ name: 'feature', type: 'string', summary: 'feature slice to write into' },
|
|
@@ -357,6 +388,8 @@ export const generateCommand: CliCommand = {
|
|
|
357
388
|
{ name: 'live', type: 'boolean', summary: 'subscribable query' },
|
|
358
389
|
{ name: 'admin', type: 'boolean', summary: 'resource: also emit the admin override' },
|
|
359
390
|
{ name: 'locales', type: 'string', summary: 'comma-separated locales, default en' },
|
|
391
|
+
{ name: 'at', type: 'string', summary: 'island, admin:page: directory to write into' },
|
|
392
|
+
{ name: 'permission', type: 'string', summary: 'admin:page: the permission it needs' },
|
|
360
393
|
{ name: 'force', type: 'boolean', summary: 'overwrite existing files' },
|
|
361
394
|
{ name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
|
|
362
395
|
],
|
|
@@ -364,23 +397,20 @@ export const generateCommand: CliCommand = {
|
|
|
364
397
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
365
398
|
const root = requireAppRoot('g', ctx.cwd).dir;
|
|
366
399
|
const kind = readKind(ctx.args.positionals[0]);
|
|
367
|
-
const name = ctx.args.positionals[1];
|
|
368
|
-
if (name === undefined) {
|
|
369
|
-
throw new UnknownCommandError({
|
|
370
|
-
path: `g ${kind}`,
|
|
371
|
-
known: GENERATORS,
|
|
372
|
-
suggestion: `g ${kind} <name>`,
|
|
373
|
-
});
|
|
374
|
-
}
|
|
400
|
+
const name = readName(ctx.args.positionals[1], kind);
|
|
375
401
|
const featureFlag = flagString(ctx.args, 'feature');
|
|
376
402
|
// Both flags are resolved before a single file is planned: a bad surface or a locale that is
|
|
377
403
|
// really a path fails here, with nothing written and nothing to undo.
|
|
378
|
-
const surface = readSurface(flagString(ctx.args, 'surface'), kind);
|
|
404
|
+
const surface = readSurface(flagString(ctx.args, 'surface'), kind, name);
|
|
379
405
|
const locales = resolveLocales(flagList(ctx.args, 'locales'));
|
|
406
|
+
const at = flagString(ctx.args, 'at');
|
|
407
|
+
const permission = flagString(ctx.args, 'permission');
|
|
380
408
|
const files = generate({
|
|
381
409
|
kind,
|
|
382
410
|
name,
|
|
383
411
|
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
412
|
+
...(at === undefined ? {} : { at }),
|
|
413
|
+
...(permission === undefined ? {} : { permission }),
|
|
384
414
|
surface,
|
|
385
415
|
live: flagBool(ctx.args, 'live'),
|
|
386
416
|
admin: flagBool(ctx.args, 'admin'),
|
|
@@ -390,9 +420,9 @@ export const generateCommand: CliCommand = {
|
|
|
390
420
|
return {
|
|
391
421
|
ok: true,
|
|
392
422
|
command: 'g',
|
|
393
|
-
summary: msg('cli.generate.
|
|
423
|
+
summary: msg('cli.generate.planned', { count: files.length, kind, name }),
|
|
394
424
|
data: { files: files.map((file) => file.path), dryRun: true },
|
|
395
|
-
lines: files.map((file) =>
|
|
425
|
+
lines: files.map((file) => msg('cli.file.added', { path: file.path })),
|
|
396
426
|
};
|
|
397
427
|
}
|
|
398
428
|
const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
|
|
@@ -403,12 +433,16 @@ export const generateCommand: CliCommand = {
|
|
|
403
433
|
// Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
|
|
404
434
|
// the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
|
|
405
435
|
// sees a resource that exists on disk but not in the manifest.
|
|
436
|
+
//
|
|
437
|
+
// REFRESHED, never introduced. An app that has not run `x manifest` has no committed contract
|
|
438
|
+
// to keep current, and writing one here hands the repo a generated file it never asked to
|
|
439
|
+
// maintain — `x g island` in such an app created `x.manifest.json` out of nothing.
|
|
406
440
|
let buildId: string | undefined;
|
|
407
441
|
// A module that would not load is omitted from the registries, so a manifest written over a
|
|
408
442
|
// partial load would replace the compatibility contract with a subset of the app. The scaffold
|
|
409
443
|
// stays on disk — only the projection is withheld, and the load failures travel as findings.
|
|
410
444
|
const loadFailures: Finding[] = [];
|
|
411
|
-
if (report.written.length > 0) {
|
|
445
|
+
if (report.written.length > 0 && existsSync(containedPath(root, MANIFEST_FILENAME))) {
|
|
412
446
|
const { manifest, findings } = await appManifest(root);
|
|
413
447
|
if (findings.length === 0) {
|
|
414
448
|
await writeAppManifest(root, manifest);
|
|
@@ -416,18 +450,19 @@ export const generateCommand: CliCommand = {
|
|
|
416
450
|
} else loadFailures.push(...findings);
|
|
417
451
|
}
|
|
418
452
|
const findings = [...report.conflicts, ...loadFailures];
|
|
453
|
+
// One list behind all three renderings. The manifest was printed as a `+` line while the count
|
|
454
|
+
// beside it came from `report.written` alone, so `x g island` said "wrote 2 file(s)" over three
|
|
455
|
+
// lines — and `--json` carried the shorter list, which is the drift `--json` exists to prevent.
|
|
456
|
+
const written = [...report.written, ...(buildId === undefined ? [] : [MANIFEST_FILENAME])];
|
|
419
457
|
return {
|
|
420
458
|
ok: findings.length === 0,
|
|
421
459
|
command: 'g',
|
|
422
|
-
summary: msg('cli.generate.wrote', { count:
|
|
460
|
+
summary: msg('cli.generate.wrote', { count: written.length, kind, name }),
|
|
423
461
|
data: {
|
|
424
|
-
files:
|
|
462
|
+
files: written,
|
|
425
463
|
...(buildId === undefined ? {} : { manifest: { buildId } }),
|
|
426
464
|
},
|
|
427
|
-
lines:
|
|
428
|
-
...report.written.map((path) => ` + ${path}`),
|
|
429
|
-
...(buildId === undefined ? [] : [` + ${MANIFEST_FILENAME}`]),
|
|
430
|
-
],
|
|
465
|
+
lines: written.map((path) => msg('cli.file.added', { path })),
|
|
431
466
|
findings,
|
|
432
467
|
};
|
|
433
468
|
},
|
package/src/cmd-help.ts
CHANGED
|
@@ -19,6 +19,9 @@ export function renderHelp(specs: readonly CommandSpec[], topic: string | undefi
|
|
|
19
19
|
(entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
|
|
20
20
|
);
|
|
21
21
|
if (spec === undefined) {
|
|
22
|
+
// `cli.hint.help` is deliberately absent from this list: it is the command's own `summary`, and
|
|
23
|
+
// `renderHuman` prints every line and THEN the summary — so the catalogue ended with the same
|
|
24
|
+
// sentence twice, once bare and once marked `✓`. One string, one place that renders it.
|
|
22
25
|
return [
|
|
23
26
|
msg('cli.tagline'),
|
|
24
27
|
'',
|
|
@@ -29,8 +32,6 @@ export function renderHelp(specs: readonly CommandSpec[], topic: string | undefi
|
|
|
29
32
|
'',
|
|
30
33
|
msg('cli.flags.heading'),
|
|
31
34
|
...GLOBAL_FLAGS.map(flagLine),
|
|
32
|
-
'',
|
|
33
|
-
msg('cli.hint.help'),
|
|
34
35
|
];
|
|
35
36
|
}
|
|
36
37
|
return [
|
|
@@ -51,6 +52,9 @@ const catalogue = (specs: readonly CommandSpec[]): JsonValue =>
|
|
|
51
52
|
usage: spec.usage,
|
|
52
53
|
aliases: [...(spec.aliases ?? [])],
|
|
53
54
|
subcommands: [...(spec.subcommands ?? [])],
|
|
55
|
+
// `null` rather than absent: an agent reading this has to be able to tell "the bare form runs
|
|
56
|
+
// this one" from "the bare form is refused", and a missing key reads as neither.
|
|
57
|
+
defaultSubcommand: spec.defaultSubcommand ?? null,
|
|
54
58
|
flags: [...(spec.flags ?? []), ...GLOBAL_FLAGS].map((flag) => ({
|
|
55
59
|
name: flag.name,
|
|
56
60
|
type: flag.type,
|
|
@@ -79,15 +83,22 @@ export function createHelpCommand(specs: () => readonly CommandSpec[]): CliComma
|
|
|
79
83
|
};
|
|
80
84
|
}
|
|
81
85
|
|
|
82
|
-
|
|
86
|
+
/**
|
|
87
|
+
* A resolver, not a string: `registry.ts` builds `COMMANDS` at module scope, and a manifest read
|
|
88
|
+
* done there runs before `main` in every process that imports `@ultimat3/cli` — including a
|
|
89
|
+
* compiled `apps/web/server.ts`, which never calls this command at all. Deferring the read into
|
|
90
|
+
* `run()` is what keeps that boot from depending on a `package.json` the binary does not carry.
|
|
91
|
+
*/
|
|
92
|
+
export function createVersionCommand(version: () => string): CliCommand {
|
|
83
93
|
return {
|
|
84
94
|
spec: { name: 'version', summary: 'the CLI version', usage: 'x version [--json]' },
|
|
85
95
|
async run(): Promise<CommandResult> {
|
|
96
|
+
const resolved = version();
|
|
86
97
|
return {
|
|
87
98
|
ok: true,
|
|
88
99
|
command: 'version',
|
|
89
|
-
summary:
|
|
90
|
-
data: { version, bun: Bun.version },
|
|
100
|
+
summary: resolved,
|
|
101
|
+
data: { version: resolved, bun: Bun.version },
|
|
91
102
|
};
|
|
92
103
|
},
|
|
93
104
|
};
|
package/src/cmd-i18n.ts
CHANGED
|
@@ -201,6 +201,8 @@ export const i18nCommand: CliCommand = {
|
|
|
201
201
|
usage: 'x i18n [check|add <locale>|sync <locale>] [--json]',
|
|
202
202
|
requiresApp: true,
|
|
203
203
|
subcommands: I18N_SUBCOMMANDS,
|
|
204
|
+
// The bare `x i18n` audits; `add` and `sync` write catalogs and must be asked for.
|
|
205
|
+
defaultSubcommand: 'check',
|
|
204
206
|
},
|
|
205
207
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
206
208
|
const root = requireAppRoot('i18n', ctx.cwd).dir;
|