-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathinstall.sh
More file actions
executable file
·1631 lines (1442 loc) · 70 KB
/
Copy pathinstall.sh
File metadata and controls
executable file
·1631 lines (1442 loc) · 70 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
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
#!/usr/bin/env bash
#
# install.sh -- brings the whole desktop up from a fresh Arch install.
#
# All the new machine needs is Arch and git:
#
# git clone https://github.com/diegoMalagrida/dotfiles
# cd dotfiles
# ./install.sh
#
# It can be re-run as many times as you like: it does not redo what is already
# done, and it never overwrites a file of yours without first saving it to
# ~/.dotfiles-backup/<date>/.
#
# It does NOT touch /boot, the bootloader, the partitions or fstab.
set -uo pipefail
REPO="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
BACKUP="$HOME/.dotfiles-backup/$(date +%Y%m%d-%H%M%S)"
DRY=0
ASSUME_YES=0
MODE=link # link | copy
UI_LANG= # es | en; empty means "ask, or take the default"
DEFAULT_LANG=es # what the desktop has always come up in
REQUESTED_PHASES=()
ALL_PHASES=(base aur packages repos config system graphics services sddm spicetify final)
# 'restore' is not part of the default run: it only happens if you name it
# explicitly, because it undoes what 'config' did.
ON_DEMAND_PHASES=(restore)
# Old Spanish names, kept working so nothing that used them breaks.
canonical_phase() {
case "$1" in
paquetes) printf 'packages' ;;
sistema) printf 'system' ;;
servicios) printf 'services' ;;
restaurar) printf 'restore' ;;
*) printf '%s' "$1" ;;
esac
}
# ----------------------------------------------------------------- presentation
if [ -t 1 ] && [ "${NO_COLOR:-}" = "" ]; then
C_TIT=$'\033[1;34m'; C_OK=$'\033[32m'; C_WARN=$'\033[33m'
C_BAD=$'\033[31m'; C_DIM=$'\033[2m'; C_OFF=$'\033[0m'
else
C_TIT=; C_OK=; C_WARN=; C_BAD=; C_DIM=; C_OFF=
fi
heading() { printf '\n%s== %s ==%s\n' "$C_TIT" "$*" "$C_OFF"; }
ok() { printf ' %sok%s %s\n' "$C_OK" "$C_OFF" "$*"; }
skip() { printf ' %s--%s %s%s%s\n' "$C_DIM" "$C_OFF" "$C_DIM" "$*" "$C_OFF"; }
warn() { printf ' %swarn%s %s\n' "$C_WARN" "$C_OFF" "$*"; }
err() { printf ' %sERROR%s %s\n' "$C_BAD" "$C_OFF" "$*" >&2; }
die() { err "$*"; exit 1; }
# Runs a command, honouring --dry-run.
run() {
if [ "$DRY" = 1 ]; then
printf ' %s[dry]%s %s\n' "$C_DIM" "$C_OFF" "$*"
return 0
fi
"$@"
}
ask() {
[ "$ASSUME_YES" = 1 ] && return 0
[ "$DRY" = 1 ] && return 0
local answer
read -r -p " $1 [y/N] " answer
[[ "$answer" =~ ^[sSyY]$ ]]
}
# The language question. It is not a yes/no, so it cannot go through ask(), but
# it plays by the same rules: --lang settles it, -y and --dry-run do not ask,
# and what nobody chooses is the Spanish the desktop has always come up in.
# What the desktop speaks right now. Without this, re-running the installer
# without --lang silently undid a language picked in Settings: the default was
# hard-wired to 'es' and set_language_quickshell dutifully wrote it back.
current_lang() {
local rice="$HOME/.config/quickshell-rice.json" cur=""
if [ -f "$rice" ]; then
if command -v jq >/dev/null 2>&1; then
cur="$(jq -r '.language // empty' "$rice" 2>/dev/null)"
else
cur="$(sed -n 's/.*"language"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' "$rice" | head -1)"
fi
fi
case "$cur" in es|en) printf '%s' "$cur" ;; *) printf 'es' ;; esac
}
ask_lang() {
[ -n "$UI_LANG" ] && return 0
DEFAULT_LANG="$(current_lang)"
UI_LANG="$DEFAULT_LANG"
[ "$ASSUME_YES" = 1 ] && return 0
[ "$DRY" = 1 ] && return 0
[ -t 0 ] || return 0 # no keyboard (a pipe, a cron job): the default
local answer
read -r -p " language for the desktop, es or en? [$DEFAULT_LANG] " answer
case "$answer" in
en|EN) UI_LANG=en ;;
es|ES) UI_LANG=es ;;
# Enter a secas = lo que ya hablaba el escritorio. Y 'es' tecleado a mano
# tiene que ASIGNAR: cuando el defecto pasó a ser lo que hay instalado, un
# caso vacío aquí dejaba UI_LANG en 'en' y se ignoraba lo que pedías.
'') ;;
*) warn "'$answer' is neither es nor en; keeping $DEFAULT_LANG" ;;
esac
}
# --lang, validated where it is read. Not a subshell helper on purpose: die()
# inside $(...) only kills the subshell and the run would carry on with nothing
# set.
set_lang() {
case "$1" in
es|en) UI_LANG="$1" ;;
*) die "unknown language: $1 (only 'es' or 'en')" ;;
esac
}
# ----------------------------------------------------------------- arguments
usage() {
cat <<'USAGE'
Usage: install.sh [options] [phase...]
Options:
-n, --dry-run show what it would do without touching anything
-y, --yes never ask (for unattended runs)
--copy copy the configs instead of symlinking them
--link symlink the configs into the repo (default)
--lang es|en what the desktop speaks (default es; it asks if you do not
say, and Settings changes it later anyway)
-h, --help this
Phases (if you name none, all of them run in this order):
base preflight checks: Arch, network, sudo, not-root
aur builds yay if it is missing
packages installs pacman.txt + extra.txt + aur.txt
repos clones oh-my-zsh, powerlevel10k and the zsh plugins
config puts home/ into your $HOME (symlinks or copies, with backup)
system copies system/etc into /etc (asks for sudo)
graphics detects the graphics card(s) and installs their drivers
services enables what is in packages/services.txt
sddm installs the hyprisland login theme (asks for sudo)
spicetify injects the theme into Spotify (asks for sudo once)
final fonts, default shell, groups, first pywal palette, sprites
Separate phase, only if you name it:
restore undoes 'config': removes the symlinks to the repo and puts
back whatever was there before, from ~/.dotfiles-backup
Examples:
./install.sh -n see the whole plan without running it
./install.sh --lang en bring the desktop up in English
./install.sh config just lay the dotfiles down again
./install.sh packages services only packages and services
./install.sh restore go back to how things were
Separate diagnostics:
./diagnose says what is missing or will not start, read-only
USAGE
}
while [ $# -gt 0 ]; do
case "$1" in
-n|--dry-run) DRY=1 ;;
-y|--yes|--si) ASSUME_YES=1 ;;
--copy|--copiar) MODE=copy ;;
--link|--enlazar) MODE=link ;;
--lang|--idioma)
shift; [ $# -gt 0 ] || die "--lang needs a value: es or en"
set_lang "$1" ;;
--lang=*|--idioma=*) set_lang "${1#*=}" ;;
-h|--help|--ayuda) usage; exit 0 ;;
-*) die "unknown option: $1 (try --help)" ;;
*) REQUESTED_PHASES+=("$(canonical_phase "$1")") ;;
esac
shift
done
if [ ${#REQUESTED_PHASES[@]} -eq 0 ]; then
PHASES=("${ALL_PHASES[@]}")
else
for f in "${REQUESTED_PHASES[@]}"; do
[[ " ${ALL_PHASES[*]} ${ON_DEMAND_PHASES[*]} " == *" $f "* ]] \
|| die "unknown phase: $f (try --help)"
done
PHASES=("${REQUESTED_PHASES[@]}")
fi
doing() { [[ " ${PHASES[*]} " == *" $1 "* ]]; }
# ----------------------------------------------------------------- helpers
# Saves whatever is at the destination path before replacing it. Symlinks that
# already point where they should are left alone, which is what makes this
# script genuinely idempotent.
save_aside() {
local dest="$1"
[ -e "$dest" ] || [ -L "$dest" ] || return 0
local rel="${dest#"$HOME"/}"
local saved="$BACKUP/$rel"
run mkdir -p "$(dirname "$saved")"
run mv "$dest" "$saved"
printf ' %s(saved to %s)%s\n' "$C_DIM" "${saved/#$HOME/\~}" "$C_OFF"
}
# Copies a whole tree. Uses rsync when present, and falls back to cp, which is
# always there because it ships in coreutils. This matters because rsync is NOT
# part of a fresh Arch install: it arrives in the 'packages' phase. If someone
# runs just `./install.sh config` on a virgin machine, without this it would
# stop halfway.
# $3 = "no-clobber" to leave anything already at the destination alone
copy_tree() {
local src="$1" dest="$2" mode="${3:-}"
run mkdir -p "$dest"
if command -v rsync >/dev/null 2>&1; then
if [ "$mode" = no-clobber ]; then
run rsync -a --ignore-existing "$src/" "$dest/"
else
run rsync -a "$src/" "$dest/"
fi
else
if [ "$mode" = no-clobber ]; then
run cp -a -n "$src/." "$dest/"
else
run cp -a "$src/." "$dest/"
fi
fi
}
# Rewrites the /home/diego spelled out inside the repo's own files so they work
# for whoever is installing.
#
# WHY THIS EXISTS. A good part of the rice points at ~/.cache/wal with an
# ABSOLUTE path, and it has to: kitty's `include`, the GTK and
# satty `@import url(file://...)`, hyprlock's lock background... none of them
# expand `~` or `$HOME`, so a relative path would be resolved against whatever
# directory the program happened to start in. On diego's machine that absolute
# path is right by definition; on anyone else's it points at a home that does
# not exist, and the result is a desktop that comes up in default colours with
# nothing failing out loud.
#
# It was worse than colours: `sddm-hyprisland/install.sh` had its own source
# directory hardcoded, so the whole `sddm` phase died with "cannot stat
# /home/diego/.config/sddm-hyprisland/Main.qml", and
# `luminous-autoselect.service` pointed its ExecStart at a binary under
# /home/diego that is not there.
#
# This runs at the START of `config`, before anything is deployed, so every
# phase after it -system, services, sddm, final- sees files that already say
# the right thing. It rewrites the repo tree itself, which is deliberate and
# the same thing the symlink repointing already did: `git diff` shows it.
adapt_home_paths() {
[ "$HOME" = /home/diego ] && return 0
# A GUARD, and not a paranoid one: this REWRITES THE REPO, so it may only
# run when $HOME really is the installing user's home. A test harness that runs
# the config phase with HOME pointing at a temporary directory, without this
# check the first pass left the whole repo pointing at a /tmp/tmp.XXXX that
# vanished when the test finished: 20 files and 3 symlinks broken, silently
# and without anything failing. It actually happened.
local passwd_home
passwd_home="$(getent passwd "${USER:-$(id -un)}" 2>/dev/null | cut -d: -f6)"
if [ -z "$passwd_home" ] || [ "$HOME" != "$passwd_home" ]; then
skip "not adapting paths: HOME ($HOME) is not $USER's home"
return 0
fi
local n_files=0 n_links=0 f link target
# 1) contents. Text files only, and never the wallpapers or the .git.
while IFS= read -r f; do
grep -Iq "/home/diego" "$f" 2>/dev/null || continue
run sed -i "s#/home/diego#$HOME#g" "$f"
n_files=$((n_files + 1))
done < <(find "$REPO/home" -type f \
-not -path "*/.git/*" \
-not -path "*/Pictures/*" \
-not -path "*/share/fonts/*" 2>/dev/null)
# 2) symlinks. These have to stay absolute -a relative one would resolve
# against the repo instead of your $HOME- so they get repointed.
while IFS= read -r link; do
target="$(readlink "$link")"
case "$target" in
/home/diego/*) run ln -sfn "$HOME${target#/home/diego}" "$link" ;;
*) continue ;;
esac
n_links=$((n_links + 1))
done < <(find "$REPO/home" -type l 2>/dev/null)
if [ $((n_files + n_links)) -gt 0 ]; then
ok "adapted to $HOME: $n_files files and $n_links symlinks rewritten"
warn "that leaves changes in the repo: 'git diff' will show them"
fi
}
# Puts src -> dest, symlinking or copying depending on $MODE.
place() {
local src="$1" dest="$2" label="$3"
if [ "$MODE" = link ]; then
if [ -L "$dest" ] && [ "$(readlink -f "$dest")" = "$(readlink -f "$src")" ]; then
skip "$label (already linked)"
return 0
fi
save_aside "$dest"
run mkdir -p "$(dirname "$dest")"
run ln -s "$src" "$dest"
ok "$label -> symlink"
else
save_aside "$dest"
run mkdir -p "$(dirname "$dest")"
run cp -a "$src" "$dest"
ok "$label -> copy"
fi
}
# ----------------------------------------------------------------- language
# The desktop speaks Spanish or English. That is ONE setting living in three
# files, because three different programs read it at three different moments:
# Quickshell reads its own JSON when the shell starts, the SDDM theme reads
# theme.conf before you have even logged in, and hyprlock reads its language
# file when the screen locks.
#
# Two rules run through all of this. First, none of the three is required: if a
# file is not there, that one is skipped and the other two are still set, so a
# partial checkout never stops the install. Second, NOTHING IS WRITTEN WHEN THE
# VALUE IS ALREADY RIGHT, and "no key at all" counts as Spanish, which is the
# default everywhere. That is what keeps the plain `./install.sh` byte-for-byte
# what it always was: on the default run these three functions write nothing.
# ~/.config/quickshell-rice.json, key "language".
#
# This file is the shell's own state, not repo configuration: Quickshell
# rewrites it whole every time you touch a setting, and it already exists, full
# of your choices, on any machine that has run the desktop once. So it is
# edited in place and one key at a time -never replaced- and when it is not
# there yet, the minimum that says what we mean is created instead.
set_language_quickshell() {
local rice="$HOME/.config/quickshell-rice.json" current tmp
if [ ! -f "$rice" ]; then
[ "$UI_LANG" = "$DEFAULT_LANG" ] && return 0
if [ "$DRY" = 1 ]; then
skip "would create ~/.config/quickshell-rice.json with \"language\": \"$UI_LANG\""
else
printf '{\n "language": "%s"\n}\n' "$UI_LANG" > "$rice" \
&& ok "quickshell language: $UI_LANG (new quickshell-rice.json)"
fi
return 0
fi
if command -v jq >/dev/null 2>&1; then
current="$(jq -r '.language // "'"$DEFAULT_LANG"'"' "$rice" 2>/dev/null)"
else
current="$(sed -n 's/.*"language"[[:space:]]*:[[:space:]]*"\([a-z]*\)".*/\1/p' "$rice" | head -1)"
fi
[ -n "$current" ] || current="$DEFAULT_LANG"
if [ "$current" = "$UI_LANG" ]; then
skip "quickshell is already in $UI_LANG"
return 0
fi
if [ "$DRY" = 1 ]; then
skip "would set \"language\": \"$UI_LANG\" in ~/.config/quickshell-rice.json"
return 0
fi
if command -v jq >/dev/null 2>&1; then
tmp="$(mktemp)"
# --indent 4 is how Quickshell itself writes the file: without it every
# install would reindent all twenty keys for one changed line.
if jq --indent 4 --arg l "$UI_LANG" '.language = $l' "$rice" > "$tmp" && [ -s "$tmp" ]; then
# cat, and not mv: with --link this path is a SYMLINK into the repo,
# and mv would drop a plain file on top of it. The two would stop
# being the same file from here on, silently.
cat "$tmp" > "$rice" && ok "quickshell language: $UI_LANG"
else
warn "could not rewrite quickshell-rice.json; its language is unchanged"
fi
rm -f "$tmp"
elif grep -q '"language"' "$rice"; then
# --follow-symlinks for the same reason as the cat above.
sed -i --follow-symlinks \
"s/\(\"language\"[[:space:]]*:[[:space:]]*\)\"[a-z]*\"/\1\"$UI_LANG\"/" "$rice" \
&& ok "quickshell language: $UI_LANG"
else
warn "jq is missing: add \"language\": \"$UI_LANG\" to ~/.config/quickshell-rice.json yourself"
fi
}
# The login theme, key language (lower case, like accent and background).
#
# It writes the INSTALLED theme, /usr/share/sddm/themes/hyprisland/theme.conf,
# and not the copy under ~/.config. Two reasons, both learned the hard way:
# SDDM reads the installed one and never the copy, so writing the copy left the
# login screen in Spanish without a word; and with the default --link mode
# ~/.config/sddm-hyprisland IS the repo, so writing there left `git status`
# dirty on every install.
#
# On a machine with no theme installed yet this does nothing and says so: the
# 'sddm' phase runs afterwards, lays the theme down and calls this again.
set_language_sddm() {
local conf=/usr/share/sddm/themes/hyprisland/theme.conf current
if [ ! -f "$conf" ]; then
skip "the login theme is not installed: the 'sddm' phase will set it to $UI_LANG"
return 0
fi
current="$(sed -n 's/^[[:space:]]*[Ll]anguage[[:space:]]*=[[:space:]]*\([A-Za-z]*\).*/\1/p' "$conf" | head -1)"
[ -n "$current" ] || current="$DEFAULT_LANG"
if [ "$current" = "$UI_LANG" ]; then
skip "the login theme is already in $UI_LANG"
return 0
fi
if [ "$DRY" = 1 ]; then
skip "would set language=$UI_LANG in $conf (needs sudo)"
return 0
fi
warn "the login theme lives in /usr, so this needs sudo"
if grep -q '^[[:space:]]*[Ll]anguage[[:space:]]*=' "$conf"; then
sudo sed -i "s/^[[:space:]]*[Ll]anguage[[:space:]]*=.*/language=$UI_LANG/" "$conf" \
&& ok "login theme language: $UI_LANG"
else
# The file is one [General] section and nothing else, so the end of it
# is still inside that section.
printf 'language=%s\n' "$UI_LANG" | sudo tee -a "$conf" >/dev/null \
&& ok "login theme language: $UI_LANG"
fi
}
# hyprlock, whose strings live in a file of their own under ~/.config/hypr.
#
# The NAME of that file is not spelled out here: it is read out of hyprlock.conf,
# from whatever it `source`s with 'lang' in it, so renaming it on the hyprlock
# side does not quietly break the installer. Two shapes are understood, which
# are the two anyone would write: either the strings ship one file per language
# (hyprlock-lang.es.conf, hyprlock-lang.en.conf) and the chosen one is copied
# into place, or there is a single file with a $lang variable at the top and the
# variable is rewritten. Anything else, and the lock screen is left alone.
set_language_hyprlock() {
local dir="$HOME/.config/hypr" target src current
target="$(sed -n \
's/^[[:space:]]*source[[:space:]]*=[[:space:]]*\(.*lang.*[^[:space:]]\)[[:space:]]*$/\1/p' \
"$dir/hyprlock.conf" 2>/dev/null | head -1)"
target="${target/#\~/$HOME}"
target="${target/#\$HOME/$HOME}"
[ -n "$target" ] || target="$dir/hyprlock-lang.conf"
src="${target%.conf}.$UI_LANG.conf"
if [ -f "$src" ]; then
if [ -f "$target" ] && cmp -s "$src" "$target"; then
skip "hyprlock is already in $UI_LANG"
elif [ "$DRY" = 1 ]; then
skip "would copy $(basename "$src") over $(basename "$target")"
else
cp -- "$src" "$target" && ok "hyprlock language: $UI_LANG"
fi
elif [ -f "$target" ] && grep -q '^[[:space:]]*\$lang' "$target"; then
current="$(sed -n 's/^[[:space:]]*\$lang[a-z]*[[:space:]]*=[[:space:]]*\([A-Za-z]*\).*/\1/p' "$target" | head -1)"
[ -n "$current" ] || current="$DEFAULT_LANG"
if [ "$current" = "$UI_LANG" ]; then
skip "hyprlock is already in $UI_LANG"
elif [ "$DRY" = 1 ]; then
skip "would set \$lang = $UI_LANG in $(basename "$target")"
else
sed -i "s/^\([[:space:]]*\$lang[a-z]*[[:space:]]*=[[:space:]]*\).*/\1$UI_LANG/" "$target" \
&& ok "hyprlock language: $UI_LANG"
fi
else
skip "no hyprlock language file in ~/.config/hypr: the lock screen keeps its language"
fi
}
# Asks (if it has to) and then writes the three of them.
apply_language() {
ask_lang
set_language_quickshell
set_language_sddm
set_language_hyprlock
}
# ----------------------------------------------------------------- phase: base
phase_base() {
heading "Preflight checks"
[ "$(id -u)" -ne 0 ] || die "do not run this as root. Run it as your user; it will ask for sudo when it needs to."
ok "regular user ($USER)"
command -v pacman >/dev/null || die "this is for Arch: I cannot find pacman."
ok "Arch detected"
command -v git >/dev/null || die "git is missing. Install it with: sudo pacman -S git"
ok "git present"
if ping -c1 -W3 archlinux.org >/dev/null 2>&1; then
ok "network is up"
else
warn "cannot reach archlinux.org. With no network, the 'aur' and 'packages' phases will fail."
fi
# sudo is NOT part of the 'base' metapackage: an Arch installed by hand may
# not have it. Without it there is no way to run the 'packages', 'system' or
# 'sddm' phases, so it is better said here than three screens further down.
if ! command -v sudo >/dev/null 2>&1; then
warn "there is no sudo on this system"
printf ' %s%s%s\n' "$C_DIM" "Install it as root and add yourself to wheel:" "$C_OFF"
printf ' %s pacman -S sudo && usermod -aG wheel %s%s\n' "$C_DIM" "$USER" "$C_OFF"
printf ' %s then uncomment the %%wheel line in /etc/sudoers (visudo)%s\n' "$C_DIM" "$C_OFF"
warn "without sudo, only the 'repos', 'config' and 'restore' phases work"
elif sudo -n true 2>/dev/null; then
ok "passwordless sudo"
else
warn "sudo will ask you for your password along the way"
fi
if [ "$USER" != diego ]; then
warn "your user is '$USER', not 'diego'. Some files have /home/diego written inside them;"
warn "the 'system' phase rewrites them for you, but do check the output."
fi
printf '\n config install mode: %s%s%s\n' "$C_TIT" "$MODE" "$C_OFF"
[ "$DRY" = 1 ] && printf ' %sDRY RUN: nothing will be modified%s\n' "$C_WARN" "$C_OFF"
return 0
}
# ----------------------------------------------------------------- phase: aur
phase_aur() {
heading "AUR helper (yay)"
if command -v yay >/dev/null; then
skip "yay is already installed"
return 0
fi
ok "yay is missing: it has to be built (the AUR's chicken-and-egg problem)"
run sudo pacman -S --needed --noconfirm base-devel git || die "could not install base-devel"
# mktemp, not /tmp/...-$$. The PID is predictable, and on a shared /tmp that
# lets another user get there first with a directory or a symlink of their
# own, right where a build is about to run. Cheap to get right.
local tmp
if [ "$DRY" = 1 ]; then
tmp='$(mktemp -d)'
else
tmp="$(mktemp -d "${TMPDIR:-/tmp}/yay-bootstrap.XXXXXXXX")" \
|| die "could not create a temporary directory"
trap "rm -rf -- '$tmp'" EXIT
fi
run git clone --depth 1 https://aur.archlinux.org/yay-bin.git "$tmp" || die "could not clone yay-bin"
if [ "$DRY" = 0 ]; then
( cd "$tmp" && makepkg -si --noconfirm ) || die "building yay failed"
rm -rf -- "$tmp"
trap - EXIT
fi
ok "yay installed"
}
# ----------------------------------------------------------------- phase: packages
phase_packages() {
heading "Packages"
local native=() aur=()
mapfile -t native < <(cat "$REPO/packages/pacman.txt" "$REPO/packages/extra.txt" 2>/dev/null \
| grep -vE '^\s*(#|$)' | tr -d ' \t' | sort -u)
mapfile -t aur < <(cat "$REPO/packages/aur.txt" 2>/dev/null \
| grep -vE '^\s*(#|$)' | tr -d ' \t' | grep -v '^yay$' | sort -u)
printf ' %d packages from the official repos, %d from the AUR\n' "${#native[@]}" "${#aur[@]}"
# --disable-download-timeout: pacman gives up on a mirror that drops below a
# trickle for ten seconds, and on a slow or shared connection -a phone
# hotspot, a hotel- that aborts the WHOLE transaction: nothing installs.
# Seen for real on 2026-08-19 in the clean-Arch container, on a link doing
# ~300 kB/s: "Operation too slow. Less than 1 bytes/sec", and 655 packages
# went nowhere. Waiting is the right trade here; you are installing a
# desktop, not timing a benchmark.
if [ ${#native[@]} -gt 0 ]; then
run sudo pacman -Syu --needed --noconfirm --disable-download-timeout -- "${native[@]}" \
&& ok "official repos up to date" \
|| packages_failed "${native[@]}"
fi
if [ ${#aur[@]} -gt 0 ]; then
command -v yay >/dev/null || { warn "no yay: skipping the AUR"; return 0; }
run yay -S --needed --noconfirm --disable-download-timeout -- "${aur[@]}" \
&& ok "AUR up to date" \
|| warn "yay errored on some AUR package. The rest did install."
fi
}
# Not every pacman failure means the same thing, and the difference decides
# whether it is worth carrying on.
#
# - ONE package was renamed or dropped from the repos. Everything else did
# install, the desktop will come up, and stopping would be theatre.
# - THE TRANSACTION never committed (a mirror timed out, the disk filled up).
# Then NOTHING installed, and every phase after this one -config, services,
# sddm, final- will run against a machine with no Hyprland, no Quickshell
# and no pywal. They do not fail loudly: they lay symlinks, enable units and
# skip the palette, and what you get at the end is a broken desktop and a
# warning lost twenty screens up. That happened in the container on
# 2026-08-19 and is why this function exists.
#
# So: ask the machine, do not guess. If the handful of packages the desktop
# cannot exist without are on disk, this was the first case.
packages_failed() {
[ "$DRY" = 1 ] && return 0
local missing=()
local p
for p in hyprland quickshell kitty zsh python-pywal awww; do
pacman -Qq "$p" >/dev/null 2>&1 || missing+=("$p")
done
if [ ${#missing[@]} -eq 0 ]; then
warn "pacman returned an error, but the desktop's own packages are installed."
warn "Look above for which package does not exist (they get renamed sometimes) and carry on."
return 0
fi
err "pacman failed and the desktop is NOT installed (missing: ${missing[*]})."
err "Nothing after this phase would work, so it stops here rather than leave you"
err "with a half-built system. The usual cause is the download, not the list:"
err " sudo pacman -Syyu refresh the databases"
err " sudo reflector --latest 20 --sort rate --save /etc/pacman.d/mirrorlist"
err "and then just re-run this phase: ./install.sh packages"
exit 1
}
# ----------------------------------------------------------------- phase: repos
phase_repos() {
heading "git repos (zsh tooling, Hyprland plugins)"
local manifest="$REPO/packages/git-repos.txt"
[ -f "$manifest" ] || { warn "no git-repos.txt, skipping"; return 0; }
while IFS=$'\t' read -r dest url extra; do
[[ "$dest" =~ ^[[:space:]]*(#|$) ]] && continue
local path="$HOME/$dest"
if [ -d "$path/.git" ]; then
skip "$dest (already cloned)"
continue
fi
local depth=()
[ "${extra:-}" = "--depth1" ] && depth=(--depth 1)
run mkdir -p "$(dirname "$path")"
run git clone "${depth[@]}" "$url" "$path" && ok "$dest" || warn "could not clone $dest"
done < "$manifest"
}
# ----------------------------------------------------------------- phase: config
phase_config() {
heading "Configuration ($MODE)"
local root="$REPO/home"
[ -d "$root" ] || die "cannot find $root"
# First of all: if you are not diego, adapt the absolute paths the repo has
# spelled out. This has to happen BEFORE anything is laid down, because the
# phases that come after (system, services, sddm) read these same files.
adapt_home_paths
# 1) loose dotfiles at the root of $HOME
local f
for f in "$root"/.[!.]*; do
[ -e "$f" ] || continue
local base; base="$(basename "$f")"
# .config, .local and Pictures are handled separately, further down
case "$base" in .config|.local) continue ;; esac
place "$f" "$HOME/$base" "$base"
done
# 1b) the git identity, which is the one thing in here that MUST NOT be
# mine. ~/.gitconfig is a symlink to the repo, so a [user] block in it
# would make your commits go out with my name and address on them. The
# repo's .gitconfig includes this file instead; git ignores the include
# when it is missing, so the only cost of leaving it half-filled is that
# git asks you who you are on your first commit, like on any new machine.
if [ ! -e "$HOME/.gitconfig.local" ] && [ "$DRY" = 0 ]; then
cat > "$HOME/.gitconfig.local" <<'EOF'
# Your git identity. Not in the dotfiles repo, and it should not be.
[user]
name = Your Name
email = you@example.com
# How you store credentials is your call. `store` keeps them in
# ~/.git-credentials in PLAIN TEXT; `cache` holds them in memory for a while,
# and libsecret hands them to your keyring.
# [credential]
# helper = cache --timeout=3600
EOF
ok ".gitconfig.local created — put your name and email in it"
elif [ -e "$HOME/.gitconfig.local" ]; then
skip ".gitconfig.local already there (your git identity is yours)"
fi
# 2) ~/.config, entry by entry (so your configs live alongside those of
# programs that are not in the repo)
run mkdir -p "$HOME/.config"
for f in "$root"/.config/*; do
[ -e "$f" ] || continue
local base; base="$(basename "$f")"
place "$f" "$HOME/.config/$base" ".config/$base"
done
# 3) ~/.local/bin: always copied, so they are genuinely executable
run mkdir -p "$HOME/.local/bin"
for f in "$root"/.local/bin/*; do
[ -e "$f" ] || continue
local base; base="$(basename "$f")"
run cp -a "$f" "$HOME/.local/bin/$base"
run chmod +x "$HOME/.local/bin/$base"
ok ".local/bin/$base"
done
# 4) fonts and icons: copied
if [ -d "$root/.local/share/fonts" ]; then
copy_tree "$root/.local/share/fonts" "$HOME/.local/share/fonts"
ok "user fonts"
fi
if [ -d "$root/.local/share/icons" ]; then
copy_tree "$root/.local/share/icons" "$HOME/.local/share/icons"
ok "user icons"
fi
# 4b) .desktop files of our own. They are here to WIN over the ones in
# /usr/share/applications -~/.local/share takes precedence in XDG- so
# that, for instance, opening a video launches the vlc wrapper in
# ~/.local/bin, the one that carries the pywal palette, and not
# /usr/bin/vlc straight up. Without this the wrapper gets copied but
# nothing ever calls it.
if [ -d "$root/.local/share/applications" ]; then
copy_tree "$root/.local/share/applications" "$HOME/.local/share/applications"
run update-desktop-database "$HOME/.local/share/applications" 2>/dev/null || true
ok "desktop entries"
fi
# 5) wallpapers: copied without clobbering. If you already have some there,
# they are left alone.
if [ -d "$root/Pictures/wallpapers" ]; then
copy_tree "$root/Pictures/wallpapers" "$HOME/Pictures/wallpapers" no-clobber
ok "wallpapers ($(find "$root/Pictures/wallpapers" -type f | wc -l) images)"
fi
# 6) the rice scripts have to be runnable
if [ "$DRY" = 0 ]; then
find "$HOME/.config/hypr" "$HOME/.config/quickshell" \
-name '*.sh' -type f -exec chmod +x {} + 2>/dev/null
ok "execute permissions on the rice scripts"
fi
# 7) seed the files the rice REWRITES while you use it.
#
# These are state, not configuration, and they are gitignored on purpose.
# The reason is a bug that actually shipped: ~/.config is symlinked INTO
# this repo, so `remote-mode` copying a profile over hypridle.conf was
# writing into git. The repo was published with remote mode switched on,
# and every clone came up with screens that never sleep and no suspend.
# Keeping them untracked means using the desktop can no longer dirty the
# working tree, and `git pull` stops conflicting on files nobody edited
# by hand. The cost is that a fresh clone has to be given a starting
# value, which is what this does.
local hypridle="$root/.config/hypr/hypridle.conf"
if [ ! -f "$hypridle" ] && [ -f "$root/.config/hypr/hypridle.d/normal.conf" ]; then
run cp -- "$root/.config/hypr/hypridle.d/normal.conf" "$hypridle" \
&& ok "hypridle.conf seeded from the normal profile"
fi
local pokestate="$root/.config/poke-theme/state"
if [ ! -f "$pokestate" ]; then
run sh -c "printf 'on\n' > '$pokestate'" && ok "poke-theme state seeded"
fi
# 8) the language. Last, because it edits files that have just been laid
# down, and before the 'sddm' phase, which copies one of them into /usr.
apply_language
}
# ----------------------------------------------------------------- phase: system
phase_system() {
heading "System files (/etc)"
local root="$REPO/system/etc"
[ -d "$root" ] || { warn "no system/etc, skipping"; return 0; }
warn "this phase writes to /etc and needs sudo"
ask "carry on?" || { skip "skipped at your request"; return 0; }
local src dest rel
while IFS= read -r src; do
rel="${src#"$root"/}"
dest="/etc/$rel"
# Comparing only reads, so it is done for real in dry-run too: otherwise
# dry mode would call everything "identical" and show you nothing.
# Almost all of /etc is readable without root; sudo is only reached for
# when it is not, so a password is asked for only when truly needed.
# cmp comes from diffutils, which is NOT in a minimal Arch. If it is
# missing, the comparison is skipped and the file is always copied:
# noisier, but correct.
if [ -e "$dest" ] && command -v cmp >/dev/null 2>&1; then
if [ -r "$dest" ]; then
cmp -s "$src" "$dest" && { skip "/etc/$rel (identical)"; continue; }
elif sudo -n cmp -s "$src" "$dest" 2>/dev/null; then
skip "/etc/$rel (identical)"; continue
fi
fi
if [ -e "$dest" ]; then
run sudo cp -a "$dest" "$dest.before-dotfiles"
printf ' %s(the previous one is kept as %s.before-dotfiles)%s\n' "$C_DIM" "$dest" "$C_OFF"
fi
run sudo mkdir -p "$(dirname "$dest")"
run sudo cp "$src" "$dest"
# Nothing under system/etc/ spells out /home/diego today -- the unit
# that did was retired -- but a file dropped into /etc is the one place
# where that would be invisible until it broke, so the net stays up.
if [ "$USER" != diego ] && [ "$DRY" = 0 ] && grep -q '/home/diego' "$dest" 2>/dev/null; then
sudo sed -i "s#/home/diego#$HOME#g" "$dest"
printf ' %s(rewrote /home/diego -> %s)%s\n' "$C_DIM" "$HOME" "$C_OFF"
fi
ok "/etc/$rel"
done < <(find "$root" -type f | sort)
run sudo systemctl daemon-reload
run sudo sysctl --system >/dev/null 2>&1
ok "systemd and sysctl reloaded"
# The rest of /etc is not a file you can drop in, it is a setting inside a
# file that pacman owns. Each one is checked first so re-running says
# nothing, and none of them is touched if it is already right.
# multilib. Not needed by anything in packages/ today -lib32-glibc lives in
# core now- but it is on in the original machine, and without it any lib32
# package you reach for later is simply not found.
if grep -qE '^\[multilib\]' /etc/pacman.conf 2>/dev/null; then
skip "multilib already enabled"
elif [ "$DRY" = 1 ]; then
skip "would enable [multilib] in /etc/pacman.conf"
else
sudo sed -i '/^#\[multilib\]/,/^#Include = .*mirrorlist/ s/^#//' /etc/pacman.conf
grep -qE '^\[multilib\]' /etc/pacman.conf && ok "multilib enabled" \
|| warn "could not enable multilib; do it by hand in /etc/pacman.conf"
fi
# Locale. The system runs in English on purpose; the keyboard is Spanish,
# which is a separate thing and set below.
if locale -a 2>/dev/null | grep -qi '^en_US.utf8$'; then
skip "locale en_US.UTF-8 already generated"
elif [ "$DRY" = 1 ]; then
skip "would generate the en_US.UTF-8 locale"
else
sudo sed -i 's/^#\(en_US.UTF-8 UTF-8\)/\1/' /etc/locale.gen
run sudo locale-gen >/dev/null 2>&1
echo 'LANG=en_US.UTF-8' | sudo tee /etc/locale.conf >/dev/null
ok "locale en_US.UTF-8 generated and set"
fi
# Spanish keyboard, console and X11/Xwayland. Hyprland reads its own layout
# from hyprland.lua, but the TTY and any Xwayland client read this.
if localectl status 2>/dev/null | grep -q 'X11 Layout: es'; then
skip "keyboard already set to es"
elif [ "$DRY" = 1 ]; then
skip "would set the keyboard to es (console and X11)"
else
run sudo localectl set-keymap es
run sudo localectl set-x11-keymap es pc105 "" terminate:ctrl_alt_bksp
ok "keyboard set to es (console and X11)"
fi
# Parallel builds for makepkg. The original machine says -j4 on 8 threads;
# here it is derived from the actual CPU instead of copying that number,
# because the whole point is that it matches the machine you are on.
local jobs; jobs=$(( $(nproc 2>/dev/null || echo 2) / 2 ))
[ "$jobs" -lt 1 ] && jobs=1
if grep -qE "^MAKEFLAGS=\"-j$jobs\"" /etc/makepkg.conf 2>/dev/null; then
skip "makepkg already at -j$jobs"
elif [ "$DRY" = 1 ]; then
skip "would set makepkg MAKEFLAGS to -j$jobs"
else
sudo sed -i "s/^#\?MAKEFLAGS=.*/MAKEFLAGS=\"-j$jobs\"/" /etc/makepkg.conf
ok "makepkg set to -j$jobs (half of $(nproc) threads)"
fi
}
# ----------------------------------------------------------------- phase: graphics
# Every graphics card in the machine, one line each: "<vendor> <pci-addr> <device-id>".
#
# Straight out of sysfs and NOT out of `lspci`, for two reasons. pciutils is not
# part of `base`, so on a machine that has not reached the 'packages' phase yet
# lspci may simply not exist; and its output is a sentence written for a human,
# while /sys/bus/pci/devices/*/{class,vendor,device} hand you the very numbers
# the kernel matched its own drivers on.
#
# The three classes are the ones that can drive a screen: 0300 VGA, 0302 "3D
# controller" (how a laptop's discrete card almost always shows up, because it
# has no VGA legacy interface) and 0380 "display controller" (virtio and some
# vGPUs). Looking only at 0300 is the classic way to miss a dGPU entirely.
gpu_scan() {
local dev class vendor devid v
for dev in /sys/bus/pci/devices/*; do
[ -r "$dev/class" ] || continue
class="$(<"$dev/class")"
case "$class" in 0x0300*|0x0302*|0x0380*) ;; *) continue ;; esac
vendor="$(<"$dev/vendor")"
devid="$(<"$dev/device")"
case "$vendor" in
0x8086) v=intel ;;
0x1002) v=amd ;; # AMD/ATI graphics have always been 1002
0x1022) v=amd ;; # AMD's own vendor id, just in case
0x10de) v=nvidia ;;
*) v=other ;; # virtio, VMware, Aspeed BMC... mesa and no more
esac
printf '%s %s %s\n' "$v" "${dev##*/}" "$devid"
done
}
# Which kernel driver has actually got hold of that card right now, or "none".
# Worth printing: "nouveau" is the tell-tale that the proprietary NVIDIA driver
# is not in place yet, which is precisely the state the tower was in.
gpu_kernel_driver() {
local drv="/sys/bus/pci/devices/$1/driver"
if [ -e "$drv" ]; then printf '%s' "$(basename "$(readlink -f "$drv")")"
else printf 'none'; fi
}
# How many monitors are plugged into the card at a given PCI address. Each
# connector lives in /sys/class/drm/cardN-<OUTPUT>/, and its card's `device`
# symlink points back at the PCI device, which is how the two are tied together
# without ever trusting the N in cardN.
gpu_connected_outputs() {
local addr="$1" n=0 st
for st in /sys/class/drm/card*-*/status; do
[ -r "$st" ] || continue
[ "$(basename "$(readlink -f "${st%/status}/../device" 2>/dev/null)")" = "$addr" ] || continue
[ "$(<"$st")" = connected ] && n=$((n + 1))
done
printf '%s' "$n"
}
# The /dev/dri node for a PCI address, by path and never by cardN. The number in
# cardN is handed out in probe order and moves between boots as soon as two
# drivers are racing -- on this very laptop the only card is card1, not card0 --
# while the PCI address is bolted to the slot on the board.
gpu_drm_node() {
local p="/dev/dri/by-path/pci-$1-card"
[ -e "$p" ] && printf '%s' "$p"
}
# Is nvidia_drm.modeset=1 in effect? Asked three different ways because at this
# point in an install the module is usually not even loaded yet.
nvidia_modeset_on() {
# `modprobe -c` merges /etc/modprobe.d, /run/modprobe.d and
# /usr/lib/modprobe.d without loading anything, and /usr/lib/modprobe.d is
# where nvidia-utils drops its own file. This works the second pacman
# finishes, no reboot needed.
modprobe -c 2>/dev/null | grep -qE '^options[[:space:]]+nvidia[-_]drm\b.*modeset=1' && return 0
# Already running with it on.
[ "$(cat /sys/module/nvidia_drm/parameters/modeset 2>/dev/null)" = Y ] && return 0
# Or somebody put it on the kernel command line by hand.
grep -qE '(^| )nvidia[-_]drm\.modeset=1( |$)' /proc/cmdline 2>/dev/null && return 0
return 1
}
# Installs the video drivers for whatever hardware is actually in this machine,
# instead of for the machine the repo happened to be born on.
#
# WHY THIS PHASE RUNS AFTER 'system' AND NOT WITH 'packages'. The 32-bit halves
# of the drivers live in [multilib], and [multilib] is switched on by the
# 'system' phase. Installing them any earlier means every lib32-* line fails
# with "target not found" on the one run where it matters most: the first.
phase_graphics() {
heading "Graphics drivers"
local gpus=()
mapfile -t gpus < <(gpu_scan)
# ' common ' always applies; the vendors found get appended to it. A string
# rather than an array so that "does this line apply?" is one glob match.