pi-web-toolkit 0.3.2 → 0.3.3

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/install.sh ADDED
@@ -0,0 +1,801 @@
1
+ #!/usr/bin/env bash
2
+ set -Eeuo pipefail
3
+
4
+ VERSION="0.3.3"
5
+ USER_AGENT="pi-web-toolkit-installer/${VERSION} (+https://github.com/Wade11s/pi-web-toolkit)"
6
+ PUBLIC_INSTANCES_URL="https://searx.space/data/instances.json"
7
+ LOCAL_SEARXNG_CONTAINER="pi-web-toolkit-searxng"
8
+ DEFAULT_SEARXNG_PORT="8080"
9
+
10
+ YES=0
11
+ DOCTOR=0
12
+ LOCAL_INSTALL=0
13
+ DEPS_ONLY=0
14
+ EXTENSION_ONLY=0
15
+ AGENT_BROWSER_WITH_DEPS=0
16
+ SEARXNG_URL_ARG=""
17
+ AUTO_SEARXNG=""
18
+ FIRECRAWL_CHOICE=""
19
+ FIRECRAWL_RUNNER="installed"
20
+ SEARXNG_PORT="$DEFAULT_SEARXNG_PORT"
21
+
22
+ CONFIG_FILE="${PI_WEB_TOOLKIT_CONFIG:-${XDG_CONFIG_HOME:-$HOME/.config}/pi-web-toolkit/config.json}"
23
+ CONFIG_DIR="$(dirname "$CONFIG_FILE")"
24
+
25
+ SELECTED_SEARXNG_URL=""
26
+ SCRAPLING_BIN=""
27
+ AGENT_BROWSER_BIN=""
28
+ FIRECRAWL_BIN=""
29
+ FIRECRAWL_FALLBACK=""
30
+ SEARXNG_SOURCE=""
31
+ FAIL_COUNT=0
32
+
33
+ usage() {
34
+ cat <<'USAGE'
35
+ pi-web-toolkit installer
36
+
37
+ Usage:
38
+ install.sh [options]
39
+
40
+ Options:
41
+ --yes, -y Accept safe non-interactive defaults
42
+ --doctor Verify readiness without changing anything
43
+ --searxng-url URL Use and verify an existing SearXNG endpoint
44
+ --auto-searxng public Explicitly auto-select a verified public endpoint
45
+ --auto-searxng local-docker Explicitly start/reuse isolated local Docker SearXNG
46
+ --searxng-port PORT Local Docker SearXNG port (default: 8080)
47
+ --with-firecrawl Install/enable optional Firecrawl keyless fallback CLI
48
+ --no-firecrawl Skip/disable optional Firecrawl fallback
49
+ --firecrawl-runner installed|npx|bunx
50
+ Explicit Firecrawl runner (default: installed)
51
+ --agent-browser-with-deps Use agent-browser install --with-deps (Linux)
52
+ --local Install the pi package from the current checkout
53
+ --deps-only Install dependencies/config only, skip pi install
54
+ --extension-only Install the pi package only, skip dependency install
55
+ --config PATH Toolkit config path (also exported as PI_WEB_TOOLKIT_CONFIG)
56
+ --help, -h Show this help
57
+
58
+ Examples:
59
+ curl -fsSL https://raw.githubusercontent.com/Wade11s/pi-web-toolkit/main/install.sh | bash
60
+ ./install.sh --yes --searxng-url https://searxng.example --no-firecrawl
61
+ ./install.sh --doctor
62
+ USAGE
63
+ }
64
+
65
+ log() { printf '%s\n' "$*"; }
66
+ warn() { printf 'WARN: %s\n' "$*" >&2; }
67
+ die() { printf 'ERROR: %s\n' "$*" >&2; exit 1; }
68
+
69
+ status() {
70
+ local state="$1"
71
+ local label="$2"
72
+ printf '%-8s %s\n' "$state" "$label"
73
+ if [ "$state" = "FAIL" ]; then
74
+ FAIL_COUNT=$((FAIL_COUNT + 1))
75
+ fi
76
+ }
77
+
78
+ is_interactive() {
79
+ [ "$YES" -eq 0 ] && [ -r /dev/tty ] && [ -w /dev/tty ]
80
+ }
81
+
82
+ read_tty() {
83
+ local __var="$1"
84
+ if ! IFS= read -r "$__var" < /dev/tty; then
85
+ printf -v "$__var" '%s' ""
86
+ fi
87
+ }
88
+
89
+ confirm() {
90
+ local prompt="$1"
91
+ local default="${2:-n}"
92
+ local suffix="[y/N]"
93
+ if [ "$default" = "y" ]; then suffix="[Y/n]"; fi
94
+ if ! is_interactive; then
95
+ [ "$default" = "y" ]
96
+ return
97
+ fi
98
+ local answer
99
+ printf '%s %s ' "$prompt" "$suffix"
100
+ read_tty answer
101
+ if [ -z "$answer" ]; then answer="$default"; fi
102
+ case "$answer" in
103
+ y|Y|yes|YES) return 0 ;;
104
+ *) return 1 ;;
105
+ esac
106
+ }
107
+
108
+ normalize_url() {
109
+ printf '%s' "$1" | sed 's#/*$##'
110
+ }
111
+
112
+ command_path() {
113
+ command -v "$1" 2>/dev/null || true
114
+ }
115
+
116
+ resolve_executable_path() {
117
+ local command_name="$1"
118
+ local found
119
+ found="$(command_path "$command_name")"
120
+ if [ -n "$found" ]; then
121
+ printf '%s' "$found"
122
+ return 0
123
+ fi
124
+ if [ -x "$HOME/.local/bin/$command_name" ]; then
125
+ printf '%s' "$HOME/.local/bin/$command_name"
126
+ return 0
127
+ fi
128
+ return 1
129
+ }
130
+
131
+ parse_args() {
132
+ while [ "$#" -gt 0 ]; do
133
+ case "$1" in
134
+ --yes|-y) YES=1; shift ;;
135
+ --doctor) DOCTOR=1; shift ;;
136
+ --local) LOCAL_INSTALL=1; shift ;;
137
+ --deps-only) DEPS_ONLY=1; shift ;;
138
+ --extension-only) EXTENSION_ONLY=1; shift ;;
139
+ --agent-browser-with-deps) AGENT_BROWSER_WITH_DEPS=1; shift ;;
140
+ --searxng-url)
141
+ [ "$#" -ge 2 ] || die "--searxng-url requires a URL"
142
+ SEARXNG_URL_ARG="$2"; shift 2 ;;
143
+ --auto-searxng)
144
+ [ "$#" -ge 2 ] || die "--auto-searxng requires public or local-docker"
145
+ AUTO_SEARXNG="$2"
146
+ case "$AUTO_SEARXNG" in public|local-docker) ;; *) die "--auto-searxng must be public or local-docker" ;; esac
147
+ shift 2 ;;
148
+ --searxng-port)
149
+ [ "$#" -ge 2 ] || die "--searxng-port requires a port"
150
+ SEARXNG_PORT="$2"; shift 2 ;;
151
+ --with-firecrawl) FIRECRAWL_CHOICE="with"; shift ;;
152
+ --no-firecrawl) FIRECRAWL_CHOICE="no"; shift ;;
153
+ --firecrawl-runner)
154
+ [ "$#" -ge 2 ] || die "--firecrawl-runner requires installed, npx, or bunx"
155
+ FIRECRAWL_RUNNER="$2"
156
+ case "$FIRECRAWL_RUNNER" in installed|npx|bunx) ;; *) die "--firecrawl-runner must be installed, npx, or bunx" ;; esac
157
+ shift 2 ;;
158
+ --config)
159
+ [ "$#" -ge 2 ] || die "--config requires a path"
160
+ CONFIG_FILE="$2"
161
+ export PI_WEB_TOOLKIT_CONFIG="$CONFIG_FILE"
162
+ CONFIG_DIR="$(dirname "$CONFIG_FILE")"
163
+ shift 2 ;;
164
+ --help|-h) usage; exit 0 ;;
165
+ *) die "Unknown option: $1" ;;
166
+ esac
167
+ done
168
+
169
+ if [ "$DEPS_ONLY" -eq 1 ] && [ "$EXTENSION_ONLY" -eq 1 ]; then
170
+ die "--deps-only and --extension-only cannot be combined"
171
+ fi
172
+ }
173
+
174
+ config_get() {
175
+ local dotted_path="$1"
176
+ [ -f "$CONFIG_FILE" ] || return 1
177
+ node - "$CONFIG_FILE" "$dotted_path" <<'NODE'
178
+ const fs = require('fs');
179
+ const file = process.argv[2];
180
+ const dotted = process.argv[3];
181
+ let cfg;
182
+ try {
183
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
184
+ } catch (err) {
185
+ console.error(`Invalid toolkit config at ${file}: ${err.message}`);
186
+ process.exit(2);
187
+ }
188
+ let cur = cfg;
189
+ for (const part of dotted.split('.')) {
190
+ if (!cur || typeof cur !== 'object' || !(part in cur)) process.exit(1);
191
+ cur = cur[part];
192
+ }
193
+ if (cur === undefined || cur === null) process.exit(1);
194
+ if (typeof cur === 'object') console.log(JSON.stringify(cur));
195
+ else console.log(String(cur));
196
+ NODE
197
+ }
198
+
199
+ validate_config_if_present() {
200
+ [ -f "$CONFIG_FILE" ] || return 0
201
+ node - "$CONFIG_FILE" <<'NODE'
202
+ const fs = require('fs');
203
+ const file = process.argv[2];
204
+ try {
205
+ const parsed = JSON.parse(fs.readFileSync(file, 'utf8'));
206
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
207
+ throw new Error('expected a JSON object');
208
+ }
209
+ const optionalString = (value, key) => {
210
+ if (value !== undefined && typeof value !== 'string') throw new Error(`${key} must be a string`);
211
+ };
212
+ optionalString(parsed.searxngUrl, 'searxngUrl');
213
+ if (parsed.firecrawlFallback !== undefined && typeof parsed.firecrawlFallback !== 'boolean') {
214
+ throw new Error('firecrawlFallback must be a boolean');
215
+ }
216
+ if (parsed.firecrawlRunner !== undefined && !['installed', 'npx', 'bunx'].includes(parsed.firecrawlRunner)) {
217
+ throw new Error('firecrawlRunner must be one of: installed, npx, bunx');
218
+ }
219
+ if (parsed.commands !== undefined) {
220
+ if (!parsed.commands || typeof parsed.commands !== 'object' || Array.isArray(parsed.commands)) {
221
+ throw new Error('commands must be an object');
222
+ }
223
+ optionalString(parsed.commands.scrapling, 'commands.scrapling');
224
+ optionalString(parsed.commands.agentBrowser, 'commands.agentBrowser');
225
+ optionalString(parsed.commands.firecrawl, 'commands.firecrawl');
226
+ }
227
+ } catch (err) {
228
+ console.error(`Invalid toolkit config at ${file}: ${err.message}`);
229
+ process.exit(1);
230
+ }
231
+ NODE
232
+ }
233
+
234
+ runtime_searxng_url() {
235
+ if [ -n "${SEARXNG_URL:-}" ]; then normalize_url "$SEARXNG_URL"; return 0; fi
236
+ local cfg_url=""
237
+ cfg_url="$(config_get searxngUrl 2>/dev/null || true)"
238
+ if [ -n "$cfg_url" ]; then normalize_url "$cfg_url"; return 0; fi
239
+ printf '%s' "http://localhost:8080"
240
+ }
241
+
242
+ runtime_firecrawl_enabled() {
243
+ if [ -n "${PI_WEB_FIRECRAWL_FALLBACK+x}" ]; then
244
+ local v
245
+ v="$(printf '%s' "$PI_WEB_FIRECRAWL_FALLBACK" | tr '[:upper:]' '[:lower:]')"
246
+ case "$v" in 0|false|no|off) return 1 ;; *) return 0 ;; esac
247
+ fi
248
+ local cfg_value=""
249
+ cfg_value="$(config_get firecrawlFallback 2>/dev/null || true)"
250
+ case "$cfg_value" in false|0|no|off) return 1 ;; *) return 0 ;; esac
251
+ }
252
+
253
+ runtime_command() {
254
+ local key="$1"
255
+ local env_name="$2"
256
+ local default_name="$3"
257
+ local env_value="${!env_name:-}"
258
+ if [ -n "$env_value" ]; then printf '%s' "$env_value"; return 0; fi
259
+ local cfg_value=""
260
+ cfg_value="$(config_get "commands.$key" 2>/dev/null || true)"
261
+ if [ -n "$cfg_value" ]; then printf '%s' "$cfg_value"; return 0; fi
262
+ printf '%s' "$default_name"
263
+ }
264
+
265
+ runtime_firecrawl_runner() {
266
+ local env_value="${PI_WEB_FIRECRAWL_RUNNER:-}"
267
+ if [ -n "$env_value" ]; then
268
+ case "$env_value" in installed|npx|bunx) printf '%s' "$env_value"; return 0 ;; *) printf '%s' "installed"; return 0 ;; esac
269
+ fi
270
+ local cfg_value=""
271
+ cfg_value="$(config_get firecrawlRunner 2>/dev/null || true)"
272
+ case "$cfg_value" in installed|npx|bunx) printf '%s' "$cfg_value" ;; *) printf '%s' "installed" ;; esac
273
+ }
274
+
275
+ command_is_available() {
276
+ local cmd="$1"
277
+ if [ -x "$cmd" ]; then return 0; fi
278
+ command -v "$cmd" >/dev/null 2>&1
279
+ }
280
+
281
+ verify_json_results() {
282
+ node -e 'let s=""; process.stdin.on("data", d => s += d); process.stdin.on("end", () => { try { const j = JSON.parse(s); process.exit(Array.isArray(j.results) ? 0 : 1); } catch { process.exit(1); } });'
283
+ }
284
+
285
+ verify_searxng_url() {
286
+ local base
287
+ base="$(normalize_url "$1")"
288
+ local body
289
+ if ! body="$(curl -fsSL -A "$USER_AGENT" --get "$base/search" --data-urlencode "q=pi-web-toolkit" --data "format=json" 2>/dev/null)"; then
290
+ return 1
291
+ fi
292
+ printf '%s' "$body" | verify_json_results
293
+ }
294
+
295
+ port_has_http_service() {
296
+ local base
297
+ base="$(normalize_url "$1")"
298
+ curl -fsSL -A "$USER_AGENT" --max-time 2 "$base/" >/dev/null 2>&1
299
+ }
300
+
301
+ check_node_required() {
302
+ if ! command -v node >/dev/null 2>&1; then
303
+ status FAIL "Node.js 22+ missing. Install Node.js 22+ before running this installer."
304
+ return
305
+ fi
306
+ if node -e 'process.exit(Number(process.versions.node.split(".")[0]) >= 22 ? 0 : 1)' >/dev/null 2>&1; then
307
+ status OK "Node.js $(node -v)"
308
+ else
309
+ status FAIL "Node.js $(node -v) is too old; install Node.js 22+."
310
+ fi
311
+ }
312
+
313
+ check_command_required() {
314
+ local cmd="$1"
315
+ local label="$2"
316
+ local hint="$3"
317
+ if command -v "$cmd" >/dev/null 2>&1; then
318
+ local version=""
319
+ case "$cmd" in
320
+ npm|pi|uv) version="$($cmd --version 2>/dev/null | head -n 1 || true)" ;;
321
+ esac
322
+ if [ -n "$version" ]; then status OK "$label $version"; else status OK "$label"; fi
323
+ else
324
+ status FAIL "$label missing. $hint"
325
+ fi
326
+ }
327
+
328
+ check_prerequisites() {
329
+ FAIL_COUNT=0
330
+ check_node_required
331
+ check_command_required npm npm "Install npm with Node.js 22+."
332
+ check_command_required pi pi "Install pi first: curl -fsSL https://pi.dev/install.sh | sh"
333
+ check_command_required curl curl "Install curl with your OS package manager."
334
+ check_command_required openssl OpenSSL "Install OpenSSL with your OS package manager."
335
+ check_command_required uv uv "Install uv from https://docs.astral.sh/uv/ before installing Scrapling."
336
+ }
337
+
338
+ ensure_prerequisites_or_exit() {
339
+ check_prerequisites
340
+ if [ "$FAIL_COUNT" -ne 0 ]; then
341
+ die "Missing required prerequisites; no system-level packages were installed."
342
+ fi
343
+ }
344
+
345
+ select_existing_searxng_url() {
346
+ local candidate="$1"
347
+ local source="$2"
348
+ candidate="$(normalize_url "$candidate")"
349
+ log "Verifying SearXNG endpoint ($source): $candidate"
350
+ if verify_searxng_url "$candidate"; then
351
+ SELECTED_SEARXNG_URL="$candidate"
352
+ SEARXNG_SOURCE="$source"
353
+ return 0
354
+ fi
355
+ return 1
356
+ }
357
+
358
+ discover_public_searxng() {
359
+ local raw candidates verified
360
+ raw="$(curl -fsSL -A "$USER_AGENT" "$PUBLIC_INSTANCES_URL")" || return 1
361
+ candidates="$(mktemp)"
362
+ verified="$(mktemp)"
363
+ printf '%s' "$raw" | node -e '
364
+ const fs = require("fs");
365
+ const data = JSON.parse(fs.readFileSync(0, "utf8"));
366
+ const rows = [];
367
+ for (const [url, meta] of Object.entries(data.instances || {})) {
368
+ if (meta.main !== true) continue;
369
+ if (meta.network_type !== "normal") continue;
370
+ if (!meta.http || meta.http.status_code !== 200 || meta.http.error) continue;
371
+ if (meta.generator !== "searxng") continue;
372
+ const uptime = meta.uptime || {};
373
+ const timing = meta.timing || {};
374
+ const search = timing.search || {};
375
+ const all = search.all || {};
376
+ const month = Number(uptime.uptimeMonth || 0);
377
+ const year = Number(uptime.uptimeYear || 0);
378
+ const success = Number(search.success_percentage || 0);
379
+ const median = Number(all.median || 999);
380
+ if (month < 95 || success < 80) continue;
381
+ const analyticsPenalty = meta.analytics ? 0.5 : 0;
382
+ const score = (month / 100) * 2 + (year / 100) + (success / 100) * 2 - Math.min(median, 10) / 10 - analyticsPenalty;
383
+ rows.push({ score, url, month, year, success, median, analytics: Boolean(meta.analytics) });
384
+ }
385
+ rows.sort((a, b) => b.score - a.score);
386
+ for (const row of rows.slice(0, 20)) {
387
+ console.log([row.score.toFixed(3), row.url, row.month, row.year, row.success, row.median, row.analytics].join("\t"));
388
+ }
389
+ ' > "$candidates"
390
+
391
+ local checked=0 found=0
392
+ while IFS=$'\t' read -r score url month year success median analytics; do
393
+ [ -n "$url" ] || continue
394
+ checked=$((checked + 1))
395
+ if verify_searxng_url "$url"; then
396
+ printf '%s\t%s\t%s\t%s\t%s\t%s\n' "$url" "$month" "$year" "$success" "$median" "$analytics" >> "$verified"
397
+ found=$((found + 1))
398
+ [ "$found" -ge 5 ] && break
399
+ fi
400
+ [ "$checked" -ge 20 ] && break
401
+ sleep 1
402
+ done < "$candidates"
403
+
404
+ if [ "$found" -eq 0 ]; then
405
+ rm -f "$candidates" "$verified"
406
+ return 1
407
+ fi
408
+
409
+ if [ "$AUTO_SEARXNG" = "public" ] || ! is_interactive; then
410
+ IFS=$'\t' read -r url month year success median analytics < "$verified"
411
+ SELECTED_SEARXNG_URL="$(normalize_url "$url")"
412
+ SEARXNG_SOURCE="public SearXNG endpoint from searx.space"
413
+ log "Selected public SearXNG endpoint: $SELECTED_SEARXNG_URL (uptime month ${month}%, year ${year}%, median ${median}s)"
414
+ rm -f "$candidates" "$verified"
415
+ return 0
416
+ fi
417
+
418
+ log "Found JSON-capable public SearXNG endpoints (queries leave your machine):"
419
+ local i=1
420
+ while IFS=$'\t' read -r url month year success median analytics; do
421
+ printf ' [%s] %s\n uptime: month %s%%, year %s%%; search success: %s%%; median: %ss; analytics: %s\n' "$i" "$url" "$month" "$year" "$success" "$median" "$analytics"
422
+ i=$((i + 1))
423
+ done < "$verified"
424
+ printf 'Choose endpoint [1-%s], or press Enter to use [1]: ' "$found"
425
+ local choice
426
+ read_tty choice
427
+ [ -n "$choice" ] || choice="1"
428
+ case "$choice" in ''|*[!0-9]*) rm -f "$candidates" "$verified"; return 1 ;; esac
429
+ if [ "$choice" -lt 1 ] || [ "$choice" -gt "$found" ]; then rm -f "$candidates" "$verified"; return 1; fi
430
+ url="$(sed -n "${choice}p" "$verified" | cut -f1)"
431
+ SELECTED_SEARXNG_URL="$(normalize_url "$url")"
432
+ SEARXNG_SOURCE="public SearXNG endpoint from searx.space"
433
+ rm -f "$candidates" "$verified"
434
+ return 0
435
+ }
436
+
437
+ ensure_local_docker_searxng() {
438
+ if ! command -v docker >/dev/null 2>&1; then
439
+ die "Docker is required for --auto-searxng local-docker. Install/start Docker and retry."
440
+ fi
441
+
442
+ local base="http://127.0.0.1:${SEARXNG_PORT}"
443
+ if verify_searxng_url "$base"; then
444
+ SELECTED_SEARXNG_URL="$base"
445
+ SEARXNG_SOURCE="existing local SearXNG endpoint"
446
+ log "Using existing JSON-capable SearXNG at $base"
447
+ return 0
448
+ fi
449
+
450
+ while port_has_http_service "$base"; do
451
+ if ! is_interactive; then
452
+ die "Port ${SEARXNG_PORT} is occupied by a non-SearXNG service. Pass --searxng-port with a free port."
453
+ fi
454
+ printf 'Port %s is occupied by a non-SearXNG service. Enter another port: ' "$SEARXNG_PORT"
455
+ read_tty SEARXNG_PORT
456
+ base="http://127.0.0.1:${SEARXNG_PORT}"
457
+ done
458
+
459
+ local searxng_config_dir="$CONFIG_DIR/searxng"
460
+ mkdir -p "$searxng_config_dir"
461
+ cat > "$searxng_config_dir/settings.yml" <<'YAML'
462
+ use_default_settings: true
463
+
464
+ search:
465
+ formats:
466
+ - html
467
+ - json
468
+ YAML
469
+
470
+ if docker ps -a --format '{{.Names}}' 2>/dev/null | grep -Fx "$LOCAL_SEARXNG_CONTAINER" >/dev/null; then
471
+ log "Reusing Docker container: $LOCAL_SEARXNG_CONTAINER"
472
+ docker start "$LOCAL_SEARXNG_CONTAINER" >/dev/null
473
+ else
474
+ log "Starting isolated local SearXNG Docker container: $LOCAL_SEARXNG_CONTAINER"
475
+ docker run -d \
476
+ --name "$LOCAL_SEARXNG_CONTAINER" \
477
+ --restart unless-stopped \
478
+ -p "127.0.0.1:${SEARXNG_PORT}:8080" \
479
+ -e FORCE_OWNERSHIP=false \
480
+ -e "SEARXNG_SECRET=$(openssl rand -hex 32)" \
481
+ -v "$searxng_config_dir/settings.yml:/etc/searxng/settings.yml:ro" \
482
+ docker.io/searxng/searxng:latest >/dev/null
483
+ fi
484
+
485
+ local attempt=1
486
+ while [ "$attempt" -le 15 ]; do
487
+ if verify_searxng_url "$base"; then
488
+ SELECTED_SEARXNG_URL="$base"
489
+ SEARXNG_SOURCE="installer-managed local Docker SearXNG"
490
+ return 0
491
+ fi
492
+ sleep 1
493
+ attempt=$((attempt + 1))
494
+ done
495
+ die "Local Docker SearXNG did not become ready at $base"
496
+ }
497
+
498
+ select_searxng() {
499
+ if [ -n "$SEARXNG_URL_ARG" ]; then
500
+ select_existing_searxng_url "$SEARXNG_URL_ARG" "--searxng-url" || die "SearXNG endpoint does not support JSON search: $SEARXNG_URL_ARG"
501
+ return
502
+ fi
503
+
504
+ if [ -n "${SEARXNG_URL:-}" ]; then
505
+ if select_existing_searxng_url "$SEARXNG_URL" "SEARXNG_URL"; then return; fi
506
+ warn "SEARXNG_URL is set but did not verify: $SEARXNG_URL"
507
+ fi
508
+
509
+ local cfg_url=""
510
+ cfg_url="$(config_get searxngUrl 2>/dev/null || true)"
511
+ if [ -n "$cfg_url" ]; then
512
+ if select_existing_searxng_url "$cfg_url" "toolkit config"; then return; fi
513
+ warn "Configured SearXNG endpoint did not verify: $cfg_url"
514
+ fi
515
+
516
+ if select_existing_searxng_url "http://localhost:8080" "localhost default"; then return; fi
517
+ if select_existing_searxng_url "http://127.0.0.1:8080" "localhost default"; then return; fi
518
+
519
+ case "$AUTO_SEARXNG" in
520
+ public)
521
+ log "Discovering public SearXNG endpoints from searx.space..."
522
+ discover_public_searxng || die "No verified public SearXNG endpoint found. Provide --searxng-url or use --auto-searxng local-docker."
523
+ return ;;
524
+ local-docker)
525
+ ensure_local_docker_searxng
526
+ return ;;
527
+ esac
528
+
529
+ if is_interactive; then
530
+ log "No JSON-capable SearXNG endpoint was found."
531
+ log "Options:"
532
+ log " 1) Enter an existing SearXNG URL"
533
+ log " 2) Choose a verified public SearXNG endpoint"
534
+ log " 3) Start an isolated local Docker SearXNG instance"
535
+ log " 4) Abort"
536
+ printf 'Choose [1-4]: '
537
+ local choice custom
538
+ read_tty choice
539
+ case "$choice" in
540
+ 1)
541
+ printf 'SearXNG URL: '
542
+ read_tty custom
543
+ select_existing_searxng_url "$custom" "custom input" || die "SearXNG endpoint does not support JSON search: $custom" ;;
544
+ 2) discover_public_searxng || die "No verified public SearXNG endpoint found." ;;
545
+ 3) ensure_local_docker_searxng ;;
546
+ *) die "SearXNG endpoint is required for web_search." ;;
547
+ esac
548
+ return
549
+ fi
550
+
551
+ die "No JSON-capable SearXNG endpoint found. Pass --searxng-url, --auto-searxng public, or --auto-searxng local-docker."
552
+ }
553
+
554
+ ensure_scrapling() {
555
+ if ! SCRAPLING_BIN="$(resolve_executable_path scrapling 2>/dev/null || true)" || [ -z "$SCRAPLING_BIN" ]; then
556
+ log "Installing Scrapling with uv..."
557
+ uv tool install "scrapling[all]"
558
+ SCRAPLING_BIN="$(resolve_executable_path scrapling 2>/dev/null || true)"
559
+ else
560
+ log "Reusing Scrapling: $SCRAPLING_BIN"
561
+ fi
562
+ [ -n "$SCRAPLING_BIN" ] || die "Scrapling was installed but is not on PATH. Add ~/.local/bin to PATH or set SCRAPLING_BIN."
563
+ "$SCRAPLING_BIN" install >/dev/null
564
+ "$SCRAPLING_BIN" --help >/dev/null
565
+ }
566
+
567
+ ensure_agent_browser() {
568
+ if ! AGENT_BROWSER_BIN="$(resolve_executable_path agent-browser 2>/dev/null || true)" || [ -z "$AGENT_BROWSER_BIN" ]; then
569
+ log "Installing agent-browser with npm..."
570
+ npm install -g agent-browser
571
+ AGENT_BROWSER_BIN="$(resolve_executable_path agent-browser 2>/dev/null || true)"
572
+ else
573
+ log "Reusing agent-browser: $AGENT_BROWSER_BIN"
574
+ fi
575
+ [ -n "$AGENT_BROWSER_BIN" ] || die "agent-browser was installed but is not on PATH. Set AGENT_BROWSER_BIN or fix npm global PATH."
576
+ if [ "$AGENT_BROWSER_WITH_DEPS" -eq 1 ]; then
577
+ "$AGENT_BROWSER_BIN" install --with-deps >/dev/null
578
+ else
579
+ "$AGENT_BROWSER_BIN" install >/dev/null
580
+ fi
581
+ "$AGENT_BROWSER_BIN" doctor >/dev/null
582
+ }
583
+
584
+ ensure_firecrawl() {
585
+ if [ -z "$FIRECRAWL_CHOICE" ]; then
586
+ if is_interactive && confirm "Install optional Firecrawl fallback? Queries/URLs may leave your machine when fallback runs." "n"; then
587
+ FIRECRAWL_CHOICE="with"
588
+ if is_interactive; then
589
+ log "Firecrawl runner options: installed (global CLI), npx, bunx."
590
+ log "npx/bunx may run or download firecrawl-cli at fallback time."
591
+ printf 'Firecrawl runner [installed/npx/bunx] (default: installed): '
592
+ local runner_choice
593
+ read_tty runner_choice
594
+ if [ -n "$runner_choice" ]; then
595
+ case "$runner_choice" in installed|npx|bunx) FIRECRAWL_RUNNER="$runner_choice" ;; *) die "Unknown Firecrawl runner: $runner_choice" ;; esac
596
+ fi
597
+ fi
598
+ else
599
+ FIRECRAWL_CHOICE="no"
600
+ fi
601
+ fi
602
+
603
+ if [ "$FIRECRAWL_CHOICE" = "no" ]; then
604
+ FIRECRAWL_FALLBACK="false"
605
+ log "Skipping optional Firecrawl fallback."
606
+ return
607
+ fi
608
+
609
+ FIRECRAWL_FALLBACK="true"
610
+ case "$FIRECRAWL_RUNNER" in
611
+ installed)
612
+ if ! FIRECRAWL_BIN="$(resolve_executable_path firecrawl 2>/dev/null || true)" || [ -z "$FIRECRAWL_BIN" ]; then
613
+ log "Installing firecrawl-cli with npm..."
614
+ npm install -g firecrawl-cli
615
+ FIRECRAWL_BIN="$(resolve_executable_path firecrawl 2>/dev/null || true)"
616
+ else
617
+ log "Reusing firecrawl: $FIRECRAWL_BIN"
618
+ fi
619
+ [ -n "$FIRECRAWL_BIN" ] || die "firecrawl-cli was installed but the firecrawl command was not found."
620
+ "$FIRECRAWL_BIN" --help >/dev/null
621
+ ;;
622
+ npx)
623
+ command_is_available npx || die "npx is required for --firecrawl-runner npx. Install npm/npx or use --firecrawl-runner installed."
624
+ npx --version >/dev/null
625
+ FIRECRAWL_BIN=""
626
+ log "Using Firecrawl runner: npx (opt-in; may run/download firecrawl-cli at fallback time)."
627
+ ;;
628
+ bunx)
629
+ command_is_available bunx || die "bunx is required for --firecrawl-runner bunx. Install Bun or use --firecrawl-runner installed."
630
+ bunx --version >/dev/null
631
+ FIRECRAWL_BIN=""
632
+ log "Using Firecrawl runner: bunx (opt-in; may run/download firecrawl-cli at fallback time)."
633
+ ;;
634
+ esac
635
+ }
636
+
637
+ write_toolkit_config() {
638
+ node - "$CONFIG_FILE" "$SELECTED_SEARXNG_URL" "$SCRAPLING_BIN" "$AGENT_BROWSER_BIN" "$FIRECRAWL_BIN" "$FIRECRAWL_FALLBACK" "$FIRECRAWL_RUNNER" <<'NODE'
639
+ const fs = require('fs');
640
+ const path = require('path');
641
+ const [file, searxngUrl, scrapling, agentBrowser, firecrawl, fallback, runner] = process.argv.slice(2);
642
+ let cfg = {};
643
+ if (fs.existsSync(file)) {
644
+ try {
645
+ cfg = JSON.parse(fs.readFileSync(file, 'utf8'));
646
+ } catch (err) {
647
+ console.error(`Invalid toolkit config at ${file}: ${err.message}`);
648
+ process.exit(1);
649
+ }
650
+ }
651
+ if (!cfg || typeof cfg !== 'object' || Array.isArray(cfg)) cfg = {};
652
+ if (searxngUrl) cfg.searxngUrl = searxngUrl.replace(/\/+$/, '');
653
+ cfg.commands = cfg.commands && typeof cfg.commands === 'object' && !Array.isArray(cfg.commands) ? cfg.commands : {};
654
+ if (scrapling) cfg.commands.scrapling = scrapling;
655
+ if (agentBrowser) cfg.commands.agentBrowser = agentBrowser;
656
+ if (firecrawl) cfg.commands.firecrawl = firecrawl;
657
+ if (fallback === 'false') {
658
+ cfg.firecrawlFallback = false;
659
+ delete cfg.firecrawlRunner;
660
+ delete cfg.commands.firecrawl;
661
+ } else if (fallback === 'true') {
662
+ cfg.firecrawlFallback = true;
663
+ cfg.firecrawlRunner = runner || 'installed';
664
+ if (cfg.firecrawlRunner !== 'installed') delete cfg.commands.firecrawl;
665
+ }
666
+ fs.mkdirSync(path.dirname(file), { recursive: true });
667
+ fs.writeFileSync(file, `${JSON.stringify(cfg, null, 2)}\n`);
668
+ NODE
669
+ }
670
+
671
+ install_pi_package() {
672
+ if [ "$DEPS_ONLY" -eq 1 ]; then
673
+ log "Skipping pi package install (--deps-only)."
674
+ return
675
+ fi
676
+ if [ "$LOCAL_INSTALL" -eq 1 ]; then
677
+ log "Installing local pi package..."
678
+ pi install ./
679
+ else
680
+ log "Installing pi package from npm..."
681
+ pi install npm:pi-web-toolkit
682
+ fi
683
+ }
684
+
685
+ run_final_verification() {
686
+ log ""
687
+ log "Final verification:"
688
+ FAIL_COUNT=0
689
+ check_node_required
690
+ check_command_required npm npm "Install npm with Node.js 22+."
691
+ check_command_required pi pi "Install pi first."
692
+ if verify_searxng_url "$SELECTED_SEARXNG_URL"; then status OK "SearXNG $SELECTED_SEARXNG_URL"; else status FAIL "SearXNG $SELECTED_SEARXNG_URL"; fi
693
+ if [ -n "$SCRAPLING_BIN" ] && "$SCRAPLING_BIN" --help >/dev/null 2>&1; then status OK "scrapling $SCRAPLING_BIN"; else status FAIL "scrapling"; fi
694
+ if [ -n "$AGENT_BROWSER_BIN" ] && "$AGENT_BROWSER_BIN" doctor >/dev/null 2>&1; then status OK "agent-browser $AGENT_BROWSER_BIN"; else status FAIL "agent-browser"; fi
695
+ if [ "$FIRECRAWL_FALLBACK" = "true" ]; then
696
+ case "$FIRECRAWL_RUNNER" in
697
+ installed)
698
+ if [ -n "$FIRECRAWL_BIN" ] && "$FIRECRAWL_BIN" --help >/dev/null 2>&1; then status OK "Firecrawl runner installed ($FIRECRAWL_BIN)"; else status FAIL "Firecrawl enabled but installed CLI not ready"; fi ;;
699
+ npx)
700
+ if command_is_available npx; then status OK "Firecrawl runner npx"; else status FAIL "Firecrawl runner npx missing"; fi ;;
701
+ bunx)
702
+ if command_is_available bunx; then status OK "Firecrawl runner bunx"; else status FAIL "Firecrawl runner bunx missing"; fi ;;
703
+ esac
704
+ else
705
+ status SKIP "Firecrawl optional fallback disabled"
706
+ fi
707
+ [ "$FAIL_COUNT" -eq 0 ] || die "Final verification failed."
708
+ }
709
+
710
+ run_doctor() {
711
+ log "pi-web-toolkit doctor"
712
+ FAIL_COUNT=0
713
+ check_node_required
714
+ check_command_required npm npm "Install npm with Node.js 22+."
715
+ check_command_required pi pi "Install pi first."
716
+ check_command_required curl curl "Install curl with your OS package manager."
717
+ check_command_required openssl OpenSSL "Install OpenSSL with your OS package manager."
718
+ check_command_required uv uv "Install uv before installing Scrapling."
719
+
720
+ if validate_config_if_present >/dev/null 2>&1; then
721
+ if [ -f "$CONFIG_FILE" ]; then status OK "toolkit config $CONFIG_FILE"; else status SKIP "toolkit config not found; defaults/env will be used"; fi
722
+ else
723
+ status FAIL "toolkit config invalid: $CONFIG_FILE"
724
+ fi
725
+
726
+ local searxng
727
+ searxng="$(runtime_searxng_url)"
728
+ if verify_searxng_url "$searxng"; then status OK "SearXNG $searxng"; else status FAIL "SearXNG $searxng did not return JSON results"; fi
729
+
730
+ local scrapling agent_browser firecrawl firecrawl_runner
731
+ scrapling="$(runtime_command scrapling SCRAPLING_BIN scrapling)"
732
+ if command_is_available "$scrapling" && "$scrapling" --help >/dev/null 2>&1; then status OK "scrapling $scrapling"; else status FAIL "scrapling missing or not working"; fi
733
+
734
+ agent_browser="$(runtime_command agentBrowser AGENT_BROWSER_BIN agent-browser)"
735
+ if command_is_available "$agent_browser" && "$agent_browser" doctor >/dev/null 2>&1; then status OK "agent-browser $agent_browser"; else status FAIL "agent-browser missing or doctor failed"; fi
736
+
737
+ if runtime_firecrawl_enabled; then
738
+ firecrawl_runner="$(runtime_firecrawl_runner)"
739
+ case "$firecrawl_runner" in
740
+ installed)
741
+ firecrawl="$(runtime_command firecrawl FIRECRAWL_BIN firecrawl)"
742
+ if command_is_available "$firecrawl" && "$firecrawl" --help >/dev/null 2>&1; then status OK "Firecrawl runner installed ($firecrawl)"; else status SKIP "Firecrawl runner installed but firecrawl CLI is not installed"; fi ;;
743
+ npx)
744
+ if command_is_available npx; then status OK "Firecrawl runner npx"; else status SKIP "Firecrawl runner npx missing; install npm/npx or choose installed/bunx"; fi ;;
745
+ bunx)
746
+ if command_is_available bunx; then status OK "Firecrawl runner bunx"; else status SKIP "Firecrawl runner bunx missing; install Bun or choose installed/npx"; fi ;;
747
+ esac
748
+ else
749
+ status SKIP "Firecrawl optional fallback disabled"
750
+ fi
751
+
752
+ if pi list 2>/dev/null | grep -q 'pi-web-toolkit'; then
753
+ status OK "pi-web-toolkit package installed"
754
+ else
755
+ status WARN "pi-web-toolkit package not listed by pi"
756
+ fi
757
+
758
+ [ "$FAIL_COUNT" -eq 0 ] || exit 1
759
+ }
760
+
761
+ run_install() {
762
+ validate_config_if_present || die "Fix or remove invalid toolkit config before installing: $CONFIG_FILE"
763
+
764
+ if [ "$EXTENSION_ONLY" -eq 0 ]; then
765
+ ensure_prerequisites_or_exit
766
+ select_searxng
767
+ ensure_scrapling
768
+ ensure_agent_browser
769
+ ensure_firecrawl
770
+ write_toolkit_config
771
+ else
772
+ log "Skipping dependency setup (--extension-only)."
773
+ fi
774
+
775
+ install_pi_package
776
+
777
+ if [ "$EXTENSION_ONLY" -eq 0 ]; then
778
+ run_final_verification
779
+ fi
780
+
781
+ log ""
782
+ log "pi-web-toolkit installation summary"
783
+ log " Toolkit config: $CONFIG_FILE"
784
+ if [ -n "$SELECTED_SEARXNG_URL" ]; then log " SearXNG endpoint: $SELECTED_SEARXNG_URL ($SEARXNG_SOURCE)"; fi
785
+ if [ -n "$SCRAPLING_BIN" ]; then log " Scrapling: $SCRAPLING_BIN"; fi
786
+ if [ -n "$AGENT_BROWSER_BIN" ]; then log " agent-browser: $AGENT_BROWSER_BIN"; fi
787
+ if [ "$FIRECRAWL_FALLBACK" = "true" ]; then
788
+ if [ "$FIRECRAWL_RUNNER" = "installed" ]; then log " Firecrawl fallback: enabled (installed: $FIRECRAWL_BIN)"; else log " Firecrawl fallback: enabled ($FIRECRAWL_RUNNER)"; fi
789
+ else
790
+ log " Firecrawl fallback: disabled/skipped"
791
+ fi
792
+ log ""
793
+ log "Next step: Restart pi. If pi-web-toolkit was already loaded and only config changed, /reload may also work."
794
+ }
795
+
796
+ parse_args "$@"
797
+ if [ "$DOCTOR" -eq 1 ]; then
798
+ run_doctor
799
+ else
800
+ run_install
801
+ fi