Repository navigation
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·784 lines (747 loc) · 37.1 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·784 lines (747 loc) · 37.1 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
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
#!/usr/bin/env bash
# Install VGS files into your home directory. Required system packages
# install in the caller's terminal or a floating terminal through the
# package runner, which owns
# elevation and its authentication prompt.
#
# curl -fsSL https://raw.githubusercontent.com/vanillagreencom/vgshell/main/install.sh | bash
# curl -fsSL https://raw.githubusercontent.com/vanillagreencom/vgshell/main/install.sh | bash -s -- [OPTIONS]
#
# install.sh [--version X.Y.Z] [--force] the newest release, or release X.Y.Z
# install.sh --git [--force] a clone of main
# install.sh --uninstall [--force] remove what this script installed
#
# A release lands in ${XDG_DATA_HOME:-~/.local/share}/vgshell/X.Y.Z. The link
# vgshell/current names it and is replaced by rename alone, and ~/.local/bin/vgshell
# links to vgshell/current/bin/vgshell: the curl layout `vgshell self status` detects
# and `vgshell self update` keeps (docs/architecture/distribution.md
# § Installation ownership). --git clones main into vgshell/git and links ~/.local/bin/vgshell to
# it. Every write under vgshell/ holds flock on vgshell/.self.lock.
#
# Before it writes anything it refuses root, a system other than Linux, an
# installed system package (/usr/bin/vgshell, or vgshell or vgshell-git in the pacman
# or rpm database) and a ~/.local/bin/vgshell it did not make; --force
# skips the last two. It checks the floor: Quickshell 0.3.1, Hyprland 0.56,
# node 18, python3, git, flock and setpriv, plus xdg-terminal-exec when
# the caller has no terminal, and
# curl, tar, gzip and
# sha256sum for a release. Each version probe runs under a private runtime
# directory, so a shell with no login session reads the true floor. On a
# miss it names each tool and the command that installs it on this
# distribution, and exits 78.
# A tool it finds but whose version it cannot read is named with the
# probe's exit status and last error lines instead of an install command.
# Required runtime packages install through the staged tree's existing
# presenter and package runner before the tree is published.
#
# A release is read from the GitHub API and downloaded over HTTPS into a
# temporary directory. The archive must match its one line in SHA256SUMS;
# SHA256SUMS must carry a good signature from the release key when the
# release has SHA256SUMS.asc and gpg holds that key. Only a verified archive
# reaches vgshell/: it is unpacked in a staging directory there, installed by
# its own packaging/install-system.sh and moved into place. A failure leaves
# vgshell/current and ~/.local/bin/vgshell as they were. No version directory is
# removed here; `vgshell self update` prunes them.
#
# --uninstall removes the version directories, vgshell/current, vgshell/git and the
# link, and keeps ${XDG_CONFIG_HOME:-~/.config}/vgshell,
# ${XDG_STATE_HOME:-~/.local/state}/vgshell and vgshell/.self.lock. It refuses while
# the running shell was started from a tree it would remove, and, unless
# --force, a vgshell/git holding changes, a stash or commits no remote branch
# has.
#
# It never starts the shell, writes a service unit or edits hyprland.lua. It
# prints the line that starts VGS with Hyprland in the background.
#
# Every refusal is one line on stderr, `install.sh: refused: <key>=<value>`,
# then English. Exit 1 for a refusal, 2 for a bad argument, 75 while another
# writer holds vgshell/.self.lock, 78 below the floor.
#
# The whole script is one brace group: main's definition, then its call
# with the script's arguments. A download cut short ends inside the group,
# which bash reads as a syntax error, so it runs none of it; not even a cut
# after the word `main` runs main without its arguments.
{
main() {
set -euo pipefail
umask 022
repository=vanillagreencom/vgshell
api_base=https://api.github.com
git_url="https://github.com/$repository.git"
# The OpenPGP fingerprint of the key that signs a release's SHA256SUMS.
# Empty while VGS publishes no release signing key.
release_key="8BC162233D519169B9574148CC6862AD90B7D7EA"
# The seconds and bytes each fetch may take, as bin/lib/self.js bounds a
# curl update's: the release query, then each download.
api_seconds=10
download_seconds=120
max_document=$((1024 * 1024))
max_archive=$((128 * 1024 * 1024))
max_sums=$((64 * 1024))
# curl's protocol for every fetch and redirect, and the prefix every
# asset URL must carry.
proto="=https"
scheme="https://"
# The floor, one row per tool: the name a refusal carries, the version it
# needs (`present`: any version the pattern reads), the extended regex
# whose first group reads the version from the probe's stdout, and the
# probe. The rows repeat bin/vgshell's preflight_floor, whose figures
# docs/architecture/runtime.md § Process states, and add the other
# required rows of config/requirements.json. Hyprland is probed through its binary,
# which answers with no compositor running; bin/vgshell asks the running
# compositor through hyprctl. floor_check runs every probe under a private
# XDG_RUNTIME_DIR, which the binary needs even for --version, so a shell
# with no login session reads the true floor. scripts/test-install-sh.sh
# fails when a row drifts from those sources.
floor='
quickshell 0.3.1 ^Quickshell[[:space:]]([0-9]+(\.[0-9]+)*) qs --version
hyprland 0.56 ^Hyprland[[:space:]]([0-9]+(\.[0-9]+)*) Hyprland --version
node 18 ^v([0-9]+(\.[0-9]+)*) node --version
python3 present ^Python[[:space:]]([0-9]+(\.[0-9]+)*) python3 --version
git present ^git[[:space:]]version[[:space:]]([0-9]+(\.[0-9]+)*) git --version
flock present ^flock[[:space:]]from[[:space:]]util-linux[[:space:]]([0-9]+(\.[0-9]+)*) flock --version
setpriv present ^setpriv[[:space:]]from[[:space:]]util-linux[[:space:]]([0-9]+(\.[0-9]+)*) setpriv --version
'
# A caller without a controlling terminal needs the floating launcher.
start_tools=(xdg-terminal-exec)
install_terminal=false
if { : 2>/dev/null <>/dev/tty; }; then
install_terminal=true
start_tools=()
fi
# The tools a release install runs besides the floor: presence alone.
release_tools=(curl tar gzip sha256sum)
# Required runtime packages, judged with every package recipe by
# scripts/check-packaging.js. AUR packages form a separate install group.
runtime_packages='
pacman bash bubblewrap chromium coreutils curl dbus fd ffmpeg file fontconfig fzf gawk git glib2 gpu-screen-recorder grim gum hyprland hyprpicker imagemagick iproute2 less libnotify power-profiles-daemon libpulse libsecret libxkbcommon mise networkmanager nodejs pacman-contrib pipewire pipewire-audio playerctl python qrencode quickshell rclone sed slurp systemd tesseract tesseract-data-eng util-linux uv wireplumber wl-clipboard wtype xdg-terminal-exec xdg-utils xkeyboard-config
aur agent-browser-bin ttfx vsys wlrctl
apt bash bubblewrap chromium coreutils curl dash dbus-bin ffmpeg file fontconfig fzf gawk git gpu-screen-recorder-cli grim gum hyprland hyprpicker imagemagick iproute2 less libglib2.0-bin libnotify-bin libsecret-tools libxkbcommon0 network-manager nodejs pipewire-bin playerctl power-profiles-daemon procps pulseaudio-utils python3 qrencode quickshell rfkill sed slurp systemd tesseract-ocr tesseract-ocr-eng util-linux wireplumber wl-clipboard wlrctl wtype xdg-terminal-exec xdg-utils xkb-data
dnf ImageMagick NetworkManager bash bubblewrap chromium coreutils curl dbus-tools fd-find ffmpeg-free file fontconfig fzf gawk git glib2 grim gum hyprland hyprpicker iproute less libnotify libsecret libxkbcommon nodejs ppd-service pipewire-utils playerctl pulseaudio-utils python3 qrencode quickshell rclone sed slurp systemd tesseract tesseract-langpack-eng util-linux util-linux-core uv wireplumber wl-clipboard wlrctl wtype xdg-terminal-exec xdg-utils xkeyboard-config
nix bash bubblewrap chromium coreutils curl dbus fd ffmpeg file fontconfig fzf gawk git glib gpu-screen-recorder grim gum hyprland hyprpicker imagemagick iproute2 less libnotify libsecret libxkbcommon mise networkmanager nodejs pipewire playerctl power-profiles-daemon pulseaudio python3 qrencode quickshell rclone gnused slurp systemd tesseract util-linux uv wireplumber wl-clipboard wlrctl wtype xdg-terminal-exec xdg-utils xkeyboard_config
'
# The package that provides each floor tool, per primary manager. The rows
# for required commands are config/requirements.json's. A
# manager with no entry for quickshell or hyprland has no package in its
# own repositories that meets the floor (docs/architecture/distribution.md).
packages='
quickshell pacman=quickshell apt=quickshell nix=quickshell
hyprland pacman=hyprland apt=hyprland nix=hyprland
node pacman=nodejs apt=nodejs dnf=nodejs nix=nodejs
python3 pacman=python apt=python3 dnf=python3 nix=python3
git pacman=git apt=git dnf=git nix=git
flock pacman=util-linux apt=util-linux dnf=util-linux-core nix=util-linux
setpriv pacman=util-linux apt=util-linux dnf=util-linux nix=util-linux
xdg-terminal-exec pacman=xdg-terminal-exec apt=xdg-terminal-exec dnf=xdg-terminal-exec nix=xdg-terminal-exec
'
# The primary package managers, as the primary rows of
# shell/Core/PackageManagers.js state them: id, the os-release identifiers
# it serves, the one release it serves as that release's os-release
# assignment (`-` for every release), its binaries (preferred first),
# whether an install needs root, and the install arguments before the
# package names, `-` where VGS plans no install.
managers='
pacman arch - pacman root -S --needed --
apt debian VERSION_CODENAME=forky apt-get root install
dnf fedora - dnf5,dnf root install
nix nixos - nix user -
'
# The distributions whose curl install completes: one per primary row.
supported='VGS installs on Arch Linux, Fedora 44, Debian 14 (forky) and NixOS.'
refuse() { # STATUS KEY [ENGLISH...]
local status="$1"
printf 'install.sh: refused: %s\n' "$2" >&2
shift 2
if (($# > 0)); then printf '%s\n' "$@" >&2; fi
exit "$status"
}
usage() {
cat <<'EOF'
usage: install.sh [--version X.Y.Z] [--force]
install.sh --git [--force]
install.sh --uninstall [--force]
EOF
}
# True when HAVE >= NEED, compared as numbers component by component; a
# missing component is 0, so 0.56 equals 0.56.0 and 0.10 exceeds 0.3.1.
version_at_least() { # HAVE NEED: dotted decimal integers
local -a have need
local i h w
IFS=. read -ra have <<<"$1"
IFS=. read -ra need <<<"$2"
for ((i = 0; i < ${#have[@]} || i < ${#need[@]}; i++)); do
h=$((10#${have[i]:-0}))
w=$((10#${need[i]:-0}))
((h > w)) && return 0
((h < w)) && return 1
done
return 0
}
# The value os-release(5) assigns KEY in FILE, or nothing, read as
# PackageManagers.osReleaseValue reads it: the last assignment wins, and
# a quoted value loses its quotes.
os_release_value() { # FILE KEY
local line value="" raw
while IFS= read -r line || [[ -n $line ]]; do
line="${line#"${line%%[![:space:]]*}"}"
line="${line%"${line##*[![:space:]]}"}"
[[ $line == "$2="* ]] || continue
raw="${line#*=}"
if ((${#raw} >= 2)) && [[ ($raw == \"*\" || $raw == \'*\') ]]; then
if [[ ${raw:0:1} == \" ]]; then
raw="${raw:1:${#raw}-2}"
raw="$(sed -E 's/\\([\\"$`])/\1/g' <<<"$raw")"
else
raw="${raw:1:${#raw}-2}"
fi
fi
value="$raw"
done <"$1"
printf '%s' "$value"
}
# Prints `<manager> <binary>` for this system's primary package manager,
# as PackageManagers.detect picks it: the first os-release identifier, ID
# then each ID_LIKE token, that a row's family holds while one of the
# row's binaries is on PATH. A row with a release serves through ID
# alone, on a system that makes the release's assignment. Prints nothing
# when no row serves.
primary_manager() {
local file="" candidate token id like m family release have bins rest b
local -a tokens binaries
for candidate in /etc/os-release /usr/lib/os-release; do
if [[ -r $candidate ]]; then file="$candidate"; break; fi
done
[[ -n $file ]] || return 0
id="$(os_release_value "$file" ID)"
like="$(os_release_value "$file" ID_LIKE)"
read -ra tokens <<<"$id $like"
for token in "${tokens[@]}"; do
while read -r m family release bins rest; do
[[ -n $m && ",$family," == *",$token,"* ]] || continue
if [[ $release != - ]]; then
[[ $token == "$id" ]] || continue
have="$(os_release_value "$file" "${release%%=*}")"
[[ $have == "${release#*=}" ]] || continue
fi
IFS=, read -ra binaries <<<"$bins"
for b in "${binaries[@]}"; do
if command -v -- "$b" >/dev/null; then
printf '%s %s\n' "$m" "$b"
return 0
fi
done
done <<<"$managers"
done
}
# Prints TOOL's package for MANAGER, or fails when the table names none.
package_for() { # TOOL MANAGER
local tool pairs pair
while read -r tool pairs; do
[[ $tool == "$1" ]] || continue
for pair in $pairs; do
if [[ ${pair%%=*} == "$2" ]]; then
printf '%s\n' "${pair#*=}"
return 0
fi
done
done <<<"$packages"
return 1
}
# Checks the floor and EXTRA tools. On a miss, prints one refusal line
# per tool, then why each installed tool's version could not be read,
# then what installs the others on this system, and exits 78. A tool on
# PATH whose probe fails or prints no version is `have=unknown` and gets
# no install command: installing it again would not change the probe.
#
# Each probe runs under a private, empty XDG_RUNTIME_DIR in $tmp.
# Hyprland --version throws before it reads its arguments when that
# variable is unset, so a shell with no login session (su -, ssh without
# systemd-logind, a container) would otherwise read an installed Hyprland
# as unreadable.
floor_check() { # EXTRA_TOOL...
local tool need pattern probe out line have status start manager="" binary="" m family release bins elevate args pkg
local -a argv lines misses=() names=() unread=() unpackaged=()
mkdir -m 700 -- "$tmp/runtime" || refuse 1 "runtime=failed path=$tmp/runtime"
while read -r tool need pattern probe; do
[[ -n $tool ]] || continue
read -ra argv <<<"$probe"
if ! command -v -- "${argv[0]}" >/dev/null; then
misses+=("$tool none $need")
continue
fi
status=0
out="$(XDG_RUNTIME_DIR="$tmp/runtime" "${argv[@]}" 2>"$tmp/probe.err" </dev/null)" || status=$?
if ((status == 0)) && [[ $out =~ $pattern ]]; then
have="${BASH_REMATCH[1]}"
[[ $need == present ]] || version_at_least "$have" "$need" || misses+=("$tool $have $need")
continue
fi
misses+=("$tool unknown $need")
if ((status != 0)); then
unread+=("$tool is installed, but its version could not be read: $probe exited $status:")
else
unread+=("$tool is installed, but its version could not be read: $probe printed no version:")
fi
# The probe's last lines: stderr, else stdout.
mapfile -t lines <"$tmp/probe.err"
if ((${#lines[@]} == 0)) && [[ -n $out ]]; then mapfile -t lines <<<"$out"; fi
start=$((${#lines[@]} > 5 ? ${#lines[@]} - 5 : 0))
for line in "${lines[@]:start}"; do unread+=(" $line"); done
done <<<"$floor"
for tool in "$@"; do
command -v -- "$tool" >/dev/null || misses+=("$tool none present")
done
((${#misses[@]} > 0)) || return 0
for tool in "${misses[@]}"; do
read -r tool have need <<<"$tool"
printf 'install.sh: refused: floor=%s have=%s need=%s\n' "$tool" "$have" "$need" >&2
[[ $have == unknown ]] || names+=("$tool")
done
if ((${#unread[@]} > 0)); then printf '%s\n' "${unread[@]}" >&2; fi
((${#names[@]} > 0)) || exit 78
read -r manager binary <<<"$(primary_manager)" || true
if [[ -z $manager ]]; then
printf 'No supported package manager found: install %s with your distribution'"'"'s package manager.\n' "${names[*]}" >&2
printf '%s\n' "$supported" >&2
exit 78
fi
local -a wanted=()
for tool in "${names[@]}"; do
if pkg="$(package_for "$tool" "$manager")"; then wanted+=("$pkg"); else unpackaged+=("$tool"); fi
done
while read -r m family release bins elevate args; do
[[ $m == "$manager" ]] || continue
if ((${#wanted[@]} == 0)); then
:
elif [[ $args == - ]]; then
printf 'Add these %s packages to your system configuration: %s\n' "$manager" "${wanted[*]}" >&2
elif [[ $elevate == root ]]; then
printf 'Install them as root: %s %s %s\n' "$binary" "$args" "${wanted[*]}" >&2
else
printf 'Install them: %s %s %s\n' "$binary" "$args" "${wanted[*]}" >&2
fi
done <<<"$managers"
for tool in "${unpackaged[@]}"; do
printf '%s: %s has no package VGS knows of that meets the floor; see https://github.com/%s/blob/main/docs/architecture/distribution.md\n' "$tool" "$manager" "$repository" >&2
done
exit 78
}
# The existing presenter runs the package runner, which owns elevation.
# The staged tree stays until its recorded run ends. Publication follows
# only after every required install group succeeds.
runtime_install() { # TREE
local tree="$1" detected selection manager names record code run row rest manifest id key="core/requirements-install"
local -a groups packages command
detected="$("$tree/bin/vgshell" pkg detect --json)" || refuse 1 "requirements=detect-failed"
selection="$(node -e '
const found = JSON.parse(process.argv[1]);
if (found.primary === null) process.exit(1);
process.stdout.write(found.primary.id + (found.overlays.some(row => row.id === "aur") ? " aur" : ""));
' "$detected")" || refuse 78 "requirements=manager-missing" "The required packages need a supported package manager." "$supported"
read -ra groups <<<"$selection"
if [[ ${groups[0]} == pacman && " $selection " != *" aur "* ]]; then
refuse 78 "requirements=manager-missing manager=aur" "The required AUR packages need an installed AUR helper."
fi
for manager in "${groups[@]}"; do
names=""
while read -r row rest; do
[[ $row != "$manager" ]] || names="$rest"
done <<<"$runtime_packages"
[[ -n $names ]] || refuse 78 "requirements=unsupported manager=$manager"
if [[ $manager == nix ]]; then
# Doctor owns the core report. Plugin requirements owns the same
# report for each shipped plugin, including ones disabled by default.
# An unmapped command is an approved channel gap, judged by packaging,
# and an on-request one installs from its plugin's Requirements.
"$tree/bin/vgshell" doctor --json >"$tmp/requirements.jsonl" || refuse 1 "requirements=report-failed manager=nix"
for manifest in "$tree/shell/plugins"/*/manifest.json; do
[[ -f $manifest ]] || continue
id="$(node "$tree/bin/vgshell-plugin-judge" id "${manifest%/manifest.json}")" || refuse 1 "requirements=report-failed manager=nix"
"$tree/bin/vgshell" plugin requirements --json "$id" >>"$tmp/requirements.jsonl" || refuse 1 "requirements=report-failed manager=nix"
done
names="$(node -e '
const fs = require("fs");
const reports = fs.readFileSync(process.argv[1], "utf8").trim().split("\n").map(line => JSON.parse(line));
const rows = reports[0].core.concat(...reports.slice(1));
const missing = new Set(rows.filter(row => !row.optional && !row.onRequest && row.state === "missing" && row.packages.nix !== undefined).map(row => row.packages.nix));
process.stdout.write([...missing].sort().join(" "));
' "$tmp/requirements.jsonl")" || refuse 1 "requirements=report-unreadable manager=nix"
[[ -z $names ]] || refuse 78 "requirements=configuration manager=nix" "Add these required packages to the Nix configuration: $names"
continue
fi
read -ra packages <<<"$names"
command=("$tree/bin/vgshell" pkg run install --manager "$manager" "${packages[@]}")
if [[ $install_terminal == true ]]; then
code=0
"$tree/bin/vgshell-tui" present --presentation plain -- "${command[@]}" </dev/tty || code=$?
else
run="install-$$-$manager"
"$tree/bin/vgshell-tui" launch --title "Install requirements" --record "$key" --run "$run" -- \
"${command[@]}" || refuse 1 "requirements=launch-failed manager=$manager"
record="$("$tree/bin/vgshell-tui" wait --record "$key" --run "$run")" || refuse 1 "requirements=wait-failed manager=$manager"
code="$(node -e '
const record = JSON.parse(process.argv[1]);
if (record.state !== "ended" || !Number.isInteger(record.code)) process.exit(1);
process.stdout.write(String(record.code));
' "$record")" || refuse 1 "requirements=result-unreadable manager=$manager"
fi
[[ $code == 0 ]] || refuse 1 "requirements=install-failed manager=$manager code=$code"
done
}
# Refuses when a system package installed VGS: /usr/bin/vgshell exists, or
# the pacman or rpm database holds vgshell or vgshell-git. A database a
# query cannot read counts as not holding it. A test run reads the path
# under its own $system_root, so the host's package stays out of its rows.
system_package() {
local name
if [[ -e $system_root/usr/bin/vgshell || -L $system_root/usr/bin/vgshell ]]; then
refuse 1 "system=package path=/usr/bin/vgshell" \
"a system package installed VGS: update it with its package manager, or pass --force for a second, user-local copy"
fi
for name in vgshell vgshell-git; do
if command -v pacman >/dev/null && pacman -Q -- "$name" >/dev/null 2>&1; then
refuse 1 "system=package manager=pacman package=$name" "pass --force for a second, user-local copy"
fi
if command -v rpm >/dev/null && rpm -q --quiet -- "$name" 2>/dev/null; then
refuse 1 "system=package manager=rpm package=$name" "pass --force for a second, user-local copy"
fi
done
}
# Prints `absent`, `ours` for a link this script makes, or `foreign`.
link_state() {
local target
if [[ ! -e $link && ! -L $link ]]; then
echo absent
elif [[ -L $link ]] && target="$(readlink -- "$link")" &&
[[ $target == "$data/current/bin/vgshell" || $target == "$data/git/bin/vgshell" ]]; then
echo ours
else
echo foreign
fi
}
# Takes vgshell/.self.lock on descriptor 9 until this process exits, then
# removes the staging directories a dead writer left behind.
take_lock() {
local dir
mkdir -p -- "$data" 2>/dev/null && { exec 9>>"$lock"; } 2>/dev/null ||
refuse 1 "lock=failed path=$lock"
flock -n 9 || refuse 75 "self=busy path=$lock" "another install.sh or vgshell self update is writing $data"
shopt -s nullglob dotglob
for dir in "$data"/.self-update-*; do rm -rf -- "$dir"; done
shopt -u nullglob dotglob
}
new_stage() {
stage="$(mktemp -d "$data/.self-update-XXXXXX")" || refuse 1 "stage=failed path=$data"
}
# Points ~/.local/bin/vgshell at TARGET through a rename.
write_link() { # TARGET
local next="$bin_dir/.vgshell.install-$$"
mkdir -p -- "$bin_dir" 2>/dev/null || refuse 1 "link=failed path=$bin_dir"
rm -f -- "$next"
if ! ln -s -- "$1" "$next" 2>/dev/null || ! mv -T -- "$next" "$link" 2>/dev/null; then
rm -f -- "$next"
refuse 1 "link=failed path=$link"
fi
}
# Fetches URL into FILE, at most MAX bytes within SECONDS.
fetch() { # KEY URL FILE MAX SECONDS
local status=0
[[ $2 == "$scheme"* ]] || refuse 1 "$1=insecure url=$2"
curl --proto "$proto" --proto-redir "$proto" --tlsv1.2 -fsSL --max-time "$5" --max-filesize "$4" -o "$3" "$2" 2>"$tmp/curl.err" || status=$?
((status == 0)) || refuse 1 "$1=failed url=$2 curl=$status" "$(cat -- "$tmp/curl.err")"
}
# Prints the URL of asset NAME in the release, or fails.
asset_url() { # NAME
local name url
while read -r name url; do
if [[ $name == "$1" ]]; then
printf '%s\n' "$url"
return 0
fi
done <<<"$assets"
return 1
}
# Prints the sha256 SUMS lists for NAME: exactly one `<hex> <name>` or
# `<hex> *<name>` line.
listed_sum() { # SUMS NAME
local line hex="" count=0 pattern='^([0-9a-f]{64}) [ *](.+)$'
while IFS= read -r line || [[ -n $line ]]; do
if [[ $line =~ $pattern && ${BASH_REMATCH[2]} == "$2" ]]; then
hex="${BASH_REMATCH[1]}"
count=$((count + 1))
fi
done <"$1"
((count == 1)) || refuse 1 "checksum=unlisted name=$2 count=$count"
printf '%s\n' "$hex"
}
# Checks SUMS against SHA256SUMS.asc when the release has one, gpg is on
# PATH and holds release_key; prints why it did not otherwise. A signature
# that is not a good one by release_key refuses.
signature_check() { # SUMS
local url line
local -a field
if ! url="$(asset_url SHA256SUMS.asc)"; then
echo "signature=unchecked reason=no-signature"
return 0
fi
if [[ -z $release_key ]]; then
echo "signature=unchecked reason=no-release-key"
return 0
fi
if ! command -v gpg >/dev/null; then
echo "signature=unchecked reason=no-gpg"
return 0
fi
if ! gpg --batch --list-keys -- "$release_key" >/dev/null 2>&1; then
echo "signature=unchecked reason=key-not-imported key=$release_key"
return 0
fi
fetch download "$url" "$tmp/SHA256SUMS.asc" "$max_sums" "$download_seconds"
gpg --batch --status-fd 1 --verify -- "$tmp/SHA256SUMS.asc" "$1" >"$tmp/gpg.status" 2>/dev/null ||
refuse 1 "signature=bad name=SHA256SUMS key=$release_key"
while IFS= read -r line; do
read -ra field <<<"$line"
[[ ${field[0]:-} == "[GNUPG:]" && ${field[1]:-} == VALIDSIG ]] || continue
if [[ ${field[2]} == "$release_key" || ${field[${#field[@]} - 1]} == "$release_key" ]]; then
echo "signature=good key=$release_key"
return 0
fi
done <"$tmp/gpg.status"
refuse 1 "signature=bad name=SHA256SUMS key=$release_key" "SHA256SUMS is signed, but not by the VGS release key"
}
# Resolves the release (VERSION, or the newest), then sets version and
# assets: one `<name> <url>` line per asset.
resolve_release() {
local url tag parsed
if [[ -n $version ]]; then
url="$api_base/repos/$repository/releases/tags/v$version"
else
url="$api_base/repos/$repository/releases/latest"
fi
fetch release "$url" "$tmp/release.json" "$max_document" "$api_seconds"
parsed="$(node -e '
const fs = require("fs");
let doc;
try { doc = JSON.parse(fs.readFileSync(process.argv[1], "utf8")); } catch (e) { process.exit(1); }
if (doc === null || typeof doc !== "object" || typeof doc.tag_name !== "string" || !Array.isArray(doc.assets)) process.exit(1);
const lines = [doc.tag_name.replace(/\s/g, "?")];
for (const a of doc.assets)
if (a !== null && typeof a.name === "string" && typeof a.browser_download_url === "string" && /^[A-Za-z0-9._+-]+$/.test(a.name) && !/\s/.test(a.browser_download_url))
lines.push(a.name + " " + a.browser_download_url);
process.stdout.write(lines.join("\n") + "\n");
' "$tmp/release.json")" || refuse 1 "release=malformed url=$url"
tag="${parsed%%$'\n'*}"
assets="${parsed#*$'\n'}"
[[ $tag =~ ^v([0-9]+\.[0-9]+\.[0-9]+)$ ]] || refuse 1 "tag=$tag reason=not-a-version"
if [[ -n $version && ${BASH_REMATCH[1]} != "$version" ]]; then
refuse 1 "tag=$tag want=v$version"
fi
version="${BASH_REMATCH[1]}"
}
install_release() {
local archive="vgshell-$version.tar.gz" sums_url archive_url want got target="$data/$version" unpacked
local -a top
if [[ -e $target || -L $target ]] && [[ -L $data/current && $(readlink -- "$data/current") == "$version" ]]; then
runtime_install "$target"
echo "ok up-to-date=vgshell version=$version path=$target"
return 0
fi
sums_url="$(asset_url SHA256SUMS)" || refuse 1 "asset=missing name=SHA256SUMS release=v$version"
archive_url="$(asset_url "$archive")" || refuse 1 "asset=missing name=$archive release=v$version"
fetch download "$sums_url" "$tmp/SHA256SUMS" "$max_sums" "$download_seconds"
fetch download "$archive_url" "$tmp/$archive" "$max_archive" "$download_seconds"
want="$(listed_sum "$tmp/SHA256SUMS" "$archive")" || exit 1
got="$(sha256sum -- "$tmp/$archive")" || refuse 1 "checksum=failed name=$archive"
got="${got%% *}"
[[ $got == "$want" ]] || refuse 1 "checksum=mismatch name=$archive want=$want got=$got"
signature_check "$tmp/SHA256SUMS"
take_lock
if [[ -e $data/current && ! -L $data/current ]]; then
refuse 1 "current=not-a-link path=$data/current" "remove it and run install.sh again"
fi
if [[ -e $target || -L $target ]]; then
refuse 1 "target=exists path=$target" "remove it and run install.sh again"
fi
new_stage
mkdir -- "$stage/source"
tar -xzf "$tmp/$archive" -C "$stage/source" --no-same-owner 2>"$stage/tar.err" ||
refuse 1 "archive=unpack name=$archive" "$(cat -- "$stage/tar.err")"
shopt -s nullglob dotglob
top=("$stage/source"/*)
shopt -u nullglob dotglob
unpacked="$stage/source/vgshell-$version"
if ((${#top[@]} != 1)) || [[ ${top[0]} != "$unpacked" || -L $unpacked || ! -d $unpacked ]]; then
refuse 1 "archive=layout name=$archive top=${top[*]##*/}"
fi
[[ -f $unpacked/VERSION ]] || refuse 1 "archive=layout name=$archive version=missing"
[[ "$(cat -- "$unpacked/VERSION"; echo .)" == "$version"$'\n.' ]] || refuse 1 "archive=version name=$archive"
runtime_install "$unpacked"
DESTDIR="$stage/tree" PREFIX=/vgshell bash "$unpacked/packaging/install-system.sh" >/dev/null 2>"$stage/install.err" ||
refuse 1 "install=failed name=$archive" "$(cat -- "$stage/install.err")"
mv -T -- "$stage/tree/vgshell/share/vgshell" "$target" 2>/dev/null || refuse 1 "target=failed path=$target"
ln -s -- "$version" "$stage/current" && mv -T -- "$stage/current" "$data/current" 2>/dev/null ||
refuse 1 "current=failed path=$data/current"
echo "ok installed=vgshell version=$version path=$target"
}
install_git() {
[[ ! -e $data/git && ! -L $data/git ]] ||
refuse 1 "git=exists path=$data/git" "update it with vgshell self update, or remove it with install.sh --uninstall"
take_lock
[[ ! -e $data/git && ! -L $data/git ]] || refuse 1 "git=exists path=$data/git"
new_stage
env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR \
GIT_TERMINAL_PROMPT=0 GIT_ASKPASS= SSH_ASKPASS_REQUIRE=never GCM_INTERACTIVE=false \
git -c core.hooksPath=/dev/null -c core.fsmonitor=false clone --quiet --branch main -- "$git_url" "$stage/git" 2>"$stage/git.err" ||
refuse 1 "git=clone url=$git_url" "$(cat -- "$stage/git.err")"
runtime_install "$stage/git"
mv -T -- "$stage/git" "$data/git" 2>/dev/null || refuse 1 "git=failed path=$data/git"
echo "ok installed=vgshell git=$data/git"
}
# Refuses while the running shell was started from a tree under vgshell/: its
# command line, `qs -p <tree>/shell`, names the tree, as `vgshell self
# update` reads it.
shell_guard() {
local rt="${XDG_RUNTIME_DIR:-/run/user/$EUID}" pid arg previous="" tree=""
[[ -r $rt/vgshell.lock ]] || return 0
pid="$(head -n 1 -- "$rt/vgshell.lock" 2>/dev/null)" || return 0
[[ $pid =~ ^[0-9]+$ && -d /proc/$pid ]] || return 0
while IFS= read -r -d '' arg; do
if [[ $previous == -p && $arg == */shell ]]; then
tree="${arg%/shell}"
break
fi
previous="$arg"
done <"/proc/$pid/cmdline" || true
[[ -n $tree ]] || refuse 1 "shell=unreadable pid=$pid" "the running shell's command line names no tree; stop it before uninstalling"
tree="$(readlink -f -- "$tree")" || return 0
if [[ $tree == "$(readlink -f -- "$data")"/* ]]; then
refuse 1 "shell=running pid=$pid path=$tree" "stop the shell, which runs from a tree this would remove, then run install.sh --uninstall again"
fi
}
# Refuses a vgshell/git that holds work found nowhere else: changes in the
# work tree or index, a stash, or commits on HEAD or a local branch that
# no remote-tracking branch holds.
git_guard() {
local out
local -a git_in=(env -u GIT_DIR -u GIT_WORK_TREE -u GIT_INDEX_FILE -u GIT_COMMON_DIR git -C "$data/git" -c core.fsmonitor=false)
[[ -d $data/git ]] || return 0
out="$("${git_in[@]}" status --porcelain 2>&1)" ||
refuse 1 "git=unreadable path=$data/git" "$out" "pass --force to remove it anyway"
[[ -z $out ]] || refuse 1 "modified=$data/git" "it holds changes; pass --force to remove them"
if "${git_in[@]}" rev-parse -q --verify refs/stash >/dev/null; then
refuse 1 "stash=present path=$data/git" "it holds stashed changes; pass --force to remove them"
fi
out="$("${git_in[@]}" rev-list --count HEAD --branches --not --remotes 2>&1)" ||
refuse 1 "git=unreadable path=$data/git" "$out" "pass --force to remove it anyway"
((out == 0)) || refuse 1 "unpublished=$out path=$data/git" "it holds commits no remote branch has; pass --force to remove them"
}
# Removes what this script installed. vgshell/.self.lock stays: a writer that
# opened it before the removal must still meet the lock this run holds.
uninstall() {
local state entry
local -a rest=()
state="$(link_state)"
if [[ -d $data ]]; then
take_lock
shell_guard
[[ $force == true ]] || git_guard
shopt -s nullglob dotglob
for entry in "$data"/*; do
if [[ ${entry##*/} =~ ^(current|git|\.self-update-.*|[0-9]+\.[0-9]+\.[0-9]+)$ ]]; then
rm -rf -- "$entry"
elif [[ ${entry##*/} != .self.lock ]]; then
rest+=("$entry")
fi
done
shopt -u nullglob dotglob
fi
[[ $state != ours ]] || rm -f -- "$link"
echo "ok uninstalled=vgshell path=$data"
if ((${#rest[@]} > 0)); then echo "kept=$data reason=not-vgs-files"; fi
if [[ $state == foreign ]]; then echo "kept=$link reason=foreign"; fi
echo "kept=$config_dir"
echo "kept=$state_dir"
}
mode=release version="" force=false
while (($# > 0)); do
case "$1" in
--version | --version=*)
if [[ $1 == --version=* ]]; then
version="${1#--version=}"
shift
else
(($# >= 2)) || refuse 2 "argument=--version value=missing"
version="$2"
shift 2
fi
version="${version#v}"
[[ $version =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] || refuse 2 "version=$version" "--version takes X.Y.Z or vX.Y.Z"
;;
--git | --uninstall)
[[ $mode == release ]] || refuse 2 "argument=$1 conflict=--$mode"
mode="${1#--}"
shift
;;
--force) force=true; shift ;;
-h | --help) usage; exit 0 ;;
*) usage >&2; refuse 2 "argument=$1" ;;
esac
done
[[ -z $version || $mode == release ]] || refuse 2 "argument=--version conflict=--$mode"
((EUID != 0)) || refuse 1 "user=root" "install.sh installs into your home directory: run it as yourself, without sudo"
os="$(uname -s)" || refuse 1 "os=unreadable"
[[ $os == Linux ]] || refuse 1 "os=$os" "VGS runs on Linux, under Hyprland"
[[ ${HOME:-} == /* ]] || refuse 1 "home=${HOME:-unset}" "HOME must be an absolute path"
data_home="${XDG_DATA_HOME:-$HOME/.local/share}"
[[ $data_home == /* ]] || refuse 1 "xdg-data-home=$data_home" "XDG_DATA_HOME must be an absolute path"
data="$data_home/vgshell"
lock="$data/.self.lock"
bin_dir="$HOME/.local/bin"
link="$bin_dir/vgshell"
config_dir="${XDG_CONFIG_HOME:-$HOME/.config}/vgshell"
state_dir="${XDG_STATE_HOME:-$HOME/.local/state}/vgshell"
system_root=""
if [[ -n ${VGS_SYSTEM_ROOT:-} ]]; then
[[ -n ${VGS_TEST_RUN:-} ]] ||
refuse 1 "system-root=refused value=$VGS_SYSTEM_ROOT" "VGS_SYSTEM_ROOT is for a test run alone"
system_root="$VGS_SYSTEM_ROOT"
fi
if [[ -n ${VGS_RELEASE_API:-} ]]; then
[[ -n ${VGS_TEST_RUN:-} && $VGS_RELEASE_API == file:///* ]] ||
refuse 1 "release-api=refused value=$VGS_RELEASE_API" "VGS_RELEASE_API is a file:// base for a test run alone"
api_base="${VGS_RELEASE_API%/}"
proto="=file"
scheme="file://"
fi
if [[ $mode == uninstall ]]; then
uninstall
exit 0
fi
[[ $force == true ]] || system_package
if [[ $force != true && $(link_state) == foreign ]]; then
refuse 1 "link=foreign path=$link" "$link is not a link install.sh made: remove it, or pass --force to replace it"
fi
stage="" tmp=""
trap 'rm -rf -- ${stage:+"$stage"} ${tmp:+"$tmp"}' EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
tmp="$(mktemp -d "${TMPDIR:-/tmp}/vgs-install.XXXXXX")" || refuse 1 "temp=failed dir=${TMPDIR:-/tmp}"
if [[ $mode == git ]]; then floor_check "${start_tools[@]}"; else floor_check "${start_tools[@]}" "${release_tools[@]}"; fi
if [[ $mode == git ]]; then
install_git
write_link "$data/git/bin/vgshell"
else
resolve_release
install_release
write_link "$data/current/bin/vgshell"
fi
echo "command=$link"
case ":${PATH:-}:" in
*":$bin_dir:"*) ;;
*) echo "path=missing dir=$bin_dir: add it to PATH to run vgshell by name" ;;
esac
echo "To start VGS with Hyprland, add this line to ${XDG_CONFIG_HOME:-$HOME/.config}/hypr/hyprland.lua:"
printf ' hl.on("hyprland.start", function () hl.exec_cmd("%s start") end)\n' "$link"
}
main "$@"
}