@ultimat3/cli 5.0.1 → 7.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 +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
// From a generator's file list to the disk: one entry per path, catalogs merged rather than
|
|
2
|
+
// clobbered, and nothing written at all if any file would conflict. Split from `cmd-generate.ts`,
|
|
3
|
+
// which decides WHICH files a generator emits — this file decides what happens to them, and `x new`
|
|
4
|
+
// and the scaffold fixture assemble their own lists and land them through exactly these rules.
|
|
5
|
+
|
|
6
|
+
// `resolve`/`sep` and not `join`: only resolving the assembled path can prove it stayed inside the
|
|
7
|
+
// app root, and `node:path` is the only API that resolves one. `node:fs` for the exists check.
|
|
8
|
+
import { existsSync } from 'node:fs';
|
|
9
|
+
import { resolve, sep } from 'node:path';
|
|
10
|
+
import { GenerateJsonInvalidError, ScaffoldPathEscapeError } from './errors';
|
|
11
|
+
import { mergeJsonDeep } from './json-merge';
|
|
12
|
+
import type { Finding } from './output';
|
|
13
|
+
import type { GeneratedFile } from './templates';
|
|
14
|
+
|
|
15
|
+
/** `undefined` when `text` does not parse as a JSON object — the one shape every catalog, whether
|
|
16
|
+
* generated or hand-edited on disk, must hold. */
|
|
17
|
+
function parseJsonObject(text: string): Record<string, unknown> | undefined {
|
|
18
|
+
let parsed: unknown;
|
|
19
|
+
try {
|
|
20
|
+
parsed = JSON.parse(text);
|
|
21
|
+
} catch {
|
|
22
|
+
return undefined;
|
|
23
|
+
}
|
|
24
|
+
return typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)
|
|
25
|
+
? (parsed as Record<string, unknown>)
|
|
26
|
+
: undefined;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/** Deterministic catalog bytes: sorted keys, 2-space indent, trailing newline — a diff shows only
|
|
30
|
+
* the keys a run actually changed, never a reordering. */
|
|
31
|
+
function prettyJson(value: Record<string, unknown>): string {
|
|
32
|
+
const sorted = Object.fromEntries(Object.entries(value).sort(([a], [b]) => a.localeCompare(b)));
|
|
33
|
+
return `${JSON.stringify(sorted, null, 2)}\n`;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Two generators can legitimately produce the same shared file. A plain file (errors.ts) keeps
|
|
38
|
+
* first-write-wins; a `merge: 'json'` catalog instead merges every contributor's keys into one
|
|
39
|
+
* file — `resourceFiles` and `routeFiles` both target the same locale's catalog, and a plain
|
|
40
|
+
* overwrite would drop whichever generator ran first. Later entries fill keys the earlier one
|
|
41
|
+
* lacks; the first occurrence wins a clash, the same rule `writeFiles` applies against the copy
|
|
42
|
+
* already on disk. Exported so `x new` (`cmd-new.ts`) and the scaffold fixture resolve a shared
|
|
43
|
+
* catalog the identical way — one merge rule, not three hand-copied ones.
|
|
44
|
+
*
|
|
45
|
+
* A `merge: 'json'` file's `contents` are the generator's own output, not user data — one that
|
|
46
|
+
* fails to parse as a JSON object is a bug in the template that produced it, so it throws here
|
|
47
|
+
* rather than being silently treated as `{}` and merged into (or written as) a catalog with
|
|
48
|
+
* attribution to nobody. `writeFiles`/`mergeJsonFile` never see a malformed *generated* payload in
|
|
49
|
+
* practice: every production caller (`generate()` below, `cmd-new.ts`'s `planNewApp()`, the
|
|
50
|
+
* scaffold fixture) runs its file list through this function first.
|
|
51
|
+
*/
|
|
52
|
+
export function dedupe(files: readonly GeneratedFile[]): readonly GeneratedFile[] {
|
|
53
|
+
const seen = new Map<string, GeneratedFile>();
|
|
54
|
+
for (const file of files) {
|
|
55
|
+
if (file.merge === 'json' && parseJsonObject(file.contents) === undefined) {
|
|
56
|
+
throw new GenerateJsonInvalidError({ path: file.path });
|
|
57
|
+
}
|
|
58
|
+
const prior = seen.get(file.path);
|
|
59
|
+
if (prior === undefined) {
|
|
60
|
+
seen.set(file.path, file);
|
|
61
|
+
} else if (prior.merge === 'json' && file.merge === 'json') {
|
|
62
|
+
// Both sides already proved parseable above — the fallback only guards a future change to
|
|
63
|
+
// that invariant, it never fires today. Deep: two generators contributing to one nested
|
|
64
|
+
// catalog share top-level keys (`app`, `admin`), and a shallow spread drops one of them.
|
|
65
|
+
const later = parseJsonObject(file.contents) ?? {};
|
|
66
|
+
const earlier = parseJsonObject(prior.contents) ?? {};
|
|
67
|
+
const { merged } = mergeJsonDeep(earlier, later);
|
|
68
|
+
seen.set(file.path, { ...prior, contents: prettyJson(merged) });
|
|
69
|
+
}
|
|
70
|
+
// else: not mergeable — first write wins, exactly as it always has.
|
|
71
|
+
}
|
|
72
|
+
return [...seen.values()];
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export interface WriteReport {
|
|
76
|
+
readonly written: readonly string[];
|
|
77
|
+
readonly conflicts: readonly Finding[];
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* `GeneratedFile.path` is documented as relative-POSIX, not enforced as it: `join` would happily
|
|
82
|
+
* walk out of the app on a `..` segment or ignore the root entirely on an absolute path. Proven
|
|
83
|
+
* before the write, once per file, because after the write there is nothing left to prove.
|
|
84
|
+
*/
|
|
85
|
+
export function containedPath(root: string, path: string): string {
|
|
86
|
+
const base = resolve(root);
|
|
87
|
+
const target = resolve(base, path);
|
|
88
|
+
if (target !== base && !target.startsWith(`${base}${sep}`))
|
|
89
|
+
// The default `fix` names the scaffold gate's own test, which repairs nothing for someone
|
|
90
|
+
// running `x g`: the fix here is the generate command, re-run as a dry run.
|
|
91
|
+
throw new ScaffoldPathEscapeError({
|
|
92
|
+
path,
|
|
93
|
+
dir: base,
|
|
94
|
+
// Command first, the caveat behind a `#`: the line runs verbatim and the shell drops the
|
|
95
|
+
// rest. `x g <kind> <name>` pasted into bash is a redirect, not a command.
|
|
96
|
+
fix: `x g resource posts --dry-run # name every file relative to the app root, no ".." segment`,
|
|
97
|
+
});
|
|
98
|
+
return target;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* A `merge: 'json'` catalog is never a conflict on existence and never subject to `--force`: an
|
|
103
|
+
* existing key on disk always wins, because it may hold a human translation, and only genuinely
|
|
104
|
+
* new keys are added — so a second, third… generator run keeps growing the same file instead of
|
|
105
|
+
* fighting over it. A file that exists but does not parse as a JSON object cannot be merged into
|
|
106
|
+
* without risking silent data loss, so that alone is reported rather than clobbered or thrown past.
|
|
107
|
+
*
|
|
108
|
+
* Typed to the `merge: 'json'` variant alone, not the general `GeneratedFile` union: a
|
|
109
|
+
* byte-carrying file has no `contents: string` to merge, and this is what stops one from ever
|
|
110
|
+
* reaching `parseJsonObject` even if a future caller forgets the `file.merge === 'json'` guard
|
|
111
|
+
* its one call site already applies.
|
|
112
|
+
*/
|
|
113
|
+
async function planJsonMerge(
|
|
114
|
+
file: Extract<GeneratedFile, { merge: 'json' }>,
|
|
115
|
+
absolute: string,
|
|
116
|
+
): Promise<WritePlan> {
|
|
117
|
+
const generated = parseJsonObject(file.contents) ?? {};
|
|
118
|
+
if (!existsSync(absolute))
|
|
119
|
+
return { kind: 'write', file, absolute, contents: prettyJson(generated) };
|
|
120
|
+
const existing = parseJsonObject(await Bun.file(absolute).text());
|
|
121
|
+
if (existing === undefined) {
|
|
122
|
+
return {
|
|
123
|
+
kind: 'conflict',
|
|
124
|
+
finding: {
|
|
125
|
+
code: 'X_GENERATE_CONFLICT',
|
|
126
|
+
cause: `${file.path} exists but is not a JSON object, so its keys cannot be merged`,
|
|
127
|
+
fix: `edit ${file.path} by hand until it parses as a JSON object, or delete it and re-run x g`,
|
|
128
|
+
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
129
|
+
at: file.path,
|
|
130
|
+
},
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
// An existing key wins because it may hold a human translation; only the new keys are added.
|
|
134
|
+
// Deep, so a nested catalog gains `site.blog.title` without losing the rest of `site`.
|
|
135
|
+
const { merged, gained } = mergeJsonDeep(existing, generated);
|
|
136
|
+
// Every key the generator wants is already there — leave the file untouched and unclaimed.
|
|
137
|
+
if (!gained) return { kind: 'skip' };
|
|
138
|
+
return { kind: 'write', file, absolute, contents: prettyJson(merged) };
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* What one generated file would do, decided without doing it. The merge case computes its own
|
|
143
|
+
* bytes here rather than at the write, so the two passes below cannot disagree about a file.
|
|
144
|
+
*/
|
|
145
|
+
type WritePlan =
|
|
146
|
+
| {
|
|
147
|
+
readonly kind: 'write';
|
|
148
|
+
readonly file: GeneratedFile;
|
|
149
|
+
readonly absolute: string;
|
|
150
|
+
readonly contents: string | Uint8Array;
|
|
151
|
+
}
|
|
152
|
+
| { readonly kind: 'skip' }
|
|
153
|
+
| { readonly kind: 'conflict'; readonly finding: Finding };
|
|
154
|
+
|
|
155
|
+
function planFile(
|
|
156
|
+
file: GeneratedFile,
|
|
157
|
+
absolute: string,
|
|
158
|
+
force: boolean,
|
|
159
|
+
invocation: string,
|
|
160
|
+
): WritePlan {
|
|
161
|
+
// A foundation file belongs to the slice, not to the generator that needs it: several generators
|
|
162
|
+
// emit the same `repo.ts`, so an existing one is the author's — never a conflict, and never
|
|
163
|
+
// overwritten, `--force` included. `--force` is about the primitive the author named; clobbering
|
|
164
|
+
// `policy.ts` to regenerate one action would delete every rule they wrote. Regenerating a slice
|
|
165
|
+
// module is `x g entity|policy`.
|
|
166
|
+
if (file.merge === 'if-absent') {
|
|
167
|
+
return existsSync(absolute)
|
|
168
|
+
? { kind: 'skip' }
|
|
169
|
+
: { kind: 'write', file, absolute, contents: file.contents };
|
|
170
|
+
}
|
|
171
|
+
if (!force && existsSync(absolute)) {
|
|
172
|
+
return {
|
|
173
|
+
kind: 'conflict',
|
|
174
|
+
finding: {
|
|
175
|
+
code: 'X_GENERATE_CONFLICT',
|
|
176
|
+
cause: `${file.path} already exists`,
|
|
177
|
+
// The caller's own invocation, not `x g <kind>`: `x g --force` is X_CLI_UNKNOWN_COMMAND
|
|
178
|
+
// when run, and a `fix:` is copied and pasted verbatim. Same construction as
|
|
179
|
+
// `generate-kinds.ts`'s `assertSurfaceSupported`.
|
|
180
|
+
fix: `${invocation} --force # overwrites ${file.path}, or pass a different name`,
|
|
181
|
+
docs: 'https://ultimate.dev/errors/X_GENERATE_CONFLICT',
|
|
182
|
+
at: file.path,
|
|
183
|
+
},
|
|
184
|
+
};
|
|
185
|
+
}
|
|
186
|
+
return { kind: 'write', file, absolute, contents: file.contents };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Never clobbers, and never half-writes. A generator that overwrites is a generator nobody runs
|
|
191
|
+
* twice; a generator that lands four of seven files and then reports a conflict is worse, because
|
|
192
|
+
* the next run conflicts on the files the failed one wrote.
|
|
193
|
+
*
|
|
194
|
+
* Two passes, and the split is the point: the first decides — containment, existence, whether a
|
|
195
|
+
* catalog can be merged into — and touches nothing, the second writes only when the first found
|
|
196
|
+
* no conflict at all. Containment was already proven up front and the rest was not.
|
|
197
|
+
*/
|
|
198
|
+
export async function writeFiles(
|
|
199
|
+
root: string,
|
|
200
|
+
files: readonly GeneratedFile[],
|
|
201
|
+
force: boolean,
|
|
202
|
+
/**
|
|
203
|
+
* The command line that produced these files, so a conflict's `fix:` can hand it back with
|
|
204
|
+
* `--force` on the end. Optional for a caller assembling files itself; the fallback is the
|
|
205
|
+
* shape, not a runnable line, and every generator path supplies the real one.
|
|
206
|
+
*/
|
|
207
|
+
invocation = 'x g <kind> <name>',
|
|
208
|
+
): Promise<WriteReport> {
|
|
209
|
+
const plans: WritePlan[] = [];
|
|
210
|
+
for (const file of files) {
|
|
211
|
+
const absolute = containedPath(root, file.path);
|
|
212
|
+
plans.push(
|
|
213
|
+
file.merge === 'json'
|
|
214
|
+
? await planJsonMerge(file, absolute)
|
|
215
|
+
: planFile(file, absolute, force, invocation),
|
|
216
|
+
);
|
|
217
|
+
}
|
|
218
|
+
const conflicts = plans.flatMap((plan) => (plan.kind === 'conflict' ? [plan.finding] : []));
|
|
219
|
+
if (conflicts.length > 0) return { written: [], conflicts };
|
|
220
|
+
|
|
221
|
+
const written: string[] = [];
|
|
222
|
+
for (const plan of plans) {
|
|
223
|
+
if (plan.kind !== 'write') continue;
|
|
224
|
+
// Bun.write creates missing parent directories, so a generator never needs an mkdir step.
|
|
225
|
+
await Bun.write(plan.absolute, plan.contents);
|
|
226
|
+
written.push(plan.file.path);
|
|
227
|
+
}
|
|
228
|
+
return { written, conflicts: [] };
|
|
229
|
+
}
|
package/src/gh-target.ts
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
// Which repository, which pull request, which branch — the three facts `x pr` and `x ci` both
|
|
2
|
+
// need before they can ask GitHub anything, resolved once here so the two commands can never
|
|
3
|
+
// disagree about what "this checkout" means.
|
|
4
|
+
|
|
5
|
+
import { UltimateError } from '@ultimat3/core';
|
|
6
|
+
import { t } from '@ultimat3/schema';
|
|
7
|
+
import { BadFlagError } from './errors';
|
|
8
|
+
import type { GhHost } from './gh';
|
|
9
|
+
import { GhFailedError, ghJson } from './gh';
|
|
10
|
+
|
|
11
|
+
/** `owner/name`, split once so a caller never re-splits it and never re-joins it wrong. */
|
|
12
|
+
export interface GhRepo {
|
|
13
|
+
readonly owner: string;
|
|
14
|
+
readonly name: string;
|
|
15
|
+
/** The `owner/name` spelling, which is what `--repo` takes and what every render prints. */
|
|
16
|
+
readonly slug: string;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* A repository name is `[A-Za-z0-9._-]+` on both sides of one slash. Refused here rather than by
|
|
21
|
+
* GitHub, because `--repo` is the one field a caller types by hand: `--repo ultimate` reaches the
|
|
22
|
+
* API as an owner with no name and comes back `Could not resolve to a Repository`, which reads as
|
|
23
|
+
* "that repository is gone" rather than "that is not a repository reference".
|
|
24
|
+
*/
|
|
25
|
+
const SLUG = /^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/;
|
|
26
|
+
|
|
27
|
+
const REPO_VIEW = t.object({ nameWithOwner: t.string });
|
|
28
|
+
|
|
29
|
+
/** No pull request for the branch this checkout is on. A number the caller knows closes it. */
|
|
30
|
+
export class PrNotFoundError extends UltimateError {
|
|
31
|
+
constructor(input: { detail: string }) {
|
|
32
|
+
super({
|
|
33
|
+
code: 'X_PR_NOT_FOUND',
|
|
34
|
+
cause: `GitHub reports no pull request for this checkout: ${input.detail}`,
|
|
35
|
+
fix: 'x pr review --pr 241 --json # or open one first with: gh pr create',
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The repository this command is about. `--repo` when given, otherwise `gh repo view`, which
|
|
42
|
+
* resolves the same remote `gh pr` and `gh run` resolve — asking git for the remote here would be
|
|
43
|
+
* a second answer to a question gh already owns.
|
|
44
|
+
*/
|
|
45
|
+
export async function resolveRepo(
|
|
46
|
+
host: GhHost,
|
|
47
|
+
command: string,
|
|
48
|
+
flag: string | undefined,
|
|
49
|
+
): Promise<GhRepo> {
|
|
50
|
+
if (flag !== undefined) {
|
|
51
|
+
if (!SLUG.test(flag)) {
|
|
52
|
+
throw new BadFlagError({
|
|
53
|
+
flag: 'repo',
|
|
54
|
+
command,
|
|
55
|
+
reason: `"${flag}" is not an owner/name repository reference`,
|
|
56
|
+
fix: `x ${command} --repo developerz-ai/ultimate --json`,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
return repoOf(flag);
|
|
60
|
+
}
|
|
61
|
+
const viewed = await ghJson(host, ['repo', 'view', '--json', 'nameWithOwner'], REPO_VIEW, {
|
|
62
|
+
label: 'gh repo view',
|
|
63
|
+
fix: `x ${command} --repo developerz-ai/ultimate --json`,
|
|
64
|
+
});
|
|
65
|
+
return repoOf(viewed.nameWithOwner);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function repoOf(slug: string): GhRepo {
|
|
69
|
+
const [owner = '', name = ''] = slug.split('/');
|
|
70
|
+
return { owner, name, slug };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const PR_VIEW = t.object({ number: t.number });
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* The pull request for the current branch. gh exits non-zero when there is none, with a message
|
|
77
|
+
* that names the branch — so the refusal keeps gh's own sentence and adds the remedy gh has no
|
|
78
|
+
* opinion about: name the number, or open the PR.
|
|
79
|
+
*/
|
|
80
|
+
export async function resolvePrNumber(host: GhHost, repo: GhRepo): Promise<number> {
|
|
81
|
+
try {
|
|
82
|
+
const viewed = await ghJson(
|
|
83
|
+
host,
|
|
84
|
+
['pr', 'view', '--repo', repo.slug, '--json', 'number'],
|
|
85
|
+
PR_VIEW,
|
|
86
|
+
{ label: 'gh pr view', fix: 'x pr review --pr 241 --json' },
|
|
87
|
+
);
|
|
88
|
+
return viewed.number;
|
|
89
|
+
} catch (error) {
|
|
90
|
+
if (error instanceof GhFailedError) throw new PrNotFoundError({ detail: error.cause });
|
|
91
|
+
throw error;
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* The branch this checkout is on, from git rather than from gh: gh has no "current branch"
|
|
97
|
+
* question, only commands that answer one for you. A detached HEAD answers `HEAD`, which matches
|
|
98
|
+
* no branch on GitHub — the caller's own "no run for this branch" refusal names `--branch`, so a
|
|
99
|
+
* second refusal here would only move the same instruction one step earlier.
|
|
100
|
+
*
|
|
101
|
+
* Spawn failures are deliberately NOT caught: `exec.ts` refuses a missing program with a fix that
|
|
102
|
+
* already names `git`, and re-labelling that as a GitHub problem would send the reader to the
|
|
103
|
+
* wrong install.
|
|
104
|
+
*/
|
|
105
|
+
export async function currentBranch(host: GhHost): Promise<string> {
|
|
106
|
+
const result = await host.runner(['git', 'rev-parse', '--abbrev-ref', 'HEAD'], {
|
|
107
|
+
cwd: host.cwd,
|
|
108
|
+
});
|
|
109
|
+
if (!result.ok) {
|
|
110
|
+
throw new GhFailedError({
|
|
111
|
+
label: 'git rev-parse --abbrev-ref HEAD',
|
|
112
|
+
code: result.code,
|
|
113
|
+
detail: result.stderr.split('\n')[0]?.trim() ?? '',
|
|
114
|
+
fix: 'x ci --branch main --json',
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return result.stdout.trim();
|
|
118
|
+
}
|
package/src/gh.ts
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
// The one seam between the CLI and the `gh` binary. Every GitHub call is built here and spawned
|
|
2
|
+
// through the injected `Runner`, so a test asserts the exact argv with no network and no `gh`
|
|
3
|
+
// installed — and the four ways this shell-out fails (no binary, no credentials, a non-zero exit,
|
|
4
|
+
// an answer that will not parse) each get one code and one executable fix instead of a stack trace.
|
|
5
|
+
|
|
6
|
+
import { renderThrowable, singleLine, UltimateError } from '@ultimat3/core';
|
|
7
|
+
import type { AnySchema, InferOutput } from '@ultimat3/schema';
|
|
8
|
+
import { formatPath } from '@ultimat3/schema';
|
|
9
|
+
import type { ExecResult, Runner } from './exec';
|
|
10
|
+
import { execOutput } from './exec';
|
|
11
|
+
|
|
12
|
+
/** What a gh call needs from a `CommandContext`, and nothing more — so a test passes two fields. */
|
|
13
|
+
export interface GhHost {
|
|
14
|
+
readonly runner: Runner;
|
|
15
|
+
readonly cwd: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface GhOptions {
|
|
19
|
+
/**
|
|
20
|
+
* A short name for this call. Every refusal is titled with it rather than with the argv,
|
|
21
|
+
* because a GraphQL document pasted into a `cause:` is a page of text where a reader needs a
|
|
22
|
+
* sentence.
|
|
23
|
+
*/
|
|
24
|
+
readonly label: string;
|
|
25
|
+
/**
|
|
26
|
+
* The caller's remedy, and REQUIRED: the seam knows a call failed and never what the operator
|
|
27
|
+
* was trying to do, so a generic fix here would be axiom 4 inverted at the one boundary every
|
|
28
|
+
* GitHub call crosses. Making it a field of the options is what turns "state a remedy" into a
|
|
29
|
+
* build error rather than a convention.
|
|
30
|
+
*/
|
|
31
|
+
readonly fix: string;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** The one binary this file spawns. Named once, so every argv assertion has a single source. */
|
|
35
|
+
export const GH_BIN = 'gh';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* No `gh` on PATH. `exec.ts` already refuses a missing program with `X_CLI_UNEXPECTED`, and that
|
|
39
|
+
* code's fix names the binary — but "the CLI itself failed" is the wrong sentence for a machine
|
|
40
|
+
* that simply has no GitHub client, and `gh auth login` is not reachable from it.
|
|
41
|
+
*/
|
|
42
|
+
export class GhUnavailableError extends UltimateError {
|
|
43
|
+
constructor(input: { cwd: string; detail: string }) {
|
|
44
|
+
super({
|
|
45
|
+
code: 'X_GH_UNAVAILABLE',
|
|
46
|
+
cause: `the GitHub CLI could not be run from ${input.cwd}: ${input.detail}`,
|
|
47
|
+
fix: 'install the GitHub CLI from https://cli.github.com, then run: gh auth login',
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/** `gh` is installed and holds no usable credentials for this host. One command closes it. */
|
|
53
|
+
export class GhNotAuthenticatedError extends UltimateError {
|
|
54
|
+
constructor(input: { label: string; detail: string }) {
|
|
55
|
+
super({
|
|
56
|
+
code: 'X_GH_NOT_AUTHENTICATED',
|
|
57
|
+
cause: `${input.label} was refused by GitHub: ${input.detail}`,
|
|
58
|
+
fix: 'gh auth login',
|
|
59
|
+
});
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/** Any other non-zero exit — a bad id, a repository that is not there, a rate limit. */
|
|
64
|
+
export class GhFailedError extends UltimateError {
|
|
65
|
+
constructor(input: { label: string; code: number; detail: string; fix: string }) {
|
|
66
|
+
super({
|
|
67
|
+
code: 'X_GH_COMMAND_FAILED',
|
|
68
|
+
cause: `${input.label} exited ${input.code}: ${input.detail}`,
|
|
69
|
+
fix: input.fix,
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* `gh` answered, and the answer is not the shape this command reads. A cast would carry the
|
|
76
|
+
* mismatch into the render and print `undefined` at whichever field moved; the parse refuses at
|
|
77
|
+
* the boundary instead, which is the only place the argv that produced it is still known.
|
|
78
|
+
*/
|
|
79
|
+
export class GhResponseInvalidError extends UltimateError {
|
|
80
|
+
constructor(input: { label: string; detail: string; fix: string }) {
|
|
81
|
+
super({
|
|
82
|
+
code: 'X_GH_RESPONSE_INVALID',
|
|
83
|
+
cause: `${input.label} answered something this command cannot read: ${input.detail}`,
|
|
84
|
+
fix: input.fix,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* The spellings `gh` uses when the token is the problem. Matched against its own output rather
|
|
91
|
+
* than against an exit code, because every one of these exits 1 exactly like a typo'd id does —
|
|
92
|
+
* and the two have different remedies.
|
|
93
|
+
*/
|
|
94
|
+
const UNAUTHENTICATED =
|
|
95
|
+
/not logged in|gh auth login|HTTP 401|Bad credentials|GH_TOKEN|GITHUB_TOKEN|authentication/i;
|
|
96
|
+
|
|
97
|
+
/** The first line a human would read, escaped and bounded — a `cause:` is one line by contract. */
|
|
98
|
+
export function ghDetail(result: ExecResult): string {
|
|
99
|
+
const merged = execOutput(result);
|
|
100
|
+
const first = merged.split('\n').find((line) => line.trim().length > 0) ?? '';
|
|
101
|
+
const text = singleLine(first.trim().replace(/^gh:\s*/, ''));
|
|
102
|
+
return text.length > 200 ? `${text.slice(0, 200)}…` : text;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* One `gh` invocation, refused four ways. A spawn failure is mapped rather than rethrown: it is
|
|
107
|
+
* the "no GitHub CLI on this machine" case, and `exec.ts`'s own refusal cannot offer `gh auth
|
|
108
|
+
* login` as the next step.
|
|
109
|
+
*/
|
|
110
|
+
export async function runGh(
|
|
111
|
+
host: GhHost,
|
|
112
|
+
args: readonly string[],
|
|
113
|
+
options: GhOptions,
|
|
114
|
+
): Promise<ExecResult> {
|
|
115
|
+
let result: ExecResult;
|
|
116
|
+
try {
|
|
117
|
+
result = await host.runner([GH_BIN, ...args], { cwd: host.cwd });
|
|
118
|
+
} catch (error) {
|
|
119
|
+
// Never interpolated: the thrown value is genuinely unknown here (Bun raises `ENOENT` for a
|
|
120
|
+
// missing program and `EACCES` for an unrunnable one), which is what `bun run error-render`
|
|
121
|
+
// refuses to see reach a `cause:` through `${…}`.
|
|
122
|
+
throw new GhUnavailableError({ cwd: host.cwd, detail: renderThrowable(error) });
|
|
123
|
+
}
|
|
124
|
+
if (result.ok) return result;
|
|
125
|
+
const detail = ghDetail(result);
|
|
126
|
+
if (UNAUTHENTICATED.test(detail)) {
|
|
127
|
+
throw new GhNotAuthenticatedError({ label: options.label, detail });
|
|
128
|
+
}
|
|
129
|
+
throw new GhFailedError({ label: options.label, code: result.code, detail, fix: options.fix });
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/**
|
|
133
|
+
* `gh --json`, parsed rather than cast. The response is untrusted input — a different `gh`
|
|
134
|
+
* version, a proxy that answered HTML, a field GitHub renamed — so it goes through the schema the
|
|
135
|
+
* caller declared and a mismatch is a coded refusal naming the call that produced it.
|
|
136
|
+
*/
|
|
137
|
+
export async function ghJson<S extends AnySchema>(
|
|
138
|
+
host: GhHost,
|
|
139
|
+
args: readonly string[],
|
|
140
|
+
schema: S,
|
|
141
|
+
options: GhOptions,
|
|
142
|
+
): Promise<InferOutput<S>> {
|
|
143
|
+
const result = await runGh(host, args, options);
|
|
144
|
+
let payload: unknown;
|
|
145
|
+
try {
|
|
146
|
+
payload = JSON.parse(result.stdout);
|
|
147
|
+
} catch (error) {
|
|
148
|
+
throw new GhResponseInvalidError({
|
|
149
|
+
label: options.label,
|
|
150
|
+
detail: renderThrowable(error),
|
|
151
|
+
fix: options.fix,
|
|
152
|
+
});
|
|
153
|
+
}
|
|
154
|
+
const parsed = schema.safeParse(payload);
|
|
155
|
+
if (parsed.issues !== undefined) {
|
|
156
|
+
const first = parsed.issues[0];
|
|
157
|
+
// `formatPath` is the schema package's own renderer for an issue path — a second spelling of
|
|
158
|
+
// `items[0].price` here would be a field name that does not match the one every other
|
|
159
|
+
// validation failure in the framework prints.
|
|
160
|
+
throw new GhResponseInvalidError({
|
|
161
|
+
label: options.label,
|
|
162
|
+
detail:
|
|
163
|
+
first === undefined
|
|
164
|
+
? 'the response matched no field this command declares'
|
|
165
|
+
: `${formatPath(first.path)} ${first.message}`.trim(),
|
|
166
|
+
fix: options.fix,
|
|
167
|
+
});
|
|
168
|
+
}
|
|
169
|
+
return parsed.value as InferOutput<S>;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/**
|
|
173
|
+
* A GraphQL call, and the two `gh` field flags are not interchangeable — the type of the variable
|
|
174
|
+
* decides which one is correct, so the type of the value decides here.
|
|
175
|
+
*
|
|
176
|
+
* A **string** rides as `-f`, never `-F`: `-F` reads `@file` as "load this from disk", so a review
|
|
177
|
+
* reply whose body begins with an `@` would post the contents of a local file. A **number** has to
|
|
178
|
+
* ride as `-F`, because `-f` sends every value as a GraphQL `String` and a `$n:Int!` parameter
|
|
179
|
+
* refuses one (measured: `gh api graphql … -f n=238` against `Int!` is a `variableNotUsed`/type
|
|
180
|
+
* error, `-F n=238` succeeds). `-F` is safe for a number precisely because a number can never
|
|
181
|
+
* spell `@file`.
|
|
182
|
+
*
|
|
183
|
+
* The caller's schema describes the WHOLE envelope (`{ data: … }`) rather than its inside: gh
|
|
184
|
+
* exits non-zero whenever the response carries `errors`, so a partial answer never reaches here,
|
|
185
|
+
* and a caller that spells out the envelope keeps every nullable GitHub returns visible.
|
|
186
|
+
*/
|
|
187
|
+
export async function ghGraphql<S extends AnySchema>(
|
|
188
|
+
host: GhHost,
|
|
189
|
+
document: string,
|
|
190
|
+
variables: Readonly<Record<string, string | number>>,
|
|
191
|
+
schema: S,
|
|
192
|
+
options: GhOptions,
|
|
193
|
+
): Promise<InferOutput<S>> {
|
|
194
|
+
const args = [
|
|
195
|
+
'api',
|
|
196
|
+
'graphql',
|
|
197
|
+
'-f',
|
|
198
|
+
`query=${document}`,
|
|
199
|
+
...Object.entries(variables).flatMap(([name, value]) =>
|
|
200
|
+
typeof value === 'number' ? ['-F', `${name}=${value}`] : ['-f', `${name}=${value}`],
|
|
201
|
+
),
|
|
202
|
+
];
|
|
203
|
+
return ghJson(host, args, schema, options);
|
|
204
|
+
}
|
package/src/i18n-audit.ts
CHANGED
|
@@ -79,6 +79,11 @@ export async function loadCatalogs(root: string): Promise<Readonly<Record<Locale
|
|
|
79
79
|
export interface AuditFacts {
|
|
80
80
|
readonly report: ExtractReport;
|
|
81
81
|
readonly catalogs: Readonly<Record<Locale, Catalog>>;
|
|
82
|
+
/** The raw scan, carried so the runtime half can re-audit against the REGISTRY rather than
|
|
83
|
+
* against the files — same keys, same plural rules, a different question. */
|
|
84
|
+
readonly extraction: Extraction;
|
|
85
|
+
/** The `prefix*` patterns derived from dynamic calls, carried for the same reason. */
|
|
86
|
+
readonly ignoreUnused: readonly string[];
|
|
82
87
|
}
|
|
83
88
|
|
|
84
89
|
/** The static head of a template literal, up to its first interpolation. */
|
|
@@ -105,7 +110,12 @@ export function runtimeKeyPatterns(extraction: Extraction): readonly string[] {
|
|
|
105
110
|
export async function auditApp(root: string): Promise<AuditFacts> {
|
|
106
111
|
const [extraction, catalogs] = await Promise.all([scanSource(root), loadCatalogs(root)]);
|
|
107
112
|
const ignoreUnused = runtimeKeyPatterns(extraction);
|
|
108
|
-
return {
|
|
113
|
+
return {
|
|
114
|
+
report: auditCatalogs({ extraction, catalogs, ignoreUnused }),
|
|
115
|
+
catalogs,
|
|
116
|
+
extraction,
|
|
117
|
+
ignoreUnused,
|
|
118
|
+
};
|
|
109
119
|
}
|
|
110
120
|
|
|
111
121
|
/**
|
|
@@ -130,6 +140,34 @@ export function resolveDefaultLocale(
|
|
|
130
140
|
return locales.length === 1 ? locales[0] : undefined;
|
|
131
141
|
}
|
|
132
142
|
|
|
143
|
+
/** Where an app's catalog module declares its own package name. */
|
|
144
|
+
const I18N_PACKAGE_JSON = 'packages/i18n/package.json';
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* The specifier a generated page imports `useT()` from — `@<app>/i18n`, read off the app's own
|
|
148
|
+
* manifest rather than derived from a directory name, because the package name is the only thing
|
|
149
|
+
* a TypeScript import can actually resolve.
|
|
150
|
+
*
|
|
151
|
+
* `undefined` when the app ships no catalog package, and every generator falls back to `t` from
|
|
152
|
+
* `@ultimat3/i18n` there: emitting an import that cannot resolve trades a wrong idiom for a file
|
|
153
|
+
* that does not compile. A manifest that will not parse, or names nothing, answers the same way —
|
|
154
|
+
* this is a generator convenience, and refusing to scaffold over it would be the wrong verdict.
|
|
155
|
+
*/
|
|
156
|
+
export async function resolveCatalogModule(root: string): Promise<string | undefined> {
|
|
157
|
+
const path = join(root, I18N_PACKAGE_JSON);
|
|
158
|
+
if (!existsSync(path)) return undefined;
|
|
159
|
+
try {
|
|
160
|
+
const parsed: unknown = await Bun.file(path).json();
|
|
161
|
+
if (typeof parsed !== 'object' || parsed === null) return undefined;
|
|
162
|
+
const name = (parsed as { name?: unknown }).name;
|
|
163
|
+
return typeof name === 'string' && name.length > 0 ? name : undefined;
|
|
164
|
+
} catch {
|
|
165
|
+
// Deliberately silent: `x g route` writing a page is not the command that should refuse an
|
|
166
|
+
// app over a malformed manifest, and `bun install` already does.
|
|
167
|
+
return undefined;
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
133
171
|
/**
|
|
134
172
|
* A sorted copy of `catalog`. Every write below goes through this, so a later `sync` (or a second
|
|
135
173
|
* `add`) diffs only the keys that actually changed, never a reshuffle.
|