@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.
Files changed (60) hide show
  1. package/CLAUDE.md +75 -6
  2. package/README.md +2 -2
  3. package/package.json +28 -24
  4. package/src/affected.ts +320 -0
  5. package/src/browser-launcher.ts +109 -0
  6. package/src/ci-log.ts +0 -0
  7. package/src/ci-runs.ts +179 -0
  8. package/src/cmd-affected.ts +109 -0
  9. package/src/cmd-build.ts +36 -3
  10. package/src/cmd-ci.ts +273 -0
  11. package/src/cmd-dev.ts +35 -2
  12. package/src/cmd-generate.ts +16 -348
  13. package/src/cmd-i18n.ts +32 -16
  14. package/src/cmd-pr.ts +308 -0
  15. package/src/cmd-shot.ts +320 -0
  16. package/src/cmd-test.ts +96 -7
  17. package/src/cmd-verify.ts +10 -427
  18. package/src/compile-externals.ts +34 -0
  19. package/src/dev-lock.ts +275 -0
  20. package/src/dev-render.ts +7 -17
  21. package/src/error-codes.ts +18 -0
  22. package/src/generate-files.ts +127 -0
  23. package/src/generate-write.ts +229 -0
  24. package/src/gh-target.ts +118 -0
  25. package/src/gh.ts +204 -0
  26. package/src/i18n-audit.ts +39 -1
  27. package/src/i18n-registration.ts +130 -0
  28. package/src/index.ts +37 -0
  29. package/src/island-bundle.ts +68 -2
  30. package/src/island-solid-production.ts +129 -0
  31. package/src/island-styles.ts +41 -0
  32. package/src/mcp-errors.ts +11 -0
  33. package/src/messages.ts +67 -0
  34. package/src/pr-threads.ts +291 -0
  35. package/src/prerender.ts +52 -10
  36. package/src/registry.ts +8 -0
  37. package/src/shot-verdict.ts +337 -0
  38. package/src/solid-loader.ts +127 -0
  39. package/src/static-report.ts +219 -0
  40. package/src/templates/admin-page.ts +46 -5
  41. package/src/templates/index.ts +1 -0
  42. package/src/templates/island-fixture.ts +76 -0
  43. package/src/templates/island.ts +129 -18
  44. package/src/templates/resource-form-island.ts +279 -0
  45. package/src/templates/resource.ts +52 -43
  46. package/src/templates/route.ts +45 -6
  47. package/src/templates/scaffold-app.ts +70 -19
  48. package/src/templates/scaffold-container.ts +2 -2
  49. package/src/templates/scaffold-db-package.ts +88 -39
  50. package/src/templates/scaffold-docs.ts +18 -1
  51. package/src/templates/scaffold-i18n.ts +9 -2
  52. package/src/templates/scaffold-mcp-package.ts +35 -2
  53. package/src/templates/scaffold-package-shape.ts +7 -2
  54. package/src/templates/scaffold-repo.ts +2 -2
  55. package/src/test-shards.ts +19 -3
  56. package/src/verify-checks.ts +349 -0
  57. package/src/verify-run.ts +122 -0
  58. package/src/verify-step.ts +7 -0
  59. package/src/workspace-graph.ts +241 -0
  60. 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
+ }
@@ -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 { report: auditCatalogs({ extraction, catalogs, ignoreUnused }), catalogs };
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.