@ultimat3/cli 5.0.1 → 7.0.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CLAUDE.md +75 -6
- package/README.md +2 -2
- package/package.json +28 -24
- package/src/affected.ts +320 -0
- package/src/browser-launcher.ts +109 -0
- package/src/ci-log.ts +0 -0
- package/src/ci-runs.ts +179 -0
- package/src/cmd-affected.ts +109 -0
- package/src/cmd-build.ts +36 -3
- package/src/cmd-ci.ts +273 -0
- package/src/cmd-dev.ts +35 -2
- package/src/cmd-generate.ts +16 -348
- package/src/cmd-i18n.ts +32 -16
- package/src/cmd-pr.ts +308 -0
- package/src/cmd-shot.ts +320 -0
- package/src/cmd-test.ts +96 -7
- package/src/cmd-verify.ts +10 -427
- package/src/compile-externals.ts +34 -0
- package/src/dev-lock.ts +275 -0
- package/src/dev-render.ts +7 -17
- package/src/error-codes.ts +18 -0
- package/src/generate-files.ts +127 -0
- package/src/generate-write.ts +229 -0
- package/src/gh-target.ts +118 -0
- package/src/gh.ts +204 -0
- package/src/i18n-audit.ts +39 -1
- package/src/i18n-registration.ts +130 -0
- package/src/index.ts +37 -0
- package/src/island-bundle.ts +68 -2
- package/src/island-solid-production.ts +129 -0
- package/src/island-styles.ts +41 -0
- package/src/mcp-errors.ts +11 -0
- package/src/messages.ts +67 -0
- package/src/pr-threads.ts +291 -0
- package/src/prerender.ts +52 -10
- package/src/registry.ts +8 -0
- package/src/shot-verdict.ts +337 -0
- package/src/solid-loader.ts +127 -0
- package/src/static-report.ts +219 -0
- package/src/templates/admin-page.ts +46 -5
- package/src/templates/index.ts +1 -0
- package/src/templates/island-fixture.ts +76 -0
- package/src/templates/island.ts +129 -18
- package/src/templates/resource-form-island.ts +279 -0
- package/src/templates/resource.ts +52 -43
- package/src/templates/route.ts +45 -6
- package/src/templates/scaffold-app.ts +70 -19
- package/src/templates/scaffold-container.ts +2 -2
- package/src/templates/scaffold-db-package.ts +88 -39
- package/src/templates/scaffold-docs.ts +18 -1
- package/src/templates/scaffold-i18n.ts +9 -2
- package/src/templates/scaffold-mcp-package.ts +35 -2
- package/src/templates/scaffold-package-shape.ts +7 -2
- package/src/templates/scaffold-repo.ts +2 -2
- package/src/test-shards.ts +19 -3
- package/src/verify-checks.ts +349 -0
- package/src/verify-run.ts +122 -0
- package/src/verify-step.ts +7 -0
- package/src/workspace-graph.ts +241 -0
- package/types/babel-modules.d.ts +31 -0
package/src/cmd-pr.ts
ADDED
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// `x pr review|resolve|reply` — the inline review, in a terminal. `gh pr view --comments` prints
|
|
2
|
+
// ISSUE comments, so the findings a reviewer anchored to a line are invisible to it and the thread
|
|
3
|
+
// id needed to resolve one exists nowhere a human can copy. This command is the one place that
|
|
4
|
+
// query lives, and the one place the two facts a reviewer's verdict hides behind are stated: a
|
|
5
|
+
// `reviewDecision` outlives the push that answered it, and a RESOLVED thread is a closed
|
|
6
|
+
// conversation rather than a fixed finding.
|
|
7
|
+
|
|
8
|
+
import type { CliCommand, CommandContext } from './command';
|
|
9
|
+
import {
|
|
10
|
+
BadFlagError,
|
|
11
|
+
MissingPositionalError,
|
|
12
|
+
MissingSubcommandError,
|
|
13
|
+
UnknownCommandError,
|
|
14
|
+
} from './errors';
|
|
15
|
+
import { parseIntFlag } from './flag-number';
|
|
16
|
+
import { PrNotFoundError, resolvePrNumber, resolveRepo } from './gh-target';
|
|
17
|
+
import { msg } from './messages';
|
|
18
|
+
import type { CommandResult, JsonValue } from './output';
|
|
19
|
+
import { flagBool, flagString } from './parse';
|
|
20
|
+
import type { PrReviewReport, PrThread } from './pr-threads';
|
|
21
|
+
import { fetchReviewReport, replyToThread, resolveThread, THREAD_PAGE } from './pr-threads';
|
|
22
|
+
|
|
23
|
+
export const PR_SUBCOMMANDS = ['review', 'resolve', 'reply'] as const;
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Every catalog key this command renders, declared. `msg()` answers `⟦key⟧` for a key nobody
|
|
27
|
+
* added — loud in a terminal and SILENT to a build — so the list is exported and
|
|
28
|
+
* `cmd-pr.test.ts` holds it against the catalog, which turns a missing string into a failing test
|
|
29
|
+
* instead of a rendered artefact of one.
|
|
30
|
+
*/
|
|
31
|
+
export const PR_MESSAGE_KEYS = [
|
|
32
|
+
'cli.pr.review.count',
|
|
33
|
+
'cli.pr.review.none',
|
|
34
|
+
'cli.pr.review.decision',
|
|
35
|
+
'cli.pr.review.stale',
|
|
36
|
+
'cli.pr.review.current',
|
|
37
|
+
'cli.pr.review.undecided',
|
|
38
|
+
'cli.pr.review.truncated',
|
|
39
|
+
'cli.pr.thread.open',
|
|
40
|
+
'cli.pr.thread.closed',
|
|
41
|
+
'cli.pr.thread.outdated',
|
|
42
|
+
'cli.pr.thread.comment',
|
|
43
|
+
'cli.pr.thread.more',
|
|
44
|
+
'cli.pr.body.truncated',
|
|
45
|
+
'cli.pr.line.unknown',
|
|
46
|
+
'cli.pr.resolved',
|
|
47
|
+
'cli.pr.replied',
|
|
48
|
+
] as const;
|
|
49
|
+
|
|
50
|
+
/** Lines of each comment body shown before it is cut. CodeRabbit's run to several thousand. */
|
|
51
|
+
export const BODY_LINES = 20;
|
|
52
|
+
|
|
53
|
+
export const prCommand: CliCommand = {
|
|
54
|
+
spec: {
|
|
55
|
+
name: 'pr',
|
|
56
|
+
summary: 'inline review threads: list them with their ids, resolve one, reply in one',
|
|
57
|
+
usage:
|
|
58
|
+
'x pr review [--pr <n>] [--repo owner/name] [--all] [--full] | x pr resolve <thread-id> | x pr reply <thread-id> --body "…"',
|
|
59
|
+
subcommands: PR_SUBCOMMANDS,
|
|
60
|
+
flags: [
|
|
61
|
+
{ name: 'repo', type: 'string', summary: 'owner/name; the checkout own remote by default' },
|
|
62
|
+
{ name: 'pr', type: 'string', summary: 'pull request number; this branch own by default' },
|
|
63
|
+
{ name: 'all', type: 'boolean', summary: 'review: resolved threads too, not just open ones' },
|
|
64
|
+
{ name: 'full', type: 'boolean', summary: 'review: whole comment bodies, never truncated' },
|
|
65
|
+
{ name: 'body', type: 'string', summary: 'reply: the comment text to post in the thread' },
|
|
66
|
+
],
|
|
67
|
+
},
|
|
68
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
69
|
+
// No `defaultSubcommand`: `resolve` and `reply` both WRITE to a pull request, so "whatever the
|
|
70
|
+
// caller left out" is not a safe guess for any of the three.
|
|
71
|
+
const sub = ctx.args.subcommand;
|
|
72
|
+
if (sub === undefined) {
|
|
73
|
+
throw new MissingSubcommandError({ command: 'pr', known: PR_SUBCOMMANDS });
|
|
74
|
+
}
|
|
75
|
+
if (sub === 'review') return runReview(ctx);
|
|
76
|
+
if (sub === 'resolve') return runResolve(ctx);
|
|
77
|
+
if (sub === 'reply') return runReply(ctx);
|
|
78
|
+
throw new UnknownCommandError({
|
|
79
|
+
path: `pr ${sub}`,
|
|
80
|
+
known: PR_SUBCOMMANDS,
|
|
81
|
+
suggestion: 'help pr',
|
|
82
|
+
});
|
|
83
|
+
},
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
/** `--pr` bounds, declared once so the refusal and the resolution cannot disagree about them. */
|
|
87
|
+
const PR_NUMBER = {
|
|
88
|
+
name: 'pr',
|
|
89
|
+
command: 'pr review',
|
|
90
|
+
min: 1,
|
|
91
|
+
example: 'x pr review --pr 241 --json',
|
|
92
|
+
} as const;
|
|
93
|
+
|
|
94
|
+
async function runReview(ctx: CommandContext): Promise<CommandResult> {
|
|
95
|
+
// 'pr review', not 'pr': `resolveRepo` builds its refusal's `fix:` from this word, and
|
|
96
|
+
// `x pr --repo …` is a command that throws `MissingSubcommandError` — a fix line that
|
|
97
|
+
// reproduces its own failure is the shape `MissingSubcommandError`'s own doc block warns about.
|
|
98
|
+
const repo = await resolveRepo(ctx, 'pr review', flagString(ctx.args, 'repo'));
|
|
99
|
+
const raw = flagString(ctx.args, 'pr');
|
|
100
|
+
const number =
|
|
101
|
+
raw === undefined ? await resolvePrNumber(ctx, repo) : parseIntFlag(raw, PR_NUMBER);
|
|
102
|
+
const report = await fetchReviewReport(ctx, repo, number);
|
|
103
|
+
if (report === null) {
|
|
104
|
+
throw new PrNotFoundError({ detail: `${repo.slug}#${number} answered no pull request` });
|
|
105
|
+
}
|
|
106
|
+
const all = flagBool(ctx.args, 'all');
|
|
107
|
+
const shown = all ? report.threads : report.threads.filter((thread) => !thread.isResolved);
|
|
108
|
+
const bodyLines = flagBool(ctx.args, 'full') ? Number.POSITIVE_INFINITY : BODY_LINES;
|
|
109
|
+
const unresolved = report.threads.filter((thread) => !thread.isResolved).length;
|
|
110
|
+
return {
|
|
111
|
+
// An inspection command, so the verdict is "the report was produced" and never "the review is
|
|
112
|
+
// clean": an agent loops read → edit → resolve on this output, and a non-zero exit for every
|
|
113
|
+
// open thread would report the work still to do as a failure of the command that listed it.
|
|
114
|
+
ok: true,
|
|
115
|
+
command: 'pr',
|
|
116
|
+
summary:
|
|
117
|
+
report.threads.length === 0
|
|
118
|
+
? msg('cli.pr.review.none', { repo: report.repo, pr: report.number })
|
|
119
|
+
: msg('cli.pr.review.count', {
|
|
120
|
+
unresolved,
|
|
121
|
+
resolved: report.threads.length - unresolved,
|
|
122
|
+
repo: report.repo,
|
|
123
|
+
pr: report.number,
|
|
124
|
+
}),
|
|
125
|
+
lines: [...decisionLines(report), ...shown.flatMap((thread) => threadLines(thread, bodyLines))],
|
|
126
|
+
data: reviewJson(report, shown, bodyLines, unresolved),
|
|
127
|
+
};
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* The staleness hazard, rendered before the threads. A `reviewDecision` survives every later push,
|
|
132
|
+
* so `CHANGES_REQUESTED` on a branch that has since been fixed reads exactly like one that has
|
|
133
|
+
* not — and the answer is the commit the review was submitted against, which is a fact rather than
|
|
134
|
+
* a timestamp comparison across a push.
|
|
135
|
+
*/
|
|
136
|
+
function decisionLines(report: PrReviewReport): readonly string[] {
|
|
137
|
+
const review = report.decidingReview;
|
|
138
|
+
if (review === null) {
|
|
139
|
+
return [msg('cli.pr.review.undecided'), ...truncationLines(report)];
|
|
140
|
+
}
|
|
141
|
+
return [
|
|
142
|
+
msg('cli.pr.review.decision', {
|
|
143
|
+
decision: report.reviewDecision ?? review.state,
|
|
144
|
+
author: review.author,
|
|
145
|
+
submitted: review.submittedAt ?? '',
|
|
146
|
+
}),
|
|
147
|
+
review.stale
|
|
148
|
+
? msg('cli.pr.review.stale', {
|
|
149
|
+
commit: short(review.commit),
|
|
150
|
+
head: short(report.headSha),
|
|
151
|
+
committed: report.headCommittedAt ?? '',
|
|
152
|
+
})
|
|
153
|
+
: msg('cli.pr.review.current', { head: short(report.headSha) }),
|
|
154
|
+
...truncationLines(report),
|
|
155
|
+
];
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const truncationLines = (report: PrReviewReport): readonly string[] =>
|
|
159
|
+
report.truncated ? [msg('cli.pr.review.truncated', { count: THREAD_PAGE })] : [];
|
|
160
|
+
|
|
161
|
+
/** Seven characters is what every GitHub UI shows, and enough to paste into `git show`. */
|
|
162
|
+
const short = (sha: string | null): string => (sha === null ? '' : sha.slice(0, 7));
|
|
163
|
+
|
|
164
|
+
const lineOf = (thread: PrThread): string =>
|
|
165
|
+
thread.line === null
|
|
166
|
+
? (thread.originalLine?.toString() ?? msg('cli.pr.line.unknown'))
|
|
167
|
+
: String(thread.line);
|
|
168
|
+
|
|
169
|
+
function threadLines(thread: PrThread, bodyLines: number): readonly string[] {
|
|
170
|
+
const head = thread.isResolved ? 'cli.pr.thread.closed' : 'cli.pr.thread.open';
|
|
171
|
+
const out = [msg(head, { path: thread.path, line: lineOf(thread), id: thread.id })];
|
|
172
|
+
if (thread.isOutdated) out.push(msg('cli.pr.thread.outdated', { line: lineOf(thread) }));
|
|
173
|
+
for (const comment of thread.comments) {
|
|
174
|
+
out.push(
|
|
175
|
+
msg('cli.pr.thread.comment', { author: comment.author, createdAt: comment.createdAt }),
|
|
176
|
+
);
|
|
177
|
+
const clamped = clampBody(comment.body, bodyLines);
|
|
178
|
+
for (const line of clamped.lines) out.push(` | ${line}`);
|
|
179
|
+
if (clamped.hidden > 0) out.push(msg('cli.pr.body.truncated', { hidden: clamped.hidden }));
|
|
180
|
+
}
|
|
181
|
+
const hidden = thread.commentCount - thread.comments.length;
|
|
182
|
+
if (hidden > 0) out.push(msg('cli.pr.thread.more', { hidden }));
|
|
183
|
+
return out;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* A review body is prose written for a browser: the ones in this repo run to six thousand
|
|
188
|
+
* characters with a shell script embedded in each. Clamped in ONE place, so `--json` carries the
|
|
189
|
+
* same bytes the terminal shows and `--full` moves both — a `lines` render that carried less than
|
|
190
|
+
* the data would be two reports of one review.
|
|
191
|
+
*/
|
|
192
|
+
export function clampBody(
|
|
193
|
+
body: string,
|
|
194
|
+
limit: number,
|
|
195
|
+
): { readonly lines: readonly string[]; readonly hidden: number } {
|
|
196
|
+
const lines = body.replaceAll('\r\n', '\n').split('\n');
|
|
197
|
+
if (lines.length <= limit) return { lines, hidden: 0 };
|
|
198
|
+
return { lines: lines.slice(0, limit), hidden: lines.length - limit };
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function reviewJson(
|
|
202
|
+
report: PrReviewReport,
|
|
203
|
+
shown: readonly PrThread[],
|
|
204
|
+
bodyLines: number,
|
|
205
|
+
unresolved: number,
|
|
206
|
+
): JsonValue {
|
|
207
|
+
const review = report.decidingReview;
|
|
208
|
+
return {
|
|
209
|
+
repo: report.repo,
|
|
210
|
+
pr: report.number,
|
|
211
|
+
url: report.url,
|
|
212
|
+
headSha: report.headSha,
|
|
213
|
+
headCommittedAt: report.headCommittedAt,
|
|
214
|
+
reviewDecision: report.reviewDecision,
|
|
215
|
+
review:
|
|
216
|
+
review === null
|
|
217
|
+
? null
|
|
218
|
+
: {
|
|
219
|
+
state: review.state,
|
|
220
|
+
author: review.author,
|
|
221
|
+
submittedAt: review.submittedAt,
|
|
222
|
+
commit: review.commit,
|
|
223
|
+
// The hazard as a field, because an agent reading `--json` never sees the line above.
|
|
224
|
+
stale: review.stale,
|
|
225
|
+
},
|
|
226
|
+
counts: {
|
|
227
|
+
total: report.threads.length,
|
|
228
|
+
unresolved,
|
|
229
|
+
resolved: report.threads.length - unresolved,
|
|
230
|
+
shown: shown.length,
|
|
231
|
+
},
|
|
232
|
+
truncated: report.truncated,
|
|
233
|
+
threads: shown.map((thread) => {
|
|
234
|
+
const comments = thread.comments.map((comment) => {
|
|
235
|
+
const clamped = clampBody(comment.body, bodyLines);
|
|
236
|
+
return {
|
|
237
|
+
author: comment.author,
|
|
238
|
+
createdAt: comment.createdAt,
|
|
239
|
+
body: clamped.lines.join('\n'),
|
|
240
|
+
bodyLinesHidden: clamped.hidden,
|
|
241
|
+
};
|
|
242
|
+
});
|
|
243
|
+
return {
|
|
244
|
+
id: thread.id,
|
|
245
|
+
path: thread.path,
|
|
246
|
+
line: thread.line,
|
|
247
|
+
originalLine: thread.originalLine,
|
|
248
|
+
isResolved: thread.isResolved,
|
|
249
|
+
isOutdated: thread.isOutdated,
|
|
250
|
+
commentCount: thread.commentCount,
|
|
251
|
+
comments,
|
|
252
|
+
};
|
|
253
|
+
}),
|
|
254
|
+
};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
/** A thread id is a positional: it comes out of `x pr review`, so it is never typed by hand. */
|
|
258
|
+
function threadIdOf(ctx: CommandContext, subcommand: string, example: string): string {
|
|
259
|
+
const id = ctx.args.positionals[0];
|
|
260
|
+
if (id === undefined) {
|
|
261
|
+
throw new MissingPositionalError({
|
|
262
|
+
command: `pr ${subcommand}`,
|
|
263
|
+
positional: 'thread-id',
|
|
264
|
+
example,
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
return id;
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Resolving is a statement about the CONVERSATION. Nothing GitHub can be asked observes whether
|
|
272
|
+
* the finding is fixed, so the summary says what happened and refuses to imply the other thing —
|
|
273
|
+
* a command that reported "addressed" would be the framework asserting a fact it cannot check.
|
|
274
|
+
*/
|
|
275
|
+
async function runResolve(ctx: CommandContext): Promise<CommandResult> {
|
|
276
|
+
const id = threadIdOf(ctx, 'resolve', 'x pr resolve PRRT_kwDOTkDHL86a6ivd --json');
|
|
277
|
+
const resolved = await resolveThread(ctx, id);
|
|
278
|
+
return {
|
|
279
|
+
ok: true,
|
|
280
|
+
command: 'pr',
|
|
281
|
+
summary: msg('cli.pr.resolved', { id: resolved }),
|
|
282
|
+
data: { threadId: resolved, isResolved: true },
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
async function runReply(ctx: CommandContext): Promise<CommandResult> {
|
|
287
|
+
const id = threadIdOf(
|
|
288
|
+
ctx,
|
|
289
|
+
'reply',
|
|
290
|
+
'x pr reply PRRT_kwDOTkDHL86a6ivd --body "fixed in 0f3a91c" --json',
|
|
291
|
+
);
|
|
292
|
+
const body = flagString(ctx.args, 'body');
|
|
293
|
+
if (body === undefined || body.trim() === '') {
|
|
294
|
+
throw new BadFlagError({
|
|
295
|
+
flag: 'body',
|
|
296
|
+
command: 'pr reply',
|
|
297
|
+
reason: 'a reply posts a comment, and an empty one says nothing to the reviewer',
|
|
298
|
+
fix: 'x pr reply PRRT_kwDOTkDHL86a6ivd --body "fixed in 0f3a91c" --json',
|
|
299
|
+
});
|
|
300
|
+
}
|
|
301
|
+
const url = await replyToThread(ctx, id, body);
|
|
302
|
+
return {
|
|
303
|
+
ok: true,
|
|
304
|
+
command: 'pr',
|
|
305
|
+
summary: msg('cli.pr.replied', { id, url }),
|
|
306
|
+
data: { threadId: id, url },
|
|
307
|
+
};
|
|
308
|
+
}
|
package/src/cmd-shot.ts
ADDED
|
@@ -0,0 +1,320 @@
|
|
|
1
|
+
// `x shot <route>` — a rendered route, on disk, for a reader who cannot open a browser. The
|
|
2
|
+
// picture is `shot.png`; the half that gates is `verdict.json`, because a picture cannot say that
|
|
3
|
+
// the island threw, that nothing hydrated, or that the document photographed is the sign-in page.
|
|
4
|
+
//
|
|
5
|
+
// Never a step of `x verify`: it needs a real browser, and a gate that goes red because a machine
|
|
6
|
+
// has no Chrome is a gate that fails for reasons unrelated to the change.
|
|
7
|
+
|
|
8
|
+
import { mkdirSync } from 'node:fs';
|
|
9
|
+
import { join, resolve } from 'node:path';
|
|
10
|
+
import { IDLE_HYDRATE_TIMEOUT_MS } from '@ultimat3/render';
|
|
11
|
+
import type { ScrapeDriver, ScrapeSession } from '@ultimat3/scraping';
|
|
12
|
+
import { DEFAULT_PAGE_TIMEOUT_MS, systemScrapeClock } from '@ultimat3/scraping';
|
|
13
|
+
import { requireAppRoot } from './app-root';
|
|
14
|
+
import { appBrowser, browserBinaryExists, executablePathFrom } from './browser-launcher';
|
|
15
|
+
import { startDev } from './cmd-dev';
|
|
16
|
+
import type { CliCommand, CommandContext } from './command';
|
|
17
|
+
import { clearLock, isProcessAlive, lockPath, parseLock, preflight, writeLock } from './dev-lock';
|
|
18
|
+
import { DEV_BINDING } from './dev-roles';
|
|
19
|
+
import { resolveServices } from './dev-services';
|
|
20
|
+
import { BadFlagError, MissingPositionalError } from './errors';
|
|
21
|
+
import { intFlagOr, PORT_RANGE } from './flag-number';
|
|
22
|
+
import type { CommandResult } from './output';
|
|
23
|
+
import type { ParsedArgs } from './parse';
|
|
24
|
+
import { flagBool, flagString } from './parse';
|
|
25
|
+
import type { ShotArtifacts } from './shot-verdict';
|
|
26
|
+
import {
|
|
27
|
+
buildVerdict,
|
|
28
|
+
ISLAND_PROBE,
|
|
29
|
+
parseIslandProbe,
|
|
30
|
+
shotLines,
|
|
31
|
+
shotSummary,
|
|
32
|
+
verdictJson,
|
|
33
|
+
} from './shot-verdict';
|
|
34
|
+
|
|
35
|
+
/** Kernel-picked by default: :3000 is usually another project's dev server, not a free port. */
|
|
36
|
+
const DEFAULT_PORT = 0;
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* How long the page is left alone after `load` before it is photographed: exactly the
|
|
40
|
+
* `requestIdleCallback` deadline `@ultimat3/render`'s hydration runtime gives an `idle` island —
|
|
41
|
+
* shoot sooner and the verdict reports `booted: 0` for a page that hydrates perfectly. READ from
|
|
42
|
+
* that runtime rather than restated, because two copies of one number that must agree is the drift
|
|
43
|
+
* axiom 2 refuses: the settle window is not "2 seconds", it is "the deadline the runtime uses".
|
|
44
|
+
*/
|
|
45
|
+
export const DEFAULT_SETTLE_MS = IDLE_HYDRATE_TIMEOUT_MS;
|
|
46
|
+
|
|
47
|
+
export const SHOT_DIR = join('.x', 'shot');
|
|
48
|
+
export const SHOT_IMAGE = 'shot.png';
|
|
49
|
+
export const SHOT_VERDICT = 'verdict.json';
|
|
50
|
+
|
|
51
|
+
/** A directory name a route can never escape: everything that is not a letter or digit is a dash. */
|
|
52
|
+
export function shotSlug(route: string): string {
|
|
53
|
+
const slug = route.replace(/[^a-zA-Z0-9]+/g, '-').replace(/^-+|-+$/g, '');
|
|
54
|
+
return slug === '' ? 'root' : slug.toLowerCase();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const pathOf = (url: string): string => {
|
|
58
|
+
try {
|
|
59
|
+
return new URL(url).pathname;
|
|
60
|
+
} catch {
|
|
61
|
+
return '/';
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* A path on the app, never a URL. `x shot https://example.com` would photograph somebody else's
|
|
67
|
+
* site through a headless browser inside your network, which is the SSRF shape `allowHosts` exists
|
|
68
|
+
* to refuse — so it is refused here, at the argument, where the reader can still see why.
|
|
69
|
+
*/
|
|
70
|
+
export function readRoute(raw: string | undefined): string {
|
|
71
|
+
if (raw === undefined || raw.trim() === '') {
|
|
72
|
+
throw new MissingPositionalError({ command: 'shot', positional: 'route', example: 'x shot /' });
|
|
73
|
+
}
|
|
74
|
+
const route = raw.trim();
|
|
75
|
+
if (/^[a-z][a-z0-9+.-]*:/i.test(route)) {
|
|
76
|
+
throw new BadFlagError({
|
|
77
|
+
flag: 'route',
|
|
78
|
+
command: 'shot',
|
|
79
|
+
reason: `"${route}" is an absolute URL; x shot photographs a route of the app under test`,
|
|
80
|
+
// The path out of the URL when it parses — the refusal's own fix line has to be runnable,
|
|
81
|
+
// and `new URL('http://')` throws, so the fallback is the route every app has.
|
|
82
|
+
fix: `x shot ${pathOf(route)} --json`,
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
return route.startsWith('/') ? route : `/${route}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* The three integer flags, each read with its NAME as an argument. `intFlagOr` takes the name in a
|
|
90
|
+
* `name:` field, which `flag-reads.ts` counts as a declaration rather than a read — so a command
|
|
91
|
+
* whose only mention of `--settle` is inside that object declares a flag the rule reports as
|
|
92
|
+
* having no reader. The example is derived from the default, so it is always a runnable line.
|
|
93
|
+
*/
|
|
94
|
+
const intFlag = (
|
|
95
|
+
args: ParsedArgs,
|
|
96
|
+
name: string,
|
|
97
|
+
min: number,
|
|
98
|
+
fallback: number,
|
|
99
|
+
max?: number,
|
|
100
|
+
): number =>
|
|
101
|
+
intFlagOr(
|
|
102
|
+
args,
|
|
103
|
+
{
|
|
104
|
+
name,
|
|
105
|
+
command: 'shot',
|
|
106
|
+
min,
|
|
107
|
+
...(max === undefined ? {} : { max }),
|
|
108
|
+
example: `x shot / --${name} ${fallback}`,
|
|
109
|
+
},
|
|
110
|
+
fallback,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
export interface ShotServer {
|
|
114
|
+
readonly url: string;
|
|
115
|
+
/** Which server the picture is of. Reported, because the two have different failure modes. */
|
|
116
|
+
readonly origin: 'booted' | 'reused';
|
|
117
|
+
stop(): Promise<void>;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
export interface ShotRun {
|
|
121
|
+
readonly route: string;
|
|
122
|
+
readonly outDir: string;
|
|
123
|
+
readonly driver: ScrapeDriver;
|
|
124
|
+
readonly boot: () => Promise<ShotServer>;
|
|
125
|
+
readonly settleMs: number;
|
|
126
|
+
readonly timeoutMs: number;
|
|
127
|
+
readonly fullPage: boolean;
|
|
128
|
+
/**
|
|
129
|
+
* `--allow-hosts`, verbatim. The app's own host is added once the server is up and never here:
|
|
130
|
+
* with `--port 0` the port — and therefore the origin — does not exist until after the boot.
|
|
131
|
+
*/
|
|
132
|
+
readonly extraHosts?: string | undefined;
|
|
133
|
+
readonly now?: (() => Date) | undefined;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/** Nothing here may replace the failure that caused it, so a teardown throw is swallowed. */
|
|
137
|
+
const quietly = async (stop: () => Promise<void>): Promise<void> => {
|
|
138
|
+
await stop().catch(() => undefined);
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
/**
|
|
142
|
+
* Boot (or find) the server, photograph one route, write both artifacts. The driver and the boot
|
|
143
|
+
* are ARGUMENTS: `bun test` drives this with `fakeBrowser()` and a stub server, so the whole
|
|
144
|
+
* command is proved on a machine with no Chrome — which this command is explicitly excluded from
|
|
145
|
+
* the gate for needing.
|
|
146
|
+
*/
|
|
147
|
+
export async function runShot(options: ShotRun): Promise<ShotArtifacts> {
|
|
148
|
+
const server = await options.boot();
|
|
149
|
+
let session: ScrapeSession | undefined;
|
|
150
|
+
try {
|
|
151
|
+
const requestedUrl = new URL(options.route, server.url).toString();
|
|
152
|
+
session = await options.driver.open({
|
|
153
|
+
name: 'x shot',
|
|
154
|
+
// The host the picture is of, plus whatever the caller named. Never `*`: a headless browser
|
|
155
|
+
// inside your network is the widest SSRF surface an app can own, and a screenshot command is
|
|
156
|
+
// not the place to open it by default. Every refusal lands in the verdict's `refused` count.
|
|
157
|
+
rules: { allowHosts: allowHostsFrom(server.url, options.extraHosts) },
|
|
158
|
+
clock: systemScrapeClock,
|
|
159
|
+
timeoutMs: options.timeoutMs,
|
|
160
|
+
});
|
|
161
|
+
const page = session.page;
|
|
162
|
+
await page.goto(requestedUrl, { timeout: options.timeoutMs });
|
|
163
|
+
if (options.settleMs > 0) await Bun.sleep(options.settleMs);
|
|
164
|
+
// The probe may legitimately answer nothing — a page that refuses evaluation, a driver with no
|
|
165
|
+
// JS engine. `null` says so; a `0` would read as "the route renders no islands", which is a
|
|
166
|
+
// different and much more alarming claim.
|
|
167
|
+
const islands = await page
|
|
168
|
+
.evaluate(ISLAND_PROBE)
|
|
169
|
+
.then(parseIslandProbe)
|
|
170
|
+
.catch(() => null);
|
|
171
|
+
const bytes = await page.screenshot({ fullPage: options.fullPage });
|
|
172
|
+
// Read AFTER the capture, so an error logged while the page settled is in the verdict that
|
|
173
|
+
// ships with the picture it explains.
|
|
174
|
+
const verdict = buildVerdict({
|
|
175
|
+
route: options.route,
|
|
176
|
+
requestedUrl,
|
|
177
|
+
finalUrl: page.url(),
|
|
178
|
+
server: server.origin,
|
|
179
|
+
capturedAt: (options.now ?? (() => new Date()))().toISOString(),
|
|
180
|
+
screenshot: SHOT_IMAGE,
|
|
181
|
+
bytes,
|
|
182
|
+
console: page.console(),
|
|
183
|
+
pageErrors: page.pageErrors(),
|
|
184
|
+
pageErrorsDropped: page.pageErrorsDropped(),
|
|
185
|
+
network: page.network(),
|
|
186
|
+
networkDropped: page.networkDropped(),
|
|
187
|
+
islands,
|
|
188
|
+
});
|
|
189
|
+
mkdirSync(options.outDir, { recursive: true });
|
|
190
|
+
const image = join(options.outDir, SHOT_IMAGE);
|
|
191
|
+
const verdictFile = join(options.outDir, SHOT_VERDICT);
|
|
192
|
+
await Bun.write(image, bytes);
|
|
193
|
+
await Bun.write(verdictFile, `${JSON.stringify(verdictJson(verdict), null, 2)}\n`);
|
|
194
|
+
return { verdict, image, verdictFile };
|
|
195
|
+
} finally {
|
|
196
|
+
// Bound to a const: narrowing a `let` does not survive into the closure below, and the session
|
|
197
|
+
// has to be closed from inside one so a teardown throw cannot replace the real failure.
|
|
198
|
+
const open = session;
|
|
199
|
+
if (open !== undefined) await quietly(() => open.close());
|
|
200
|
+
await quietly(() => server.stop());
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
/**
|
|
205
|
+
* One rule, two branches: photograph the `x dev` this checkout already has, or boot a scratch one.
|
|
206
|
+
* Reusing is not a convenience — embedded Postgres is a single-writer directory, so a second boot
|
|
207
|
+
* on one checkout is `X_DEV_ALREADY_RUNNING` and the picture would never be taken at all.
|
|
208
|
+
*/
|
|
209
|
+
export async function devServerFor(
|
|
210
|
+
root: string,
|
|
211
|
+
env: Readonly<Record<string, string | undefined>>,
|
|
212
|
+
port: number,
|
|
213
|
+
): Promise<ShotServer> {
|
|
214
|
+
const services = resolveServices(root, env);
|
|
215
|
+
const file = Bun.file(lockPath(services.stateDir));
|
|
216
|
+
if (await file.exists()) {
|
|
217
|
+
const lock = parseLock(await file.text());
|
|
218
|
+
if (lock !== null && isProcessAlive(lock.pid)) {
|
|
219
|
+
return { url: lock.url, origin: 'reused', stop: () => Promise.resolve() };
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
await preflight({
|
|
223
|
+
stateDir: services.stateDir,
|
|
224
|
+
port,
|
|
225
|
+
hostname: DEV_BINDING.hostname,
|
|
226
|
+
embeddedDb: services.db.mode === 'embedded',
|
|
227
|
+
});
|
|
228
|
+
const dev = await startDev({ root, port, env });
|
|
229
|
+
await writeLock(services.stateDir, {
|
|
230
|
+
pid: process.pid,
|
|
231
|
+
port,
|
|
232
|
+
url: dev.url,
|
|
233
|
+
startedAt: new Date().toISOString(),
|
|
234
|
+
});
|
|
235
|
+
return {
|
|
236
|
+
url: dev.url,
|
|
237
|
+
origin: 'booted',
|
|
238
|
+
async stop() {
|
|
239
|
+
clearLock(services.stateDir);
|
|
240
|
+
await dev.stop();
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/** `--allow-hosts a.com,b.com` on top of the app's own host. Empty means the app's host alone. */
|
|
246
|
+
export const allowHostsFrom = (url: string, extra: string | undefined): readonly string[] => {
|
|
247
|
+
const named = (extra ?? '')
|
|
248
|
+
.split(',')
|
|
249
|
+
.map((host) => host.trim())
|
|
250
|
+
.filter((host) => host.length > 0);
|
|
251
|
+
return [new URL(url).hostname, ...named];
|
|
252
|
+
};
|
|
253
|
+
|
|
254
|
+
export const shotResult = (artifacts: ShotArtifacts): CommandResult => ({
|
|
255
|
+
ok: artifacts.verdict.ok,
|
|
256
|
+
command: 'shot',
|
|
257
|
+
summary: shotSummary(artifacts.verdict),
|
|
258
|
+
lines: shotLines(artifacts),
|
|
259
|
+
data: {
|
|
260
|
+
image: artifacts.image,
|
|
261
|
+
verdictFile: artifacts.verdictFile,
|
|
262
|
+
verdict: verdictJson(artifacts.verdict),
|
|
263
|
+
},
|
|
264
|
+
});
|
|
265
|
+
|
|
266
|
+
export const shotCommand: CliCommand = {
|
|
267
|
+
spec: {
|
|
268
|
+
name: 'shot',
|
|
269
|
+
summary: 'photograph one route from a real browser, with a verdict a picture cannot carry',
|
|
270
|
+
usage: 'x shot <route> [--port 0] [--out <dir>] [--no-full] [--settle 2000] [--json]',
|
|
271
|
+
requiresApp: true,
|
|
272
|
+
flags: [
|
|
273
|
+
{ name: 'port', type: 'string', summary: 'dev port (0 lets the kernel pick a free one)' },
|
|
274
|
+
{ name: 'out', type: 'string', summary: 'where shot.png and verdict.json are written' },
|
|
275
|
+
{ name: 'full', type: 'boolean', summary: 'whole page, not the fold', default: true },
|
|
276
|
+
{ name: 'settle', type: 'string', summary: 'ms to wait after load before capturing' },
|
|
277
|
+
{ name: 'timeout', type: 'string', summary: 'ms one navigation may take' },
|
|
278
|
+
{ name: 'browser', type: 'string', summary: 'browser executable puppeteer-core launches' },
|
|
279
|
+
{ name: 'allow-hosts', type: 'string', summary: 'extra hosts the page may request' },
|
|
280
|
+
],
|
|
281
|
+
},
|
|
282
|
+
async run(ctx: CommandContext): Promise<CommandResult> {
|
|
283
|
+
const root = requireAppRoot('shot', ctx.cwd).dir;
|
|
284
|
+
// Every value read before anything boots: a typo must not cost a browser and a dev server to
|
|
285
|
+
// report, which is the rule `x routes` and `x mcp` already follow.
|
|
286
|
+
const route = readRoute(ctx.args.positionals[0]);
|
|
287
|
+
const port = intFlag(ctx.args, 'port', PORT_RANGE.min, DEFAULT_PORT, PORT_RANGE.max);
|
|
288
|
+
const settleMs = intFlag(ctx.args, 'settle', 0, DEFAULT_SETTLE_MS);
|
|
289
|
+
const timeoutMs = intFlag(ctx.args, 'timeout', 1, DEFAULT_PAGE_TIMEOUT_MS);
|
|
290
|
+
const executablePath = executablePathFrom(flagString(ctx.args, 'browser'), ctx.env);
|
|
291
|
+
if (executablePath !== undefined && !browserBinaryExists(executablePath)) {
|
|
292
|
+
throw new BadFlagError({
|
|
293
|
+
flag: 'browser',
|
|
294
|
+
command: 'shot',
|
|
295
|
+
reason: `no executable at "${executablePath}"`,
|
|
296
|
+
fix: `x shot ${route} --browser /usr/bin/chromium`,
|
|
297
|
+
});
|
|
298
|
+
}
|
|
299
|
+
const out = flagString(ctx.args, 'out');
|
|
300
|
+
const boot = (): Promise<ShotServer> => devServerFor(root, ctx.env, port);
|
|
301
|
+
// Resolved before the boot for the same reason: an app with no browser installed must not pay
|
|
302
|
+
// an embedded Postgres to be told to run `bun add -d puppeteer-core`.
|
|
303
|
+
const driver = await appBrowser({
|
|
304
|
+
root,
|
|
305
|
+
...(executablePath === undefined ? {} : { executablePath }),
|
|
306
|
+
});
|
|
307
|
+
return shotResult(
|
|
308
|
+
await runShot({
|
|
309
|
+
route,
|
|
310
|
+
outDir: out === undefined ? join(root, SHOT_DIR, shotSlug(route)) : resolve(root, out),
|
|
311
|
+
driver,
|
|
312
|
+
boot,
|
|
313
|
+
settleMs,
|
|
314
|
+
timeoutMs,
|
|
315
|
+
fullPage: flagBool(ctx.args, 'full'),
|
|
316
|
+
extraHosts: flagString(ctx.args, 'allow-hosts'),
|
|
317
|
+
}),
|
|
318
|
+
);
|
|
319
|
+
},
|
|
320
|
+
};
|