@ultimat3/cli 1.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/LICENSE +21 -0
- package/README.md +100 -0
- package/package.json +60 -0
- package/src/app-agents-md.ts +27 -0
- package/src/app-boundaries.ts +206 -0
- package/src/app-evals.ts +74 -0
- package/src/app-load.ts +136 -0
- package/src/app-manifest.ts +137 -0
- package/src/app-openapi.ts +12 -0
- package/src/app-root.ts +57 -0
- package/src/bin.ts +17 -0
- package/src/boundary-cuts.ts +219 -0
- package/src/budgets.ts +92 -0
- package/src/cmd-build.ts +109 -0
- package/src/cmd-db.ts +187 -0
- package/src/cmd-deploy.ts +124 -0
- package/src/cmd-dev.ts +286 -0
- package/src/cmd-doctor.ts +178 -0
- package/src/cmd-errors.ts +99 -0
- package/src/cmd-fix.ts +126 -0
- package/src/cmd-generate.ts +434 -0
- package/src/cmd-help.ts +94 -0
- package/src/cmd-i18n.ts +212 -0
- package/src/cmd-jobs.ts +237 -0
- package/src/cmd-manifest.ts +97 -0
- package/src/cmd-mcp.ts +176 -0
- package/src/cmd-new.ts +133 -0
- package/src/cmd-planned.ts +119 -0
- package/src/cmd-policy.ts +136 -0
- package/src/cmd-registries.ts +195 -0
- package/src/cmd-routes.ts +73 -0
- package/src/cmd-tasks.ts +151 -0
- package/src/cmd-test.ts +109 -0
- package/src/cmd-verify.ts +265 -0
- package/src/command.ts +33 -0
- package/src/dev-assets.ts +177 -0
- package/src/dev-dashboard.ts +242 -0
- package/src/dev-hooks.ts +51 -0
- package/src/dev-policy.ts +82 -0
- package/src/dev-queue.ts +109 -0
- package/src/dev-render.ts +129 -0
- package/src/dev-replicator.ts +92 -0
- package/src/dev-roles.ts +246 -0
- package/src/dev-runtime.ts +203 -0
- package/src/dev-services.ts +75 -0
- package/src/dev-traces.ts +141 -0
- package/src/dispatch.ts +98 -0
- package/src/drift.ts +86 -0
- package/src/error-catalog.ts +156 -0
- package/src/error-contract.ts +212 -0
- package/src/errors.ts +367 -0
- package/src/exec.ts +70 -0
- package/src/hold.ts +48 -0
- package/src/i18n-audit.ts +183 -0
- package/src/index.ts +179 -0
- package/src/jobs-drain.ts +151 -0
- package/src/jobs-json.ts +134 -0
- package/src/jobs-report.ts +132 -0
- package/src/jobs-table.ts +34 -0
- package/src/json-merge.ts +40 -0
- package/src/mcp-db-target.ts +50 -0
- package/src/mcp-errors.ts +99 -0
- package/src/mcp-host.ts +282 -0
- package/src/mcp-test-output.ts +57 -0
- package/src/messages.ts +119 -0
- package/src/output.ts +174 -0
- package/src/parse.ts +243 -0
- package/src/policy-facts.ts +196 -0
- package/src/policy-fixture.ts +71 -0
- package/src/registry.ts +73 -0
- package/src/scaffold-fixture.ts +69 -0
- package/src/scaffold-typecheck.ts +240 -0
- package/src/source-files.ts +38 -0
- package/src/table.ts +19 -0
- package/src/tasks-facts.ts +113 -0
- package/src/templates/action.ts +193 -0
- package/src/templates/admin.ts +46 -0
- package/src/templates/catalog-json.ts +17 -0
- package/src/templates/entity.ts +157 -0
- package/src/templates/index.ts +23 -0
- package/src/templates/job.ts +148 -0
- package/src/templates/locales.ts +93 -0
- package/src/templates/naming.ts +97 -0
- package/src/templates/policy.ts +120 -0
- package/src/templates/query.ts +116 -0
- package/src/templates/resource.ts +199 -0
- package/src/templates/route.ts +138 -0
- package/src/templates/scaffold-app.ts +320 -0
- package/src/templates/scaffold-docs.ts +156 -0
- package/src/templates/scaffold-i18n.ts +149 -0
- package/src/templates/scaffold-icon.ts +54 -0
- package/src/templates/scaffold-package-shape.ts +49 -0
- package/src/templates/scaffold-repo.ts +427 -0
- package/src/test-select.ts +130 -0
- package/src/test-shards.ts +188 -0
- package/src/thrown-by.ts +24 -0
- package/src/ts-scan.ts +217 -0
- package/src/verify-step.ts +83 -0
- package/src/verify-tests.ts +166 -0
- package/src/version-loader.ts +16 -0
- package/src/workspace-checks.ts +288 -0
|
@@ -0,0 +1,434 @@
|
|
|
1
|
+
// `x g <primitive> <name>` — scaffolding with tests that pass on the first run. A generator that
|
|
2
|
+
// emits a TODO has moved the work, not done it; every file this writes typechecks, and every
|
|
3
|
+
// primitive arrives with the test that pins its distant invariants (policy, idempotency, budget).
|
|
4
|
+
|
|
5
|
+
// `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
|
|
6
|
+
// app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
|
|
7
|
+
import { existsSync } from 'node:fs';
|
|
8
|
+
import { resolve, sep } from 'node:path';
|
|
9
|
+
import { MANIFEST_FILENAME } from '@ultimat3/manifest';
|
|
10
|
+
import { appManifest, writeAppManifest } from './app-manifest';
|
|
11
|
+
import { requireAppRoot } from './app-root';
|
|
12
|
+
import type { CliCommand, CommandContext } from './command';
|
|
13
|
+
import {
|
|
14
|
+
BadFlagError,
|
|
15
|
+
CliNotImplementedError,
|
|
16
|
+
GenerateJsonInvalidError,
|
|
17
|
+
ScaffoldPathEscapeError,
|
|
18
|
+
UnknownCommandError,
|
|
19
|
+
} from './errors';
|
|
20
|
+
import { mergeJsonDeep } from './json-merge';
|
|
21
|
+
import { msg } from './messages';
|
|
22
|
+
import type { CommandResult, Finding } from './output';
|
|
23
|
+
import { flagBool, flagList, flagString } from './parse';
|
|
24
|
+
import type { GeneratedFile, Surface } from './templates';
|
|
25
|
+
import {
|
|
26
|
+
actionFiles,
|
|
27
|
+
CATALOG_ROOT,
|
|
28
|
+
entityFiles,
|
|
29
|
+
i18nIndex,
|
|
30
|
+
jobFiles,
|
|
31
|
+
policyFiles,
|
|
32
|
+
queryFiles,
|
|
33
|
+
resolveLocales,
|
|
34
|
+
resourceFiles,
|
|
35
|
+
routeFiles,
|
|
36
|
+
taskFiles,
|
|
37
|
+
} from './templates';
|
|
38
|
+
|
|
39
|
+
export const GENERATORS = [
|
|
40
|
+
'resource',
|
|
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];
|
|
52
|
+
|
|
53
|
+
export interface GenerateOptions {
|
|
54
|
+
readonly kind: Generator;
|
|
55
|
+
readonly name: string;
|
|
56
|
+
readonly feature?: string;
|
|
57
|
+
readonly surface?: Surface;
|
|
58
|
+
readonly live?: boolean;
|
|
59
|
+
/** `resource` only: also emit the per-entity admin override. */
|
|
60
|
+
readonly admin?: boolean;
|
|
61
|
+
/** Every locale a generated i18n catalog entry ships for. Defaults to `['en']`. */
|
|
62
|
+
readonly locales?: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const DEFAULT_SURFACE_DIR: Record<Surface, string> = {
|
|
66
|
+
site: 'apps/web/site',
|
|
67
|
+
app: 'apps/web/app',
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
/** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
|
|
71
|
+
* generated or hand-edited on disk, must hold. */
|
|
72
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
73
|
+
let parsed: unknown;
|
|
74
|
+
try {
|
|
75
|
+
parsed = JSON.parse(text);
|
|
76
|
+
} catch {
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
80
|
+
? (parsed as Record<string, unknown>)
|
|
81
|
+
: undefined;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
|
|
85
|
+
* the keys a run actually changed, never a reordering. */
|
|
86
|
+
function prettyJson(value: Record<string, unknown>): string {
|
|
87
|
+
const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
88
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
|
|
93
|
+
* first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
|
|
94
|
+
* file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
|
|
95
|
+
* overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
|
|
96
|
+
* lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
|
|
97
|
+
* already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
|
|
98
|
+
* catalog the identical way — one merge rule, not three hand-copied ones.
|
|
99
|
+
*
|
|
100
|
+
* A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
|
|
101
|
+
* fails to parse as a JSON object is a bug in the template that produced it, so it throws here
|
|
102
|
+
* rather than being silently treated as `{}` and merged into (or written as) a catalog with
|
|
103
|
+
* attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
|
|
104
|
+
* practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
|
|
105
|
+
* scaffold fixture) runs its file list through this function first.
|
|
106
|
+
*/
|
|
107
|
+
export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
|
|
108
|
+
const seen = new Map<string, GeneratedFile>();
|
|
109
|
+
for (const file of files) {
|
|
110
|
+
if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
|
|
111
|
+
throw new GenerateJsonInvalidError({ path: file.path });
|
|
112
|
+
}
|
|
113
|
+
const prior = seen.get(file.path);
|
|
114
|
+
if (prior === undefined) {
|
|
115
|
+
seen.set(file.path, file);
|
|
116
|
+
} else if (prior.merge === 'json' && file.merge === 'json') {
|
|
117
|
+
// Both sides already proved parseable above — the fallback only guards a future change to
|
|
118
|
+
// that invariant, it never fires today. Deep: two generators contributing to one nested
|
|
119
|
+
// catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
|
|
120
|
+
const later = parseJsonObject(file.contents) ?? {};
|
|
121
|
+
const earlier = parseJsonObject(prior.contents) ?? {};
|
|
122
|
+
const { merged } = mergeJsonDeep(earlier, later);
|
|
123
|
+
seen.set(file.path, { ...prior, contents: prettyJson(merged) });
|
|
124
|
+
}
|
|
125
|
+
// else: not mergeable — first write wins, exactly as it always has.
|
|
126
|
+
}
|
|
127
|
+
return [...seen.values()];
|
|
128
|
+
}
|
|
129
|
+
|
|
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
|
+
/**
|
|
148
|
+
* Pure: returns the files a generator would write. `x g` writes them, the generator test asserts
|
|
149
|
+
* on them, and nothing has to run a filesystem to review what a generator produces.
|
|
150
|
+
*/
|
|
151
|
+
export function generate(options: GenerateOptions): readonly GeneratedFile[] {
|
|
152
|
+
const surface: Surface = options.surface ?? 'app';
|
|
153
|
+
assertSurfaceSupported(options.kind, surface);
|
|
154
|
+
const surfaceDir = DEFAULT_SURFACE_DIR[surface];
|
|
155
|
+
const feature = options.feature ?? options.name;
|
|
156
|
+
const target = { surfaceDir, feature };
|
|
157
|
+
switch (options.kind) {
|
|
158
|
+
case 'resource':
|
|
159
|
+
return dedupe(
|
|
160
|
+
resourceFiles(options.name, {
|
|
161
|
+
...target,
|
|
162
|
+
admin: options.admin === true,
|
|
163
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
164
|
+
}),
|
|
165
|
+
);
|
|
166
|
+
case 'action':
|
|
167
|
+
return dedupe(actionFiles(options.name, target));
|
|
168
|
+
case 'mutator':
|
|
169
|
+
return dedupe(actionFiles(options.name, { ...target, mutator: true }));
|
|
170
|
+
case 'entity':
|
|
171
|
+
return dedupe(entityFiles(options.name, target));
|
|
172
|
+
case 'policy':
|
|
173
|
+
return dedupe(policyFiles(options.name, target));
|
|
174
|
+
case 'query':
|
|
175
|
+
return dedupe(queryFiles(options.name, { ...target, live: options.live === true }));
|
|
176
|
+
case 'job':
|
|
177
|
+
return dedupe(jobFiles(options.name, target));
|
|
178
|
+
case 'task':
|
|
179
|
+
return dedupe(taskFiles(options.name, target));
|
|
180
|
+
case 'route':
|
|
181
|
+
// `--locales` reaches the route generator too: its catalog entry is the route's title and
|
|
182
|
+
// description, and a locale asked for on the command line is a locale that gets a file.
|
|
183
|
+
return dedupe(
|
|
184
|
+
routeFiles(options.name, {
|
|
185
|
+
surface,
|
|
186
|
+
...(options.locales === undefined ? {} : { locales: options.locales }),
|
|
187
|
+
}),
|
|
188
|
+
);
|
|
189
|
+
default:
|
|
190
|
+
throw new CliNotImplementedError({
|
|
191
|
+
feature: `generator "${String(options.kind)}"`,
|
|
192
|
+
fix: `x g ${GENERATORS.join('|')}`,
|
|
193
|
+
});
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
export interface WriteReport {
|
|
198
|
+
readonly written: readonly string[];
|
|
199
|
+
readonly conflicts: readonly Finding[];
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
|
|
204
|
+
* walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
|
|
205
|
+
* before the write, once per file, because after the write there is nothing left to prove.
|
|
206
|
+
*/
|
|
207
|
+
export function containedPath(root: string, path: string): string {
|
|
208
|
+
const base = resolve(root);
|
|
209
|
+
const target = resolve(base, path);
|
|
210
|
+
if (target !== base && !target.startsWith(`${base}${sep}`))
|
|
211
|
+
// The default `fix` names the scaffold gate's own test, which repairs nothing for someone
|
|
212
|
+
// running `x g`: the fix here is the generate command, re-run as a dry run.
|
|
213
|
+
throw new ScaffoldPathEscapeError({
|
|
214
|
+
path,
|
|
215
|
+
dir: base,
|
|
216
|
+
fix: `name the file relative to the app root with no ".." segment, then re-run: x g ${GENERATORS.join('|')} <name> --dry-run`,
|
|
217
|
+
});
|
|
218
|
+
return target;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
|
|
223
|
+
* existing key on disk always wins, because it may hold a human translation, and only genuinely
|
|
224
|
+
* new keys are added — so a second, third… generator run keeps growing the same file instead of
|
|
225
|
+
* fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
|
|
226
|
+
* without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
|
|
227
|
+
*
|
|
228
|
+
* Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
|
|
229
|
+
* byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
|
|
230
|
+
* reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
|
|
231
|
+
* its one call site already applies.
|
|
232
|
+
*/
|
|
233
|
+
async function mergeJsonFile(
|
|
234
|
+
file: Extract<GeneratedFile, { merge: 'json' }>,
|
|
235
|
+
absolute: string,
|
|
236
|
+
): Promise<{ written: boolean; conflict?: Finding }> {
|
|
237
|
+
const generated = parseJsonObject(file.contents) ?? {};
|
|
238
|
+
if (!existsSync(absolute)) {
|
|
239
|
+
await Bun.write(absolute, prettyJson(generated));
|
|
240
|
+
return { written: true };
|
|
241
|
+
}
|
|
242
|
+
const existing = parseJsonObject(await Bun.file(absolute).text());
|
|
243
|
+
if (existing === undefined) {
|
|
244
|
+
return {
|
|
245
|
+
written: false,
|
|
246
|
+
conflict: {
|
|
247
|
+
code: 'X_GENERATE_CONFLICT',
|
|
248
|
+
cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
|
|
249
|
+
fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
|
|
250
|
+
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
251
|
+
at: file.path,
|
|
252
|
+
},
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
// An existing key wins because it may hold a human translation; only the new keys are added.
|
|
256
|
+
// Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
|
|
257
|
+
const { merged, gained } = mergeJsonDeep(existing, generated);
|
|
258
|
+
// Every key the generator wants is already there — leave the file untouched and unclaimed.
|
|
259
|
+
if (!gained) return { written: false };
|
|
260
|
+
await Bun.write(absolute, prettyJson(merged));
|
|
261
|
+
return { written: true };
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/** Never clobbers. A generator that overwrites is a generator nobody runs twice. */
|
|
265
|
+
export async function writeFiles(
|
|
266
|
+
root: string,
|
|
267
|
+
files: readonly GeneratedFile[],
|
|
268
|
+
force: boolean,
|
|
269
|
+
): Promise<WriteReport> {
|
|
270
|
+
const written: string[] = [];
|
|
271
|
+
const conflicts: Finding[] = [];
|
|
272
|
+
// Containment first, for every file: a run that stopped at the offender would already have put
|
|
273
|
+
// the earlier files on disk, so the whole set is proven before any of it lands.
|
|
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;
|
|
281
|
+
}
|
|
282
|
+
if (!force && existsSync(absolute)) {
|
|
283
|
+
conflicts.push({
|
|
284
|
+
code: 'X_GENERATE_CONFLICT',
|
|
285
|
+
cause: `${file.path} already exists`,
|
|
286
|
+
fix: `x g --force to overwrite, or pass a different name`,
|
|
287
|
+
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
288
|
+
at: file.path,
|
|
289
|
+
});
|
|
290
|
+
continue;
|
|
291
|
+
}
|
|
292
|
+
// Bun.write creates missing parent directories, so a generator never needs an mkdir step.
|
|
293
|
+
await Bun.write(absolute, file.contents);
|
|
294
|
+
written.push(file.path);
|
|
295
|
+
}
|
|
296
|
+
return { written, conflicts };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const I18N_INDEX_PATH = 'packages/i18n/src/index.ts';
|
|
300
|
+
|
|
301
|
+
/**
|
|
302
|
+
* `packages/i18n/src/index.ts` is the one module the app imports catalogs through, and it is
|
|
303
|
+
* written once, at `x new` time, importing whichever locales existed then. A later `x g
|
|
304
|
+
* ... --locales=es` lands `packages/i18n/catalogs/es.json` on disk, but nothing would otherwise
|
|
305
|
+
* teach the index about it — the catalog file would exist with real keys in it and the app could
|
|
306
|
+
* still never select that locale. Every run that wrote at least one file re-derives the FULL
|
|
307
|
+
* locale set from `packages/i18n/catalogs/` — not just the locales this invocation asked for —
|
|
308
|
+
* and rewrites the index to match. Bypasses `writeFiles` on purpose: this file is a projection of
|
|
309
|
+
* the catalog directory, never app-authored content a conflict check should protect. An app with
|
|
310
|
+
* no i18n package (deleted, or never scaffolded) is left alone.
|
|
311
|
+
*/
|
|
312
|
+
async function syncI18nIndex(root: string): Promise<void> {
|
|
313
|
+
const indexAbsolute = containedPath(root, I18N_INDEX_PATH);
|
|
314
|
+
if (!existsSync(indexAbsolute)) return;
|
|
315
|
+
const catalogDir = containedPath(root, CATALOG_ROOT);
|
|
316
|
+
const locales: string[] = [];
|
|
317
|
+
if (existsSync(catalogDir)) {
|
|
318
|
+
for await (const entry of new Bun.Glob('*.json').scan({ cwd: catalogDir, absolute: false })) {
|
|
319
|
+
locales.push(entry.replace(/\.json$/, ''));
|
|
320
|
+
}
|
|
321
|
+
}
|
|
322
|
+
await Bun.write(indexAbsolute, i18nIndex(locales));
|
|
323
|
+
}
|
|
324
|
+
|
|
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
|
+
export const generateCommand: CliCommand = {
|
|
348
|
+
spec: {
|
|
349
|
+
name: 'g',
|
|
350
|
+
aliases: ['generate'],
|
|
351
|
+
summary: 'scaffold a primitive with its passing test',
|
|
352
|
+
usage: 'x g resource|action|mutator|job|route|policy|entity|query|task <name> [--feature f]',
|
|
353
|
+
requiresApp: true,
|
|
354
|
+
flags: [
|
|
355
|
+
{ name: 'feature', type: 'string', summary: 'feature slice to write into' },
|
|
356
|
+
{ name: 'surface', type: 'string', summary: 'site | app', default: 'app' },
|
|
357
|
+
{ name: 'live', type: 'boolean', summary: 'subscribable query' },
|
|
358
|
+
{ name: 'admin', type: 'boolean', summary: 'resource: also emit the admin override' },
|
|
359
|
+
{ name: 'locales', type: 'string', summary: 'comma-separated locales, default en' },
|
|
360
|
+
{ name: 'force', type: 'boolean', summary: 'overwrite existing files' },
|
|
361
|
+
{ name: 'dry-run', type: 'boolean', summary: 'print the file list, write nothing' },
|
|
362
|
+
],
|
|
363
|
+
},
|
|
364
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
365
|
+
const root = requireAppRoot('g', ctx.cwd).dir;
|
|
366
|
+
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
|
+
}
|
|
375
|
+
const featureFlag = flagString(ctx.args, 'feature');
|
|
376
|
+
// Both flags are resolved before a single file is planned: a bad surface or a locale that is
|
|
377
|
+
// really a path fails here, with nothing written and nothing to undo.
|
|
378
|
+
const surface = readSurface(flagString(ctx.args, 'surface'), kind);
|
|
379
|
+
const locales = resolveLocales(flagList(ctx.args, 'locales'));
|
|
380
|
+
const files = generate({
|
|
381
|
+
kind,
|
|
382
|
+
name,
|
|
383
|
+
...(featureFlag === undefined ? {} : { feature: featureFlag }),
|
|
384
|
+
surface,
|
|
385
|
+
live: flagBool(ctx.args, 'live'),
|
|
386
|
+
admin: flagBool(ctx.args, 'admin'),
|
|
387
|
+
locales,
|
|
388
|
+
});
|
|
389
|
+
if (flagBool(ctx.args, 'dry-run')) {
|
|
390
|
+
return {
|
|
391
|
+
ok: true,
|
|
392
|
+
command: 'g',
|
|
393
|
+
summary: msg('cli.generate.wrote', { count: files.length, kind, name }),
|
|
394
|
+
data: { files: files.map((file) => file.path), dryRun: true },
|
|
395
|
+
lines: files.map((file) => ` + ${file.path}`),
|
|
396
|
+
};
|
|
397
|
+
}
|
|
398
|
+
const report = await writeFiles(root, files, flagBool(ctx.args, 'force'));
|
|
399
|
+
// A locale's catalog existing on disk and the app being able to select it are two different
|
|
400
|
+
// facts — see `syncI18nIndex`. Runs before the manifest load below so a route or resource
|
|
401
|
+
// this same invocation just wrote never gets projected against a stale catalog registration.
|
|
402
|
+
if (report.written.length > 0) await syncI18nIndex(root);
|
|
403
|
+
// Facts, not prose: every `x g` run leaves the route/action/entity/job/policy table current,
|
|
404
|
+
// the same guarantee `x manifest` makes on its own — an agent reading it after `x g` never
|
|
405
|
+
// sees a resource that exists on disk but not in the manifest.
|
|
406
|
+
let buildId: string | undefined;
|
|
407
|
+
// A module that would not load is omitted from the registries, so a manifest written over a
|
|
408
|
+
// partial load would replace the compatibility contract with a subset of the app. The scaffold
|
|
409
|
+
// stays on disk — only the projection is withheld, and the load failures travel as findings.
|
|
410
|
+
const loadFailures: Finding[] = [];
|
|
411
|
+
if (report.written.length > 0) {
|
|
412
|
+
const { manifest, findings } = await appManifest(root);
|
|
413
|
+
if (findings.length === 0) {
|
|
414
|
+
await writeAppManifest(root, manifest);
|
|
415
|
+
buildId = manifest.buildId;
|
|
416
|
+
} else loadFailures.push(...findings);
|
|
417
|
+
}
|
|
418
|
+
const findings = [...report.conflicts, ...loadFailures];
|
|
419
|
+
return {
|
|
420
|
+
ok: findings.length === 0,
|
|
421
|
+
command: 'g',
|
|
422
|
+
summary: msg('cli.generate.wrote', { count: report.written.length, kind, name }),
|
|
423
|
+
data: {
|
|
424
|
+
files: report.written,
|
|
425
|
+
...(buildId === undefined ? {} : { manifest: { buildId } }),
|
|
426
|
+
},
|
|
427
|
+
lines: [
|
|
428
|
+
...report.written.map((path) => ` + ${path}`),
|
|
429
|
+
...(buildId === undefined ? [] : [` + ${MANIFEST_FILENAME}`]),
|
|
430
|
+
],
|
|
431
|
+
findings,
|
|
432
|
+
};
|
|
433
|
+
},
|
|
434
|
+
};
|
package/src/cmd-help.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
// `x help` — the command catalogue, generated from the same specs the parser uses, so help can
|
|
2
|
+
// never describe a flag that does not exist. Built as a factory over the spec list to keep the
|
|
3
|
+
// registry acyclic.
|
|
4
|
+
|
|
5
|
+
import type { CliCommand, CommandContext } from './command';
|
|
6
|
+
import { msg } from './messages';
|
|
7
|
+
import type { CommandResult, JsonValue } from './output';
|
|
8
|
+
import type { CommandSpec, FlagSpec } from './parse';
|
|
9
|
+
import { GLOBAL_FLAGS } from './parse';
|
|
10
|
+
|
|
11
|
+
const flagLine = (flag: FlagSpec): string => {
|
|
12
|
+
const short = flag.short === undefined ? ' ' : `-${flag.short}, `;
|
|
13
|
+
const name = flag.type === 'string' ? `--${flag.name} <value>` : `--${flag.name}`;
|
|
14
|
+
return ` ${short}${name.padEnd(24)} ${flag.summary}`;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
export function renderHelp(specs: readonly CommandSpec[], topic: string | undefined): string[] {
|
|
18
|
+
const spec = specs.find(
|
|
19
|
+
(entry) => entry.name === topic || (entry.aliases ?? []).includes(topic ?? ''),
|
|
20
|
+
);
|
|
21
|
+
if (spec === undefined) {
|
|
22
|
+
return [
|
|
23
|
+
msg('cli.tagline'),
|
|
24
|
+
'',
|
|
25
|
+
msg('cli.usage'),
|
|
26
|
+
'',
|
|
27
|
+
msg('cli.commands.heading'),
|
|
28
|
+
...specs.map((entry) => ` ${entry.name.padEnd(10)} ${entry.summary}`),
|
|
29
|
+
'',
|
|
30
|
+
msg('cli.flags.heading'),
|
|
31
|
+
...GLOBAL_FLAGS.map(flagLine),
|
|
32
|
+
'',
|
|
33
|
+
msg('cli.hint.help'),
|
|
34
|
+
];
|
|
35
|
+
}
|
|
36
|
+
return [
|
|
37
|
+
`${spec.name} — ${spec.summary}`,
|
|
38
|
+
'',
|
|
39
|
+
spec.usage,
|
|
40
|
+
...(spec.subcommands === undefined ? [] : ['', `subcommands: ${spec.subcommands.join(' | ')}`]),
|
|
41
|
+
'',
|
|
42
|
+
msg('cli.flags.heading'),
|
|
43
|
+
...[...(spec.flags ?? []), ...GLOBAL_FLAGS].map(flagLine),
|
|
44
|
+
];
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const catalogue = (specs: readonly CommandSpec[]): JsonValue =>
|
|
48
|
+
specs.map((spec) => ({
|
|
49
|
+
name: spec.name,
|
|
50
|
+
summary: spec.summary,
|
|
51
|
+
usage: spec.usage,
|
|
52
|
+
aliases: [...(spec.aliases ?? [])],
|
|
53
|
+
subcommands: [...(spec.subcommands ?? [])],
|
|
54
|
+
flags: [...(spec.flags ?? []), ...GLOBAL_FLAGS].map((flag) => ({
|
|
55
|
+
name: flag.name,
|
|
56
|
+
type: flag.type,
|
|
57
|
+
summary: flag.summary,
|
|
58
|
+
})),
|
|
59
|
+
}));
|
|
60
|
+
|
|
61
|
+
export function createHelpCommand(specs: () => readonly CommandSpec[]): CliCommand {
|
|
62
|
+
return {
|
|
63
|
+
spec: {
|
|
64
|
+
name: 'help',
|
|
65
|
+
summary: 'this catalogue, or the usage for one command',
|
|
66
|
+
usage: 'x help [command] [--json]',
|
|
67
|
+
},
|
|
68
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
69
|
+
const all = specs();
|
|
70
|
+
const topic = ctx.args.positionals[0];
|
|
71
|
+
return {
|
|
72
|
+
ok: true,
|
|
73
|
+
command: 'help',
|
|
74
|
+
summary: msg('cli.hint.help'),
|
|
75
|
+
lines: renderHelp(all, topic),
|
|
76
|
+
data: topic === undefined ? catalogue(all) : catalogue(all.filter((s) => s.name === topic)),
|
|
77
|
+
};
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function createVersionCommand(version: string): CliCommand {
|
|
83
|
+
return {
|
|
84
|
+
spec: { name: 'version', summary: 'the CLI version', usage: 'x version [--json]' },
|
|
85
|
+
async run(): Promise<CommandResult> {
|
|
86
|
+
return {
|
|
87
|
+
ok: true,
|
|
88
|
+
command: 'version',
|
|
89
|
+
summary: version,
|
|
90
|
+
data: { version, bun: Bun.version },
|
|
91
|
+
};
|
|
92
|
+
},
|
|
93
|
+
};
|
|
94
|
+
}
|