pi-unsloth-webtools 0.8.0 → 0.9.1

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