@ultimat3/cli 6.0.0 → 8.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 +65 -5
- package/README.md +8 -3
- package/package.json +25 -24
- package/src/affected.ts +320 -0
- package/src/app-boundaries.ts +55 -5
- package/src/bin.ts +6 -3
- 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 +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-db-backfill.ts +240 -0
- package/src/cmd-db-branch.ts +3 -2
- package/src/cmd-db.ts +35 -156
- package/src/cmd-deploy.ts +37 -3
- package/src/cmd-dev.ts +7 -1
- package/src/cmd-errors.ts +2 -3
- package/src/cmd-fix.ts +3 -3
- package/src/cmd-i18n.ts +67 -5
- package/src/cmd-jobs.ts +27 -4
- package/src/cmd-mcp.ts +18 -9
- package/src/cmd-new.ts +91 -4
- package/src/cmd-policy.ts +3 -2
- package/src/cmd-pr.ts +359 -0
- package/src/cmd-registries.ts +3 -2
- package/src/cmd-shot.ts +382 -0
- package/src/cmd-tasks.ts +9 -4
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +47 -6
- package/src/dev-cache.ts +1 -1
- package/src/dev-lock.ts +124 -12
- package/src/dev-queue.ts +12 -7
- package/src/dev-replicator.ts +3 -7
- package/src/dev-roles-fixture.ts +1 -1
- package/src/dev-roles.ts +40 -8
- package/src/dev-runtime.ts +96 -4
- package/src/dev-sync.ts +9 -4
- package/src/dispatch.ts +35 -5
- package/src/drift.ts +52 -7
- package/src/error-codes.ts +21 -0
- package/src/framework-scope.ts +57 -5
- package/src/generate-kinds.ts +19 -1
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-registration.ts +67 -4
- package/src/index.ts +38 -1
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/jobs-report.ts +10 -13
- package/src/mcp-errors.ts +12 -0
- package/src/messages.ts +76 -0
- package/src/output.ts +22 -2
- package/src/parse.ts +81 -37
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/realtime-browser-probe-fixture.ts +9 -0
- package/src/registry.ts +8 -0
- package/src/runtime-overrides.ts +11 -3
- package/src/shot-settle.ts +57 -0
- package/src/shot-verdict.ts +360 -0
- package/src/static-report.ts +219 -0
- package/src/sync-authenticator.ts +86 -14
- package/src/templates/guard-bare-error.ts +122 -0
- package/src/templates/guard-raw-colour.ts +138 -0
- package/src/templates/guard-untranslated-string.ts +138 -0
- package/src/templates/guard-unzoned-date.ts +142 -0
- package/src/templates/index.ts +4 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +130 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +20 -41
- package/src/templates/route.ts +15 -2
- package/src/templates/scaffold-app.ts +13 -78
- package/src/templates/scaffold-container.ts +30 -4
- package/src/templates/scaffold-db-package.ts +46 -7
- package/src/templates/scaffold-docs.ts +24 -13
- package/src/templates/scaffold-entries.ts +131 -0
- package/src/templates/scaffold-guards.ts +26 -0
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +37 -6
- package/src/test-select.ts +4 -3
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/verify-run.ts +25 -3
- package/src/verify-step.ts +11 -2
- package/src/verify-tests.ts +11 -3
- package/src/workspace-graph.ts +241 -0
- package/src/write-line.ts +23 -5
package/src/drift.ts
CHANGED
|
@@ -11,7 +11,9 @@
|
|
|
11
11
|
|
|
12
12
|
import { existsSync } from 'node:fs';
|
|
13
13
|
import { join } from 'node:path';
|
|
14
|
+
import { describeEntities } from '@ultimat3/entity';
|
|
14
15
|
import { countDeclaredEntities } from './app-entities';
|
|
16
|
+
import { loadApp } from './app-load';
|
|
15
17
|
// One declaration of where migrations live, and it belongs to the module that reads them —
|
|
16
18
|
// `x db migrate` and this sidecar must never disagree about the directory they share.
|
|
17
19
|
import { hashFileName, MIGRATIONS_DIR } from './migrations';
|
|
@@ -20,7 +22,48 @@ import type { Finding } from './output';
|
|
|
20
22
|
export const DB_PACKAGE = join('packages', 'db');
|
|
21
23
|
const SCHEMA_GLOB = 'packages/db/src/**/*.ts';
|
|
22
24
|
|
|
23
|
-
/**
|
|
25
|
+
/**
|
|
26
|
+
* Canonical JSON: object keys sorted, arrays in their own order. The registry's description is a
|
|
27
|
+
* BUILD INPUT committed to disk as a hash, so a field reordered inside `describe()` upstream would
|
|
28
|
+
* otherwise move every app's hash and report drift over a framework upgrade nobody made.
|
|
29
|
+
*/
|
|
30
|
+
function canonicalJson(value: unknown): string {
|
|
31
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(',')}]`;
|
|
32
|
+
if (typeof value === 'object' && value !== null) {
|
|
33
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([a], [b]) =>
|
|
34
|
+
a < b ? -1 : a > b ? 1 : 0,
|
|
35
|
+
);
|
|
36
|
+
return `{${entries.map(([key, held]) => `${JSON.stringify(key)}:${canonicalJson(held)}`).join(',')}}`;
|
|
37
|
+
}
|
|
38
|
+
// `undefined` has no JSON form and an optional field left unset must hash as absent, not throw.
|
|
39
|
+
return JSON.stringify(value) ?? 'null';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* What the app's entities declare, as the registry describes them — the half `SCHEMA_GLOB` cannot
|
|
44
|
+
* see. `x new` puts an entity at `apps/web/app/<feature>/entity.ts` and `packages/db/src/schema.ts`
|
|
45
|
+
* merely re-exports it, so a column added there moved NO byte the glob reads: three generated
|
|
46
|
+
* entities and one migration reported clean, with every `.hash` sidecar identical. The registry is
|
|
47
|
+
* the same fact `x db gen` diffs (`describeEntities()`), so the check and the generator now read
|
|
48
|
+
* one schema instead of two.
|
|
49
|
+
*
|
|
50
|
+
* `loadApp` is what fills that registry, and it is called on every path rather than only where the
|
|
51
|
+
* caller happens to have loaded already: a hash computed against an EMPTY registry would differ
|
|
52
|
+
* from the one `x db gen` recorded with the app loaded, and every app would read as drifted.
|
|
53
|
+
* A module that will not import leaves the registry SHORT rather than raising — the stance
|
|
54
|
+
* `countDeclaredEntities` already documents — so `x doctor` still answers on the app it diagnoses.
|
|
55
|
+
*/
|
|
56
|
+
async function declaredSchemaJson(root: string): Promise<string> {
|
|
57
|
+
await loadApp(root);
|
|
58
|
+
return canonicalJson(describeEntities());
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Content hash of the whole schema: the entity registry first, then every non-test file under
|
|
63
|
+
* `packages/db/src`, order-independent per file path. Both halves, because they answer different
|
|
64
|
+
* questions — the registry is what reaches the database, and the glob is what catches a seed or a
|
|
65
|
+
* helper moving under it (which is what `reconcileSchemaHash` exists to re-record).
|
|
66
|
+
*/
|
|
24
67
|
export async function schemaHash(root: string): Promise<string> {
|
|
25
68
|
const glob = new Bun.Glob(SCHEMA_GLOB);
|
|
26
69
|
const paths: string[] = [];
|
|
@@ -29,6 +72,7 @@ export async function schemaHash(root: string): Promise<string> {
|
|
|
29
72
|
}
|
|
30
73
|
paths.sort();
|
|
31
74
|
const hasher = new Bun.CryptoHasher('sha256');
|
|
75
|
+
hasher.update(await declaredSchemaJson(root));
|
|
32
76
|
for (const path of paths) {
|
|
33
77
|
hasher.update(path);
|
|
34
78
|
hasher.update(await Bun.file(join(root, path)).text());
|
|
@@ -77,9 +121,10 @@ export interface HashReconciliation {
|
|
|
77
121
|
}
|
|
78
122
|
|
|
79
123
|
/**
|
|
80
|
-
* Re-record the sidecar for a migration that is already the right one.
|
|
81
|
-
* non-test file under `packages/db/src`,
|
|
82
|
-
*
|
|
124
|
+
* Re-record the sidecar for a migration that is already the right one. Neither half of the hash is
|
|
125
|
+
* DDL-only — `SCHEMA_GLOB` covers every non-test file under `packages/db/src`, and the registry
|
|
126
|
+
* carries invariants and tags a diff can leave empty — so an edit can move the hash with no
|
|
127
|
+
* statement behind it, and `X_DB_DRIFT`'s `fix:` has to have somewhere to
|
|
83
128
|
* land or the instruction is unfollowable. The caller owes the proof that the DDL genuinely did not
|
|
84
129
|
* move (`db-generate.ts` reaches this only on an empty diff off a fully loaded registry); this
|
|
85
130
|
* function decides only whether a write is needed.
|
|
@@ -107,9 +152,9 @@ export type DeclaredEntityCount = () => Promise<number>;
|
|
|
107
152
|
* Empty result = no drift. A missing db package is not drift (an app may have no database yet);
|
|
108
153
|
* a schema with no migration at all is — *provided* the app declares an entity for one to record.
|
|
109
154
|
*
|
|
110
|
-
* The entity count is read lazily and ONLY in that first branch
|
|
111
|
-
*
|
|
112
|
-
*
|
|
155
|
+
* The entity count is read lazily and ONLY in that first branch; the hash itself loads the app on
|
|
156
|
+
* every path, because the registry is half of what it covers. Still no database anywhere here,
|
|
157
|
+
* which is what lets the gate run this in a CI with nothing listening.
|
|
113
158
|
*/
|
|
114
159
|
export async function checkSourceDrift(
|
|
115
160
|
root: string,
|
package/src/error-codes.ts
CHANGED
|
@@ -56,6 +56,10 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
56
56
|
'X_ROLE_UNKNOWN',
|
|
57
57
|
'X_PORT_INVALID',
|
|
58
58
|
'X_DEV_ALREADY_RUNNING',
|
|
59
|
+
// The OTHER thing the preflight can find, and it was reported as the one above: an unreadable
|
|
60
|
+
// lock this process could not remove made `DevAlreadyRunningError` name THIS pid as the holder,
|
|
61
|
+
// so the remedy printed was `kill <self>` — unrunnable, and a cause that was simply untrue.
|
|
62
|
+
'X_DEV_LOCK_UNREADABLE',
|
|
59
63
|
// The boot's own consistency check. `startServices` captures the drivers it built, and
|
|
60
64
|
// `loadApp` runs AFTER it — so an app module calling `setJobDriver(theirs)` moved the ambient
|
|
61
65
|
// slot and left the captured object alone: every `handle.enqueue()` went to their queue while
|
|
@@ -96,6 +100,14 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
96
100
|
// CLI's problem alone, and core would have no `fix:` to offer for one.
|
|
97
101
|
'X_SECRETS_EDITOR_MISSING',
|
|
98
102
|
'X_SECRETS_EDIT_FAILED',
|
|
103
|
+
'X_WORKSPACE_DEP_UNDECLARED',
|
|
104
|
+
'X_SHOT_BROWSER_MISSING',
|
|
105
|
+
'X_GH_UNAVAILABLE',
|
|
106
|
+
'X_GH_NOT_AUTHENTICATED',
|
|
107
|
+
'X_GH_COMMAND_FAILED',
|
|
108
|
+
'X_GH_RESPONSE_INVALID',
|
|
109
|
+
'X_PR_NOT_FOUND',
|
|
110
|
+
'X_CI_RUN_NOT_FOUND',
|
|
99
111
|
] as const;
|
|
100
112
|
|
|
101
113
|
/**
|
|
@@ -169,6 +181,7 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
169
181
|
X_ROLE_UNKNOWN: 'ROLE names something that is not a role',
|
|
170
182
|
X_PORT_INVALID: 'PORT is not a TCP port number',
|
|
171
183
|
X_DEV_ALREADY_RUNNING: 'another x dev already owns this checkout',
|
|
184
|
+
X_DEV_LOCK_UNREADABLE: 'the dev lock file cannot be read or removed',
|
|
172
185
|
X_RUNTIME_DRIVER_SPLIT: 'the ambient driver is not the one this process serves',
|
|
173
186
|
X_GENERATE_CONFLICT: 'a generator would overwrite a file',
|
|
174
187
|
X_PORT_IN_USE: 'the dev port is taken',
|
|
@@ -188,6 +201,14 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
188
201
|
X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
|
|
189
202
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
190
203
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
204
|
+
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
205
|
+
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
206
|
+
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
207
|
+
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
208
|
+
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
|
209
|
+
X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
|
|
210
|
+
X_PR_NOT_FOUND: 'no pull request for this checkout',
|
|
211
|
+
X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
|
|
191
212
|
};
|
|
192
213
|
|
|
193
214
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
package/src/framework-scope.ts
CHANGED
|
@@ -4,9 +4,10 @@
|
|
|
4
4
|
// directory or they describe different builds.
|
|
5
5
|
|
|
6
6
|
// `node:fs`/`node:path` because Bun ships neither: `dirname` walks a resolved module up to the
|
|
7
|
-
// directory that owns it,
|
|
7
|
+
// directory that owns it, `basename` names the two segments the store layout is recognised by,
|
|
8
|
+
// and `existsSync` is what says which directory is really there.
|
|
8
9
|
import { existsSync } from 'node:fs';
|
|
9
|
-
import { dirname, join } from 'node:path';
|
|
10
|
+
import { basename, dirname, join } from 'node:path';
|
|
10
11
|
|
|
11
12
|
/**
|
|
12
13
|
* Deep enough for `src/index.ts` and for any entry an `exports` map could point at, shallow enough
|
|
@@ -14,6 +15,45 @@ import { dirname, join } from 'node:path';
|
|
|
14
15
|
*/
|
|
15
16
|
const MAX_DEPTH = 6;
|
|
16
17
|
|
|
18
|
+
/** The two segments Bun's isolated layout is recognised by: `node_modules/.bun/<pkg>@<version>/`. */
|
|
19
|
+
const STORE_DIR = '.bun';
|
|
20
|
+
const NODE_MODULES = 'node_modules';
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* `node_modules/.bun/@ultimat3+core@7.0.0/node_modules/@ultimat3` → `node_modules/@ultimat3`.
|
|
24
|
+
*
|
|
25
|
+
* Bun's **isolated** layout gives every package its own store entry, and a store entry's scope
|
|
26
|
+
* directory holds exactly the one package it was created for. `Bun.resolveSync` follows the
|
|
27
|
+
* install's symlink into that store, so walking up from the resolved entry lands there rather
|
|
28
|
+
* than in the tree an app actually installed: measured on a fixture install, `x docs` saw
|
|
29
|
+
* **1** of 6 packages, and `x errors explain` answered *"nothing in the installed framework raises
|
|
30
|
+
* X_…"* — with `ok: true` — for 400 of 405 codes. A confident wrong answer, from a walk that had
|
|
31
|
+
* never looked at the app's own `node_modules`.
|
|
32
|
+
*
|
|
33
|
+
* The store is recognised by its own shape and never by an app root handed in from outside: the
|
|
34
|
+
* two callers here are a module-scope memo (`error-fixes.ts`) and a command that may run under
|
|
35
|
+
* `--cwd`, so a cwd-derived root would be wrong for one of them and absent for the other.
|
|
36
|
+
*
|
|
37
|
+
* `undefined` — leaving the caller on the resolved answer — whenever this is not a store path or
|
|
38
|
+
* the sibling scope directory is not there. A hoisted install already resolves to
|
|
39
|
+
* `node_modules/@ultimat3/core` and a workspace checkout to `packages/core`, and neither has a
|
|
40
|
+
* `.bun` above it.
|
|
41
|
+
*/
|
|
42
|
+
function installedScopeFor(storeScope: string): string | undefined {
|
|
43
|
+
const scopeName = basename(storeScope);
|
|
44
|
+
let dir = storeScope;
|
|
45
|
+
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
46
|
+
const parent = dirname(dir);
|
|
47
|
+
if (parent === dir) return undefined;
|
|
48
|
+
if (basename(dir) === STORE_DIR && basename(parent) === NODE_MODULES) {
|
|
49
|
+
const candidate = join(parent, scopeName);
|
|
50
|
+
return existsSync(candidate) ? candidate : undefined;
|
|
51
|
+
}
|
|
52
|
+
dir = parent;
|
|
53
|
+
}
|
|
54
|
+
return undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
17
57
|
/**
|
|
18
58
|
* Resolved from the CLI's own dependency on `@ultimat3/core` rather than from the user's cwd:
|
|
19
59
|
* these are the packages this `x` would actually run. Resolution follows the symlink, so a
|
|
@@ -29,18 +69,30 @@ const MAX_DEPTH = 6;
|
|
|
29
69
|
* framework raises the code. Walking up from the entry to the directory that owns its
|
|
30
70
|
* `package.json` depends on nothing but the entry that is already imported.
|
|
31
71
|
*
|
|
72
|
+
* Following the symlink is also what makes the last step necessary rather than optional: under
|
|
73
|
+
* Bun's isolated layout the entry resolves *into the store*, whose scope directory holds one
|
|
74
|
+
* package. `installedScopeFor` is the correction, and it is a shape test on the path — never a
|
|
75
|
+
* `readdir` of the resolved package's parent, which is the read that reported one package as the
|
|
76
|
+
* whole framework.
|
|
77
|
+
*
|
|
78
|
+
* `resolveFrom` exists so a test can point this at a fixture install; nothing passes it in
|
|
79
|
+
* production, where the only defensible base is this module's own directory.
|
|
80
|
+
*
|
|
32
81
|
* `undefined` means the CLI cannot see its own dependency, which is a broken install and not
|
|
33
82
|
* merely an undocumented one; every caller reports that rather than answering emptily.
|
|
34
83
|
*/
|
|
35
|
-
export function frameworkScopeDir(): string | undefined {
|
|
84
|
+
export function frameworkScopeDir(resolveFrom: string = import.meta.dir): string | undefined {
|
|
36
85
|
let dir: string;
|
|
37
86
|
try {
|
|
38
|
-
dir = dirname(Bun.resolveSync('@ultimat3/core',
|
|
87
|
+
dir = dirname(Bun.resolveSync('@ultimat3/core', resolveFrom));
|
|
39
88
|
} catch {
|
|
40
89
|
return undefined;
|
|
41
90
|
}
|
|
42
91
|
for (let depth = 0; depth < MAX_DEPTH; depth += 1) {
|
|
43
|
-
if (existsSync(join(dir, 'package.json')))
|
|
92
|
+
if (existsSync(join(dir, 'package.json'))) {
|
|
93
|
+
const scope = dirname(dir);
|
|
94
|
+
return installedScopeFor(scope) ?? scope;
|
|
95
|
+
}
|
|
44
96
|
const parent = dirname(dir);
|
|
45
97
|
if (parent === dir) return undefined;
|
|
46
98
|
dir = parent;
|
package/src/generate-kinds.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// because "what a generator emits" and "which spelling reaches it" are two jobs — and the file
|
|
3
3
|
// that held both had reached the 500-line ceiling, one generator short of failing its own gate.
|
|
4
4
|
|
|
5
|
+
import { nearestName } from '@ultimat3/core';
|
|
5
6
|
import { BadFlagError, MissingPositionalError, UnknownCommandError } from './errors';
|
|
6
7
|
import type { Surface } from './templates';
|
|
7
8
|
|
|
@@ -42,13 +43,30 @@ export function assertSurfaceSupported(kind: Generator, surface: Surface, name:
|
|
|
42
43
|
});
|
|
43
44
|
}
|
|
44
45
|
|
|
46
|
+
/**
|
|
47
|
+
* The generator a spelling reaches, or a refusal that leads with the one it is nearest.
|
|
48
|
+
*
|
|
49
|
+
* The lead used to be the literal `g resource`, whatever was typed: `x g rout x` — one edit from
|
|
50
|
+
* `route` — answered `fix: x g resource`, the WRONG PRIMITIVE, and one that refuses in turn
|
|
51
|
+
* because it carries no `<name>`. `nearestName` is what `parse.ts` already does with a mistyped
|
|
52
|
+
* command, over this file's own list.
|
|
53
|
+
*
|
|
54
|
+
* Two rules hold the fix line to a command that runs. A near miss is completed with that
|
|
55
|
+
* generator's own `EXAMPLE_NAME`, because `x g route <name>` pasted into a shell is a redirect.
|
|
56
|
+
* A word near NOTHING gets `x help g` rather than an invented lead — the same rule `parse.test.ts`
|
|
57
|
+
* pins for a command that resembles nothing, and the reason `nearestName` is never asked about an
|
|
58
|
+
* ABSENT kind: the empty string is within the cutoff of `job`, so `x g` would "suggest" a
|
|
59
|
+
* generator nobody typed.
|
|
60
|
+
*/
|
|
45
61
|
export function readKind(raw: string | undefined): Generator {
|
|
46
62
|
const kinds: readonly string[] = GENERATORS;
|
|
47
63
|
if (raw !== undefined && kinds.includes(raw)) return raw as Generator;
|
|
64
|
+
const near = raw === undefined ? undefined : nearestName(raw, kinds);
|
|
65
|
+
const suggestion = GENERATORS.find((kind) => kind === near);
|
|
48
66
|
throw new UnknownCommandError({
|
|
49
67
|
path: `g ${raw ?? ''}`.trim(),
|
|
50
68
|
known: GENERATORS,
|
|
51
|
-
suggestion: 'g
|
|
69
|
+
suggestion: suggestion === undefined ? 'help g' : `g ${suggestion} ${EXAMPLE_NAME[suggestion]}`,
|
|
52
70
|
});
|
|
53
71
|
}
|
|
54
72
|
|
package/src/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
|
+
}
|