@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 CHANGED
@@ -67,6 +67,17 @@ and the `tsc -b` that proves it took. Private packages are exempt (a generated a
67
67
  private), and a root that declares **no** `references` array is not judged at all: project
68
68
  references are opt-in, and a scaffolded app builds through `extends` + `include`.
69
69
 
70
+ `workspace-graph.ts` is `package-shape`'s fifth rule: **every cross-workspace import is declared
71
+ in the importing workspace's own manifest**. Without it a scaffolded repo's dependency graph exists
72
+ only inside `tsc` — imports resolve through the root `tsconfig.json` `paths`, so affected-package
73
+ detection, `bun --filter` ordering and "what breaks if I change this" all read manifests and all
74
+ answer too small a set (issue #239, found in a real app where a change reaching five packages
75
+ reported one). `X_WORKSPACE_DEP_UNDECLARED` names the manifest and the exact line to add. Shipped
76
+ source only: a test file's import is not judged, because `packages/*` here declares no
77
+ `devDependencies` by design and the root's hoist is what resolves them. A manifest the scan cannot
78
+ read is its own finding rather than a silent skip — a skipped workspace is a hiding place for the
79
+ very edge the rule is looking for.
80
+
70
81
  `app-agents-md.ts` is why the `manifest` step declares no `applies` at all. The drift half needs
71
82
  a committed `x.manifest.json` to compare against, but `AGENTS.md` is required of every repo the
72
83
  gate runs in — so the step always has a question to answer, and gating both halves on the file
@@ -139,6 +150,38 @@ emits a file `defineCatalogs` rejects at the app's first boot. `merge: 'json'` u
139
150
  (`json-merge.ts`) for the same reason: `x new` and `x g resource` both contribute under `app`, and
140
151
  a shallow spread keeps one of them.
141
152
 
153
+ ## Three commands that reach outside the process, and none of them is a gate step
154
+
155
+ `x shot`, `x pr` and `x ci` exist because of the one line in the root `CLAUDE.md` that shapes this
156
+ whole package: **the primary developer is an AI agent.** An agent cannot open a browser, cannot look
157
+ at a running dev server and cannot read the GitHub web UI. It can read a file, and it can run a
158
+ command that prints. These three turn each of those into a file and a print.
159
+
160
+ They are also the only three commands that need something the process does not have — a browser, a
161
+ network, a GitHub token — which is why **none of them is a step of `x verify`**, and why that is not
162
+ an oversight to be corrected later. A gate that needs a browser goes red for reasons unrelated to the
163
+ change, and CI does not install one.
164
+
165
+ | | Reaches for | Never |
166
+ |---|---|---|
167
+ | `x shot <route>` | `x dev` on a scratch port, plus the app's own `puppeteer-core` through `@ultimat3/scraping` | the static build — `--target static` prerenders `site/` only, so an `app/` route would photograph the landing page |
168
+ | `x pr review\|resolve\|reply` | `gh api graphql`, through the injected `Runner` | `gh pr view --comments`, which shows *issue* comments and not the line-anchored threads that carry the findings |
169
+ | `x ci` | `gh run view --log-failed`, one call | a per-job log fetch — the run and all its jobs come back together |
170
+
171
+ **`verdict.json` names its own blind spots, and that is the design.** `x shot` reports what it could
172
+ not observe alongside what it did. A capture tool that silently omits what it cannot see is worse
173
+ than one that says so, because the omission reads as a clean result.
174
+
175
+ **`x shot` reuses a running `x dev` rather than booting a second one.** Embedded Postgres is
176
+ single-writer, so a second boot is `X_DEV_ALREADY_RUNNING` and no picture is ever taken. A reused
177
+ server's `stop()` deliberately does not clear the other process's lock.
178
+
179
+ **`gh` is invoked through `ctx.runner`, never `Bun.spawn` directly** — that is what lets every test
180
+ supply a reply table and assert the exact argv with no network and no `gh` installed. `GhOptions.fix`
181
+ is a **required** field, so shelling out to GitHub without stating a remedy is a type error rather
182
+ than a review comment. A GraphQL response is untrusted input and is parsed against a schema, never
183
+ cast: a `null` where an id was expected would otherwise become a mutation against `undefined`.
184
+
142
185
  ## The `errors` step enforces the error contract
143
186
 
144
187
  | File | Job |
@@ -816,10 +859,13 @@ typechecked there by default; an app whose tsconfig names an explicit `include`
816
859
  ## Two generators that scaffold something other than a primitive
817
860
 
818
861
  `x g island <name> [--at <dir>]` writes a **client entry point**, not a component: the filename is
819
- how the bundler discovers it and `mount` is how the hydration runtime calls it, so those two are
820
- what `templates/island.test.ts` pins and everything else in the file is example code. `--at` takes
821
- the directory directly rather than deriving one, because the caller that cannot guess is
822
- `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
862
+ how the bundler discovers it and `mount` is how the hydration runtime calls it, so the filename,
863
+ the `mount` export and that `mount` RENDERS are what `templates/island.test.ts` pins — it builds
864
+ the emitted entry with `buildIslands` and drives it with `mountIsland`, so a template that
865
+ typechecks and does not mount is a failing test. It runs the mutation too, rather than describing
866
+ it: the same island with `{count()}` replaced by `{0}` must fail the assertion the live one passes.
867
+ `--at` takes the directory directly rather than deriving one, because the caller that cannot guess
868
+ is `X_ISLAND_INVALID` — its cause already holds the exact path a page's `src` resolved to, so its
823
869
  `fix:` hands that path straight back.
824
870
 
825
871
  `x g admin:page <name> --permission <perm> [--at <dir>]` writes an ordinary TSX component and **no
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ultimat3/cli",
3
- "version": "6.0.0",
3
+ "version": "7.0.0",
4
4
  "description": "The `x` binary: new, dev, build, verify, generate, db, mcp, doctor, deploy",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -37,29 +37,30 @@
37
37
  },
38
38
  "dependencies": {
39
39
  "@babel/core": "^7.28.4",
40
- "@ultimat3/action": "6.0.0",
41
- "@ultimat3/admin": "6.0.0",
42
- "@ultimat3/ai": "6.0.0",
43
- "@ultimat3/cache": "6.0.0",
44
- "@ultimat3/core": "6.0.0",
45
- "@ultimat3/db": "6.0.0",
46
- "@ultimat3/entity": "6.0.0",
47
- "@ultimat3/http": "6.0.0",
48
- "@ultimat3/i18n": "6.0.0",
49
- "@ultimat3/jobs": "6.0.0",
50
- "@ultimat3/mail": "6.0.0",
51
- "@ultimat3/manifest": "6.0.0",
52
- "@ultimat3/mcp": "6.0.0",
53
- "@ultimat3/policy": "6.0.0",
54
- "@ultimat3/pwa": "6.0.0",
55
- "@ultimat3/query": "6.0.0",
56
- "@ultimat3/realtime": "6.0.0",
57
- "@ultimat3/render": "6.0.0",
58
- "@ultimat3/schema": "6.0.0",
59
- "@ultimat3/seo": "6.0.0",
60
- "@ultimat3/storage": "6.0.0",
61
- "@ultimat3/testing": "6.0.0",
62
- "@ultimat3/time": "6.0.0",
40
+ "@ultimat3/action": "7.0.0",
41
+ "@ultimat3/admin": "7.0.0",
42
+ "@ultimat3/ai": "7.0.0",
43
+ "@ultimat3/cache": "7.0.0",
44
+ "@ultimat3/core": "7.0.0",
45
+ "@ultimat3/db": "7.0.0",
46
+ "@ultimat3/entity": "7.0.0",
47
+ "@ultimat3/http": "7.0.0",
48
+ "@ultimat3/i18n": "7.0.0",
49
+ "@ultimat3/jobs": "7.0.0",
50
+ "@ultimat3/mail": "7.0.0",
51
+ "@ultimat3/manifest": "7.0.0",
52
+ "@ultimat3/mcp": "7.0.0",
53
+ "@ultimat3/policy": "7.0.0",
54
+ "@ultimat3/pwa": "7.0.0",
55
+ "@ultimat3/query": "7.0.0",
56
+ "@ultimat3/realtime": "7.0.0",
57
+ "@ultimat3/render": "7.0.0",
58
+ "@ultimat3/schema": "7.0.0",
59
+ "@ultimat3/scraping": "7.0.0",
60
+ "@ultimat3/seo": "7.0.0",
61
+ "@ultimat3/storage": "7.0.0",
62
+ "@ultimat3/testing": "7.0.0",
63
+ "@ultimat3/time": "7.0.0",
63
64
  "babel-preset-solid": "^1.9.15"
64
65
  }
65
66
  }
@@ -0,0 +1,320 @@
1
+ // What a diff touches: the changed-file list read out of git, and the workspaces that list forces
2
+ // a re-test of — closed TRANSITIVELY over the workspace graph, because A → B → C means an edit in
3
+ // C breaks A and a single pass over an unordered list only ever reaches B.
4
+ //
5
+ // The diff defaults to a REF, never the working tree. Several agents share one checkout here (root
6
+ // `CLAUDE.md`, the "Note": no worktrees, "run them as a team in this same checkout"), so a
7
+ // working-tree diff returns every other agent's uncommitted work and the "affected" set silently
8
+ // widens to nearly the whole monorepo — the command stops narrowing anything and nobody can tell.
9
+ // A ref diff is stable under that concurrency; `--dirty` opts back in, which is right for one
10
+ // developer iterating alone and wrong as a default here.
11
+
12
+ // `join`/`relative` are `node:`-only by necessity: Bun exposes no path primitive, and a workspace
13
+ // directory is checkout-relative while a caller's scan yields paths relative to its own root.
14
+ import { join, relative } from 'node:path';
15
+ import { singleLine, UltimateError } from '@ultimat3/core';
16
+ import { docsFor } from './error-codes';
17
+ import { BadFlagError } from './errors';
18
+ import type { ExecResult, Runner } from './exec';
19
+ import { execOutput } from './exec';
20
+ import type { JsonValue } from './output';
21
+ import type { ParsedArgs } from './parse';
22
+ import { flagBool, flagString } from './parse';
23
+ import type { WorkspaceNode } from './workspace-graph';
24
+ import { readWorkspaceGraph } from './workspace-graph';
25
+
26
+ /** The branch a change is measured against when `--base` says nothing. */
27
+ export const DEFAULT_BASE = 'main';
28
+
29
+ /**
30
+ * Root files that belong to no workspace and change what every workspace compiles to: a compiler
31
+ * option, a lint rule, the root manifest, the resolved dependency tree, the test preload, the app
32
+ * config. A "scoped" run that skipped them reports green over packages the edit just broke.
33
+ *
34
+ * Matched on the WHOLE path — `packages/cli/package.json` is the cli workspace's own file and
35
+ * reaches only cli's dependents, `package.json` at the root reaches everything.
36
+ */
37
+ export const ROOT_WIDE_FILES: readonly string[] = [
38
+ 'app.config.ts',
39
+ 'biome.json',
40
+ 'bun.lock',
41
+ 'bunfig.toml',
42
+ 'package.json',
43
+ 'tsconfig.json',
44
+ ];
45
+
46
+ /**
47
+ * A file with no compilation unit behind it. A doc or a plan re-checks nothing, so it maps to no
48
+ * workspace at all rather than to the one it happens to sit inside — `packages/cli/README.md` is
49
+ * not a reason to run `packages/cli`'s tests.
50
+ */
51
+ const isDoc = (path: string): boolean => path.endsWith('.md');
52
+
53
+ const owns = (node: WorkspaceNode, path: string): boolean =>
54
+ path === node.dir || path.startsWith(`${node.dir}/`);
55
+
56
+ /**
57
+ * The workspace a path belongs to, longest directory first: nested workspaces exist (a scaffolded
58
+ * app's `apps/web` inside its own root), and the shorter prefix would swallow the inner one.
59
+ */
60
+ export function owningWorkspace(
61
+ graph: readonly WorkspaceNode[],
62
+ path: string,
63
+ ): WorkspaceNode | undefined {
64
+ let best: WorkspaceNode | undefined;
65
+ for (const node of graph) {
66
+ if (!owns(node, path)) continue;
67
+ if (best === undefined || node.dir.length > best.dir.length) best = node;
68
+ }
69
+ return best;
70
+ }
71
+
72
+ /**
73
+ * Every workspace that depends on one of `seeds`, however many edges away.
74
+ *
75
+ * A queue with a growing cursor, not one pass over `seeds`: with `A → B → C`, one pass answers
76
+ * `{ C, B }` and leaves A untested while the change that broke it is in the diff — a green
77
+ * checkmark on a broken repo, which is the whole reason this command exists rather than each agent
78
+ * inventing its own scoping. `reached` doubles as the cycle guard.
79
+ */
80
+ function withDependents(
81
+ graph: readonly WorkspaceNode[],
82
+ seeds: ReadonlySet<string>,
83
+ ): ReadonlySet<string> {
84
+ const dependents = new Map<string, string[]>();
85
+ for (const node of graph) {
86
+ for (const dependency of node.dependencies) {
87
+ const known = dependents.get(dependency);
88
+ if (known === undefined) dependents.set(dependency, [node.name]);
89
+ else known.push(node.name);
90
+ }
91
+ }
92
+ const reached = new Set(seeds);
93
+ const queue = [...reached];
94
+ for (let cursor = 0; cursor < queue.length; cursor += 1) {
95
+ const name = queue[cursor];
96
+ if (name === undefined) continue;
97
+ for (const dependent of dependents.get(name) ?? []) {
98
+ if (reached.has(dependent)) continue;
99
+ reached.add(dependent);
100
+ queue.push(dependent);
101
+ }
102
+ }
103
+ return reached;
104
+ }
105
+
106
+ export interface AffectedPlan {
107
+ /** Every path the diff reported, verbatim and in git's order. */
108
+ readonly changed: readonly string[];
109
+ /** The subset with no compilation unit behind it, named so an empty answer explains itself. */
110
+ readonly ignored: readonly string[];
111
+ /** The root files that forced every workspace in, empty when none did. */
112
+ readonly rootWide: readonly string[];
113
+ readonly workspaces: readonly WorkspaceNode[];
114
+ }
115
+
116
+ const byName = (a: WorkspaceNode, b: WorkspaceNode): number => (a.name > b.name ? 1 : -1);
117
+
118
+ /**
119
+ * Pure: the graph and the file list in, the plan out. No git, no disk — so the transitive rule is
120
+ * testable without a checkout whose state would decide the verdict.
121
+ */
122
+ export function planAffected(
123
+ graph: readonly WorkspaceNode[],
124
+ changed: readonly string[],
125
+ ): AffectedPlan {
126
+ const ignored = changed.filter(isDoc);
127
+ const considered = changed.filter((path) => !isDoc(path));
128
+ const rootWide = considered.filter((path) => ROOT_WIDE_FILES.includes(path));
129
+ if (rootWide.length > 0) {
130
+ return { changed, ignored, rootWide, workspaces: [...graph].sort(byName) };
131
+ }
132
+ const seeds = new Set<string>();
133
+ for (const path of considered) {
134
+ const node = owningWorkspace(graph, path);
135
+ if (node !== undefined) seeds.add(node.name);
136
+ }
137
+ const reached = withDependents(graph, seeds);
138
+ return {
139
+ changed,
140
+ ignored,
141
+ rootWide,
142
+ workspaces: graph.filter((node) => reached.has(node.name)).sort(byName),
143
+ };
144
+ }
145
+
146
+ /**
147
+ * How the calling command spells this scoping, so a `fix:` re-runs the invocation that actually
148
+ * failed. `x test --base main` is refused by `x test` itself — without `--affected` the flag
149
+ * narrows nothing — so a fix line that dropped it would reproduce its own failure, verbatim.
150
+ */
151
+ const invocationOf = (command: string): string =>
152
+ command === 'affected' ? 'x affected' : `x ${command} --affected`;
153
+
154
+ export interface AffectedSelection {
155
+ readonly base: string;
156
+ readonly dirty: boolean;
157
+ }
158
+
159
+ /**
160
+ * One reader for `--base` and `--dirty`, shared by `x affected` and `x test --affected`: two
161
+ * readers would be two answers to "what is this diff measured against", and the second command's
162
+ * scoping is only trustworthy if it is the first command's.
163
+ */
164
+ export function readAffectedSelection(args: ParsedArgs, command: string): AffectedSelection {
165
+ const base = flagString(args, 'base') ?? DEFAULT_BASE;
166
+ if (base.trim().length === 0) {
167
+ throw new BadFlagError({
168
+ flag: 'base',
169
+ command,
170
+ reason: 'needs a git ref and got an empty value',
171
+ fix: `${invocationOf(command)} --base ${DEFAULT_BASE} --json`,
172
+ });
173
+ }
174
+ return { base, dirty: flagBool(args, 'dirty') };
175
+ }
176
+
177
+ const git = (runner: Runner, cwd: string, args: readonly string[]): Promise<ExecResult> =>
178
+ runner(['git', ...args], { cwd });
179
+
180
+ /** NUL-delimited, so a path holding a space, a quote or a newline survives the read intact. */
181
+ const paths = (stdout: string): readonly string[] =>
182
+ stdout.split('\0').filter((path) => path.length > 0);
183
+
184
+ /**
185
+ * The checkout's own root, which is what every path git prints is relative to — so it is also the
186
+ * root the workspace graph has to be read from, or a `packages/cli/...` path would be matched
187
+ * against dirs resolved somewhere else.
188
+ */
189
+ export async function gitRoot(runner: Runner, cwd: string, command: string): Promise<string> {
190
+ const result = await git(runner, cwd, ['rev-parse', '--show-toplevel']);
191
+ if (result.ok) return result.stdout.trim();
192
+ throw new UltimateError({
193
+ code: 'X_CLI_UNEXPECTED',
194
+ cause: `x ${command} reads its diff from git and "git rev-parse --show-toplevel" exited ${result.code} in ${cwd}: ${singleLine(execOutput(result))}`,
195
+ fix: `run x ${command} from inside a git checkout — confirm with: git rev-parse --show-toplevel`,
196
+ docs: docsFor('X_CLI_UNEXPECTED'),
197
+ });
198
+ }
199
+
200
+ export interface ChangedFilesOptions {
201
+ readonly cwd: string;
202
+ readonly command: string;
203
+ readonly selection: AffectedSelection;
204
+ }
205
+
206
+ /**
207
+ * `<base>...HEAD` — three dots, so the answer is "what this branch changed since it forked",
208
+ * never "how this branch differs from a base that has moved on underneath it". A two-dot diff
209
+ * reports someone else's merged commits as this branch's work.
210
+ *
211
+ * `--dirty` unions the working tree on top: tracked edits against HEAD, plus untracked files that
212
+ * are not ignored. Both halves are needed — a brand-new file is invisible to `git diff`.
213
+ */
214
+ export async function changedFiles(
215
+ runner: Runner,
216
+ options: ChangedFilesOptions,
217
+ ): Promise<readonly string[]> {
218
+ const { base, dirty } = options.selection;
219
+ const resolved = await git(runner, options.cwd, [
220
+ 'rev-parse',
221
+ '--verify',
222
+ '--quiet',
223
+ `${base}^{commit}`,
224
+ ]);
225
+ if (!resolved.ok) {
226
+ throw new BadFlagError({
227
+ flag: 'base',
228
+ command: options.command,
229
+ reason: `git resolves no commit named "${base}" in this checkout`,
230
+ fix: `git fetch --no-tags origin ${base}:${base}, then re-run: ${invocationOf(options.command)} --base ${base} --json`,
231
+ });
232
+ }
233
+ const runs = [
234
+ await git(runner, options.cwd, ['diff', '--name-only', '-z', `${base}...HEAD`]),
235
+ ...(dirty
236
+ ? [
237
+ await git(runner, options.cwd, ['diff', '--name-only', '-z', 'HEAD']),
238
+ await git(runner, options.cwd, ['ls-files', '-z', '--others', '--exclude-standard']),
239
+ ]
240
+ : []),
241
+ ];
242
+ const failed = runs.find((run) => !run.ok);
243
+ if (failed !== undefined) {
244
+ throw new UltimateError({
245
+ code: 'X_CLI_UNEXPECTED',
246
+ cause: `"${failed.command.join(' ')}" exited ${failed.code} in ${options.cwd}: ${singleLine(execOutput(failed))}`,
247
+ fix: `run it yourself to see why: ${failed.command.join(' ')}`,
248
+ docs: docsFor('X_CLI_UNEXPECTED'),
249
+ });
250
+ }
251
+ return [...new Set(runs.flatMap((run) => paths(run.stdout)))].sort();
252
+ }
253
+
254
+ /**
255
+ * One resolved answer, in the two shapes its two callers need: the `plan` (`x affected` reports
256
+ * it) and the `prefixes` (`x test --affected` selects with them). `plan.workspaces` holds dirs
257
+ * relative to the CHECKOUT; `prefixes` is the same set relative to the directory the caller scans,
258
+ * which is what its own paths are relative to.
259
+ */
260
+ export interface AffectedScope {
261
+ readonly selection: AffectedSelection;
262
+ /** The checkout root git reported every path against. */
263
+ readonly root: string;
264
+ readonly plan: AffectedPlan;
265
+ readonly prefixes: readonly string[];
266
+ }
267
+
268
+ /**
269
+ * An empty prefix means the scan root IS an affected workspace, so every path it yields is in
270
+ * scope; a prefix starting with `..` is a workspace outside that root, which has no file there to
271
+ * select and must not be allowed to collapse into a match-everything empty string.
272
+ */
273
+ const scopePrefixes = (root: string, cwd: string, dirs: readonly string[]): readonly string[] =>
274
+ dirs
275
+ .map((dir) => relative(cwd, join(root, dir)).split('\\').join('/'))
276
+ .filter((path) => !path.startsWith('..'));
277
+
278
+ export const inScope = (path: string, prefixes: readonly string[]): boolean =>
279
+ prefixes.some((prefix) => prefix === '' || path === prefix || path.startsWith(`${prefix}/`));
280
+
281
+ export interface AffectedScopeOptions {
282
+ readonly runner: Runner;
283
+ /** The directory the caller scans, which the prefixes come back relative to. */
284
+ readonly cwd: string;
285
+ readonly args: ParsedArgs;
286
+ readonly command: string;
287
+ }
288
+
289
+ /**
290
+ * The whole answer, resolved once: the diff, the graph, the closure and the prefixes. `x affected`
291
+ * and `x test --affected` both come through here, so the second can never scope a run differently
292
+ * from what the first reports — an invented scoping that misses a transitive dependent is a green
293
+ * checkmark on a broken repo, and two implementations is how one of them gets it wrong.
294
+ */
295
+ export async function affectedScope(options: AffectedScopeOptions): Promise<AffectedScope> {
296
+ const selection = readAffectedSelection(options.args, options.command);
297
+ const root = await gitRoot(options.runner, options.cwd, options.command);
298
+ const plan = planAffected(
299
+ await readWorkspaceGraph(root),
300
+ await changedFiles(options.runner, { cwd: root, command: options.command, selection }),
301
+ );
302
+ return {
303
+ selection,
304
+ root,
305
+ plan,
306
+ prefixes: scopePrefixes(
307
+ root,
308
+ options.cwd,
309
+ plan.workspaces.map((workspace) => workspace.dir),
310
+ ),
311
+ };
312
+ }
313
+
314
+ /** The scope as `--json` carries it, from whichever command narrowed by it. */
315
+ export const affectedScopeJson = (scope: AffectedScope): JsonValue => ({
316
+ base: scope.selection.base,
317
+ dirty: scope.selection.dirty,
318
+ changed: scope.plan.changed.length,
319
+ workspaces: scope.plan.workspaces.map((workspace) => workspace.dir),
320
+ });
@@ -0,0 +1,109 @@
1
+ // The app's own browser library, resolved from the app's own `node_modules` — never a dependency
2
+ // of this package. `@ultimat3/scraping` declares the launcher's shape structurally (`cdp-port.ts`)
3
+ // precisely so the framework can drive a browser without shipping one, and `x shot` is a CLI
4
+ // command holding to the same bargain: the app installs `puppeteer-core`, the CLI asks for it.
5
+
6
+ import { existsSync } from 'node:fs';
7
+ import { UltimateError } from '@ultimat3/core';
8
+ import type { CdpLauncherLike, ScrapeDriver } from '@ultimat3/scraping';
9
+ import { localBrowser } from '@ultimat3/scraping';
10
+ import { docsFor } from './error-codes';
11
+
12
+ /**
13
+ * The one library this works against. Playwright is not an alternative and is not a flag:
14
+ * `packages/scraping/src/cdp-port.ts` records that its `connectOverCDP` cannot perform the
15
+ * WebSocket upgrade under Bun (oven-sh/bun#9911), verified against puppeteer-core 25.8.0.
16
+ */
17
+ export const BROWSER_PACKAGE = 'puppeteer-core';
18
+
19
+ /** Where a browser binary is named when the flag does not name one. Read in this order. */
20
+ export const BROWSER_PATH_VARS = ['PUPPETEER_EXECUTABLE_PATH', 'CHROME_PATH'] as const;
21
+
22
+ /**
23
+ * A missing browser is an instruction, not a crash (axiom 4). The cause distinguishes the two
24
+ * shapes — nothing resolved, or something resolved that is not a launcher — while the fix is the
25
+ * same install either way, because both are answered by putting the real package in the app.
26
+ */
27
+ export class ShotBrowserMissingError extends UltimateError {
28
+ constructor(input: { root: string; detail: string }) {
29
+ super({
30
+ code: 'X_SHOT_BROWSER_MISSING',
31
+ cause: `x shot drives a real browser and ${BROWSER_PACKAGE} ${input.detail} from ${input.root}`,
32
+ // One literal, not `bun add -d ${BROWSER_PACKAGE}`: `fix-scan.ts` can only read a fix that IS
33
+ // one literal, and a fix line the gate cannot read is a fix line nothing holds to the
34
+ // contract. `browser-launcher.test.ts` pins it against the constant instead.
35
+ fix: 'bun add -d puppeteer-core',
36
+ docs: docsFor('X_SHOT_BROWSER_MISSING'),
37
+ meta: { root: input.root, package: BROWSER_PACKAGE },
38
+ });
39
+ }
40
+ }
41
+
42
+ /**
43
+ * Structural, because this is somebody else's module: a namespace object, a CJS `default`, or a
44
+ * transpiled interop wrapper are all shapes `import()` legitimately hands back, and only one
45
+ * question decides — is there a `launch` to call?
46
+ */
47
+ const launcherIn = (module: unknown): CdpLauncherLike | undefined => {
48
+ if (typeof module !== 'object' || module === null) return undefined;
49
+ const candidate = module as { launch?: unknown; default?: unknown };
50
+ if (typeof candidate.launch === 'function') return candidate as CdpLauncherLike;
51
+ // `module.exports.default = module.exports` is a real CJS interop shape, so the self-reference is
52
+ // refused rather than followed: one unbounded recursion here is a stack overflow instead of the
53
+ // instruction this whole function exists to produce.
54
+ if (candidate.default === undefined || candidate.default === candidate) return undefined;
55
+ return launcherIn(candidate.default);
56
+ };
57
+
58
+ export interface AppBrowserOptions {
59
+ readonly root: string;
60
+ /** `--browser`, then `PUPPETEER_EXECUTABLE_PATH`, then `CHROME_PATH`. */
61
+ readonly executablePath?: string | undefined;
62
+ /** Test seam: the resolver and the loader, so a test proves the refusal without an install. */
63
+ readonly resolve?: (specifier: string, from: string) => string;
64
+ readonly load?: (path: string) => Promise<unknown>;
65
+ }
66
+
67
+ /** The path a run will launch, or `undefined` for "let the library find its own". */
68
+ export const executablePathFrom = (
69
+ flag: string | undefined,
70
+ env: Readonly<Record<string, string | undefined>>,
71
+ ): string | undefined => {
72
+ if (flag !== undefined && flag.length > 0) return flag;
73
+ for (const name of BROWSER_PATH_VARS) {
74
+ const value = env[name];
75
+ if (value !== undefined && value.length > 0) return value;
76
+ }
77
+ return undefined;
78
+ };
79
+
80
+ /** True when a named executable is really there — a bad `--browser` is refused before a boot. */
81
+ export const browserBinaryExists = (path: string): boolean => existsSync(path);
82
+
83
+ /**
84
+ * The app's `puppeteer-core`, as a `ScrapeDriver`. Resolved FROM THE APP ROOT rather than from
85
+ * this module: `import('puppeteer-core')` here would find the CLI's own tree, which by design has
86
+ * no such dependency, and would answer "missing" for an app that installed it correctly.
87
+ */
88
+ export async function appBrowser(options: AppBrowserOptions): Promise<ScrapeDriver> {
89
+ const resolve = options.resolve ?? ((specifier, from) => Bun.resolveSync(specifier, from));
90
+ const load = options.load ?? ((path: string) => import(path) as Promise<unknown>);
91
+ let entry: string;
92
+ try {
93
+ entry = resolve(BROWSER_PACKAGE, options.root);
94
+ } catch {
95
+ throw new ShotBrowserMissingError({ root: options.root, detail: 'does not resolve' });
96
+ }
97
+ const launcher = launcherIn(await load(entry));
98
+ if (launcher === undefined) {
99
+ throw new ShotBrowserMissingError({
100
+ root: options.root,
101
+ detail: `resolved to ${entry}, which exports no launch()`,
102
+ });
103
+ }
104
+ return localBrowser({
105
+ launcher,
106
+ headless: true,
107
+ ...(options.executablePath === undefined ? {} : { executablePath: options.executablePath }),
108
+ });
109
+ }
package/src/ci-log.ts ADDED
Binary file