Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·652 lines (631 loc) · 32.2 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·652 lines (631 loc) · 32.2 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
#!/bin/sh
# Lens in one line:
#
# curl -fsSL https://raw.githubusercontent.com/adeelahmad/lense/main/install.sh | sh
#
# Gets the code (or updates it), writes the secrets once, builds and starts the stack with Docker (or in a microsandbox
# microVM, if chosen), waits until the web app answers, then opens it on the setup page with the setup code already
# filled in. Run it again to update: the secrets, the database and the archive are kept.
#
# Settings, all optional (put them before `sh`: `... | LENS_DIR=/srv/lens sh`):
# LENS_DIR where Lens goes (default: ~/lens)
# LENS_REF the branch or tag to run (default: main)
# LENS_PORT the web app's port (default: 3000, or the next free one)
# LENS_PUBLIC 1: reachable from other machines on the network, 0: this machine only (default: 1 on a server
# without a desktop, else 0)
# LENS_TARGET full (reads Office files, web pages and emails too) or lean (smaller) (default: as the last run
# left it, else full)
# LENS_BACKEND docker or microsandbox (one microVM, no Docker: docs/microsandbox.md). Asked on a first install
# when microsandbox can run here (Linux with KVM, or a Mac with Apple silicon); default: docker
# LENS_DNS_FALLBACK in a microsandbox: the public resolvers it looks names up with when this machine's don't
# answer there, comma-separated (default: 1.1.1.1,9.9.9.9; empty: none; docs/microsandbox.md).
# Kept for later runs
# GITHUB_TOKEN a GitHub token to use when downloading (not needed; avoids GitHub's rate limits)
# LENS_NO_OPEN 1: don't open a browser
# LENS_YES 1: install what Lens needs without asking (it only asks at a terminal)
# LENS_HTTPS 1: publish the HTTPS and HTTP ports (443, 80; LENS_HTTPS_PORT / LENS_HTTP_PORT for others) so Lens
# can serve https:// with certificates it gets itself, set up in Settings › HTTPS (docs/https.md);
# 0: don't (default: as the last run left it, else 0)
# LENS_UPGRADE_AGENT 0: don't set up the agent that lets the web app install new releases (docs/updates.md)
# LENS_WAIT how long to wait for the web app to answer, in seconds (default: 1800 in a microsandbox, whose
# first start builds everything, else 600)
# LENS_WIZARD 1: answer everything in the web installer, in the browser (the default for a first install at a
# terminal, where Python 3 is there); 0: here in the terminal
set -eu
# All in a function, so nothing runs until the whole script has arrived (curl | sh streams it).
main() {
REPO="${LENS_REPO:-adeelahmad/lense}"
DIR="${LENS_DIR:-$HOME/lens}"
REF="${LENS_REF:-main}"
TARGET="${LENS_TARGET:-}"
say() { printf '\033[1m%s\033[0m\n' "$*"; }
fail() {
printf '\033[31m%s\033[0m\n' "$*" >&2
exit 1
}
have() { command -v "$1" >/dev/null 2>&1; }
secret() {
if have openssl; then openssl rand -hex 32; else od -An -N32 -tx1 /dev/urandom | tr -d ' \n'; fi
}
code() { LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c 12; }
os="$(uname -s)"
sudo_ok() { # the web installer hands sudo your password through SUDO_ASKPASS
if [ "$(id -u)" = 0 ]; then echo ""; elif [ -n "${SUDO_ASKPASS:-}" ]; then echo "sudo -A"; else echo sudo; fi
}
# --- What runs it: Docker, or a microsandbox microVM ----------------------------------------------------------------
# microsandbox needs hardware virtualization: KVM on 64-bit Linux (or a CPU that has it, whose module isn't loaded
# yet), Apple silicon on a Mac
msb_ok() {
case "$os/$(uname -m)" in
Linux/x86_64) [ -e /dev/kvm ] || grep -Eqw 'vmx|svm' /proc/cpuinfo 2>/dev/null ;;
Linux/aarch64) [ -e /dev/kvm ] ;;
Darwin/arm64) true ;;
*) false ;;
esac
}
tty_ok() { [ "${LENS_YES:-0}" != 1 ] && (: </dev/tty) 2>/dev/null; }
# has_db VOLUME: whether a microsandbox volume may hold a Lens database. Only one it can see has none counts as without:
# empty, or with run.sh's tools (.lens) and no database (surreal/lens.db) next to them. One it can't look into (not
# where microsandbox keeps them by default) counts as with.
has_db() {
vol="$HOME/.microsandbox/volumes/$1"
[ -d "$vol" ] || return 0
[ -n "$(ls -A "$vol" 2>/dev/null)" ] || return 1
! { [ -d "$vol/.lens" ] && [ ! -e "$vol/surreal/lens.db" ]; }
}
# --- The web installer: a first install at a terminal continues in the browser ----------------------------------------
# It asks everything there (what to install, how Lens runs, the AI model and its key, the extras), then runs this
# script again with the answers (LENS_WIZARD=0) and shows its progress. Python 3.8+ runs it; without it, the
# questions are asked here.
py_ok() {
have python3 && python3 -c 'import sys; sys.exit(sys.version_info < (3, 8))' 2>/dev/null || return 1
[ "$os" != Darwin ] || xcode-select -p >/dev/null 2>&1 # else python3 is a stub that pops up an install dialog
}
WIZARD="${LENS_WIZARD:-}"
if [ -z "$WIZARD" ]; then
WIZARD=0
[ ! -f "$DIR/.env" ] && [ -z "${LENS_BACKEND:-}" ] && tty_ok && WIZARD=1
fi
# Started from a launcher (Install Lens.app, the .desktop file) there is no te
8000
rminal to fall back to: say why in a
# window instead.
gui_fail() {
printf '%s\n' "$1" >&2
if ! (: </dev/tty) 2>/dev/null; then
if [ "$os" = Darwin ]; then
osascript -e "display dialog \"$1\" buttons {\"OK\"} default button 1 with title \"Install Lens\" with icon caution" >/dev/null 2>&1
elif have zenity; then
zenity --error --title "Install Lens" --text "$1" 2>/dev/null
elif have kdialog; then
kdialog --title "Install Lens" --error "$1" 2>/dev/null
elif have notify-send; then
notify-send "Install Lens" "$1" 2>/dev/null
fi
exit 1
fi
}
if [ "$WIZARD" = 1 ] && py_ok && have curl; then
wiz="$(mktemp -d)"
trap 'rm -rf "$wiz"' EXIT
base="${LENS_INSTALLER_URL:-https://raw.githubusercontent.com/$REPO/$REF/installer}"
if curl -fsSL "$base/lens_installer.py" -o "$wiz/lens_installer.py" && curl -fsSL "$base/page.html" -o "$wiz/page.html"; then
say "Opening the installer in your browser..."
LENS_INSTALLER_FETCH=1 python3 "$wiz/lens_installer.py" --dir "$DIR" --repo "$REPO" --ref "$REF"
exit $?
fi
gui_fail "Couldn't download Lens's installer. Check the internet connection, and try again."
echo "Couldn't download the web installer; asking here instead."
elif [ "$WIZARD" = 1 ]; then
have curl || gui_fail "Lens needs curl to download itself. Install curl (on Ubuntu: sudo apt install curl), then try again."
if [ "$os" = Darwin ] && ! xcode-select -p >/dev/null 2>&1; then
xcode-select --install 2>/dev/null || true
gui_fail "Lens's installer needs Apple's Command Line Tools. Install them in the window that opened, then open Install Lens again."
fi
gui_fail "Lens's installer needs Python 3.8 or later. Install Python 3 with your software center, then try again."
echo "The web installer needs Python 3.8 or later; asking here instead."
fi
BACKEND="${LENS_BACKEND:-}"
if [ -z "$BACKEND" ] && [ -f "$DIR/.env" ]; then # an existing install keeps what it runs on (its data is there)
BACKEND="$(grep '^LENS_BACKEND=' "$DIR/.env" | cut -d= -f2- || true)"
[ -n "$BACKEND" ] || BACKEND=docker
fi
if [ -z "$BACKEND" ]; then
BACKEND=docker
# asked only when there's someone to ask: piped into sh, the answer comes from the terminal, not stdin
if msb_ok && tty_ok; then
printf '\n\033[1mHow should Lens run?\033[0m\n'
echo " 1) Docker (default): a container for each part, the usual way"
echo " 2) microsandbox: one lightweight virtual machine, no Docker needed"
printf 'Choose [1]: '
read -r answer </dev/tty || answer=
case "$answer" in 2 | m*) BACKEND=microsandbox ;; esac
fi
fi
case "$BACKEND" in
docker) ;;
microsandbox) msb_ok || fail "microsandbox needs KVM on 64-bit Linux or a Mac with Apple silicon. Use LENS_BACKEND=docker." ;;
*) fail "LENS_BACKEND is docker or microsandbox, not $BACKEND." ;;
esac
case "${LENS_WAIT:-0}" in *[!0-9]*) fail "LENS_WAIT is a number of seconds, not ${LENS_WAIT:-}." ;; esac
# --- What's missing: listed, and installed once you say yes ---------------------------------------------------------
# Asked only at a terminal; piped in by a script or CI (no terminal, or LENS_YES=1) it goes ahead, as before.
export PATH="$HOME/.local/bin:$HOME/.microsandbox/bin:$PATH"
plan=""
need() { plan="$plan
- $*"; }
if [ "$BACKEND" = microsandbox ]; then
have msb || need "microsandbox (from install.microsandbox.dev, into ~/.microsandbox)"
if [ "$os" = Linux ]; then
[ -e /dev/kvm ] || need "the KVM kernel module (modprobe, with sudo)"
{ [ -r /dev/kvm ] && [ -w /dev/kvm ]; } || need "access to KVM for $(id -un) (the kvm group, with sudo)"
fi
else
if ! have docker; then
case "$os" in
Linux) need "Docker Engine with Compose (from get.docker.com, with sudo)" ;;
Darwin)
have brew || need "Homebrew (from brew.sh, asks for your password)"
need "OrbStack, which runs Docker on a Mac (with Homebrew)"
;;
esac
elif ! docker compose version >/dev/null 2>&1 &&
! have docker-compose && [ "$os" = Linux ]; then
need "the Docker Compose plugin (with your package manager, with sudo)"
fi
fi
if [ -n "$plan" ]; then
printf '\n\033[1mLens needs these, and will install them:\033[0m%s\n' "$plan"
if tty_ok; then
printf 'Install them now? [Y/n] '
read -r answer </dev/tty || answer=
case "$answer" in
n* | N*) fail "Nothing was installed. Install them yourself, or run this again and answer yes." ;;
esac
fi
fi
if [ "$BACKEND" = microsandbox ]; then
# --- microsandbox -----------------------------------------------------------------------------------------------
if ! have msb; then
say "Installing microsandbox (install.microsandbox.dev)..."
have curl || fail "curl is needed to install microsandbox."
msb_installer="$(curl -fsSL https://install.microsandbox.dev)" || fail "Couldn't download the microsandbox installer."
# Its installer asks GitHub's API for the latest release, which refuses busy shared addresses (CI, offices); the
# release page's redirect names it without that limit, so hand the installer the version when we can find it.
msb_repo="$(printf '%s\n' "$msb_installer" | sed -n 's/^GITHUB_REPO="\(.*\)"$/\1/p' | head -1)"
msb_tag=""
[ -n "$msb_repo" ] && msb_tag="$(curl -fsSLI -o /dev/null -w '%{url_effective}' "https://github.com/$msb_repo/releases/latest" 2>/dev/null | sed -n 's#.*/releases/tag/##p')"
if [ -n "$msb_tag" ]; then
printf '%s\n' "$msb_installer" | sed "s|^ get_latest_version\$| VERSION=$msb_tag|" | sh
else
printf '%s\n' "$msb_installer" | sh
fi
have msb || fail "microsandbox didn't install. See https://docs.microsandbox.dev, then run this again."
fi
if [ "$os" = Linux ] && [ ! -e /dev/kvm ]; then
<
8000
div>
say "Loading KVM..."
$(sudo_ok) modprobe kvm_intel 2>/dev/null || $(sudo_ok) modprobe kvm_amd 2>/dev/null || $(sudo_ok) modprobe kvm 2>/dev/null || true
[ -e /dev/kvm ] || fail "KVM didn't load. Turn on virtualization (VT-x or AMD-V) in the firmware settings, or use LENS_BACKEND=docker."
fi
if [ "$os" = Linux ] && ! { [ -r /dev/kvm ] && [ -w /dev/kvm ]; }; then
say "Letting $(id -un) use KVM (the kvm group)..."
$(sudo_ok) usermod -aG kvm "$(id -un)" 2>/dev/null || true
# the group counts from the next sign-in; for this run, access for this user alone
have setfacl && $(sudo_ok) setfacl -m "u:$(id -un):rw" /dev/kvm 2>/dev/null || true
[ -r /dev/kvm ] && [ -w /dev/kvm ] || fail "Can't use /dev/kvm. Sign out and in again (or run: msb doctor), then run this again."
fi
else
# --- Docker -------------------------------------------------------------------------------------------------------
if ! have docker; then
case "$os" in
Linux)
say "Installing Docker (get.docker.com)..."
have curl || fail "curl is needed to install Docker."
curl -fsSL https://get.docker.com | $(sudo_ok) sh
$(sudo_ok) systemctl enable --now docker 2>/dev/null || true
;;
Darwin)
if ! have brew && { tty_ok || [ -n "${SUDO_ASKPASS:-}" ]; }; then
say "Installing Homebrew (brew.sh)..."
brew_sh="$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
if tty_ok; then
/bin/bash -c "$brew_sh" </dev/tty
else # from the web installer: no questions, and sudo asks the installer for the password
NONINTERACTIVE=1 /bin/bash -c "$brew_sh" </dev/null
fi
for b in /opt/homebrew/bin/brew /usr/local/bin/brew; do [ -x "$b" ] && eval "$("$b" shellenv)" && break; done
fi
if have brew; then
say "Installing OrbStack, which runs Docker on a Mac (brew)..."
brew install --cask orbstack
open -a OrbStack
else
fail "Lens runs in Docker. Install OrbStack (https://orbstack.dev) or Docker Desktop, then run this again."
fi
;;
*) fail "Lens runs in Docker. Install Docker for your system, then run this again." ;;
esac
fi
DOCKER=docker
if ! docker info >/dev/null 2>&1; then
if [ "$os" = Linux ] && $(sudo_ok) docker info >/dev/null 2>&1; then
DOCKER="$(sudo_ok) docker" # not in the docker group yet: works for this run
else
say "Waiting for Docker to start..."
i=0
until docker info >/dev/null 2>&1; do
i=$((i + 1))
[ $i -gt 60 ] && fail "Docker isn't running. Start it, then run this again."
sleep 2
done
fi
fi
if $DOCKER compose version >/dev/null 2>&1; then
COMPOSE="$DOCKER compose"
elif have docker-compose; then
COMPOSE="docker-compose"
elif [ "$os" = Linux ] && {
if have apt-get; then $(sudo_ok) apt-get install -y -q docker-compose-plugin
elif have dnf; then $(sudo_ok) dnf install -y -q docker-compose-plugin
else false; fi
} >/dev/null 2>&1 && $DOCKER compose version >/dev/null 2>&1; then
COMPOSE="$DOCKER compose"
else
fail "Docker Compose is missing. Install the Compose plugin (docker-compose-plugin), then run this again."
fi
fi
# --- The code -----------------------------------------------------------------------------------------------------
auth_url="https://github.com/$REPO.git"
[ -n "${GITHUB_TOKEN:-}" ] && auth_url="https://x-access-token:$GITHUB_TOKEN@github.com/$REPO.git"
if [ -d "$DIR/.git" ]; then
say "Updating Lens in $DIR..."
git -C "$DIR" fetch -q --depth 1 "$auth_url" "$REF" && git -C "$DIR" checkout -q -B "$REF" FETCH_HEAD
elif have git; then
say "Getting Lens into $DIR..."
git clone -q --depth 1 --branch "$REF" "$auth_url" "$DIR" ||
fail "Couldn't get the code from GitHub ($REF). Check the network and LENS_REF, and try again."
git -C "$DIR" remote set-url origin "https://github.com/$REPO.git" # the token isn't kept on disk
else
say "Getting Lens into $DIR..."
mkdir -p "$DIR"
set -- -fsSL
[ -n "${GITHUB_TOKEN:-}" ] && set -- "$@" -H "Authorization: Bearer $GITHUB_TOKEN"
curl "$@" "https://api.github.com/repos/$REPO/tarball/$REF" | tar -xz -C "$DIR" --strip-components 1 ||
fail "Couldn't get the code from GitHub ($REF). Check the network and LENS_REF, and try again."
fi
cd "$DIR"
# --- The edition: the one asked for, else the one this install already has (a lean install stays lean), else full ---
[ -n "$TARGET" ] || TARGET="$( [ -f .env ] && grep '^LENS_TARGET=' .env | cut -d= -f2- || true)"
TARGET="${TARGET:-full}"
case "$TARGET" in full | lean) ;; *) fail "LENS_TARGET is full or lean, not $TARGET." ;; esac
# --- The port: the one asked for, else the one this install already uses, else the first free one from 3000 -------
free() { # nothing listening: curl can't connect (exit 7)
curl -s -o /dev/null --max-time 2 "http://127.0.0.1:$1/" 2>/dev/null
[ $? -eq 7 ]
}
PORT="${LENS_PORT:-$( [ -f .env ] && grep '^LENS_PORT=' .env | cut -d= -f2- || true)}"
if [ -z "$PORT" ]; then
PORT=3000
until free "$PORT"; do
PORT=$((PORT + 1))
[ "$PORT" -gt 3100 ] && fail "No free port between 3000 and 3100. Set LENS_PORT to one that is."
done
[ "$PORT" = 3000 ] || say "Port 3000 is taken, so Lens uses $PORT."
fi
# --- Where it's reached -------------------------------------------------------------------------------------------
if [ -z "${LENS_PUBLIC:-}" ] && [ -f .env ] && grep -q '^LENS_BIND=' .env; then # as the last run left it
LENS_PUBLIC=0
grep -q '^LENS_BIND=0.0.0.0' .env && LENS_PUBLIC=1
fi
if [ -z "${LENS_PUBLIC:-}" ]; then
LENS_PUBLIC=0
if [ "$os" = Linux ] && [ -z "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ]; then LENS_PUBLIC=1; fi # a server: used from elsewhere
fi
host=localhost
bind=127.0.0.1
if [ "$LENS_PUBLIC" = 1 ]; then
bind=0.0.0.0
ip="$(hostname -I 2>/dev/null | awk '{print $1}')"
[ -z "$ip" ] && ip="$(ipconfig getifaddr en0 2>/dev/null || true)"
[ -n "$ip" ] && host="$ip"
fi
# --- HTTPS (docs/https.md): the ports Caddy answers on, run by Lens while Settings › HTTPS has it on ---------------
if [ -z "${LENS_HTTPS:-}" ] && [ -f .env ]; then # as the last run left it
LENS_HTTPS="$(grep '^LENS_HTTPS=' .env | cut -d= -f2- || true)"
fi
[ "${LENS_HTTPS:-0}" = 1 ] || LENS_HTTPS=0
HTTPS_PORT="${LENS_HTTPS_PORT:-$( [ -f .env ] && grep '^LENS_HTTPS_PORT=' .env | cut -d= -f2- || true)}"
HTTP_PORT="${LENS_HTTP_PORT:-$( [ -f .env ] && grep '^LENS_HTTP_PORT=' .env | cut -d= -f2- || true)}"
HTTPS_PORT="${HTTPS_PORT:-443}"
HTTP_PORT="${HTTP_PORT:-80}"
# --- Secrets, once: changing them later would sign everyone out and make stored credentials unreadable ------------
setenv() { # key value: replace the line, or add it
if grep -q "^$1=" .env; then
sed "s|^$1=.*|$1=$2|" .env >.env.tmp && mv .env.tmp .env
else
printf '%s=%s\n' "$1" "$2" >>.env
fi
}
fresh=0 # a first install: its keys are new
if [ ! -f .env ]; then
fresh=1
umask 077
printf '# Written by install.sh. Keep it with the volumes.\n' >.env
setenv ACCESS_SECRET_KEY "$(secret)"
setenv ARCHIVE_SECRET_KEY "$(secret)"
setenv AUTH_SECRET "$(secret)"
setenv SURREAL_PASS "$(secret)"
fi
grep -q '^LENS_SETUP_CODE=' .env || setenv LENS_SETUP_CODE "$(code)" # only used until the first admin exists
setenv LENS_BACKEND "$BACKEND"
setenv LENS_TARGET "$TARGET"
setenv LENS_BIND "$bind"
setenv LENS_PORT "$PORT"
setenv FRONTEND_URL "http://$host:$PORT"
setenv LENS_REPO "$REPO"
setenv LENS_HTTPS "$LENS_HTTPS"
if [ "$LENS_HTTPS" = 1 ]; then
setenv LENS_HTTPS_PORT "$HTTPS_PORT"
setenv LENS_HTTP_PORT "$HTTP_PORT"
fi
setenv LENS_DIR "$DIR"
# the SMB share's port on this machine (docs/smb.md), when the install line names one: kept for later runs
case "${LENS_SMB_PORT:-}" in '' | *[!0-9]*) ;; *) setenv LENS_SMB_PORT "$LENS_SMB_PORT" ;; esac
# the microsandbox's public resolvers (microsandbox/dns.sh), when the install line names them, empty included: kept
[ -z "${LENS_DNS_FALLBACK+set}" ] || setenv LENS_DNS_FALLBACK "$(printf '%s' "$LENS_DNS_FALLBACK" | tr -s ' ' ',')"
# from the web installer (docs/configuration.md): the first namespace, and settings Lens saves once at its first start
for k in LENS_NAMESPACE LENS_SETUP_SETTINGS; do
eval "v=\${$k:-}"
[ -z "$v" ] || setenv "$k" "$v"
done
setup_code="$(grep '^LENS_SETUP_CODE=' .env | cut -d= -f2-)"
# --- The control folder: the web app asks for upgrades here, and reads how they went (docs/updates.md) -----------
# The containers write as their own user (uid 1000), so the inbox is open to them; the agent only ever installs a
# release tag or this install's branch, whoever writes the request.
mkdir -p .lens-control/inbox
chmod 755 .lens-control
chmod 777 .lens-control/inbox
# --- Build and start ----------------------------------------------------------------------------------------------
if [ "$BACKEND" = microsandbox ]; then
# the code it was built from: the sandbox builds again when this changes
{ git rev-parse HEAD 2>/dev/null || date +%s; } >.lens-build
# Memory: three quarters of this machine's, 2 to 8 GB (building the web app needs about 2)
if [ "$os" = Darwin ]; then mem=$(($(sysctl -n hw.memsize) / 1048576)); else mem=$(($(awk '/MemTotal/ {print $2}' /proc/meminfo) / 1024)); fi
mem=$((mem * 3 / 4))
[ "$mem" -lt 2048 ] && mem=2048
[ "$mem" -gt 8192 ] && mem=8192
cpus="$(getconf _NPROCESSORS_ONLN 2>/dev/null || echo 2)"
sensors="${LENS_SENSOR_BIND:-$bind}"
# The stock Python image, from mirrors that need no sign-in first: Docker Hub can refuse it ("Not authorized") when
# this machine has Docker Hub credentials that microsandbox can't use, and limits anonymous pulls.
say "Getting the base image..."
image=""
for ref in mirror.gcr.io/library/python:3.12-slim-bookworm public.ecr.aws/docker/library/python:3.12-slim-bookworm \
docker.io/library/python:3.12-slim-bookworm; do
if out="$(msb pull -q "$ref" 2>&1)"; then
image="$ref"
break
fi
done
[ -n "$image" ] || fail "Couldn't download the base image (python:3.12-slim-bookworm): $out"
# The internet, this network (mail servers, model servers) and this machine (Ollama, LM Studio); only the web app
# the sensor ports and the file shares' ports (S3, WebDAV) are published, as with Docker.
set -- run -d -q --name lens --replace -c "$cpus" -m "${mem}M" --root-disk 16G --net public,private,host \
-p "$bind:$PORT:3000" \
-p "$sensors:${LENS_MQTT_PORT:-1883}:1883" \
-p "$sensors:${LENS_SYSLOG_PORT:-5514}:5514/udp" -p "$sensors:${LENS_SYSLOG_PORT:-5514}:5514/tcp" \
-p "${LENS_SHARE_BIND:-$bind}:${LENS_S3_PORT:-9555}:9555" -p "${LENS_SHARE_BIND:-$bind}:${LENS_WEBDAV_PORT:-9556}:9556"
if [ "$LENS_HTTPS" = 1 ]; then # Caddy listens on 8443 and 8080 inside (docs/https.md)
set -- "$@" -p "${LENS_HTTPS_BIND:-0.0.0.0}:$HTTPS_PORT:8443" -p "${LENS_HTTPS_BIND:-0.0.0.0}:$HTTP_PORT:8080"
fi
# The volume its data is kept in (the database, the archive, the builds). A database opens only with the keys of
# the install that made it, which are in that install's .env. So a first install that finds the volume of an earlier
# one whose folder is gone (deleted, or installed elsewhere) starts on a volume of its own, and leaves that one as it
# is: its data is still there for that install's .env. A volume without a database yet is used as it is.
VOLUME="$(grep '^LENS_VOLUME=' .env | cut -d= -f2- || true)"
if [ -z "$VOLUME" ]; then
VOLUME=lens-data
if [ "$fresh" = 1 ]; then
volumes="$(msb volume ls -q </dev/null 2>/dev/null || true)"
n=1
while pri
8BD0
ntf '%s\n' "$volumes" | grep -qx "$VOLUME" && has_db "$VOLUME"; do
n=$((n + 1))
VOLUME="lens-data-$n"
done
if [ "$VOLUME" != lens-data ]; then
say "Starting Lens on a microsandbox volume of its own, $VOLUME..."
echo " The volume lens-data holds an earlier install's data, which opens only with that install's .env. It's left"
echo " as it is: msb volume rm lens-data removes it."
fi
fi
setenv LENS_VOLUME "$VOLUME"
fi
set -- "$@" -v "$PWD:/lens:ro" -v "$PWD/.lens-control:/control" --mount-named "$VOLUME:/data" \
--entrypoint /bin/sh "$image" -- /lens/microsandbox/run.sh
say "Starting Lens in a microsandbox (the first start installs and builds it, which takes a while)..."
# The command that starts it, kept for a restart of the machine: a sandbox started again (msb start) boots without
# running Lens, so it is always replaced; its packages come from the cache in the volume, its build stays there.
{
echo "#!/bin/sh"
echo "# Starts Lens in its microsandbox (written by install.sh)"
printf 'exec %s' "'$(command -v msb)'"
for a in "$@"; do printf " '%s'" "$a"; done
echo
} >.lens-sandbox
chmod +x .lens-sandbox
# Run again: the running sandbox stops first, so its database closes cleanly (a replace kills it after 10 seconds)
# and its port is free for the new one
if msb ls --running -q </dev/null 2>/dev/null | grep -qx lens; then
say "Stopping the Lens that's running..."
msb stop -q -t 60 lens </dev/null >/dev/null 2>&1 || msb stop -q -f lens </dev/null >/dev/null 2>&1 || true
fi
i=0
until free "$PORT"; do
i=$((i + 1))
if [ $i -gt 15 ]; then
who="$(lsof -nP -iTCP:"$PORT" -sTCP:LISTEN 2>/dev/null | awk 'NR == 2 {print $1 " (process " $2 ")"}' || true)"
fail "Port $PORT is in use${who:+ by $who}, so Lens can't start on it. Stop that, or run this again with LENS_PORT set to a free port."
fi
sleep 2
done
since="$(date -u +%Y-%m-%dT%H:%M:%SZ)" # what the new sandbox logs, for the progress below
./.lens-sandbox >/dev/null
LOGS="msb logs -f lens"
TAIL="msb logs lens --tail 40"
STOP="msb stop lens"
WAIT=1800 # 30 minutes: the first start installs the system packages and builds everything
# back after a restart of the machine, as Docker's restart policy does
if [ "$os" = Linux ] && have systemctl && [ -d /run/systemd/system ]; then
unit=/etc/systemd/system/lens-microsandbox.service
printf '%s\n' "[Unit]" "Description=Lens (microsandbox)" "After=network-online.target" "Wants=network-online.target" "" \
"[Service]" "Type=oneshot" "RemainAfterExit=yes" "User=$(id -un)" "ExecStart=$PWD/.lens-sandbox" \
"ExecStop=$(command -v msb) stop lens" "" "[Install]" "WantedBy=multi-user.target" |
$(sudo_ok) tee "$unit" >/dev/null && $(sudo_ok) systemctl enable lens-microsandbox.service >/dev/null 2>&1 || true
elif [ "$os" = Darwin ]; then
plist="$HOME/Library/LaunchAgents/dev.lens.microsandbox.plist"
mkdir -p "$(dirname "$plist")"
cat >"$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>dev.lens.microsandbox</string>
<key>ProgramArguments</key><array><string>$PWD/.lens-sandbox</string></array>
<key>RunAtLoad</key><true/>
</dict></plist>
PLIST
fi
else
files="-f docker-compose.prod.yml"
[ "$LENS_HTTPS" = 1 ] && files="$files -f docker-compose.https.yml" # the HTTPS ports (docs/https.md)
say "Building and starting Lens (the first build takes a few minutes)..."
# shellcheck disable=SC2086
$COMPOSE $files up -d --build
LOGS="$COMPOSE $files logs"
TAIL="$COMPOSE $files logs --tail 40"
STOP="$COMPOSE $files down"
WAIT=600
fi
# --- The upgrade agent: runs packaging/host/lens-upgrade.sh when the web app asks for a new release ---------------
# systemd on Linux, launchd on a Mac; not set up again by an upgrade it runs (LENS_AGENT_RUN)
if [ "${LENS_UPGRADE_AGENT:-1}" != 0 ] && [ "${LENS_AGENT_RUN:-0}" != 1 ]; then
agent=""
if [ "$os" = Linux ] && have systemctl && [ -d /run/systemd/system ]; then
# the user who installed it, unless only root can use Docker here
run_as="$(id -un)"
case "${DOCKER:-docker}" in sudo*) run_as=root ;; esac
unit=/etc/systemd/system/lens-upgrade
if printf '%s\n' "[Unit]" "Description=Lens upgrade agent: installs the release the web app asks for" "" \
"[Path]" "PathExistsGlob=$PWD/.lens-control/inbox/upgrade" "" "[Install]" "WantedBy=multi-user.target" |
$(sudo_ok) tee "$unit.path" >/dev/null 2>&1 &&
printf '%s\n' "[Unit]" "Description=Lens upgrade (docs/updates.md)" "After=network-online.target" "" \
"[Service]" "Type=oneshot" "User=$run_as" "Environment=HOME=$HOME" \
"Environment=PATH=$HOME/.local/bin:$HOME/.microsandbox/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin" \
"ExecStart=/bin/sh $PWD/packaging/host/lens-upgrade.sh $PWD" |
$(sudo_ok) tee "$unit.service" >/dev/null 2>&1 &&
$(sudo_ok) systemctl daemon-reload >/dev/null 2>&1 &&
$(sudo_ok) systemctl enable --now lens-upgrade.path >/dev/null 2>&1; then
agent=systemd
fi
elif [ "$os" = Darwin ] && have launchctl; then
plist="$HOME/Library/LaunchAgents/dev.lens.upgrade.plist"
mkdir -p "$(dirname "$plist")"
cat >"$plist" <<PLIST
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0"><dict>
<key>Label</key><string>dev.lens.upgrade</string>
<key>ProgramArguments</key><array><string>/bin/sh</string><string>$PWD/packaging/host/lens-upgrade.sh</string><string>$PWD</string></array>
<key>WatchPaths</key><array><string>$PWD/.lens-control/inbox</string></array>
<key>EnvironmentVariables</key><dict><key>PATH</key><string>$HOME/.orbstack/bin:$HOME/.microsandbox/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string></dict>
</dict></plist>
PLIST
launchctl bootout "gui/$(id -u)" "$plist" >/dev/null 2>&1 || true
launchctl bootstrap "gui/$(id -u)" "$plist" >/dev/null 2>&1 && agent=launchd
fi
if [ -n "$agent" ]; then
echo "kind=$agent" >.lens-control/agent
chmod 644 .lens-control/agent # the containers read it (a first install writes with umask 077, for .env)
else
rm -f .lens-control/agent # the web app shows the line to run instead
fi
fi
say "Waiting for the web app..."
# the page and the API behind it: the setup page needs both, and the API starts a few seconds after the page. Each
# try gives up after a few seconds: a port that takes the connection but never answers mustn't hold this forever.
up() {
curl -fsS -o /dev/null --max-time 5 "http://127.0.0.1:$PORT/login" 2>/dev/null &&
curl -fsS -o /dev/null --max-time 5 "http://127.0.0.1:$PORT/api/v1/auth/status" 2>/dev/null
}
# why it stopped waiting, after the last lines Lens logged
stuck() {
echo "The last lines Lens logged:"
$TAIL 2>&1 </dev/null | tail -n 40 || true
fail "$1 See everything: cd $DIR && $LOGS"
}
# the steps the sandbox reports (run.sh's "[lens] ..." lines), each shown once; it stops waiting when one failed or
# the sandbox stopped
seen=0 down=0 step=""
sandbox_progress() {
lines="$(msb logs lens --since "$since" --grep '\[lens\]' </dev/null 2>/dev/null | grep '^\[lens\] ' || true)"
n="$(printf '%s\n' "$lines" | grep -c . || true)"
if [ "$n" -gt "$seen" ]; then
# as steps of their own (the web installer lists them)
printf '%s\n' "$lines" | sed -n "$((seen + 1)),\$p" | sed 's/^\[lens\] //' | while IFS= read -r line; do say " $line"; done
seen=$n
shown=$now
step="$(printf '%s\n' "$lines" | tail -n 1 | sed 's/^\[lens\] //; s/[.][.][.]$//')"
fi
failed="$(printf '%s\n' "$lines" | sed -n 's/^\[lens\] \(Failed.*\)/\1/p' | tail -n 1)"
[ -z "$failed" ] || stuck "Lens couldn't start in its microsandbox. $failed."
# a part that stops again and again (run.sh starts it again each time) won't start by waiting longer
looping="$(printf '%s\n' "$lines" | sed -n 's/^\[lens\] \([a-z]*\) stopped; starting it again.*/\1/p' | sort | uniq -c |
awk '$1 >= 3 {print $2; exit}')"
case "$looping" in
'') ;;
surrealdb) stuck "Lens's database keeps stopping in its microsandbox." ;;
api) stuck "Lens's API keeps stopping in its microsandbox." ;;
web) stuck "Lens's web app keeps stopping in its microsandbox." ;;
worker) stuck "Lens's job worker keeps stopping in its microsandbox." ;;
*) stuck "Lens's $looping keeps stopping in its microsandbox." ;;
esac
if running="$(msb ls --running -q </dev/null 2>/dev/null)"; then # twice in a row, as it may be booting
if printf '%s\n' "$running" | grep -qx lens; then down=0; else down=$((down + 1)); fi
fi
[ $down -lt 2 ] || stuck "Lens's microsandbox stopped${step:+ at: $step}."
}
WAIT="${LENS_WAIT:-$WAIT}"
start=$(date +%s)
shown=$start checked=0
until up; do
now=$(date +%s)
if [ "$BACKEND" = microsandbox ] && [ $((now - checked)) -ge 10 ]; then
checked=$now
sandbox_progress
fi
if [ $((now - start)) -ge "$WAIT" ]; then
if [ "$WAIT" -lt 120 ]; then within="$WAIT seconds"; else within="$((WAIT / 60)) minutes"; fi
why="Lens didn't answer on port $PORT within $within${step:+ (its last step: $step)}."
# up in the sandbox, but not on this machine's port: the port is the problem, not Lens
if [ "$BACKEND" = microsandbox ] &&
msb exec lens -- curl -fsS -o /dev/null --max-time 5 http://127.0.0.1:3000/login </dev/null >/dev/null 2>&1; then
why="Lens is running in its microsandbox, but port $PORT on this machine doesn't reach it. Is another program using the port?"
fi
stuck "$why"
fi
if [ $((now - shown)) -ge 60 ]; then # never silent for long
echo " still starting ($(((now - start) / 60)) min so far)..."
shown=$now
fi
sleep 2
done
url="http://$host:$PORT/setup?code=$setup_code"
say "Lens is running: $url"
echo " (once the admin account exists, the link opens the sign-in page)"
echo " Stop: cd $DIR && $STOP Update: run the same line again"
if [ "${LENS_NO_OPEN:-0}" != 1 ]; then
if [ "$os" = Darwin ]; then
open "$url" 2>/dev/null || true
elif [ -n "${DISPLAY:-}${WAYLAND_DISPLAY:-}" ] && have xdg-open; then
xdg-open "$url" >/dev/null 2>&1 || true
fi
fi
}
main "$@"