@ultimat3/cli 6.0.0 → 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 +50 -4
- package/package.json +25 -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 +29 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/error-codes.ts +16 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +62 -3
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +9 -0
- package/src/messages.ts +64 -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/static-report.ts +219 -0
- 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 +20 -41
- package/src/templates/route.ts +14 -1
- package/src/templates/scaffold-app.ts +17 -3
- package/src/templates/scaffold-db-package.ts +32 -1
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +11 -1
- package/src/workspace-graph.ts +241 -0
package/src/cmd-test.ts
CHANGED
|
@@ -1,14 +1,20 @@
|
|
|
1
1
|
// `x test`'s command surface: the flags and the one positional it accepts, and the refusals that
|
|
2
2
|
// happen before a single process starts. Which files run is test-select.ts, how they are split and
|
|
3
3
|
// spawned is test-shards.ts — this file only turns argv into their inputs, so a parsing bug can
|
|
4
|
-
// never be read as a sharding one.
|
|
4
|
+
// never be read as a sharding one. `--affected` is the one narrowing decided here rather than
|
|
5
|
+
// there, because it is a fact about a git diff and not about a path: what the diff touches is
|
|
6
|
+
// `affected.ts`, and this file only maps that answer onto the paths discovery yields.
|
|
5
7
|
|
|
8
|
+
import type { AffectedScope } from './affected';
|
|
9
|
+
import { affectedScope, affectedScopeJson, DEFAULT_BASE, inScope } from './affected';
|
|
6
10
|
import type { CliCommand, CommandContext } from './command';
|
|
11
|
+
import { ok } from './command';
|
|
7
12
|
import { BadFlagError, NoTestFilesError } from './errors';
|
|
8
13
|
import { readIntFlag } from './flag-number';
|
|
9
|
-
import
|
|
14
|
+
import { msg } from './messages';
|
|
15
|
+
import type { CommandResult, JsonValue } from './output';
|
|
10
16
|
import type { ParsedArgs } from './parse';
|
|
11
|
-
import { flagString } from './parse';
|
|
17
|
+
import { flagBool, flagString } from './parse';
|
|
12
18
|
import { quoteArg } from './shell-quote';
|
|
13
19
|
import { discoverTests, missingSelection, readSample, readType, sampleFiles } from './test-select';
|
|
14
20
|
import { runShards } from './test-shards';
|
|
@@ -53,12 +59,52 @@ function readOnlyType(positionals: readonly string[]): TestType | undefined {
|
|
|
53
59
|
});
|
|
54
60
|
}
|
|
55
61
|
|
|
62
|
+
/**
|
|
63
|
+
* `--affected`, and the two flags that only mean something with it. The scope itself is
|
|
64
|
+
* `affected.ts`'s — `x affected` reports exactly what this narrows to, or the two commands would
|
|
65
|
+
* be two answers to one question and only one of them would be the one an agent trusts.
|
|
66
|
+
*/
|
|
67
|
+
async function readAffectedScope(ctx: CommandContext): Promise<AffectedScope | undefined> {
|
|
68
|
+
if (flagBool(ctx.args, 'affected')) {
|
|
69
|
+
return affectedScope({ runner: ctx.runner, cwd: ctx.cwd, args: ctx.args, command: 'test' });
|
|
70
|
+
}
|
|
71
|
+
// A flag that parses and changes nothing is a promise `x help test` cannot keep: without
|
|
72
|
+
// `--affected` the whole suite runs, and a `--base` on the line would read as if it had not.
|
|
73
|
+
const idle = flagString(ctx.args, 'base') !== undefined ? 'base' : 'dirty';
|
|
74
|
+
if (flagString(ctx.args, 'base') !== undefined || flagBool(ctx.args, 'dirty')) {
|
|
75
|
+
throw new BadFlagError({
|
|
76
|
+
flag: idle,
|
|
77
|
+
command: 'test',
|
|
78
|
+
reason: 'only narrows a run together with --affected, and on its own it changes nothing',
|
|
79
|
+
fix: `x test --affected --${idle}${idle === 'base' ? ` ${DEFAULT_BASE}` : ''}`,
|
|
80
|
+
});
|
|
81
|
+
}
|
|
82
|
+
return undefined;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// The cast is guarded by the three lines above it and is the narrowing TS will not do on its own:
|
|
86
|
+
// `Array.isArray` is declared `value is any[]`, which does not remove `readonly JsonValue[]` from
|
|
87
|
+
// the union, so every branch here still carries the array arm however the check is written.
|
|
88
|
+
const asObject = (value: JsonValue | undefined): Readonly<Record<string, JsonValue>> =>
|
|
89
|
+
typeof value === 'object' && value !== null && !Array.isArray(value)
|
|
90
|
+
? (value as Readonly<Record<string, JsonValue>>)
|
|
91
|
+
: {};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The scope, carried onto whatever the shards reported. `--json` is what an agent reads, and a
|
|
95
|
+
* narrowed run that does not say what it narrowed to is indistinguishable from a full one.
|
|
96
|
+
*/
|
|
97
|
+
const withScope = (result: CommandResult, scope: AffectedScope): CommandResult => ({
|
|
98
|
+
...result,
|
|
99
|
+
data: { ...asObject(result.data), affected: affectedScopeJson(scope) },
|
|
100
|
+
});
|
|
101
|
+
|
|
56
102
|
export const testCommand: CliCommand = {
|
|
57
103
|
spec: {
|
|
58
104
|
name: 'test',
|
|
59
105
|
summary:
|
|
60
106
|
'run one test type — or the whole suite — across N processes, one isolated database per worker',
|
|
61
|
-
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--workers N] [--worker I] [--json]`,
|
|
107
|
+
usage: `x test [${TEST_TYPES.join('|')}] [--filter text] [--sample N] [--affected [--base ref] [--dirty]] [--workers N] [--worker I] [--json]`,
|
|
62
108
|
positionalChoices: TEST_TYPES,
|
|
63
109
|
flags: [
|
|
64
110
|
{
|
|
@@ -78,17 +124,53 @@ export const testCommand: CliCommand = {
|
|
|
78
124
|
summary:
|
|
79
125
|
'run at most N files of the selected type — a fast signal for the eval loop, never a gate',
|
|
80
126
|
},
|
|
127
|
+
{
|
|
128
|
+
name: 'affected',
|
|
129
|
+
type: 'boolean',
|
|
130
|
+
summary: 'only the workspaces a diff touches, and everything that depends on one of them',
|
|
131
|
+
},
|
|
132
|
+
{
|
|
133
|
+
name: 'base',
|
|
134
|
+
type: 'string',
|
|
135
|
+
summary: `--affected: git ref to diff against, merge-base style (default: ${DEFAULT_BASE})`,
|
|
136
|
+
},
|
|
137
|
+
{
|
|
138
|
+
name: 'dirty',
|
|
139
|
+
type: 'boolean',
|
|
140
|
+
summary:
|
|
141
|
+
'--affected: also count uncommitted work, whichever agent in this checkout made it',
|
|
142
|
+
},
|
|
81
143
|
],
|
|
82
144
|
},
|
|
83
145
|
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
84
146
|
const type = readOnlyType(ctx.args.positionals);
|
|
85
147
|
const filter = flagString(ctx.args, 'filter');
|
|
86
148
|
const sample = readSample(ctx.args);
|
|
149
|
+
const scope = await readAffectedScope(ctx);
|
|
87
150
|
const discovered = await discoverTests(ctx.cwd, filter, type);
|
|
88
151
|
if (discovered.length === 0) {
|
|
89
152
|
throw new NoTestFilesError({ root: ctx.cwd, ...missingSelection(type, filter) });
|
|
90
153
|
}
|
|
91
|
-
const
|
|
154
|
+
const selected =
|
|
155
|
+
scope === undefined
|
|
156
|
+
? discovered
|
|
157
|
+
: discovered.filter((file) => inScope(file.path, scope.prefixes));
|
|
158
|
+
if (scope !== undefined && selected.length === 0) {
|
|
159
|
+
// Green, and it spawns nothing — a `.md`-only diff genuinely re-checks nothing, and failing
|
|
160
|
+
// a build for editing a doc is the wrong answer. It never reads as "the suite passed": the
|
|
161
|
+
// summary counts the files that ran (zero) and `data.affected` names the diff it asked about,
|
|
162
|
+
// so a caller can always tell "green because nothing is affected" from "green because
|
|
163
|
+
// everything passed". Nothing reaches `runShards`, whose empty file list would be a
|
|
164
|
+
// `bun test` with no arguments — that is, the whole suite.
|
|
165
|
+
return ok('test', msg('cli.test.affected.none', { base: scope.selection.base }), {
|
|
166
|
+
data: {
|
|
167
|
+
...(type === undefined ? {} : { type }),
|
|
168
|
+
files: 0,
|
|
169
|
+
affected: affectedScopeJson(scope),
|
|
170
|
+
},
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
const files = sample === undefined ? selected : sampleFiles(selected, sample);
|
|
92
174
|
const requested = readIndex(ctx.args, 'workers', 1) ?? defaultWorkers();
|
|
93
175
|
const workers = Math.max(1, Math.min(requested, files.length));
|
|
94
176
|
const only = readIndex(ctx.args, 'worker', 0);
|
|
@@ -99,7 +181,7 @@ export const testCommand: CliCommand = {
|
|
|
99
181
|
reason: `shard ${only} does not exist in a ${workers}-worker split (0..${workers - 1})`,
|
|
100
182
|
});
|
|
101
183
|
}
|
|
102
|
-
|
|
184
|
+
const result = await runShards({
|
|
103
185
|
root: ctx.cwd,
|
|
104
186
|
runner: ctx.runner,
|
|
105
187
|
files,
|
|
@@ -108,7 +190,14 @@ export const testCommand: CliCommand = {
|
|
|
108
190
|
...(filter === undefined ? {} : { filter }),
|
|
109
191
|
...(type === undefined ? {} : { type }),
|
|
110
192
|
// `kept` is the corpus the split saw; a `--worker` rerun must name it, not its own shard.
|
|
111
|
-
|
|
193
|
+
// `selected`, not `discovered`: with `--affected` the sample was taken from the narrowed
|
|
194
|
+
// set, and reporting the whole tree as its total would name a corpus no run ever had.
|
|
195
|
+
...(sample === undefined ? {} : { sample: { kept: files.length, total: selected.length } }),
|
|
196
|
+
// The fourth input to the split. Without it a failing shard's `fix:` re-splits the whole
|
|
197
|
+
// corpus, so its shard 2 is a different shard 2 — reproducing nothing, which is the one
|
|
198
|
+
// thing `reproduceFor` exists to prevent.
|
|
199
|
+
...(scope === undefined ? {} : { affected: scope.selection }),
|
|
112
200
|
});
|
|
201
|
+
return scope === undefined ? result : withScope(result, scope);
|
|
113
202
|
},
|
|
114
203
|
};
|
package/src/error-codes.ts
CHANGED
|
@@ -96,6 +96,14 @@ export const CLI_OWNED_ERROR_CODES = [
|
|
|
96
96
|
// CLI's problem alone, and core would have no `fix:` to offer for one.
|
|
97
97
|
'X_SECRETS_EDITOR_MISSING',
|
|
98
98
|
'X_SECRETS_EDIT_FAILED',
|
|
99
|
+
'X_WORKSPACE_DEP_UNDECLARED',
|
|
100
|
+
'X_SHOT_BROWSER_MISSING',
|
|
101
|
+
'X_GH_UNAVAILABLE',
|
|
102
|
+
'X_GH_NOT_AUTHENTICATED',
|
|
103
|
+
'X_GH_COMMAND_FAILED',
|
|
104
|
+
'X_GH_RESPONSE_INVALID',
|
|
105
|
+
'X_PR_NOT_FOUND',
|
|
106
|
+
'X_CI_RUN_NOT_FOUND',
|
|
99
107
|
] as const;
|
|
100
108
|
|
|
101
109
|
/**
|
|
@@ -188,6 +196,14 @@ export const CLI_ERROR_TITLES: Readonly<Record<CliOwnedErrorCode, string>> = {
|
|
|
188
196
|
X_CLI_FLAG_UNREAD: 'a command declares a flag no code reads',
|
|
189
197
|
X_SECRETS_EDITOR_MISSING: 'no $EDITOR to open the decrypted secrets in',
|
|
190
198
|
X_SECRETS_EDIT_FAILED: 'the editor exited non-zero, so nothing was resealed',
|
|
199
|
+
X_WORKSPACE_DEP_UNDECLARED: 'a workspace imports another workspace it does not declare',
|
|
200
|
+
X_SHOT_BROWSER_MISSING: 'x shot found no browser library in the app',
|
|
201
|
+
X_GH_UNAVAILABLE: 'the GitHub CLI is not runnable from here',
|
|
202
|
+
X_GH_NOT_AUTHENTICATED: 'gh holds no credentials for this host',
|
|
203
|
+
X_GH_COMMAND_FAILED: 'a gh invocation exited non-zero',
|
|
204
|
+
X_GH_RESPONSE_INVALID: "gh's output is not the shape the command reads",
|
|
205
|
+
X_PR_NOT_FOUND: 'no pull request for this checkout',
|
|
206
|
+
X_CI_RUN_NOT_FOUND: 'no workflow run for this branch',
|
|
191
207
|
};
|
|
192
208
|
|
|
193
209
|
// One unconditional call, so a second package claiming one of the CLI's codes throws
|
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/index.ts
CHANGED
|
@@ -60,6 +60,12 @@ export {
|
|
|
60
60
|
plannedCommands,
|
|
61
61
|
plannedSubcommand,
|
|
62
62
|
} from './cmd-planned';
|
|
63
|
+
// `shotCommand`, `prCommand` and `ciCommand` are deliberately NOT re-exported here. They reach
|
|
64
|
+
// `x` through `registry.ts`, which is the only thing that makes a command exist — and the barrel
|
|
65
|
+
// is the surface an APP imports. Exporting them puts `cmd-shot.ts` in the module graph of every
|
|
66
|
+
// app that imports `@ultimat3/cli`, which then has to resolve `@ultimat3/scraping` — a browser
|
|
67
|
+
// driver it never uses. Measured: it reds `tsc -b` on `dummy/social-media-clone` with five
|
|
68
|
+
// TS2307s in files that app never calls. The app path does not pay for the tool path.
|
|
63
69
|
export { actionsCommand, entitiesCommand, queriesCommand } from './cmd-registries';
|
|
64
70
|
export { renderRouteTable, routesCommand } from './cmd-routes';
|
|
65
71
|
export { testCommand } from './cmd-test';
|
|
@@ -177,6 +183,13 @@ export type { DeclaredFlag } from './flag-reads';
|
|
|
177
183
|
export { checkFlagReads, declaredFlags, readsFlag } from './flag-reads';
|
|
178
184
|
export type { Guard } from './guards';
|
|
179
185
|
export { findingProblem, GUARD_DIR, guardFindings, guardPaths } from './guards';
|
|
186
|
+
// The island bundler, and only its entry point. An island is the one module Ultimate ships to a
|
|
187
|
+
// browser, so an app has to be able to build one to TEST one — `mountIsland` from
|
|
188
|
+
// `@ultimat3/testing` takes this function as its `build` parameter (issue #260). `discoverIslands`,
|
|
189
|
+
// `islandBundle`, `writeIslands`, `ISLAND_BASE_PATH` and `ISLAND_GLOB` stay internal: they are
|
|
190
|
+
// `x build`'s and `x dev`'s wiring, and every name here is a semver promise forever.
|
|
191
|
+
export type { IslandBundle, IslandChunk } from './island-bundle';
|
|
192
|
+
export { buildIslands } from './island-bundle';
|
|
180
193
|
export type { DrainFailure, DrainOutcome, DrainSkip } from './jobs-drain';
|
|
181
194
|
export { drainJobs } from './jobs-drain';
|
|
182
195
|
export type { JobsListFilter, JobsListResult } from './jobs-report';
|
|
@@ -235,6 +248,24 @@ export {
|
|
|
235
248
|
isVendored,
|
|
236
249
|
SOURCE_GLOBS,
|
|
237
250
|
} from './source-files';
|
|
251
|
+
export type {
|
|
252
|
+
EmittedPage,
|
|
253
|
+
RouteFacts,
|
|
254
|
+
SkippedRoute,
|
|
255
|
+
SkipReason,
|
|
256
|
+
StaticReport,
|
|
257
|
+
} from './static-report';
|
|
258
|
+
export {
|
|
259
|
+
parseStaticReport,
|
|
260
|
+
readStaticReport,
|
|
261
|
+
removeStaticReport,
|
|
262
|
+
renderStaticReport,
|
|
263
|
+
SKIP_REASONS,
|
|
264
|
+
STATIC_REPORT_FILE,
|
|
265
|
+
skippedRoute,
|
|
266
|
+
skipReasonFor,
|
|
267
|
+
writeStaticReport,
|
|
268
|
+
} from './static-report';
|
|
238
269
|
export type { TestCounts } from './test-counts';
|
|
239
270
|
export { countsOf } from './test-counts';
|
|
240
271
|
export type { TestFile } from './test-select';
|
|
@@ -286,4 +317,10 @@ export {
|
|
|
286
317
|
SEMVER,
|
|
287
318
|
workspacePackages,
|
|
288
319
|
} from './workspace-checks';
|
|
320
|
+
export type { WorkspaceNode, WorkspaceScan } from './workspace-graph';
|
|
321
|
+
// The graph itself, not just the gate's verdict on it: issue #239's complaint is that a
|
|
322
|
+
// scaffolded repo's dependency graph exists only inside `tsc`, so an app's own tooling has
|
|
323
|
+
// nothing to read. `checkWorkspaceDependencies` stays internal — it is reached through
|
|
324
|
+
// `x verify`, which is the one way a rule is enforced here.
|
|
325
|
+
export { readWorkspaceGraph, scanWorkspaces } from './workspace-graph';
|
|
289
326
|
export { writeLine } from './write-line';
|
package/src/island-bundle.ts
CHANGED
|
@@ -13,6 +13,8 @@ import {
|
|
|
13
13
|
islandModuleId,
|
|
14
14
|
} from '@ultimat3/render';
|
|
15
15
|
import { IslandBuildFailedError } from './errors';
|
|
16
|
+
import { solidProductionPlugin } from './island-solid-production';
|
|
17
|
+
import { islandStylesPlugin } from './island-styles';
|
|
16
18
|
import { solidJsxPlugin } from './solid-loader';
|
|
17
19
|
|
|
18
20
|
/**
|
|
@@ -84,7 +86,13 @@ async function buildOne(root: string, file: string): Promise<IslandChunk> {
|
|
|
84
86
|
// an island. The app's tsconfig says `jsx: "preserve"`, which makes the bundler fall back to
|
|
85
87
|
// classic `React.createElement` — emitted into a browser chunk that imports no React, with
|
|
86
88
|
// `success: true` and no log. Every island shipped that way through five majors.
|
|
87
|
-
|
|
89
|
+
//
|
|
90
|
+
// The other two close the same shape of failure — a wrong answer `Bun.build` reports as
|
|
91
|
+
// `success: true`: without the second, `target: 'browser'` resolves the `development`
|
|
92
|
+
// export condition and the chunk carries Solid's dev build; without the third, Bun's file
|
|
93
|
+
// loader resolves a `.module.scss` to its asset PATH, so `styles['x']` is `undefined` and
|
|
94
|
+
// every element renders unclassed.
|
|
95
|
+
plugins: [solidJsxPlugin, solidProductionPlugin, islandStylesPlugin],
|
|
88
96
|
});
|
|
89
97
|
} catch (error) {
|
|
90
98
|
throw new IslandBuildFailedError({ file, logs: describeBuildError(error) });
|
|
@@ -121,13 +129,64 @@ function describeBuildError(error: unknown): string {
|
|
|
121
129
|
return error instanceof Error ? error.message : String(error);
|
|
122
130
|
}
|
|
123
131
|
|
|
132
|
+
export interface BuildIslandsOptions {
|
|
133
|
+
/**
|
|
134
|
+
* Build ONE island, named app-root-relative — the whole option surface. A test that mounts a
|
|
135
|
+
* single island otherwise pays every OTHER island's Babel pass and `Bun.build` on every file,
|
|
136
|
+
* and the reference app is the one that feels it.
|
|
137
|
+
*
|
|
138
|
+
* Optional, and it must stay optional: `buildIslands` is on `@ultimat3/cli`'s public surface and
|
|
139
|
+
* `@ultimat3/testing`'s `IslandBuilder` satisfies it STRUCTURALLY as `(root: string) => …`, which
|
|
140
|
+
* is what keeps the `cli -> testing` edge pointing the one legal way.
|
|
141
|
+
*/
|
|
142
|
+
readonly only?: string;
|
|
143
|
+
}
|
|
144
|
+
|
|
124
145
|
/** Build every island in the app. An app with none returns an empty bundle and costs one glob. */
|
|
125
|
-
export async function buildIslands(
|
|
126
|
-
|
|
146
|
+
export async function buildIslands(
|
|
147
|
+
root: string,
|
|
148
|
+
options: BuildIslandsOptions = {},
|
|
149
|
+
): Promise<IslandBundle> {
|
|
150
|
+
const discovered = await discoverIslands(root);
|
|
151
|
+
const only = options.only;
|
|
152
|
+
const files = only === undefined ? discovered : discovered.filter((file) => file === only);
|
|
153
|
+
// A filter that matches nothing is a typo in the CALLER, never an app with no islands. Answering
|
|
154
|
+
// an empty bundle here would surface two steps later, as a chunk table with no entry for a file
|
|
155
|
+
// the caller can see on disk.
|
|
156
|
+
if (only !== undefined && files.length === 0) throw onlyMissing(only, discovered);
|
|
127
157
|
const chunks = await Promise.all(files.map((file) => buildOne(root, file)));
|
|
128
158
|
return islandBundle(chunks);
|
|
129
159
|
}
|
|
130
160
|
|
|
161
|
+
/**
|
|
162
|
+
* Same code as an unbuildable `src`: "this path cannot become a client entry" is one condition.
|
|
163
|
+
*
|
|
164
|
+
* Two fixes, because there are two causes and only one of them can be repaired by naming a path.
|
|
165
|
+
* The line was `pass only: '<app-root-relative path>.island.tsx'` — a placeholder nobody can run,
|
|
166
|
+
* which no gate could see: `fixProblem` fails a fix only for ADVICE with no command token, and a
|
|
167
|
+
* sentence with neither is not advice. Both forms below are constructed from what the caller
|
|
168
|
+
* already handed in, so neither can name a path this app does not have.
|
|
169
|
+
*/
|
|
170
|
+
function onlyMissing(only: string, discovered: readonly string[]): IslandInvalidError {
|
|
171
|
+
const cause =
|
|
172
|
+
`buildIslands was asked for ${JSON.stringify(only)} alone, which is not one of the ` +
|
|
173
|
+
`${discovered.length} islands this app has (${discovered.length === 0 ? 'none' : discovered.join(', ')})`;
|
|
174
|
+
// The basename match first: a filter that misses normally missed on the PREFIX — a route-relative
|
|
175
|
+
// specifier where `discoverIslands`' app-root-relative path was wanted — and the filename
|
|
176
|
+
// survives that. Falling back to the first keeps the fix a real path rather than a shape.
|
|
177
|
+
const nearest =
|
|
178
|
+
discovered.find((file) => posix.basename(file) === posix.basename(only)) ?? discovered[0];
|
|
179
|
+
// An app with no islands cannot be pointed at one, so the fix WRITES the file that was asked
|
|
180
|
+
// for — the same command `entryMissing` hands back, split off the same path.
|
|
181
|
+
if (nearest === undefined) {
|
|
182
|
+
return new IslandInvalidError(
|
|
183
|
+
cause,
|
|
184
|
+
`x g island ${posix.basename(only, ISLAND_EXTENSION)} --at ${posix.dirname(only)}`,
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return new IslandInvalidError(cause, `buildIslands(root, { only: '${nearest}' })`);
|
|
188
|
+
}
|
|
189
|
+
|
|
131
190
|
export function islandBundle(chunks: readonly IslandChunk[]): IslandBundle {
|
|
132
191
|
const byFile = new Map(chunks.map((chunk) => [chunk.file, chunk]));
|
|
133
192
|
const byUrl = new Map(chunks.map((chunk) => [chunk.url, chunk]));
|