pi-unsloth-webtools 0.8.0 → 0.9.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/lightpanda.ts ADDED
@@ -0,0 +1,276 @@
1
+ import { spawn as spawnProcess } from "node:child_process";
2
+ import { visibleChars } from "./html-to-md.ts";
3
+ import {
4
+ checkUrlAccess,
5
+ MAX_SIGNAL_TIMEOUT_MS,
6
+ normalizeUrlScheme,
7
+ type WebsitePolicy,
8
+ } from "./web-access.ts";
9
+ import {
10
+ pagePrefixedMarkdown,
11
+ parseLocalPath,
12
+ resolveAndValidateHost,
13
+ TRUNCATED_BODY_NOTICE,
14
+ truncatePageText,
15
+ type ResolvedHost,
16
+ } from "./web-fetch.ts";
17
+
18
+ const DEFAULT_BINARY = "lightpanda";
19
+ const DEFAULT_WAIT_UNTIL = "networkidle";
20
+ const DEFAULT_TIMEOUT_MS = 60_000;
21
+ const MAX_DUMP_BYTES = 4 * 1024 * 1024;
22
+ const PROBE_TIMEOUT_MS = 5_000;
23
+ const METADATA_LINE_RE = /^(?:Title|Author|Date|Site|URL|Rendered via): /;
24
+ const PROVENANCE = "Rendered via Lightpanda (local browser; nothing sent to a third party).";
25
+ const CANCELLED_MESSAGE = "Failed to render URL: cancelled.";
26
+ const TIMED_OUT_MESSAGE = "Failed to render URL: timed out.";
27
+ const LOCAL_FILE_MESSAGE = "Blocked: the local renderer cannot fetch local files.";
28
+ const NO_TEXT_MESSAGE = "(page returned no readable text)";
29
+
30
+ export type LightpandaDump = "html" | "markdown";
31
+ export type LightpandaWaitUntil = "networkidle" | "load" | "domcontentloaded" | "done";
32
+
33
+ export interface LightpandaSettings {
34
+ enabled: boolean;
35
+ binaryPath: string | null;
36
+ command: string[] | null;
37
+ }
38
+
39
+ interface LightpandaProcess {
40
+ stdout: AsyncIterable<Buffer> | null;
41
+ stderr: AsyncIterable<Buffer> | null;
42
+ on(event: "error", listener: (error: Error) => void): unknown;
43
+ on(event: "close", listener: (code: number | null) => void): unknown;
44
+ kill(signal?: string): unknown;
45
+ }
46
+
47
+ export type LightpandaSpawn = (
48
+ binary: string,
49
+ args: string[],
50
+ options: { stdio: ["ignore", "pipe", "pipe"] },
51
+ ) => LightpandaProcess;
52
+
53
+ export interface LightpandaRenderOptions {
54
+ timeoutMs?: number;
55
+ maxChars?: number;
56
+ signal?: AbortSignal;
57
+ websitePolicy?: WebsitePolicy | null;
58
+ settings?: LightpandaSettings | null;
59
+ binaryPath?: string | null;
60
+ command?: string[] | null;
61
+ dump?: LightpandaDump;
62
+ waitUntil?: LightpandaWaitUntil;
63
+ spawn?: LightpandaSpawn;
64
+ resolve?: (hostname: string, signal?: AbortSignal, allowPrivateAddresses?: boolean) => Promise<ResolvedHost>;
65
+ }
66
+
67
+ interface RunResult {
68
+ code: number | null;
69
+ stdout: string;
70
+ stderr: string;
71
+ timedOut: boolean;
72
+ cancelled: boolean;
73
+ truncated: boolean;
74
+ missing: boolean;
75
+ }
76
+
77
+ const availability = new Map<string, Promise<boolean>>();
78
+
79
+ function binaryAvailable(launch: string[], spawn: LightpandaSpawn): Promise<boolean> {
80
+ const key = launch.join(" ");
81
+ const cached = availability.get(key);
82
+ if (cached) return cached;
83
+ const probe = new Promise<boolean>((resolve) => {
84
+ let settled = false;
85
+ const finish = (value: boolean) => {
86
+ if (settled) return;
87
+ settled = true;
88
+ clearTimeout(timer);
89
+ resolve(value);
90
+ };
91
+ const timer = setTimeout(() => {
92
+ try {
93
+ child.kill("SIGKILL");
94
+ } catch {}
95
+ finish(false);
96
+ }, PROBE_TIMEOUT_MS);
97
+ let child: LightpandaProcess;
98
+ try {
99
+ child = spawn(launch[0], [...launch.slice(1), "version"], { stdio: ["ignore", "pipe", "pipe"] });
100
+ } catch {
101
+ finish(false);
102
+ return;
103
+ }
104
+ child.on("error", () => finish(false));
105
+ child.on("close", (code) => finish(code === 0));
106
+ });
107
+ availability.set(key, probe);
108
+ return probe;
109
+ }
110
+
111
+ function runLightpanda(
112
+ launch: string[],
113
+ args: string[],
114
+ options: { timeoutMs: number; signal?: AbortSignal; spawn: LightpandaSpawn },
115
+ ): Promise<RunResult> {
116
+ return new Promise<RunResult>((resolve) => {
117
+ const state = {
118
+ code: null as number | null,
119
+ stdout: [] as Buffer[],
120
+ stderr: [] as string[],
121
+ timedOut: false,
122
+ cancelled: false,
123
+ truncated: false,
124
+ missing: false,
125
+ bytes: 0,
126
+ };
127
+ let settled = false;
128
+ const finish = (error: Error | null) => {
129
+ if (settled) return;
130
+ settled = true;
131
+ clearTimeout(timer);
132
+ options.signal?.removeEventListener("abort", onAbort);
133
+ if (error && (error as NodeJS.ErrnoException).code === "ENOENT") state.missing = true;
134
+ const stdout = Buffer.concat(state.stdout).toString("utf-8");
135
+ resolve({ ...state, stdout, stderr: state.stderr.join("") });
136
+ };
137
+ const kill = () => {
138
+ try {
139
+ child.kill("SIGKILL");
140
+ } catch {}
141
+ };
142
+ const onAbort = () => {
143
+ state.cancelled = true;
144
+ kill();
145
+ };
146
+ const timer = setTimeout(() => {
147
+ state.timedOut = true;
148
+ kill();
149
+ }, options.timeoutMs);
150
+ let child: LightpandaProcess;
151
+ try {
152
+ child = options.spawn(launch[0], [...launch.slice(1), ...args], { stdio: ["ignore", "pipe", "pipe"] });
153
+ } catch (error) {
154
+ finish(error instanceof Error ? error : new Error(String(error)));
155
+ return;
156
+ }
157
+ const collect = async (stream: AsyncIterable<Buffer> | null, push: (chunk: Buffer) => void): Promise<void> => {
158
+ if (!stream) return;
159
+ try {
160
+ for await (const chunk of stream) push(chunk);
161
+ } catch {}
162
+ };
163
+ const stdoutDone = collect(child.stdout, (chunk) => {
164
+ if (state.bytes >= MAX_DUMP_BYTES) {
165
+ state.truncated = true;
166
+ return;
167
+ }
168
+ state.bytes += chunk.length;
169
+ if (state.bytes > MAX_DUMP_BYTES) {
170
+ state.truncated = true;
171
+ state.stdout.push(chunk.subarray(0, chunk.length - (state.bytes - MAX_DUMP_BYTES)));
172
+ return;
173
+ }
174
+ state.stdout.push(chunk);
175
+ });
176
+ const stderrDone = collect(child.stderr, (chunk) => state.stderr.push(chunk.toString("utf-8")));
177
+ child.on("error", (error) => finish(error));
178
+ child.on("close", (code) => {
179
+ state.code = code;
180
+ void Promise.all([stdoutDone, stderrDone]).then(() => finish(null));
181
+ });
182
+ options.signal?.addEventListener("abort", onAbort, { once: true });
183
+ if (options.signal?.aborted) onAbort();
184
+ });
185
+ }
186
+
187
+ function bodyChars(text: string): number {
188
+ const body = text
189
+ .split("\n")
190
+ .filter((line) => !METADATA_LINE_RE.test(line))
191
+ .join("\n");
192
+ return visibleChars(body);
193
+ }
194
+
195
+ function withProvenance(prefixed: string, url: string): string {
196
+ const lines = prefixed.split("\n");
197
+ let index = 0;
198
+ while (index < lines.length && METADATA_LINE_RE.test(lines[index])) index++;
199
+ const header = [`URL: ${url}`, PROVENANCE];
200
+ if (index === 0) return `${header.join("\n")}\n\n${prefixed}`;
201
+ return [...lines.slice(0, index), ...header, ...lines.slice(index)].join("\n");
202
+ }
203
+
204
+ export function lightpandaBinary(options: LightpandaRenderOptions = {}): string {
205
+ return (
206
+ options.binaryPath ??
207
+ options.settings?.binaryPath ??
208
+ process.env.PI_LIGHTPANDA_BIN?.trim() ??
209
+ DEFAULT_BINARY
210
+ );
211
+ }
212
+
213
+ export function lightpandaLaunch(options: LightpandaRenderOptions = {}): string[] {
214
+ const command = options.command ?? options.settings?.command ?? null;
215
+ if (command && command.length) return command;
216
+ return [lightpandaBinary(options)];
217
+ }
218
+
219
+ export async function renderPageWithLightpanda(
220
+ url: string,
221
+ options: LightpandaRenderOptions = {},
222
+ ): Promise<string | null> {
223
+ if (options.settings && options.settings.enabled === false) return null;
224
+ const target = typeof url === "string" ? url.trim() : "";
225
+ const policy = options.websitePolicy ?? null;
226
+ if (!target) return checkUrlAccess("", policy)[1];
227
+ if (parseLocalPath(target) !== null) return LOCAL_FILE_MESSAGE;
228
+ const normalized = normalizeUrlScheme(target);
229
+ const [allowed, reason, hostname] = checkUrlAccess(normalized, policy);
230
+ if (!allowed) return reason;
231
+ const launch = lightpandaLaunch(options);
232
+ const spawn = options.spawn ?? (spawnProcess as unknown as LightpandaSpawn);
233
+ if (!(await binaryAvailable(launch, spawn))) return null;
234
+ const timeoutMs = Math.min(
235
+ MAX_SIGNAL_TIMEOUT_MS,
236
+ Math.max(1, Math.floor(options.timeoutMs ?? DEFAULT_TIMEOUT_MS)),
237
+ );
238
+ const timeoutSignal = AbortSignal.timeout(timeoutMs);
239
+ const signal = options.signal ? AbortSignal.any([options.signal, timeoutSignal]) : timeoutSignal;
240
+ const resolve = options.resolve ?? resolveAndValidateHost;
241
+ const resolved = await resolve(hostname, signal, false);
242
+ if (options.signal?.aborted) return CANCELLED_MESSAGE;
243
+ if (timeoutSignal.aborted) return TIMED_OUT_MESSAGE;
244
+ if (!resolved.ok) {
245
+ return resolved.reason.startsWith("Blocked:") ? resolved.reason : `Failed to render URL: ${resolved.reason}`;
246
+ }
247
+ const dump = options.dump ?? "html";
248
+ const result = await runLightpanda(
249
+ launch,
250
+ [
251
+ "fetch",
252
+ "--dump",
253
+ dump,
254
+ "--wait-until",
255
+ options.waitUntil ?? DEFAULT_WAIT_UNTIL,
256
+ "--block-private-networks",
257
+ "--http-timeout",
258
+ String(timeoutMs),
259
+ normalized,
260
+ ],
261
+ { timeoutMs, signal: options.signal, spawn },
262
+ );
263
+ if (result.missing) return null;
264
+ if (result.cancelled) return CANCELLED_MESSAGE;
265
+ if (result.timedOut) return TIMED_OUT_MESSAGE;
266
+ if (result.code !== 0) {
267
+ return `Failed to render URL: Lightpanda exited with code ${result.code ?? "unknown"}.`;
268
+ }
269
+ const raw = result.stdout;
270
+ const rendered =
271
+ dump === "html" ? pagePrefixedMarkdown(result.truncated ? raw + TRUNCATED_BODY_NOTICE : raw) : raw.trim();
272
+ if (bodyChars(rendered) === 0) return NO_TEXT_MESSAGE;
273
+ const withHeader =
274
+ dump === "html" || !result.truncated ? withProvenance(rendered, normalized) : withProvenance(rendered + TRUNCATED_BODY_NOTICE, normalized);
275
+ return truncatePageText(withHeader, options.maxChars);
276
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-unsloth-webtools",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "description": "Pi extension: web_search and web_fetch tools that began as a port of the Unsloth Studio codebase and now diverge from it (multi-engine search, opt-in SSRF guard, HTML-to-Markdown extraction)",
6
6
  "main": "index.ts",
@@ -23,11 +23,10 @@
23
23
  "license": "AGPL-3.0",
24
24
  "files": [
25
25
  "index.ts",
26
- "config.ts",
27
- "config-ui.ts",
28
26
  "web-search.ts",
29
27
  "web-fetch.ts",
30
- "web-render.ts",
28
+ "tls-fetch.ts",
29
+ "lightpanda.ts",
31
30
  "web-access.ts",
32
31
  "html-to-md.ts",
33
32
  "engines.ts",
@@ -39,7 +38,8 @@
39
38
  "settings.ts",
40
39
  "agent-dir.ts",
41
40
  "README.md",
42
- "LICENSE"
41
+ "LICENSE",
42
+ "scripts/install-lightpanda.sh"
43
43
  ],
44
44
  "pi": {
45
45
  "extensions": [
@@ -58,6 +58,11 @@
58
58
  "test": "vitest run",
59
59
  "test:unit": "vitest run --exclude **/smoke.test.ts",
60
60
  "test:smoke": "vitest run test/smoke.test.ts",
61
+ "test:lightpanda": "vitest run test/lightpanda-live.test.ts",
62
+ "compare:fetch": "node scripts/fetch-compare.ts",
63
+ "compare:browsers": "node scripts/compare-browsers.ts",
64
+ "camoufox:warmup": "node scripts/camoufox-warmup.ts",
65
+ "stealth:matrix": "node scripts/stealth-matrix.ts",
61
66
  "typecheck": "tsc --noEmit",
62
67
  "check:package": "node -e \"const fs=require('fs');const pkg=require('./package.json');const listed=new Set(pkg.files);const bad=[];for(const f of pkg.files){if(!fs.existsSync(f))bad.push('missing file: '+f)}for(const f of fs.readdirSync('.').filter(f=>f.endsWith('.ts'))){if(!listed.has(f))bad.push('unlisted source: '+f)}if(bad.length){console.error(bad.join('\\n'));process.exit(1)}\"",
63
68
  "prepublishOnly": "npm run typecheck && npm run lint && npm run test:unit && npm run check:package && npm run check:publint",
@@ -65,6 +70,9 @@
65
70
  "check:publint": "publint",
66
71
  "check:attw": "attw --pack"
67
72
  },
73
+ "dependencies": {
74
+ "wreq-js": "^3.2.0"
75
+ },
68
76
  "devDependencies": {
69
77
  "@earendil-works/pi-coding-agent": "^0.87.0",
70
78
  "@earendil-works/pi-tui": "^0.87.0",
@@ -0,0 +1,206 @@
1
+ #!/usr/bin/env bash
2
+ set -euo pipefail
3
+
4
+ REPO="lightpanda-io/browser"
5
+ DEFAULT_DIR="${XDG_DATA_HOME:-$HOME/.local/share}/pi-unsloth-webtools/lightpanda"
6
+ PINNED_FALLBACK_VERSION="1.0.0"
7
+
8
+ DIR="$DEFAULT_DIR"
9
+ VERSION=""
10
+ FORCE=0
11
+ ALLOW_SHIM=1
12
+ PRINT_PATH=0
13
+
14
+ usage() {
15
+ cat <<'EOF'
16
+ Install the Lightpanda browser used by this package's local rendering tier.
17
+
18
+ Usage: bash scripts/install-lightpanda.sh [options]
19
+
20
+ --version=X Release tag to install (default: the newest stable release, e.g. 1.0.0;
21
+ pass --version=nightly for the nightly build)
22
+ --dir=PATH Install directory (default: $XDG_DATA_HOME/pi-unsloth-webtools/lightpanda)
23
+ --force Reinstall even when a working binary is already present
24
+ --no-shim Fail instead of bundling a newer libc when the system glibc is too old
25
+ --print-path Print only the launcher path (for scripts and CI)
26
+ -h, --help Show this message
27
+
28
+ On Linux, if the release needs a newer glibc than the system provides, the script
29
+ downloads Debian's libc6 for the current stable suite, extracts it into the install
30
+ directory, and writes a launcher shim that runs the binary with that libc. Point
31
+ `webRender.lightpandaPath` (settings.json) or `PI_LIGHTPANDA_BIN` at the printed path.
32
+ EOF
33
+ }
34
+
35
+ for arg in "$@"; do
36
+ case "$arg" in
37
+ --version=*) VERSION="${arg#*=}" ;;
38
+ --dir=*) DIR="${arg#*=}" ;;
39
+ --force) FORCE=1 ;;
40
+ --no-shim) ALLOW_SHIM=0 ;;
41
+ --print-path) PRINT_PATH=1 ;;
42
+ -h|--help) usage; exit 0 ;;
43
+ *) echo "unknown option: $arg" >&2; usage >&2; exit 2 ;;
44
+ esac
45
+ done
46
+
47
+ need() {
48
+ if ! command -v "$1" >/dev/null 2>&1; then
49
+ echo "missing required command: $1" >&2
50
+ exit 1
51
+ fi
52
+ }
53
+
54
+ need curl
55
+ need awk
56
+ need sed
57
+
58
+ os="$(uname -s)"
59
+ arch="$(uname -m)"
60
+ deb_arch=""
61
+ case "$os/$arch" in
62
+ Linux/x86_64) asset="lightpanda-x86_64-linux"; deb_arch="amd64" ;;
63
+ Linux/aarch64|Linux/arm64) asset="lightpanda-aarch64-linux"; deb_arch="arm64" ;;
64
+ Darwin/x86_64) asset="lightpanda-x86_64-macos" ;;
65
+ Darwin/arm64) asset="lightpanda-aarch64-macos" ;;
66
+ MINGW*|MSYS*|CYGWIN*)
67
+ cat >&2 <<'EOF'
68
+ Lightpanda ships no native Windows build. Run it inside WSL2 instead:
69
+
70
+ 1. in WSL: bash scripts/install-lightpanda.sh
71
+ (it prints a path such as /home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run)
72
+
73
+ 2. in Windows settings.json, drive that binary through wsl.exe:
74
+
75
+ {
76
+ "webRender": {
77
+ "lightpandaCommand": ["wsl.exe", "-e", "/home/<you>/.local/share/pi-unsloth-webtools/lightpanda/lightpanda-run"]
78
+ }
79
+ }
80
+
81
+ Everything else in this package (search, the browser-fingerprint transport) runs natively on Windows.
82
+ EOF
83
+ exit 1 ;;
84
+ *) echo "unsupported platform: $os/$arch" >&2; exit 1 ;;
85
+ esac
86
+
87
+ if [ -z "$VERSION" ]; then
88
+ VERSION="$(curl -fsSL "https://api.github.com/repos/$REPO/releases?per_page=30" 2>/dev/null \
89
+ | grep -o '"tag_name":[[:space:]]*"[^"]*"' \
90
+ | sed -n 's/.*"\([^"]*\)"$/\1/p' \
91
+ | grep -E '^[0-9]+\.[0-9]+\.[0-9]+$' \
92
+ | head -1 || true)"
93
+ if [ -z "$VERSION" ]; then
94
+ echo "could not resolve the newest release; falling back to $PINNED_FALLBACK_VERSION" >&2
95
+ VERSION="$PINNED_FALLBACK_VERSION"
96
+ fi
97
+ fi
98
+
99
+ mkdir -p "$DIR"
100
+ binary="$DIR/lightpanda"
101
+ shim="$DIR/lightpanda-run"
102
+
103
+ report_path() {
104
+ if [ "$PRINT_PATH" = 1 ]; then
105
+ printf '%s\n' "$1"
106
+ else
107
+ echo "already installed and working: $1" >&2
108
+ fi
109
+ exit 0
110
+ }
111
+
112
+ if [ "$FORCE" = 0 ] && [ -x "$shim" ] && "$shim" version >/dev/null 2>&1; then
113
+ report_path "$shim"
114
+ fi
115
+ if [ "$FORCE" = 0 ] && [ -x "$binary" ] && "$binary" version >/dev/null 2>&1; then
116
+ report_path "$binary"
117
+ fi
118
+
119
+ url="https://github.com/$REPO/releases/download/$VERSION/$asset"
120
+ if [ "$PRINT_PATH" = 1 ]; then
121
+ echo "downloading $VERSION ($asset)" >&2
122
+ curl -fL --silent --show-error "$url" -o "$binary.part"
123
+ else
124
+ echo "downloading $VERSION ($asset)"
125
+ curl -fL --progress-bar "$url" -o "$binary.part"
126
+ fi
127
+ chmod +x "$binary.part"
128
+ mv -f "$binary.part" "$binary"
129
+ rm -f "$shim"
130
+
131
+ run_path="$binary"
132
+ if ! "$binary" version >/dev/null 2>&1; then
133
+ run_path=""
134
+ if [ "$ALLOW_SHIM" = 0 ] || [ "$os" != "Linux" ]; then
135
+ echo "the $VERSION binary cannot run on this system:" >&2
136
+ "$binary" version 2>&1 | head -3 >&2
137
+ exit 1
138
+ fi
139
+ required="$({ "$binary" version 2>&1 || true; } | sed -n "s/.*GLIBC_\([0-9][0-9.]*\).*/\1/p" | sort -V | tail -1)"
140
+ if [ -z "$required" ]; then
141
+ echo "the $VERSION binary failed to start and the cause is not a glibc version:" >&2
142
+ "$binary" version 2>&1 | head -3 >&2
143
+ exit 1
144
+ fi
145
+ system_glibc="$(ldd --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
146
+ echo "binary needs glibc $required, system has ${system_glibc:-unknown}: bundling a newer libc"
147
+ need dpkg-deb
148
+ glibc_dir="$DIR/glibc"
149
+ rm -rf "$glibc_dir"
150
+ for suite in trixie stable sid; do
151
+ index="https://deb.debian.org/debian/dists/$suite/main/binary-$deb_arch/Packages.gz"
152
+ deb_path="$(curl -fsSL "$index" 2>/dev/null | gzip -dc 2>/dev/null \
153
+ | awk '/^Package: libc6$/{found=1} found && /^Filename:/{print $2; exit}' || true)"
154
+ [ -n "$deb_path" ] || continue
155
+ echo "fetching libc6 from debian/$suite"
156
+ curl -fsSL "https://deb.debian.org/debian/$deb_path" -o "$DIR/libc6.deb" || continue
157
+ mkdir -p "$glibc_dir"
158
+ dpkg-deb -x "$DIR/libc6.deb" "$glibc_dir"
159
+ rm -f "$DIR/libc6.deb"
160
+ loader="$(find "$glibc_dir" -path "*-linux-gnu/ld-linux-*.so.1" | head -1)"
161
+ [ -n "$loader" ] || continue
162
+ bundled_glibc="$("$loader" --version 2>/dev/null | head -1 | sed -n 's/.* \([0-9][0-9.]*\)$/\1/p')"
163
+ if [ -z "$bundled_glibc" ]; then
164
+ rm -rf "$glibc_dir"
165
+ continue
166
+ fi
167
+ newest="$(printf '%s\n%s\n' "$required" "$bundled_glibc" | sort -V | tail -1)"
168
+ if [ "$newest" != "$bundled_glibc" ]; then
169
+ echo "bundled glibc $bundled_glibc is older than required $required, trying the next suite" >&2
170
+ rm -rf "$glibc_dir"
171
+ continue
172
+ fi
173
+ libdir="$(dirname "$loader")"
174
+ {
175
+ echo "#!/bin/sh"
176
+ echo "exec \"$loader\" --library-path \"$libdir\" \"$binary\" \"\$@\""
177
+ } > "$shim"
178
+ chmod +x "$shim"
179
+ if "$shim" version >/dev/null 2>&1; then
180
+ run_path="$shim"
181
+ echo "bundled glibc $bundled_glibc at $glibc_dir"
182
+ break
183
+ fi
184
+ rm -rf "$glibc_dir" "$shim"
185
+ done
186
+ if [ -z "$run_path" ]; then
187
+ echo "could not make $VERSION runnable on this system." >&2
188
+ echo "Options: upgrade the distribution, run Lightpanda in a container, or install an older" >&2
189
+ echo "release that targets this glibc (for example 0.2.9)." >&2
190
+ exit 1
191
+ fi
192
+ fi
193
+
194
+ installed_version="$("$run_path" version 2>/dev/null | head -1)"
195
+ if [ "$PRINT_PATH" = 1 ]; then
196
+ printf '%s\n' "$run_path"
197
+ exit 0
198
+ fi
199
+
200
+ echo
201
+ echo "Lightpanda $installed_version installed"
202
+ echo " run this: $run_path"
203
+ echo " configure one of:"
204
+ echo " export PI_LIGHTPANDA_BIN=$run_path"
205
+ echo " settings.json: { \"webRender\": { \"lightpandaPath\": \"$run_path\" } }"
206
+ echo " verify: $run_path fetch --dump markdown --wait-until networkidle https://example.com/"
package/settings.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
3
  import { agentDir } from "./agent-dir.ts";
4
+ import type { FetchTransport } from "./web-fetch.ts";
4
5
 
5
6
  async function readJson(file: string): Promise<Record<string, unknown> | undefined> {
6
7
  try {
@@ -49,6 +50,23 @@ function pickBoolean(data: Record<string, unknown>, paths: string[][]): boolean
49
50
  return undefined;
50
51
  }
51
52
 
53
+ function pickStringArray(data: Record<string, unknown>, paths: string[][]): string[] | undefined {
54
+ for (const path of paths) {
55
+ let cur: unknown = data;
56
+ for (const key of path) {
57
+ if (cur && typeof cur === "object" && !Array.isArray(cur)) cur = (cur as Record<string, unknown>)[key];
58
+ else {
59
+ cur = undefined;
60
+ break;
61
+ }
62
+ }
63
+ if (!Array.isArray(cur)) continue;
64
+ const values = cur.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0);
65
+ if (values.length) return values;
66
+ }
67
+ return undefined;
68
+ }
69
+
52
70
  function pickString(data: Record<string, unknown>, paths: string[][]): string | undefined {
53
71
  for (const path of paths) {
54
72
  let cur: unknown = data;
@@ -94,11 +112,27 @@ const ALLOW_LOCAL_FILES_PATHS: string[][] = [
94
112
  ["webFetch", "allowLocalFiles"],
95
113
  ];
96
114
 
97
- const JINA_API_KEY_PATHS: string[][] = [
98
- ["unslothWebTools", "jinaApiKey"],
99
- ["webRender", "jinaApiKey"],
115
+ const TRANSPORT_PATHS: string[][] = [
116
+ ["unslothWebTools", "transport"],
117
+ ["webFetch", "transport"],
118
+ ];
119
+
120
+ const TRANSPORT_VALUES: ReadonlySet<string> = new Set(["tls-first", "direct-first", "off"]);
121
+
122
+ const LIGHTPANDA_ENABLED_PATHS: string[][] = [
123
+ ["unslothWebTools", "lightpandaEnabled"],
124
+ ["webRender", "lightpandaEnabled"],
125
+ ];
126
+
127
+ const LIGHTPANDA_PATH_PATHS: string[][] = [
128
+ ["unslothWebTools", "lightpandaPath"],
129
+ ["webRender", "lightpandaPath"],
100
130
  ];
101
131
 
132
+ const LIGHTPANDA_COMMAND_PATHS: string[][] = [
133
+ ["unslothWebTools", "lightpandaCommand"],
134
+ ["webRender", "lightpandaCommand"],
135
+ ];
102
136
  function clampMaxResults(value: number): number {
103
137
  return Math.min(20, Math.max(1, value));
104
138
  }
@@ -151,11 +185,13 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
151
185
  timeoutMs?: number;
152
186
  allowPrivateAddresses: boolean;
153
187
  allowLocalFiles: boolean;
188
+ transport: FetchTransport;
154
189
  }> {
155
190
  let maxChars: number | undefined;
156
191
  let timeoutMs: number | undefined;
157
192
  let allowPrivateAddresses = true;
158
193
  let allowLocalFiles = true;
194
+ let transport: FetchTransport = "tls-first";
159
195
  for (const data of await settingsEntries(cwd)) {
160
196
  const c = pickNumber(data, FETCH_MAX_CHARS_PATHS);
161
197
  if (c !== undefined && c > 0) maxChars = c;
@@ -165,17 +201,27 @@ export async function loadDefaultFetchSettings(cwd?: string): Promise<{
165
201
  if (p !== undefined) allowPrivateAddresses = p;
166
202
  const l = pickBoolean(data, ALLOW_LOCAL_FILES_PATHS);
167
203
  if (l !== undefined) allowLocalFiles = l;
204
+ const mode = pickString(data, TRANSPORT_PATHS);
205
+ if (mode !== undefined && TRANSPORT_VALUES.has(mode)) transport = mode as FetchTransport;
168
206
  }
169
- return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles };
207
+ return { maxChars, timeoutMs, allowPrivateAddresses, allowLocalFiles, transport };
170
208
  }
171
209
 
172
- export async function loadJinaApiKey(cwd?: string): Promise<string | undefined> {
173
- let result: string | undefined;
210
+ export async function loadLightpandaSettings(cwd?: string): Promise<{
211
+ enabled: boolean;
212
+ binaryPath: string | null;
213
+ command: string[] | null;
214
+ }> {
215
+ let enabled = true;
216
+ let binaryPath: string | null = null;
217
+ let command: string[] | null = null;
174
218
  for (const data of await settingsEntries(cwd)) {
175
- const candidate = pickString(data, JINA_API_KEY_PATHS);
176
- if (candidate !== undefined) result = candidate;
219
+ const e = pickBoolean(data, LIGHTPANDA_ENABLED_PATHS);
220
+ if (e !== undefined) enabled = e;
221
+ const p = pickString(data, LIGHTPANDA_PATH_PATHS);
222
+ if (p !== undefined) binaryPath = p;
223
+ const c = pickStringArray(data, LIGHTPANDA_COMMAND_PATHS);
224
+ if (c !== undefined) command = c;
177
225
  }
178
- if (result !== undefined) return result;
179
- const env = process.env.JINA_API_KEY?.trim();
180
- return env ? env : undefined;
226
+ return { enabled, binaryPath, command };
181
227
  }