pi-lean-dimension 0.5.0 → 0.6.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/README.md +5 -3
- package/node_modules/pi-lean-host/AGENTS.md +13 -6
- package/node_modules/pi-lean-host/__tests__/api-learn-fetch-recipe.test.ts +282 -10
- package/node_modules/pi-lean-host/__tests__/api-learn-multi-file.test.ts +8 -16
- package/node_modules/pi-lean-host/__tests__/api-probe.test.ts +276 -279
- package/node_modules/pi-lean-host/__tests__/api-scaffold.test.ts +12 -33
- package/node_modules/pi-lean-host/__tests__/api-toggle.test.ts +14 -12
- package/node_modules/pi-lean-host/__tests__/bootstrap-command.test.ts +1 -7
- package/node_modules/pi-lean-host/__tests__/delete-command.test.ts +1 -12
- package/node_modules/pi-lean-host/__tests__/guide-catalog.test.ts +484 -0
- package/node_modules/pi-lean-host/__tests__/helpers.test.ts +0 -132
- package/node_modules/pi-lean-host/__tests__/oauth-command.test.ts +95 -0
- package/node_modules/pi-lean-host/__tests__/oauth-flow.test.ts +163 -0
- package/node_modules/pi-lean-host/__tests__/oauth-mint.test.ts +79 -6
- package/node_modules/pi-lean-host/__tests__/parse-api-guide.test.ts +4 -409
- package/node_modules/pi-lean-host/__tests__/response-spill.test.ts +0 -1
- package/node_modules/pi-lean-host/__tests__/secrets-command.test.ts +19 -35
- package/node_modules/pi-lean-host/__tests__/smoke.test.ts +1 -82
- package/node_modules/pi-lean-host/__tests__/ssrf-guard.test.ts +98 -0
- package/node_modules/pi-lean-host/__tests__/test-utils.ts +73 -0
- package/node_modules/pi-lean-host/__tests__/tools.test.ts +2 -518
- package/node_modules/pi-lean-host/__tests__/transport.test.ts +178 -31
- package/node_modules/pi-lean-host/__tests__/verify-command.test.ts +1 -8
- package/node_modules/pi-lean-host/__tests__/verify-stamp.test.ts +1 -4
- package/node_modules/pi-lean-host/api-guides/boe/local-helper.test.ts +9 -20
- package/node_modules/pi-lean-host/api-guides/dnb/error-envelope.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/dnb/resumption-token.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/frost-sensorthings/dotted-key.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/github/static-key.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/inaturalist/derived-id.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/internet-archive/multi-recipe.test.ts +6 -26
- package/node_modules/pi-lean-host/api-guides/stripe/has-more.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/telegram-bot/path-auth.test.ts +7 -25
- package/node_modules/pi-lean-host/api-guides/twitch/oauth2.test.ts +12 -47
- package/node_modules/pi-lean-host/api-guides/twitch-user/oauth-user.test.ts +11 -52
- package/node_modules/pi-lean-host/api-guides/usgs/transform.test.ts +10 -27
- package/node_modules/pi-lean-host/api-guides/wikidata-search/numeric-cursor.test.ts +6 -18
- package/node_modules/pi-lean-host/api-guides/wikimedia-action/token-bag.test.ts +9 -20
- package/node_modules/pi-lean-host/core/auth.ts +2 -1
- package/node_modules/pi-lean-host/core/helpers.ts +29 -35
- package/node_modules/pi-lean-host/core/oauth-command.ts +10 -8
- package/node_modules/pi-lean-host/core/oauth-flow.ts +20 -4
- package/node_modules/pi-lean-host/core/parse-api-guide.ts +3 -5
- package/node_modules/pi-lean-host/core/transport.ts +1 -1
- package/node_modules/pi-lean-host/package.json +1 -1
- package/node_modules/pi-lean-host/tools/api-guide.ts +18 -57
- package/node_modules/pi-lean-host/tools/api-learn.ts +20 -49
- package/node_modules/pi-lean-host/tools/api-probe.ts +17 -26
- package/node_modules/pi-lean-host/tools/api-scaffold.ts +20 -42
- package/node_modules/pi-lean-host/tools/utils.ts +69 -0
- package/node_modules/pi-lean-portal/AGENTS.md +4 -3
- package/node_modules/pi-lean-portal/README.md +9 -9
- package/node_modules/pi-lean-portal/__tests__/accessibility-tree.test.ts +0 -15
- package/node_modules/pi-lean-portal/__tests__/browser-install.test.ts +737 -0
- package/node_modules/pi-lean-portal/__tests__/browser-navigate.test.ts +33 -2
- package/node_modules/pi-lean-portal/__tests__/browser-status.test.ts +48 -25
- package/node_modules/pi-lean-portal/__tests__/browser-toggle-profile.test.ts +3 -9
- package/node_modules/pi-lean-portal/__tests__/browser-toggle.test.ts +3 -29
- package/node_modules/pi-lean-portal/__tests__/fetch-backend.test.ts +51 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/__pycache__/mock-python-bridge.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-pi.ts +34 -0
- package/node_modules/pi-lean-portal/__tests__/helpers/mock-python-bridge.py +14 -24
- package/node_modules/pi-lean-portal/__tests__/plugin-registry.test.ts +0 -90
- package/node_modules/pi-lean-portal/__tests__/python-adapter.test.ts +32 -125
- package/node_modules/pi-lean-portal/__tests__/router-dispatch.test.ts +12 -170
- package/node_modules/pi-lean-portal/__tests__/router-session.test.ts +39 -151
- package/node_modules/pi-lean-portal/__tests__/web-guides.test.ts +1 -44
- package/node_modules/pi-lean-portal/backends/chromium/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/firefox/index.ts +1 -1
- package/node_modules/pi-lean-portal/backends/playwright-base/playwright-plugin.ts +1 -1
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/playwright_base.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-312.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/__pycache__/transport.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/playwright_base.py +43 -57
- package/node_modules/pi-lean-portal/backends/python-base/pi_browser_bridge/transport.py +6 -21
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_accessibility.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_playwright_base_quirks.cpython-313.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/__pycache__/test_transport.cpython-313-pytest-9.1.1.pyc +0 -0
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_playwright_base_quirks.py +157 -73
- package/node_modules/pi-lean-portal/backends/python-base/tests/test_transport.py +15 -41
- package/node_modules/pi-lean-portal/browser-install.ts +454 -0
- package/node_modules/pi-lean-portal/browser-status.ts +21 -0
- package/node_modules/pi-lean-portal/browser-toggle.ts +13 -11
- package/node_modules/pi-lean-portal/core/fetch-backend.ts +2 -2
- package/node_modules/pi-lean-portal/core/router.ts +7 -32
- package/node_modules/pi-lean-portal/core/shared/session-manager.ts +1 -3
- package/node_modules/pi-lean-portal/index.ts +1 -18
- package/node_modules/pi-lean-portal/package.json +2 -1
- package/node_modules/pi-lean-portal/tools/browser-navigate.ts +6 -2
- package/node_modules/pi-lean-portal/tools/utils.ts +5 -6
- package/node_modules/pi-lean-search/AGENTS.md +5 -3
- package/node_modules/pi-lean-search/__tests__/web-search.test.ts +180 -127
- package/node_modules/pi-lean-search/index.ts +0 -3
- package/node_modules/pi-lean-search/package.json +1 -1
- package/node_modules/pi-lean-search/web-search-tool.ts +22 -13
- package/node_modules/yaml/browser/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/browser/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/dist/compose/resolve-flow-scalar.js +19 -18
- package/node_modules/yaml/dist/nodes/Alias.js +25 -23
- package/node_modules/yaml/package.json +1 -1
- package/package.json +4 -4
|
@@ -0,0 +1,454 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser Install — `/web install` subcommand.
|
|
3
|
+
*
|
|
4
|
+
* The `playwright` npm package ships no browser binaries, and the generic
|
|
5
|
+
* `npx playwright install` advice can resolve a different playwright copy
|
|
6
|
+
* than the one the backends import — installing revisions the backends
|
|
7
|
+
* don't match. This command resolves the *bundled* playwright CLI (the same
|
|
8
|
+
* copy the backends use) and downloads engines from it, guaranteeing
|
|
9
|
+
* revision parity.
|
|
10
|
+
*
|
|
11
|
+
* Forms:
|
|
12
|
+
* /web install — checkbox dialog (TUI only; other modes print
|
|
13
|
+
* the manual bundled-CLI command)
|
|
14
|
+
* /web install chromium — direct download, no dialog (TUI + RPC spawn;
|
|
15
|
+
* print/JSON modes print the manual command)
|
|
16
|
+
*
|
|
17
|
+
* Never throws: resolution, detection, and download failures all fall back
|
|
18
|
+
* to printing a manual command. Removal is intentionally out of scope —
|
|
19
|
+
* `playwright uninstall` takes no per-engine arguments and the cache
|
|
20
|
+
* directory is machine-global; document the real CLI instead.
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { spawn } from "node:child_process";
|
|
24
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
25
|
+
import { createRequire } from "node:module";
|
|
26
|
+
import { basename, dirname, join } from "node:path";
|
|
27
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
28
|
+
import {
|
|
29
|
+
Container,
|
|
30
|
+
type SelectItem,
|
|
31
|
+
SelectList,
|
|
32
|
+
Text,
|
|
33
|
+
} from "@earendil-works/pi-tui";
|
|
34
|
+
|
|
35
|
+
// ─── Engines ─────────────────────────────────────────────────────
|
|
36
|
+
|
|
37
|
+
export const ENGINES = ["chromium", "firefox"] as const;
|
|
38
|
+
export type Engine = (typeof ENGINES)[number];
|
|
39
|
+
|
|
40
|
+
/** Rough per-engine download sizes, for the dialog rows. */
|
|
41
|
+
const ENGINE_SIZES = {
|
|
42
|
+
chromium: "~170 MB",
|
|
43
|
+
firefox: "~90 MB",
|
|
44
|
+
} as const;
|
|
45
|
+
|
|
46
|
+
// ─── Bundled playwright resolution ───────────────────────────────
|
|
47
|
+
|
|
48
|
+
export interface BundledPlaywright {
|
|
49
|
+
/** The playwright module the backends import (same copy, same revisions). */
|
|
50
|
+
pw: typeof import("playwright");
|
|
51
|
+
/** Absolute path to the bundled playwright CLI entry. */
|
|
52
|
+
cliJs: string;
|
|
53
|
+
/** Node interpreter for spawning the CLI. */
|
|
54
|
+
nodeExe: string;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Node interpreter for spawning the bundled CLI. `process.execPath` is only
|
|
59
|
+
* a Node binary under the npm-shim distribution; pi also ships a
|
|
60
|
+
* bun-compiled standalone binary where `process.execPath` is the pi binary
|
|
61
|
+
* itself — spawning it with playwright's args re-runs pi, not the CLI. The
|
|
62
|
+
* `.exe` strip handles Windows (`...\node.exe` there); on POSIX the replace
|
|
63
|
+
* is a no-op.
|
|
64
|
+
*/
|
|
65
|
+
export function resolveNodeInterpreter(): string {
|
|
66
|
+
return basename(process.execPath).replace(/\.exe$/i, "") === "node"
|
|
67
|
+
? process.execPath
|
|
68
|
+
: "node";
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/**
|
|
72
|
+
* Resolve the bundled playwright module + CLI path. Throws if playwright is
|
|
73
|
+
* missing entirely (corrupted install) — callers must catch and fall back to
|
|
74
|
+
* the generic manual command. Must be called lazily (inside the command
|
|
75
|
+
* handler), never at module top level: this module is imported by the `/web`
|
|
76
|
+
* dispatcher, and a throwing top-level require would break the entire `/web`
|
|
77
|
+
* command in exactly the corrupt-install scenario this command exists for.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveBundledPlaywright(): BundledPlaywright {
|
|
80
|
+
const req = createRequire(import.meta.url);
|
|
81
|
+
const pw = req("playwright");
|
|
82
|
+
// `playwright/package.json` is exported; `playwright/cli.js` is not —
|
|
83
|
+
// resolving the CLI subpath directly throws ERR_PACKAGE_PATH_NOT_EXPORTED.
|
|
84
|
+
const pkgDir = dirname(req.resolve("playwright/package.json"));
|
|
85
|
+
return {
|
|
86
|
+
pw,
|
|
87
|
+
cliJs: join(pkgDir, "cli.js"),
|
|
88
|
+
nodeExe: resolveNodeInterpreter(),
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// ─── Installed-browser detection (no launch) ─────────────────────
|
|
93
|
+
|
|
94
|
+
export interface BrowserDetection {
|
|
95
|
+
chromium: boolean;
|
|
96
|
+
firefox: boolean;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Detect installed Node-backend browsers without launching. Unexpected
|
|
101
|
+
* layouts degrade to `missing` — the safe direction, since re-running
|
|
102
|
+
* install is harmless.
|
|
103
|
+
*/
|
|
104
|
+
export function detectInstalledBrowsers(
|
|
105
|
+
pw: typeof import("playwright"),
|
|
106
|
+
): BrowserDetection {
|
|
107
|
+
return { chromium: detectChromium(pw), firefox: detectFirefox(pw) };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Chromium detection is keyed on the headless-shell binary, not the full
|
|
112
|
+
* browser: portal launches headless, and headless launches use
|
|
113
|
+
* `chromium_headless_shell-<rev>`, not the full `chromium-<rev>` binary that
|
|
114
|
+
* `executablePath()` reports — keying on the full browser would report
|
|
115
|
+
* "downloaded" in exactly the broken-cache scenario this command fixes
|
|
116
|
+
* (full chromium present, shell missing).
|
|
117
|
+
*
|
|
118
|
+
* ponytail: the directory-name transform is a heuristic on the documented
|
|
119
|
+
* cache layout (`chromium-<rev>/` ↔ `chromium_headless_shell-<rev>/`, same
|
|
120
|
+
* parent); the shell revision comes from playwright-core's `browsers.json`,
|
|
121
|
+
* not the chromium path, so the two may safely diverge. Correctness rests on
|
|
122
|
+
* the `INSTALLATION_COMPLETE` marker (what `playwright install` writes as
|
|
123
|
+
* its final step), which is immune to inner-layout renames. If playwright
|
|
124
|
+
* renames the shell *directory*, detection degrades to "missing" (safe) —
|
|
125
|
+
* resolve the shell directory through the registry only if that misfires.
|
|
126
|
+
*/
|
|
127
|
+
function detectChromium(pw: typeof import("playwright")): boolean {
|
|
128
|
+
try {
|
|
129
|
+
const exec = pw.chromium.executablePath();
|
|
130
|
+
const revMatch = /([/\\])chromium-(\d+)[/\\]/.exec(exec);
|
|
131
|
+
if (!revMatch) return false;
|
|
132
|
+
let shellRev = revMatch[2];
|
|
133
|
+
try {
|
|
134
|
+
const req = createRequire(import.meta.url);
|
|
135
|
+
const coreDir = dirname(req.resolve("playwright-core/package.json"));
|
|
136
|
+
const browsers = JSON.parse(
|
|
137
|
+
readFileSync(join(coreDir, "browsers.json"), "utf8"),
|
|
138
|
+
) as { browsers: Array<{ name: string; revision: number | string }> };
|
|
139
|
+
const entry = browsers.browsers.find(
|
|
140
|
+
(b) => b.name === "chromium-headless-shell",
|
|
141
|
+
);
|
|
142
|
+
// Independent revision field from chromium's — read the shell's own,
|
|
143
|
+
// fall back to the chromium revision if the read/entry fails.
|
|
144
|
+
if (entry) shellRev = String(entry.revision);
|
|
145
|
+
} catch {
|
|
146
|
+
/* keep chromium-revision fallback */
|
|
147
|
+
}
|
|
148
|
+
const shellDir = exec.replace(
|
|
149
|
+
/([/\\])chromium-\d+[/\\].*/,
|
|
150
|
+
`$1chromium_headless_shell-${shellRev}`,
|
|
151
|
+
);
|
|
152
|
+
// replace() returning the input means the layout didn't match.
|
|
153
|
+
return (
|
|
154
|
+
shellDir !== exec && existsSync(join(shellDir, "INSTALLATION_COMPLETE"))
|
|
155
|
+
);
|
|
156
|
+
} catch {
|
|
157
|
+
return false;
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Firefox has no headless-shell split — one binary. Same
|
|
163
|
+
* INSTALLATION_COMPLETE marker as chromium: a partial download that has
|
|
164
|
+
* extracted the executable but not finished would otherwise false-report
|
|
165
|
+
* "downloaded".
|
|
166
|
+
*/
|
|
167
|
+
function detectFirefox(pw: typeof import("playwright")): boolean {
|
|
168
|
+
try {
|
|
169
|
+
const exec = pw.firefox.executablePath();
|
|
170
|
+
const dirMatch = /[/\\]firefox-\d+[/\\]/.exec(exec);
|
|
171
|
+
if (!dirMatch) return false;
|
|
172
|
+
return existsSync(
|
|
173
|
+
join(
|
|
174
|
+
exec.slice(0, dirMatch.index + dirMatch[0].length),
|
|
175
|
+
"INSTALLATION_COMPLETE",
|
|
176
|
+
),
|
|
177
|
+
);
|
|
178
|
+
} catch {
|
|
179
|
+
return false;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ─── Manual-command fallback ─────────────────────────────────────
|
|
184
|
+
|
|
185
|
+
function manualCommand(bundled: BundledPlaywright, engines: Engine[]): string {
|
|
186
|
+
return `Install: ${bundled.nodeExe} ${bundled.cliJs} install ${engines.join(" ")}`;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
function genericManualCommand(): string {
|
|
190
|
+
// Bundled path unresolvable — only the generic advice remains.
|
|
191
|
+
return "Install: npx playwright install chromium firefox";
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Emit a manual-command fallback everywhere a feedback channel exists.
|
|
196
|
+
* `notify` reaches the TUI and RPC clients but is a no-op in print/JSON
|
|
197
|
+
* (their UI context is a no-op stub), where stderr is the only channel —
|
|
198
|
+
* write there too so those modes still print the command.
|
|
199
|
+
*/
|
|
200
|
+
function emitManualCommand(ctx: ExtensionContext, msg: string): void {
|
|
201
|
+
ctx.ui.notify(msg, "info");
|
|
202
|
+
if (!ctx.hasUI) process.stderr.write(`${msg}\n`);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// ─── Checklist dialog (copied from pi-lean-host select-picker.ts) ─
|
|
206
|
+
//
|
|
207
|
+
// pi's ctx.ui.select is single-choice and portal must not depend on host,
|
|
208
|
+
// so pickChecklist is copied here. Keep the dialog rendering visually
|
|
209
|
+
// identical to the host copy via the shared pickerTheme shape; the
|
|
210
|
+
// behavioral deltas are intentional: an Install confirm row, pre-checked
|
|
211
|
+
// rows, and an empty-selection guard (host resolves empty as []). UX
|
|
212
|
+
// changes should propagate to
|
|
213
|
+
// packages/pi-lean-host/core/select-picker.ts.
|
|
214
|
+
|
|
215
|
+
/** Shared SelectList theme callbacks — keep both pickers visually identical. */
|
|
216
|
+
function pickerTheme(theme: { fg(color: string, text: string): string }) {
|
|
217
|
+
return {
|
|
218
|
+
selectedPrefix: (text: string) => theme.fg("accent", text),
|
|
219
|
+
selectedText: (text: string) => theme.fg("accent", text),
|
|
220
|
+
description: (text: string) => theme.fg("muted", text),
|
|
221
|
+
scrollInfo: (text: string) => theme.fg("dim", text),
|
|
222
|
+
noMatch: (text: string) => theme.fg("warning", text),
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
const INSTALL_VALUE = "__install__";
|
|
227
|
+
const INSTALL_TITLE = "🌐 Select browsers to install";
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Multi-select checklist: Enter toggles ✓/○, Enter on the Install row
|
|
231
|
+
* resolves the checked values, Esc returns undefined. Confirming with
|
|
232
|
+
* nothing checked notifies and keeps the dialog open. TUI-only — callers
|
|
233
|
+
* must guard with `ctx.mode === "tui"`.
|
|
234
|
+
*/
|
|
235
|
+
export async function pickBrowsersToInstall(
|
|
236
|
+
ctx: ExtensionContext,
|
|
237
|
+
rows: SelectItem[],
|
|
238
|
+
preChecked: string[],
|
|
239
|
+
): Promise<string[] | undefined> {
|
|
240
|
+
const checked = new Set<string>(preChecked);
|
|
241
|
+
return ctx.ui.custom<string[] | undefined>((tui, theme, _kb, done) => {
|
|
242
|
+
const container = new Container();
|
|
243
|
+
let selectedIndex = 0;
|
|
244
|
+
let list = buildList();
|
|
245
|
+
|
|
246
|
+
function buildList(): SelectList {
|
|
247
|
+
const selectItems: SelectItem[] = rows.map((r) => ({
|
|
248
|
+
...r,
|
|
249
|
+
label: `${checked.has(r.value) ? "✓" : "○"} ${r.label}`,
|
|
250
|
+
}));
|
|
251
|
+
selectItems.push({
|
|
252
|
+
value: INSTALL_VALUE,
|
|
253
|
+
label: "Install — download the checked browsers",
|
|
254
|
+
});
|
|
255
|
+
const fresh = new SelectList(
|
|
256
|
+
selectItems,
|
|
257
|
+
Math.min(selectItems.length, 12),
|
|
258
|
+
pickerTheme(theme),
|
|
259
|
+
);
|
|
260
|
+
fresh.setSelectedIndex(Math.min(selectedIndex, selectItems.length - 1));
|
|
261
|
+
fresh.onSelectionChange = (item) => {
|
|
262
|
+
const at = selectItems.findIndex((s) => s.value === item.value);
|
|
263
|
+
if (at >= 0) selectedIndex = at;
|
|
264
|
+
};
|
|
265
|
+
fresh.onSelect = (item) => {
|
|
266
|
+
if (item.value === INSTALL_VALUE) {
|
|
267
|
+
if (checked.size === 0) {
|
|
268
|
+
ctx.ui.notify(
|
|
269
|
+
"Nothing selected — toggle a browser row first.",
|
|
270
|
+
"warning",
|
|
271
|
+
);
|
|
272
|
+
return;
|
|
273
|
+
}
|
|
274
|
+
done([...checked]);
|
|
275
|
+
return;
|
|
276
|
+
}
|
|
277
|
+
if (checked.has(item.value)) checked.delete(item.value);
|
|
278
|
+
else checked.add(item.value);
|
|
279
|
+
// Rebuild so the ✓/○ prefixes re-render; the highlight follows
|
|
280
|
+
// the tracked index across the swap.
|
|
281
|
+
container.removeChild(list);
|
|
282
|
+
list = buildList();
|
|
283
|
+
container.addChild(list);
|
|
284
|
+
// addChild appends — keep the footer below the rebuilt list.
|
|
285
|
+
container.removeChild(footer);
|
|
286
|
+
container.addChild(footer);
|
|
287
|
+
};
|
|
288
|
+
fresh.onCancel = () => done(undefined);
|
|
289
|
+
return fresh;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
container.addChild(new Text(theme.fg("accent", theme.bold(INSTALL_TITLE))));
|
|
293
|
+
container.addChild(list);
|
|
294
|
+
const footer = new Text(
|
|
295
|
+
theme.fg("dim", "↑↓ navigate • enter toggle/select • esc cancel"),
|
|
296
|
+
);
|
|
297
|
+
container.addChild(footer);
|
|
298
|
+
return {
|
|
299
|
+
render(width: number) {
|
|
300
|
+
return container.render(width);
|
|
301
|
+
},
|
|
302
|
+
invalidate() {
|
|
303
|
+
container.invalidate();
|
|
304
|
+
},
|
|
305
|
+
handleInput(data: string) {
|
|
306
|
+
list.handleInput(data);
|
|
307
|
+
tui.requestRender();
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
// ─── Spawn & stream ──────────────────────────────────────────────
|
|
314
|
+
|
|
315
|
+
/**
|
|
316
|
+
* Run the bundled playwright CLI install for the given engines, streaming
|
|
317
|
+
* output into a widget above the editor. Never throws — spawn errors and non-zero
|
|
318
|
+
* exits notify with the manual command instead.
|
|
319
|
+
*/
|
|
320
|
+
async function runInstall(
|
|
321
|
+
ctx: ExtensionContext,
|
|
322
|
+
bundled: BundledPlaywright,
|
|
323
|
+
engines: Engine[],
|
|
324
|
+
): Promise<void> {
|
|
325
|
+
const manual = manualCommand(bundled, engines);
|
|
326
|
+
await new Promise<void>((resolve) => {
|
|
327
|
+
let out = "";
|
|
328
|
+
const child = spawn(bundled.nodeExe, [bundled.cliJs, "install", ...engines], {
|
|
329
|
+
stdio: "pipe",
|
|
330
|
+
});
|
|
331
|
+
// Bound the accumulated output: a long install must not grow memory
|
|
332
|
+
// without limit or re-split the whole buffer per chunk. The tail is
|
|
333
|
+
// enough — the host-validation warning is emitted near the end.
|
|
334
|
+
const MAX_TAIL = 64 * 1024;
|
|
335
|
+
// Show the last few download lines in a widget above the editor instead
|
|
336
|
+
// of the status bar: full progress visibility without crowding the
|
|
337
|
+
// footer. Piped (non-TTY) playwright prints a newline-terminated 10%-step
|
|
338
|
+
// progress line, so plain line splitting is enough; \r is handled for
|
|
339
|
+
// safety in case output shape changes.
|
|
340
|
+
const WIDGET_LINES = 8;
|
|
341
|
+
const tail = (chunk: Buffer) => {
|
|
342
|
+
out = (out + chunk.toString()).slice(-MAX_TAIL);
|
|
343
|
+
// ponytail: no progress-bar parsing — playwright's output format is
|
|
344
|
+
// not a stable API; the raw tail lines are enough.
|
|
345
|
+
const lines = out.trimEnd().split(/\r\n|\r|\n/);
|
|
346
|
+
ctx.ui.setWidget("install", [
|
|
347
|
+
`⬇ Installing ${engines.join(", ")}…`,
|
|
348
|
+
...lines.slice(-WIDGET_LINES).map((l) => ` ${l.trim().slice(0, 120)}`),
|
|
349
|
+
]);
|
|
350
|
+
};
|
|
351
|
+
child.stdout?.on("data", tail);
|
|
352
|
+
child.stderr?.on("data", tail);
|
|
353
|
+
// Node can fire both `error` and `close` for one failed spawn — settle
|
|
354
|
+
// once so the user gets a single notification.
|
|
355
|
+
let settled = false;
|
|
356
|
+
const finish = (notifyMsg: string, type: "info" | "warning") => {
|
|
357
|
+
if (settled) return;
|
|
358
|
+
settled = true;
|
|
359
|
+
ctx.ui.setWidget("install", undefined);
|
|
360
|
+
ctx.ui.notify(notifyMsg, type);
|
|
361
|
+
resolve();
|
|
362
|
+
};
|
|
363
|
+
child.on("error", (err) => {
|
|
364
|
+
// ENOENT, non-Node runtime with no `node` on PATH, etc.
|
|
365
|
+
finish(`Browser install failed: ${err.message}\n${manual}`, "warning");
|
|
366
|
+
});
|
|
367
|
+
child.on("close", (code) => {
|
|
368
|
+
if (code === 0) {
|
|
369
|
+
let msg = `✅ Installed: ${engines.join(", ")}`;
|
|
370
|
+
// The multi-line host-validation warning scrolls past in status
|
|
371
|
+
// text on exactly the fresh-Linux-host case where it matters —
|
|
372
|
+
// surface it in the completion notify instead. Still no
|
|
373
|
+
// --with-deps (needs root/apt): the warning names the exact
|
|
374
|
+
// install-deps command.
|
|
375
|
+
if (out.includes("Playwright Host validation warning")) {
|
|
376
|
+
msg +=
|
|
377
|
+
"\n⚠ Playwright reported missing host dependencies — run the install-deps command it printed (needs root).";
|
|
378
|
+
}
|
|
379
|
+
finish(msg, "info");
|
|
380
|
+
} else {
|
|
381
|
+
finish(`Browser install failed (exit code ${code}).\n${manual}`, "warning");
|
|
382
|
+
}
|
|
383
|
+
});
|
|
384
|
+
});
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ─── Handler ─────────────────────────────────────────────────────
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Handle the `/web install` subcommand.
|
|
391
|
+
*
|
|
392
|
+
* @param sub The text after "install" ("" | "chromium" | "firefox")
|
|
393
|
+
*/
|
|
394
|
+
export async function handleInstallSubcommand(
|
|
395
|
+
sub: string,
|
|
396
|
+
ctx: ExtensionContext,
|
|
397
|
+
): Promise<void> {
|
|
398
|
+
const arg = sub.trim().toLowerCase();
|
|
399
|
+
const direct: Engine[] | null =
|
|
400
|
+
arg === "chromium" || arg === "firefox" ? [arg] : null;
|
|
401
|
+
if (arg !== "" && !direct) {
|
|
402
|
+
ctx.ui.notify(
|
|
403
|
+
`Unknown install sub-command: "${arg}". Usage: /web install [chromium|firefox]`,
|
|
404
|
+
"warning",
|
|
405
|
+
);
|
|
406
|
+
return;
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Lazy resolution — see resolveBundledPlaywright. On failure the bundled
|
|
410
|
+
// path doesn't exist to print, so only the generic command remains.
|
|
411
|
+
let bundled: BundledPlaywright;
|
|
412
|
+
try {
|
|
413
|
+
bundled = resolveBundledPlaywright();
|
|
414
|
+
} catch {
|
|
415
|
+
emitManualCommand(ctx, genericManualCommand());
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
if (direct) {
|
|
420
|
+
// Direct form: spawn wherever a feedback channel exists — TUI and RPC
|
|
421
|
+
// (notify reaches the RPC client, so at least the completion message is
|
|
422
|
+
// observable); in print/JSON they're no-ops and the ~170 MB download
|
|
423
|
+
// would appear to hang, so print the manual command instead.
|
|
424
|
+
if (ctx.hasUI) {
|
|
425
|
+
await runInstall(ctx, bundled, direct);
|
|
426
|
+
} else {
|
|
427
|
+
emitManualCommand(ctx, manualCommand(bundled, direct));
|
|
428
|
+
}
|
|
429
|
+
return;
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// Bare form: dialog in TUI only; every other mode (RPC included — the
|
|
433
|
+
// checklist dialog does not exist outside TUI) prints the manual command.
|
|
434
|
+
if (ctx.mode !== "tui") {
|
|
435
|
+
emitManualCommand(ctx, manualCommand(bundled, [...ENGINES]));
|
|
436
|
+
return;
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// The dialog always opens, even when detection reports both downloaded:
|
|
440
|
+
// it cannot see a corrupted binary behind a valid-looking cache marker,
|
|
441
|
+
// so a forced redownload must stay reachable (downloaded rows are
|
|
442
|
+
// toggleable; missing ones come pre-checked).
|
|
443
|
+
const detection = detectInstalledBrowsers(bundled.pw);
|
|
444
|
+
const rows: SelectItem[] = ENGINES.map((engine) => ({
|
|
445
|
+
value: engine,
|
|
446
|
+
label: `${engine === "chromium" ? "Chromium" : "Firefox"} (${ENGINE_SIZES[engine]}) — ${detection[engine] ? "downloaded" : "missing"}`,
|
|
447
|
+
}));
|
|
448
|
+
const preChecked = ENGINES.filter((e) => !detection[e]);
|
|
449
|
+
|
|
450
|
+
const checked = await pickBrowsersToInstall(ctx, rows, preChecked);
|
|
451
|
+
if (checked === undefined) return; // Esc — user cancelled
|
|
452
|
+
if (checked.length === 0) return; // guarded in the dialog; defensive no-op
|
|
453
|
+
await runInstall(ctx, bundled, checked as Engine[]);
|
|
454
|
+
}
|
|
@@ -10,6 +10,11 @@ import { sessionManager } from "./core/shared/session-manager.js";
|
|
|
10
10
|
import { pluginRegistry } from "./core/plugin-registry.js";
|
|
11
11
|
import { listProfiles } from "./browser-profile.js";
|
|
12
12
|
import { isSessionProfile } from "./core/shared/storage-state.js";
|
|
13
|
+
import {
|
|
14
|
+
ENGINES,
|
|
15
|
+
detectInstalledBrowsers,
|
|
16
|
+
resolveBundledPlaywright,
|
|
17
|
+
} from "./browser-install.js";
|
|
13
18
|
|
|
14
19
|
/**
|
|
15
20
|
* Show detailed browser runtime status.
|
|
@@ -41,6 +46,22 @@ export function handleStatusSubcommand(
|
|
|
41
46
|
}
|
|
42
47
|
}
|
|
43
48
|
msg += `\nPlugins: ${backendLines.join(", ")}`;
|
|
49
|
+
|
|
50
|
+
// Browser-binary presence for the Node backends, via the same detection
|
|
51
|
+
// /web install uses. Resolution failure (corrupt install) just omits the
|
|
52
|
+
// line — /web install reports the fallback command in that case.
|
|
53
|
+
try {
|
|
54
|
+
const bundled = resolveBundledPlaywright();
|
|
55
|
+
const detected = detectInstalledBrowsers(bundled.pw);
|
|
56
|
+
const states = ENGINES.map((e) => `${e} ${detected[e] ? "✓" : "✗"}`).join(
|
|
57
|
+
" ",
|
|
58
|
+
);
|
|
59
|
+
const anyMissing = ENGINES.some((e) => !detected[e]);
|
|
60
|
+
msg += `\nBrowsers: ${states}${anyMissing ? " (run /web install)" : ""}`;
|
|
61
|
+
} catch {
|
|
62
|
+
/* playwright unresolvable — omit */
|
|
63
|
+
}
|
|
64
|
+
|
|
44
65
|
msg += `\nUse web-fetch for stateless HTTP fetches.`;
|
|
45
66
|
|
|
46
67
|
if (active.length > 0) {
|
|
@@ -2,7 +2,7 @@ import type {
|
|
|
2
2
|
ExtensionAPI,
|
|
3
3
|
ExtensionContext,
|
|
4
4
|
} from "@earendil-works/pi-coding-agent";
|
|
5
|
-
import { updateFooterStatus } from "./tools/utils.js";
|
|
5
|
+
import { updateFooterStatus, getLastCtx, setLastCtx } from "./tools/utils.js";
|
|
6
6
|
import {
|
|
7
7
|
defineToolset,
|
|
8
8
|
TOOLSET_EVENTS,
|
|
@@ -52,9 +52,6 @@ let _lastToggleState = true;
|
|
|
52
52
|
/** @internal Last known learn state for status bar coloring. */
|
|
53
53
|
let _lastLearnState = false;
|
|
54
54
|
|
|
55
|
-
/** @internal Last captured ExtensionContext for event-driven status-bar rendering. */
|
|
56
|
-
let _lastCtx: ExtensionContext | null = null;
|
|
57
|
-
|
|
58
55
|
export function getToggleState(): boolean {
|
|
59
56
|
return _lastToggleState;
|
|
60
57
|
}
|
|
@@ -105,7 +102,6 @@ function restoreProfile(_pi: ExtensionAPI, ctx: ExtensionContext): void {
|
|
|
105
102
|
export function resetToggleModuleState(): void {
|
|
106
103
|
_lastToggleState = true;
|
|
107
104
|
_lastLearnState = false;
|
|
108
|
-
_lastCtx = null;
|
|
109
105
|
_conversationDefaultProfile = undefined;
|
|
110
106
|
}
|
|
111
107
|
|
|
@@ -125,8 +121,9 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
125
121
|
const syncCachedState = () => {
|
|
126
122
|
_lastToggleState = webToolset.isEnabled(pi);
|
|
127
123
|
_lastLearnState = learnToolset.isEnabled(pi);
|
|
128
|
-
|
|
129
|
-
|
|
124
|
+
const ctx = getLastCtx();
|
|
125
|
+
if (ctx) {
|
|
126
|
+
updateFooterStatus(ctx);
|
|
130
127
|
}
|
|
131
128
|
};
|
|
132
129
|
|
|
@@ -137,7 +134,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
137
134
|
pi.registerCommand("web", {
|
|
138
135
|
description:
|
|
139
136
|
"Enable/disable browser automation tools. " +
|
|
140
|
-
"Usage: /web on | off | learn | status",
|
|
137
|
+
"Usage: /web on | off | learn | install | status",
|
|
141
138
|
handler: async (args, ctx) => {
|
|
142
139
|
const cmd = args.trim().toLowerCase();
|
|
143
140
|
|
|
@@ -187,6 +184,10 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
187
184
|
const sub = cmd.slice("cookies".length).trim();
|
|
188
185
|
const { handleCookiesSubcommand } = await import("./browser-cookies.js");
|
|
189
186
|
await handleCookiesSubcommand(sub, ctx);
|
|
187
|
+
} else if (cmd === "install" || cmd.startsWith("install ")) {
|
|
188
|
+
const sub = cmd.slice("install".length).trim();
|
|
189
|
+
const { handleInstallSubcommand } = await import("./browser-install.js");
|
|
190
|
+
await handleInstallSubcommand(sub, ctx);
|
|
190
191
|
} else if (cmd === "status") {
|
|
191
192
|
const { handleStatusSubcommand } = await import("./browser-status.js");
|
|
192
193
|
handleStatusSubcommand(
|
|
@@ -203,6 +204,7 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
203
204
|
`📖 Learn mode: ${learnOn}\n` +
|
|
204
205
|
` /web profile manage browser profiles\n` +
|
|
205
206
|
` /web cookies inspect or clear session cookies\n` +
|
|
207
|
+
` /web install install browser binaries (bundled playwright CLI)\n` +
|
|
206
208
|
` /web off disable all browser tools\n` +
|
|
207
209
|
` /web on enable browsing only\n` +
|
|
208
210
|
` /web learn enable browsing + guide-saving\n` +
|
|
@@ -217,17 +219,17 @@ export default function initBrowserToggle(pi: ExtensionAPI) {
|
|
|
217
219
|
// ── Session handlers: restore profile + render status bar ─
|
|
218
220
|
pi.on("session_start", async (_event, ctx) => {
|
|
219
221
|
restoreProfile(pi, ctx);
|
|
220
|
-
|
|
222
|
+
setLastCtx(ctx);
|
|
221
223
|
syncCachedState();
|
|
222
224
|
});
|
|
223
225
|
|
|
224
226
|
pi.on("session_tree", async (_event, ctx) => {
|
|
225
227
|
restoreProfile(pi, ctx);
|
|
226
|
-
|
|
228
|
+
setLastCtx(ctx);
|
|
227
229
|
syncCachedState();
|
|
228
230
|
});
|
|
229
231
|
|
|
230
232
|
pi.on("session_shutdown", async () => {
|
|
231
|
-
|
|
233
|
+
setLastCtx(null);
|
|
232
234
|
});
|
|
233
235
|
}
|
|
@@ -111,8 +111,6 @@ async function performFetch(
|
|
|
111
111
|
redirect: "follow",
|
|
112
112
|
});
|
|
113
113
|
|
|
114
|
-
clearTimeout(timeoutId);
|
|
115
|
-
|
|
116
114
|
if (!response.ok) {
|
|
117
115
|
throw new Error(`HTTP ${response.status} ${response.statusText ?? ""}`);
|
|
118
116
|
}
|
|
@@ -128,6 +126,8 @@ async function performFetch(
|
|
|
128
126
|
|
|
129
127
|
return { title, needsJavaScript, root };
|
|
130
128
|
} finally {
|
|
129
|
+
// Timer stays armed through the body read, so `timeout` bounds the
|
|
130
|
+
// whole request, not just the response headers.
|
|
131
131
|
clearTimeout(timeoutId);
|
|
132
132
|
}
|
|
133
133
|
}
|
|
@@ -83,36 +83,6 @@ export interface NavigateOptions {
|
|
|
83
83
|
piSessionId?: string;
|
|
84
84
|
}
|
|
85
85
|
|
|
86
|
-
// ─── Profile Change Callback ───────────────────────────────────────
|
|
87
|
-
|
|
88
|
-
/**
|
|
89
|
-
* Simple nullable callback for profile changes.
|
|
90
|
-
* Used by index.ts to update the TUI status bar.
|
|
91
|
-
*/
|
|
92
|
-
let _onProfileChange:
|
|
93
|
-
| ((
|
|
94
|
-
taskId: string,
|
|
95
|
-
profileName?: string,
|
|
96
|
-
profileMode?: "none" | "session" | "named",
|
|
97
|
-
) => void)
|
|
98
|
-
| null = null;
|
|
99
|
-
|
|
100
|
-
/**
|
|
101
|
-
* Set the profile change callback.
|
|
102
|
-
* Called once at extension startup from index.ts.
|
|
103
|
-
*/
|
|
104
|
-
export function setOnProfileChange(
|
|
105
|
-
handler:
|
|
106
|
-
| ((
|
|
107
|
-
taskId: string,
|
|
108
|
-
profileName?: string,
|
|
109
|
-
profileMode?: "none" | "session" | "named",
|
|
110
|
-
) => void)
|
|
111
|
-
| null,
|
|
112
|
-
): void {
|
|
113
|
-
_onProfileChange = handler;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
86
|
// ─── Helpers ─────────────────────────────────────────────────────────
|
|
117
87
|
|
|
118
88
|
/**
|
|
@@ -489,8 +459,13 @@ export async function navigate(
|
|
|
489
459
|
resolvedProfileName = profileInput;
|
|
490
460
|
}
|
|
491
461
|
|
|
492
|
-
// ── Notify profile change
|
|
493
|
-
|
|
462
|
+
// ── Notify profile change ─────────────────────
|
|
463
|
+
if (process.env.BROWSER_DEBUG) {
|
|
464
|
+
const parts = [`[browser] profile_changed: task=${taskId}`];
|
|
465
|
+
if (resolvedProfileName) parts.push(`profile=${resolvedProfileName}`);
|
|
466
|
+
if (profileMode) parts.push(`mode=${profileMode}`);
|
|
467
|
+
console.error(parts.join(" "));
|
|
468
|
+
}
|
|
494
469
|
|
|
495
470
|
// ── Create session and navigate ───────────────────────────
|
|
496
471
|
sessionManager.createSession(taskId, plugin.name);
|
|
@@ -2,8 +2,6 @@ import { getProfileLabel } from "./storage-state.js";
|
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Session manager — tracks browser session lifecycle per task_id.
|
|
5
|
-
|
|
6
|
-
|
|
7
5
|
*
|
|
8
6
|
* Design: sessions track metadata; browsers and contexts are managed
|
|
9
7
|
* entirely by the plugin. The session manager is Playwright-agnostic.
|
|
@@ -112,7 +110,7 @@ class SessionManager {
|
|
|
112
110
|
this.#lastNav.delete(taskId);
|
|
113
111
|
}
|
|
114
112
|
|
|
115
|
-
|
|
113
|
+
removeAll(): void {
|
|
116
114
|
this.#sessions.clear();
|
|
117
115
|
this.#lastNav.clear();
|
|
118
116
|
}
|