@vibelord/lanes 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,603 @@
1
+ #!/bin/sh
2
+ # lanes — client installer for macOS and Linux. POSIX sh, no dependencies beyond OpenSSH.
3
+ # Idempotent and additive: it adds new names only, and `--uninstall` removes exactly what it added.
4
+ #
5
+ # ./install.sh install or update the client on this machine
6
+ # ./install.sh --server install or update the Linux workspace server
7
+ # ./install.sh --check verify terminal, SSH path, key and server (changes nothing)
8
+ # ./install.sh --uninstall remove what the installer added (your SSH key and ~/Lanes Uploads are kept)
9
+ #
10
+ # Options
11
+ # --device NAME name of this machine's view session (default: <user>-<hostname>; kept on re-install)
12
+ # --key PATH SSH private key to use (default: the one already configured, else ~/.ssh/id_ed25519,
13
+ # else a new dedicated key ~/.ssh/lanes_ed25519)
14
+ # --site FILE server profile (default: site/site.conf, else the remembered client profile)
15
+ # --link symlink the client into this checkout instead of copying it (for working on the harness)
16
+ # --coder-launch also provide the command name `coder-launch` (an existing one is kept as coder-launch-legacy)
17
+ # --no-desktop skip desktop integration (Lanes.app / .desktop entry / Hyprland key binding)
18
+ # --no-uploads skip the uploads watch folder and the upload helper service
19
+ # --yes never prompt (a new key is then created WITHOUT a passphrase)
20
+ #
21
+ # Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
22
+ # 4 installed, but the server is not reachable (Tailscale?) or did not answer in time (busy?) · 1 error
23
+ set -eu
24
+ here=$(cd "$(dirname "$0")" && pwd)
25
+ if [ "${1:-}" = --server ]; then
26
+ shift
27
+ exec sh "$here/install/host-install.sh" "$@"
28
+ fi
29
+ os=$(uname -s)
30
+ bin=$HOME/.local/bin
31
+ config=${XDG_CONFIG_HOME:-$HOME/.config}/lanes
32
+ share=${XDG_DATA_HOME:-$HOME/.local/share}/lanes
33
+ state=${XDG_STATE_HOME:-$HOME/.local/state}/lanes-client # helper.alive + logs of a client with the service side
34
+ stamp=$(date -u +%Y%m%dT%H%M%SZ)
35
+ label=ai.beyondwork.lanes-upload
36
+ helper_label=ai.beyondwork.lanes-helper
37
+
38
+ mode=install device= key= site_file=$here/site/site.conf link=0 coder_launch=0 desktop=1 uploads=1 assume_yes=0
39
+ [ -r "$site_file" ] || site_file=$config/client-site.conf
40
+ while [ $# -gt 0 ]; do
41
+ case "$1" in
42
+ --check) mode=check ;;
43
+ --uninstall) mode=uninstall ;;
44
+ --device) device=${2:?--device NAME}; shift ;;
45
+ --key) key=${2:?--key PATH}; shift ;;
46
+ --site) site_file=${2:?--site FILE}; shift ;;
47
+ --link) link=1 ;;
48
+ --coder-launch) coder_launch=1 ;;
49
+ --no-desktop) desktop=0 ;;
50
+ --no-uploads) uploads=0 ;;
51
+ --yes|-y) assume_yes=1 ;;
52
+ -h|--help) sed -n '2,22p' "$0"; exit 0 ;;
53
+ *) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
54
+ esac
55
+ shift
56
+ done
57
+
58
+ say() { printf ' %s\n' "$*"; }
59
+ head_() { printf '\n%s\n' "$*"; }
60
+ warn() { printf ' ! %s\n' "$*" >&2; }
61
+ die() { printf 'install.sh: %s\n' "$*" >&2; exit 1; }
62
+ tilde() { case "$1" in "$HOME"/*) printf '~/%s' "${1#"$HOME"/}" ;; *) printf '%s' "$1" ;; esac; }
63
+
64
+ # ---- site -----------------------------------------------------------------------------------------------------------
65
+ site_value() { sed -n "s/^$1=//p" "$site_file" 2>/dev/null | head -n 1; }
66
+ load_site() {
67
+ [ -r "$site_file" ] || die "choose a server: --site /path/to/site.conf (see site/README.md; team profile: --site site/beyondwork/site.conf)"
68
+ case "$(site_value LANES_SITE_SCHEMA)" in ''|1) ;; *) die "site file $site_file has a newer schema than this installer: update the checkout" ;; esac
69
+ server_host=$(site_value LANES_SERVER_HOST); server_user=$(site_value LANES_SERVER_USER)
70
+ server_alias=$(site_value LANES_SERVER_ALIAS); server_name=$(site_value LANES_SERVER_NAME)
71
+ server_public=$(site_value LANES_SERVER_PUBLIC)
72
+ [ -n "$server_public" ] || server_public=$(sed -n 's/^LANES_SERVER_PUBLIC=//p' "$config/site.cache" 2>/dev/null | head -n 1)
73
+ for value in "$server_host" "$server_user" "$server_alias"; do
74
+ case "$value" in ''|*[!A-Za-z0-9_.:-]*) die "site file $site_file: LANES_SERVER_HOST/USER/ALIAS missing or malformed" ;; esac
75
+ done
76
+ case "$server_public" in *[!A-Za-z0-9_.:-]*) server_public= ;; esac
77
+ pinned=$(dirname "$site_file")/known_hosts
78
+ [ "$site_file" != "$config/client-site.conf" ] || pinned=$config/client-known-hosts
79
+ }
80
+
81
+ # ---- identity -------------------------------------------------------------------------------------------------------
82
+ default_device() {
83
+ name="$(id -un)-$(hostname -s 2>/dev/null || hostname 2>/dev/null || cat /etc/hostname)"
84
+ printf '%s' "$name" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9_.-' '-' | sed 's/--*/-/g; s/^-*//; s/-*$//' | cut -c1-32
85
+ }
86
+ choose_device() {
87
+ if [ -z "$device" ] && [ -r "$config/device" ]; then device=$(head -n 1 "$config/device"); fi
88
+ [ -n "$device" ] || device=$(default_device)
89
+ device=$(printf '%s' "$device" | tr 'A-Z' 'a-z' | tr -c 'a-z0-9_.-' '-' | sed 's/--*/-/g; s/^-*//; s/-*$//' | cut -c1-32)
90
+ [ -n "$device" ] || die 'could not derive a device name; pass --device NAME'
91
+ }
92
+ choose_key() {
93
+ if [ -z "$key" ] && [ -r "$HOME/.ssh/config.d/lanes.conf" ]; then
94
+ key=$(awk '$1 == "IdentityFile" { print $2; exit }' "$HOME/.ssh/config.d/lanes.conf")
95
+ fi
96
+ case "$key" in "~/"*) key=$HOME/${key#"~/"} ;; esac
97
+ if [ -z "$key" ]; then
98
+ if [ -r "$HOME/.ssh/id_ed25519" ]; then key=$HOME/.ssh/id_ed25519; else key=$HOME/.ssh/lanes_ed25519; fi
99
+ fi
100
+ }
101
+ ensure_key() {
102
+ mkdir -p "$HOME/.ssh"; chmod 700 "$HOME/.ssh"
103
+ if [ ! -r "$key" ]; then
104
+ [ "$key" = "$HOME/.ssh/lanes_ed25519" ] || die "SSH key not found: $key"
105
+ say "creating a dedicated SSH key: $(tilde "$key")"
106
+ if [ "$assume_yes" = 1 ] || [ ! -t 0 ]; then
107
+ ssh-keygen -q -t ed25519 -N '' -C "lanes:$device" -f "$key"
108
+ else
109
+ say '(choose a passphrase, or press Enter twice for none — it is asked once and then kept in your agent/keychain)'
110
+ ssh-keygen -q -t ed25519 -C "lanes:$device" -f "$key"
111
+ fi
112
+ fi
113
+ [ -r "$key.pub" ] || ssh-keygen -y -f "$key" > "$key.pub"
114
+ }
115
+
116
+ # ---- ssh ------------------------------------------------------------------------------------------------------------
117
+ # Does THIS machine's ssh know an option? (-F /dev/null: judge the binary, not the user's config.)
118
+ ssh_knows() { ssh -F /dev/null -o "$1" -G localhost >/dev/null 2>&1; }
119
+ optional() { # optional <MARKER> <yes|no>: keep or drop the template lines carrying #@MARKER@
120
+ if [ "$2" = yes ]; then sed "s/^#@$1@ \{0,1\}//"; else grep -v "^#@$1@" || :; fi
121
+ }
122
+ render_stanza() { # -> stdout
123
+ identity=$(tilde "$key")
124
+ public=no; [ -n "$server_public" ] && public=yes
125
+ obscure=no; ssh_knows ObscureKeystrokeTiming=no && obscure=yes
126
+ keychain=no; ssh_knows UseKeychain=yes && keychain=yes
127
+ sed -e "s|@HOST@|$server_host|g" -e "s|@USER@|$server_user|g" -e "s|@ALIAS@|$server_alias|g" \
128
+ -e "s|@IDENTITY@|$identity|g" -e "s|@PUBLIC_HOST@|$server_public|g" "$src/ssh.stanza.in" |
129
+ optional PUBLIC "$public" | optional OBSCURE "$obscure" | optional KEYCHAIN "$keychain"
130
+ }
131
+ install_ssh() {
132
+ mkdir -p "$HOME/.ssh/config.d"; chmod 700 "$HOME/.ssh" "$HOME/.ssh/config.d"
133
+ # Guard: our file is parsed for EVERY ssh destination. If the configuration parsed before and does not parse
134
+ # after, put the previous file back — never leave a machine whose ssh is broken for other hosts.
135
+ parsed_before=no; ssh -G lanes-selftest.invalid >/dev/null 2>&1 && parsed_before=yes
136
+ stanza=$HOME/.ssh/config.d/lanes.conf
137
+ rm -f "$stanza.prev"; [ -e "$stanza" ] && cp -p "$stanza" "$stanza.prev"
138
+ render_stanza > "$stanza.new"
139
+ chmod 600 "$stanza.new"; mv "$stanza.new" "$stanza"
140
+ touch "$HOME/.ssh/config"; chmod 600 "$HOME/.ssh/config"
141
+ # OpenSSH applies the FIRST value it sees and requires Include before any Host block: the line goes on top.
142
+ if ! grep -q '^Include config.d/lanes.conf' "$HOME/.ssh/config"; then
143
+ cp -p "$HOME/.ssh/config" "$HOME/.ssh/config.pre-lanes.$stamp"
144
+ { printf '# lanes workspace server (added %s by the lanes installer)\nInclude config.d/lanes.conf\n\n' "$stamp"
145
+ cat "$HOME/.ssh/config.pre-lanes.$stamp"; } > "$HOME/.ssh/config.new"
146
+ chmod 600 "$HOME/.ssh/config.new"; mv "$HOME/.ssh/config.new" "$HOME/.ssh/config"
147
+ say "~/.ssh/config: added one Include line (backup: config.pre-lanes.$stamp)"
148
+ fi
149
+ if ! ssh -G lanes >/dev/null 2>&1 || { [ "$parsed_before" = yes ] && ! ssh -G lanes-selftest.invalid >/dev/null 2>&1; }; then
150
+ problem=$(ssh -G lanes-selftest.invalid 2>&1 | head -n 2 | tr '\n' ' ')
151
+ if [ -e "$stanza.prev" ]; then mv "$stanza.prev" "$stanza"; else rm -f "$stanza"; fi
152
+ die "the generated SSH stanza does not parse on this machine — put the previous one back. ssh said: $problem"
153
+ fi
154
+ rm -f "$stanza.prev"
155
+ say "ssh: host alias 'lanes' -> $server_user@$server_host, key $(tilde "$key")$([ -n "$server_public" ] && echo ', fallback lanes-public')"
156
+ pin_host_key
157
+ }
158
+ # The server key is PINNED from the package (site/known_hosts): a new machine never trusts on first use.
159
+ pin_host_key() {
160
+ [ -r "$pinned" ] || { warn "no pinned host key next to the site file: you will be asked to verify the fingerprint"; return 0; }
161
+ want=$(awk -v a="$server_alias" '$1 == a && $2 == "ssh-ed25519" { print $3; exit }' "$pinned")
162
+ have=$(ssh-keygen -F "$server_alias" 2>/dev/null | awk '!/^#/ && $2 == "ssh-ed25519" { print $3; exit }')
163
+ if [ -z "$have" ]; then
164
+ touch "$HOME/.ssh/known_hosts"; chmod 600 "$HOME/.ssh/known_hosts"
165
+ grep "^$server_alias " "$pinned" >> "$HOME/.ssh/known_hosts"
166
+ say "known_hosts: pinned the server key as '$server_alias'"
167
+ elif [ -n "$want" ] && [ "$have" != "$want" ]; then
168
+ warn "known_hosts already has a DIFFERENT key for '$server_alias' than this package pins. Not changed — ask the server admin which one is right."
169
+ fi
170
+ }
171
+
172
+ # ---- client files ---------------------------------------------------------------------------------------------------
173
+ install_client() {
174
+ mkdir -p "$bin" "$config"
175
+ if [ "$site_file" != "$config/client-site.conf" ]; then
176
+ cp "$site_file" "$config/client-site.conf"
177
+ if [ -r "$pinned" ]; then cp "$pinned" "$config/client-known-hosts"; else rm -f "$config/client-known-hosts"; fi
178
+ fi
179
+ existing=$(readlink "$bin/lanes-launch" 2>/dev/null || :)
180
+ case "$existing" in "$here"/client/*) link=1 ;; esac # a development checkout stays linked
181
+ if [ "$link" = 1 ]; then
182
+ target=$here/client
183
+ else
184
+ target=$share/client
185
+ mkdir -p "$target"
186
+ rm -rf "$target.new"; mkdir -p "$target.new"
187
+ cp -R "$here/client/." "$target.new/"
188
+ cp "$here/VERSION" "$target.new/VERSION" 2>/dev/null || :
189
+ rm -f "$target.new/install.sh" "$target.new/omarchy/discover.sh" # checkout-only helpers
190
+ rm -rf "$target.old"; [ -d "$target" ] && mv "$target" "$target.old"
191
+ mv "$target.new" "$target"; rm -rf "$target.old"
192
+ fi
193
+ chmod +x "$target/lanes-launch" "$target/lanes-window" "$target/lanes-upload"
194
+ for name in lanes-launch lanes-window lanes-upload; do ln -sfn "$target/$name" "$bin/$name"; done
195
+ say "commands: lanes-launch, lanes-window, lanes-upload in $(tilde "$bin") ($([ "$link" = 1 ] && echo "linked to this checkout" || echo "copied to $(tilde "$target")"))"
196
+ printf '%s\n' "$device" > "$config/device"
197
+ say "device (view session) name: $device"
198
+ # (a non-interactive ssh session has a minimal PATH: ask the login shell before complaining)
199
+ login_path=$("${SHELL:-/bin/sh}" -lc 'printf %s "$PATH"' 2>/dev/null || :)
200
+ case ":$PATH:$login_path:" in *":$bin:"*) ;; *)
201
+ rc='~/.profile'; case "${SHELL:-}" in */zsh) rc='~/.zprofile' ;; */bash) [ "$os" = Darwin ] && rc='~/.bash_profile' ;; */fish) rc='~/.config/fish/config.fish' ;; esac
202
+ warn "$(tilde "$bin") is not on your PATH. Add it (then open a new terminal):"
203
+ case "${SHELL:-}" in */fish) warn " fish_add_path $bin" ;; *) warn " echo 'export PATH=\"\$HOME/.local/bin:\$PATH\"' >> $rc" ;; esac ;;
204
+ esac
205
+ if [ "$coder_launch" = 1 ]; then
206
+ if [ -e "$bin/coder-launch" ] && [ ! -L "$bin/coder-launch" ]; then
207
+ mv "$bin/coder-launch" "$bin/coder-launch-legacy"; say 'kept the previous coder-launch as coder-launch-legacy'
208
+ fi
209
+ ln -sfn "$target/lanes-launch" "$bin/coder-launch"; say '`coder-launch` now starts the lanes client'
210
+ fi
211
+ # Ghostty: a SEPARATE instance with its own config file; the everyday Ghostty config is only read, never written.
212
+ { echo "# Generated by the lanes installer — edit the sources in client/ghostty/ and re-run it."
213
+ echo "command = $bin/lanes-launch"
214
+ cat "$target/ghostty/common.conf"
215
+ if [ "$os" = Darwin ]; then cat "$target/ghostty/macos.conf"; else cat "$target/ghostty/omarchy.conf"; fi
216
+ } > "$config/ghostty.conf"
217
+ }
218
+
219
+ find_terminal() {
220
+ if [ "$os" = Darwin ]; then
221
+ for app in /Applications/Ghostty.app "$HOME/Applications/Ghostty.app"; do
222
+ [ -d "$app" ] && { say "terminal: Ghostty ($app)"; return 0; }
223
+ done
224
+ warn 'Ghostty is not installed: `brew install --cask ghostty` (or ghostty.org). Until then run `lanes-launch` in any terminal.'
225
+ elif command -v ghostty >/dev/null 2>&1; then say "terminal: Ghostty ($(ghostty --version 2>/dev/null | head -n 1))"
226
+ elif command -v foot >/dev/null 2>&1; then
227
+ say 'terminal: Foot (Ghostty is used automatically once installed — Omarchy: omarchy-pkg-add ghostty)'
228
+ else
229
+ warn 'no Ghostty or Foot found: install Ghostty for the lanes window, or run `lanes-launch` in any terminal.'
230
+ fi
231
+ }
232
+
233
+ # ---- desktop integration --------------------------------------------------------------------------------------------
234
+ install_desktop() {
235
+ if [ "$os" = Darwin ]; then
236
+ app="$HOME/Applications/Lanes.app"
237
+ if [ ! -d "$app" ] && command -v osacompile >/dev/null 2>&1; then
238
+ mkdir -p "$HOME/Applications"
239
+ osacompile -o "$app" -e "do shell script \"$bin/lanes-window >/dev/null 2>&1 &\"" >/dev/null 2>&1 &&
240
+ say "app: $(tilde "$app") (Spotlight: Lanes)"
241
+ fi
242
+ return 0
243
+ fi
244
+ mkdir -p "$HOME/.local/share/applications"
245
+ sed "s|^Exec=.*|Exec=$bin/lanes-window|" "$target/omarchy/lanes.desktop" > "$HOME/.local/share/applications/lanes.desktop"
246
+ # Hyprland (Omarchy): follow the LIVE config generation — Omarchy 4 = Lua, Omarchy 3 = hyprlang. One added
247
+ # line, validated with `hyprctl configerrors`, and reverted automatically if Hyprland reports any error.
248
+ hypr=$HOME/.config/hypr
249
+ if [ -f "$hypr/hyprland.lua" ]; then
250
+ main=$hypr/hyprland.lua; piece=$hypr/lanes.lua; line='require("hypr.lanes")'; cp "$target/omarchy/lanes.lua" "$piece"
251
+ elif [ -f "$hypr/hyprland.conf" ]; then
252
+ main=$hypr/hyprland.conf; piece=$hypr/lanes.conf; line='source = ~/.config/hypr/lanes.conf'; cp "$target/omarchy/lanes.conf" "$piece"
253
+ else
254
+ say 'desktop: added a "Lanes" launcher entry (no Hyprland config found, so no key binding)'; return 0
255
+ fi
256
+ if grep -qF "$line" "$main"; then say 'Hyprland: lanes key bindings already present (bind file refreshed)'; return 0; fi
257
+ backup=$main.pre-lanes.$stamp; cp -p "$main" "$backup"
258
+ comment='--'; [ "${main##*.}" = lua ] || comment='#'
259
+ printf '\n%s lanes workspace server (remove this line and %s to uninstall)\n%s\n' "$comment" "$(basename "$piece")" "$line" >> "$main"
260
+ signature=$(ls -t "/run/user/$(id -u)/hypr/" 2>/dev/null | head -n 1)
261
+ if [ -n "$signature" ] && command -v hyprctl >/dev/null 2>&1; then
262
+ export HYPRLAND_INSTANCE_SIGNATURE=$signature
263
+ hyprctl reload >/dev/null 2>&1 || :; sleep 1
264
+ if [ -n "$(hyprctl configerrors 2>/dev/null | tr -d '[:space:]')" ]; then
265
+ cp -p "$backup" "$main"; rm -f "$piece"; hyprctl reload >/dev/null 2>&1 || :
266
+ warn "Hyprland reported config errors -> reverted $(tilde "$main") (nothing changed)"
267
+ else
268
+ say "Hyprland: Super+Shift+L opens (or focuses) the lanes window — validated, backup $(basename "$backup")"
269
+ fi
270
+ else
271
+ say "Hyprland config updated (no running instance to validate against) — backup $(basename "$backup")"
272
+ fi
273
+ }
274
+
275
+ # ---- uploads --------------------------------------------------------------------------------------------------------
276
+ install_uploads() {
277
+ watch=$(cat "$config/uploads-dir" 2>/dev/null || echo "$HOME/Lanes Uploads")
278
+ mkdir -p "$watch"
279
+ for board in ${boards:-}; do mkdir -p "$watch/$board"; done
280
+ [ -e "$watch/README.txt" ] || printf '%s\n' \
281
+ 'Drop files here: they are uploaded to the uploads/ folder of the board your lanes view is showing.' \
282
+ 'Drop them into a <board>/ subfolder to choose the board. Sent files move to .sent/<date>/.' \
283
+ 'The in-container path lands on your clipboard. Command line: lanes-upload --help' > "$watch/README.txt"
284
+ if [ "$os" = Darwin ]; then
285
+ plist=$HOME/Library/LaunchAgents/$label.plist
286
+ mkdir -p "$HOME/Library/LaunchAgents"
287
+ { printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
288
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
289
+ '<plist version="1.0"><dict>' "<key>Label</key><string>$label</string>" \
290
+ "<key>ProgramArguments</key><array><string>/bin/sh</string><string>$bin/lanes-upload</string><string>--watch-run</string></array>" \
291
+ '<key>ThrottleInterval</key><integer>2</integer>' '<key>WatchPaths</key><array>' "<string>$watch</string>"
292
+ for board in ${boards:-}; do printf '<string>%s</string>\n' "$watch/$board"; done
293
+ printf '%s\n' '</array>' "<key>StandardErrorPath</key><string>$config/upload-watch.log</string>" '</dict></plist>'
294
+ } > "$plist"
295
+ launchctl bootout "gui/$(id -u)/$label" >/dev/null 2>&1 || :
296
+ launchctl bootstrap "gui/$(id -u)" "$plist" >/dev/null 2>&1 || launchctl load "$plist" >/dev/null 2>&1 || :
297
+ # A droplet: drag files onto it (Dock / Finder toolbar) to upload AND type the path into the lane you look at.
298
+ app="$HOME/Applications/Lanes Upload.app"
299
+ if [ "$desktop" = 1 ] && [ ! -d "$app" ] && command -v osacompile >/dev/null 2>&1; then
300
+ mkdir -p "$HOME/Applications"
301
+ osacompile -o "$app" -e 'on open theItems' -e 'set args to ""' -e 'repeat with anItem in theItems' \
302
+ -e 'set args to args & " " & quoted form of POSIX path of anItem' -e 'end repeat' \
303
+ -e "do shell script \"$bin/lanes-upload --paste\" & args" -e 'end open' \
304
+ -e 'on run' -e "do shell script \"$bin/lanes-upload --clipboard --paste\"" -e 'end run' >/dev/null 2>&1 &&
305
+ say "droplet: $(tilde "$app") (drop files on it; double-click = upload the clipboard screenshot)"
306
+ fi
307
+ elif command -v systemctl >/dev/null 2>&1; then
308
+ units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
309
+ mkdir -p "$units"
310
+ { printf '%s\n' '[Unit]' 'Description=lanes: upload files dropped into the watch folder' '[Path]' "PathChanged=$watch"
311
+ for board in ${boards:-}; do printf 'PathChanged=%s\n' "$watch/$board"; done
312
+ printf '%s\n' 'Unit=lanes-upload.service' '[Install]' 'WantedBy=default.target'; } > "$units/lanes-upload.path"
313
+ printf '%s\n' '[Unit]' 'Description=lanes: process the uploads watch folder' '[Service]' 'Type=oneshot' \
314
+ "ExecStart=/bin/sh $bin/lanes-upload --watch-run" > "$units/lanes-upload.service"
315
+ systemctl --user daemon-reload >/dev/null 2>&1 || :
316
+ systemctl --user enable --now lanes-upload.path >/dev/null 2>&1 || :
317
+ fi
318
+ say "uploads: drop files into \"$(tilde "$watch")\" (or a <board>/ subfolder) — or drag them onto the box in the sidebar"
319
+ }
320
+
321
+ # ---- upload helper service ------------------------------------------------------------------------------------------
322
+ # The helper that serves the sidebar's upload box is a child of the lanes window, so it dies with it. As ONE service
323
+ # per machine (`lanes-launch --helper <device> --service`, kept alive by launchd / systemd) it outlives every window.
324
+ # That takes a client that does its half: touch $state/helper.alive on every READY/PING line from the server, start
325
+ # the in-window helper only while that file is older than 90 s, and back off while the server is away. An older
326
+ # lanes-launch ignores --service and retries its ssh login every 3 s — as a service that is around the clock, against
327
+ # a server that may be thrashing. So the service is installed ONLY for a client that knows helper.alive, judged on
328
+ # the INSTALLED file (that is what would run); with any other client the open window serves the upload box, as before.
329
+ helper_supported() { grep -q 'helper\.alive' "$bin/lanes-launch" 2>/dev/null; }
330
+ helper_loaded() {
331
+ if [ "$os" = Darwin ]; then launchctl print "gui/$(id -u)/$helper_label" >/dev/null 2>&1
332
+ else [ "$(systemctl --user is-active lanes-helper.service 2>/dev/null)" = active ]; fi
333
+ }
334
+ # Where the service's stderr goes: launchd writes the file named in the plist, a systemd user unit writes the journal.
335
+ helper_log() { if [ "$os" = Darwin ]; then printf '%s' "$(tilde "$state")/helper-service.log"; else printf 'journalctl --user -u lanes-helper'; fi; }
336
+ install_macos_helper_app() {
337
+ helper_app=$HOME/Applications/Lanes\ Helper.app
338
+ helper_source=$here/client/macos-helper.applescript
339
+ # Keep the signed bundle unchanged on ordinary reinstalls, so macOS keeps its folder grants.
340
+ if [ -f "$helper_app/Contents/Resources/source.applescript" ] &&
341
+ cmp -s "$helper_source" "$helper_app/Contents/Resources/source.applescript" &&
342
+ codesign --verify "$helper_app" >/dev/null 2>&1; then return 0; fi
343
+ mkdir -p "$share" "$HOME/Applications"
344
+ build=$(mktemp -d "$share/helper-build.XXXXXX")
345
+ built=$build/Lanes\ Helper.app
346
+ osacompile -o "$built" "$helper_source"
347
+ cp "$helper_source" "$built/Contents/Resources/source.applescript"
348
+ /usr/libexec/PlistBuddy -c "Add :CFBundleIdentifier string $helper_label" "$built/Contents/Info.plist"
349
+ /usr/libexec/PlistBuddy -c 'Add :LSUIElement bool true' "$built/Contents/Info.plist"
350
+ for folder in Downloads Desktop Documents; do
351
+ /usr/libexec/PlistBuddy -c "Add :NS${folder}FolderUsageDescription string Upload the files you drop onto Lanes to your workspace server." "$built/Contents/Info.plist"
352
+ done
353
+ codesign --force --sign - "$built" >/dev/null 2>&1
354
+ rm -rf "$helper_app"
355
+ mv "$built" "$helper_app"
356
+ rmdir "$build"
357
+ }
358
+ install_helper_service() {
359
+ if ! helper_supported; then say 'upload helper: the open lanes window serves the upload box (this client has no service mode)'; return 0; fi
360
+ mkdir -p "$state"
361
+ if [ "$os" = Darwin ]; then
362
+ install_macos_helper_app
363
+ plist=$HOME/Library/LaunchAgents/$helper_label.plist
364
+ mkdir -p "$HOME/Library/LaunchAgents"
365
+ printf '%s\n' '<?xml version="1.0" encoding="UTF-8"?>' \
366
+ '<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">' \
367
+ '<plist version="1.0"><dict>' "<key>Label</key><string>$helper_label</string>" \
368
+ "<key>ProgramArguments</key><array><string>$helper_app/Contents/MacOS/applet</string></array>" \
369
+ '<key>KeepAlive</key><true/>' '<key>RunAtLoad</key><true/>' '<key>ProcessType</key><string>Interactive</string>' \
370
+ '<key>ThrottleInterval</key><integer>10</integer>' \
371
+ "<key>StandardErrorPath</key><string>$state/helper-service.log</string>" '</dict></plist>' > "$plist"
372
+ launchctl bootout "gui/$(id -u)/$helper_label" >/dev/null 2>&1 || :
373
+ # bootout returns before a RUNNING job is gone, and bootstrap fails (error 5) until it is: wait for it.
374
+ tries=0; while helper_loaded && [ "$tries" -lt 5 ]; do sleep 1; tries=$((tries + 1)); done
375
+ launchctl bootstrap "gui/$(id -u)" "$plist" >/dev/null 2>&1 || launchctl load "$plist" >/dev/null 2>&1 || :
376
+ say 'macOS folder access: choose Allow when Lanes Helper asks to read a folder you upload from'
377
+ elif command -v systemctl >/dev/null 2>&1; then
378
+ units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
379
+ mkdir -p "$units"
380
+ printf '%s\n' '[Unit]' 'Description=lanes: upload helper of this machine (serves the upload box of every lanes window)' \
381
+ '[Service]' "ExecStart=/bin/sh $bin/lanes-launch --helper $device --service" 'Restart=always' 'RestartSec=5' \
382
+ '[Install]' 'WantedBy=default.target' > "$units/lanes-helper.service"
383
+ systemctl --user daemon-reload >/dev/null 2>&1 || :
384
+ # enable + restart (not `enable --now`): an update must run the NEW client, a first install connects once.
385
+ systemctl --user enable lanes-helper.service >/dev/null 2>&1 || :
386
+ systemctl --user restart lanes-helper.service >/dev/null 2>&1 || :
387
+ # A user unit has no terminal to ask for a passphrase and cannot be handed SSH_AUTH_SOCK by this script
388
+ # (PassEnvironment is not valid in user units). (-P '': succeeds only for a key without a passphrase.)
389
+ ssh-keygen -y -P '' -f "$key" >/dev/null 2>&1 ||
390
+ warn "your key has a passphrase: the helper service can only log in through an agent the systemd user manager knows (\`systemctl --user import-environment SSH_AUTH_SOCK\` at session start, then \`systemctl --user restart lanes-helper\`). Until then the open window serves the upload box."
391
+ else
392
+ say 'upload helper: no launchd or systemd here — the open lanes window serves the upload box'; return 0
393
+ fi
394
+ if helper_loaded; then say "upload helper: a background service for '$device' — independent of any window, restarted by the system"
395
+ else warn "upload helper: the service did not start (log: $(helper_log)) — the open window serves the upload box instead"; fi
396
+ }
397
+ # Epoch mtime. GNU form first: GNU `stat -f` means "file system" and prints that before failing; BSD rejects -c silently.
398
+ mtime() { stat -c %Y "$1" 2>/dev/null || stat -f %m "$1" 2>/dev/null; }
399
+ # --check: is anything serving the upload box? Whoever does (the service, or a window's own helper when there is no
400
+ # service) touches helper.alive on every READY/PING line from the server (one every 5 s); serving = younger than 90 s.
401
+ check_helper() {
402
+ # (a client without the service side never writes helper.alive: there is nothing to measure, and nothing wrong)
403
+ if ! helper_supported; then say 'upload helper: an open lanes window serves the upload box itself (this client has no service mode)'; return 0; fi
404
+ service='no service (./install.sh adds it)'; if helper_loaded; then service='service running'; fi
405
+ seen=$(mtime "$state/helper.alive") || seen=
406
+ if [ -z "$seen" ] && helper_loaded; then warn "upload helper: $service, but it has never reached the server (log: $(helper_log))"; return 0; fi
407
+ # (no service and no heartbeat may be a choice: --no-uploads, or no window opened yet)
408
+ if [ -z "$seen" ]; then say "upload helper: $service — an open lanes window serves the upload box itself"; return 0; fi
409
+ age=$(( $(date +%s) - seen ))
410
+ if [ "$age" -lt 90 ]; then say "ok upload helper: $service; last heard from the server ${age}s ago"
411
+ else warn "upload helper: $service; last heard from the server ${age}s ago (log: $(helper_log))"; fi
412
+ }
413
+
414
+ # ---- server ---------------------------------------------------------------------------------------------------------
415
+ # Every call to the server is BOUNDED. A thrashing host accepts the connection and then says nothing for minutes;
416
+ # ConnectTimeout does not cover the remote command, and macOS has no timeout(1) — so a watchdog subshell does it.
417
+ # bounded <command…> runs it until $deadline (epoch seconds) -> $out, $err, $rc; rc 124 = killed, not finished.
418
+ # The watchdog watches the temp file, not the pid: once the child is reaped its pid can belong to anyone. The
419
+ # child keeps our stdin through fd 9 (an async job gets /dev/null otherwise; the terminfo push is piped in).
420
+ bounded() {
421
+ left=$(( deadline - $(date +%s) )); out=; err=; rc=124
422
+ [ "$left" -gt 0 ] || return 124
423
+ tmp=$(mktemp)
424
+ exec 9<&0
425
+ "$@" <&9 9<&- > "$tmp" 2> "$tmp.err" &
426
+ pid=$!
427
+ exec 9<&-
428
+ ( while [ "$left" -gt 0 ]; do sleep 1; left=$((left - 1)); [ -e "$tmp" ] || exit 0; done
429
+ : > "$tmp.late"; kill -TERM "$pid" 2>/dev/null; sleep 2
430
+ if [ -e "$tmp" ]; then kill -KILL "$pid" 2>/dev/null; fi ) </dev/null >/dev/null 2>&1 &
431
+ rc=0; wait "$pid" 2>/dev/null || rc=$? # (2>: bash reports a child that died of our TERM as "Terminated: 15")
432
+ if [ -e "$tmp.late" ]; then rc=124; fi
433
+ out=$(cat "$tmp"); err=$(cat "$tmp.err")
434
+ rm -f "$tmp" "$tmp.err" "$tmp.late"
435
+ return "$rc"
436
+ }
437
+ # -> server_state: ok | unauthorized | unreachable | hostkey | old | slow ; server_note: detail
438
+ # The whole server part of an install takes at most ~25 s: 15 s for the greeting + 10 s for after_authorized
439
+ # (the greeting gets 90 s in ONE case: a terminal and a key with a passphrase, where ssh may be waiting for a human).
440
+ check_server() {
441
+ # ConnectTimeout also covers banner + key exchange: 10 s, because a busy server needs seconds for those alone.
442
+ opts='-o RequestTTY=no -o ConnectTimeout=10 -o PasswordAuthentication=no -o KbdInteractiveAuthentication=no'
443
+ patience=15
444
+ # With a terminal, let ssh ask for the key's passphrase once; without one, never hang on a prompt.
445
+ if [ -t 0 ] && [ "$assume_yes" = 0 ]; then
446
+ opts="$opts -o BatchMode=no"
447
+ # ...and then a human may be typing: do not kill the prompt. (-P '' succeeds only for a key without a passphrase.)
448
+ ssh-keygen -y -P '' -f "$key" >/dev/null 2>&1 || patience=90
449
+ else
450
+ opts="$opts -o BatchMode=yes"
451
+ fi
452
+ deadline=$(( $(date +%s) + patience ))
453
+ # shellcheck disable=SC2086
454
+ bounded ssh $opts -o LogLevel=ERROR lanes "\$HOME/.local/bin/lanes hello $device" || :
455
+ out=$(printf '%s\n%s' "$out" "$err")
456
+ server_note=$(printf '%s' "$out" | tail -n 1)
457
+ case "$out" in
458
+ ok\ *|*"
459
+ ok "*) server_state=ok; server_note=$(printf '%s\n' "$out" | grep '^ok ' | tail -n 1) ;;
460
+ *'Host key verification failed'*|*'REMOTE HOST IDENTIFICATION HAS CHANGED'*) server_state=hostkey ;;
461
+ *'Permission denied'*|*'Too many authentication failures'*) server_state=unauthorized ;;
462
+ # The TCP connection stood but sshd never greeted: the host is there and overloaded, not "Tailscale is down".
463
+ *'during banner exchange'*) server_state=slow ;;
464
+ *) if [ "$rc" = 124 ]; then server_state=slow; elif [ "$rc" = 255 ]; then server_state=unreachable; else server_state=old; fi ;;
465
+ esac
466
+ }
467
+ after_authorized() {
468
+ deadline=$(( $(date +%s) + 10 ))
469
+ # site facts served by the server (kept out of the distributed package), e.g. the public fallback address
470
+ bounded ssh -o RequestTTY=no -o BatchMode=yes lanes '$HOME/.local/bin/lanes client-config' || :
471
+ facts=$(printf '%s\n' "$out" | grep '^LANES_[A-Z_]*=[A-Za-z0-9_.:@/-]*$' || :)
472
+ if [ -n "$facts" ]; then
473
+ printf '%s\n' "$facts" > "$config/site.cache"
474
+ public=$(printf '%s\n' "$facts" | sed -n 's/^LANES_SERVER_PUBLIC=//p' | head -n 1)
475
+ if [ -n "$public" ] && [ "$public" != "$server_public" ]; then server_public=$public; install_ssh >/dev/null; say "ssh: added the fallback path lanes-public"; fi
476
+ fi
477
+ # the terminal's terminfo entry for the ssh login on the server (user-level, no sudo)
478
+ if infocmp -x xterm-ghostty >/dev/null 2>&1 &&
479
+ ! bounded ssh -o RequestTTY=no -o BatchMode=yes lanes 'infocmp xterm-ghostty >/dev/null 2>&1' && [ "$rc" != 124 ]; then
480
+ infocmp -x xterm-ghostty | bounded ssh -o RequestTTY=no -o BatchMode=yes lanes 'mkdir -p ~/.terminfo && tic -x -o ~/.terminfo - 2>/dev/null' &&
481
+ say 'pushed the xterm-ghostty terminfo entry to the server' || say 'terminfo push failed (the session falls back to xterm-256color)'
482
+ fi
483
+ bounded ssh -o RequestTTY=no -o BatchMode=yes lanes '$HOME/.local/bin/lanes workspaces' || :
484
+ boards=$(printf '%s\n' "$out" | grep -E '^[A-Za-z0-9_.-]+$' || :)
485
+ # $deadline is absolute: once it has passed every later call returns 124 at once, so the last rc tells it all.
486
+ # Nothing is lost — the facts, the terminfo entry and the board folders come with the next run.
487
+ if [ "$rc" = 124 ]; then server_state=slow; fi
488
+ }
489
+ report_server() {
490
+ case "$server_state" in
491
+ ok) say "server: $server_note" ;;
492
+ old) warn "server answered, but without the expected greeting (an older server?): $server_note" ;;
493
+ unauthorized)
494
+ head_ 'One step left: this machine is not authorized on the server yet.'
495
+ say 'Send this PUBLIC key to your workspace-server admin (it is safe to share):'
496
+ printf '\n %s\n\n' "$(cat "$key.pub")"
497
+ say "They add that line to ~$server_user/.ssh/authorized_keys on the server ($server_alias)."
498
+ say 'Then run: ./install.sh --check and open the window with: lanes-window' ;;
499
+ hostkey) warn "the server's host key does not match what this machine trusts as '$server_alias' — do NOT connect; ask the admin." ;;
500
+ slow)
501
+ # An overloaded server is not a broken client (and not lost lanes): finish the local install, say so, exit 4.
502
+ if [ "$mode" = install ]; then warn 'server did not answer in time (busy?) — installed; run ./install.sh --check later'
503
+ else warn 'server did not answer in time (busy?) — run ./install.sh --check again later'; fi ;;
504
+ unreachable)
505
+ head_ "The server ($server_host) is not reachable from this machine."
506
+ say 'The path runs over Tailscale: is it running, and is this machine in the tailnet?'
507
+ ts=$(command -v tailscale 2>/dev/null || echo /Applications/Tailscale.app/Contents/MacOS/Tailscale)
508
+ if [ -x "$ts" ]; then say "tailscale: $("$ts" status 2>&1 | head -n 1)"; else say 'tailscale: not installed (tailscale.com/download)'; fi
509
+ say "detail: $server_note" ;;
510
+ esac
511
+ }
512
+
513
+ # ---- modes ----------------------------------------------------------------------------------------------------------
514
+ do_install() {
515
+ command -v ssh >/dev/null 2>&1 || die 'OpenSSH (ssh) is required'
516
+ case "$os" in Darwin|Linux) ;; *) die "unsupported system: $os (macOS and Linux only)" ;; esac
517
+ load_site; src=$here/client
518
+ head_ "lanes client $(cat "$here/VERSION" 2>/dev/null || echo dev) -> $server_user@$server_alias"
519
+ choose_device; choose_key; ensure_key
520
+ install_client; src=$target
521
+ install_ssh
522
+ find_terminal
523
+ head_ 'Checking the server…'
524
+ check_server
525
+ boards=
526
+ if [ "$server_state" = ok ] || [ "$server_state" = old ]; then after_authorized; fi
527
+ report_server
528
+ if [ "$desktop" = 1 ]; then head_ 'Desktop'; install_desktop; fi
529
+ if [ "$uploads" = 1 ]; then [ "$desktop" = 1 ] || head_ 'Uploads'; install_uploads; install_helper_service; fi
530
+ case "$server_state" in
531
+ ok|old)
532
+ head_ 'Ready.'
533
+ say 'Open the window: lanes-window (Omarchy: Super+Shift+L · macOS: the Lanes app)'
534
+ say 'In any terminal: lanes-launch one lane only: lanes-launch lane <id> · list: lanes-launch ls'
535
+ say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · close the window to detach'
536
+ exit 0 ;;
537
+ unauthorized) exit 3 ;;
538
+ *) exit 4 ;;
539
+ esac
540
+ }
541
+
542
+ do_check() {
543
+ load_site; src=$here/client; choose_device; choose_key
544
+ head_ "lanes client check -> $server_user@$server_alias"
545
+ for name in lanes-launch lanes-window lanes-upload; do
546
+ if [ -x "$bin/$name" ]; then say "ok $name -> $(readlink "$bin/$name" 2>/dev/null || echo "$bin/$name")"; else warn "missing: $bin/$name (run ./install.sh)"; fi
547
+ done
548
+ say "device: $device · key: $(tilde "$key")$([ -r "$key" ] || echo ' (MISSING)')"
549
+ ssh -G lanes 2>/dev/null | awk '$1 == "hostname" { print " ssh alias lanes -> " $2 }'
550
+ find_terminal
551
+ check_helper
552
+ check_server; report_server
553
+ case "$server_state" in ok) exit 0 ;; unauthorized) exit 3 ;; *) exit 4 ;; esac
554
+ }
555
+
556
+ do_uninstall() {
557
+ head_ 'Removing the lanes client from this machine'
558
+ for name in lanes-launch lanes-window lanes-upload coder-launch; do
559
+ case "$(readlink "$bin/$name" 2>/dev/null || :)" in */lanes/client/*|"$here"/client/*) rm -f "$bin/$name"; say "removed $bin/$name" ;; esac
560
+ done
561
+ if [ -e "$bin/coder-launch-legacy" ] && [ ! -e "$bin/coder-launch" ]; then mv "$bin/coder-launch-legacy" "$bin/coder-launch"; say 'restored the previous coder-launch'; fi
562
+ if [ "$os" = Darwin ]; then
563
+ for agent in "$label" "$helper_label"; do
564
+ launchctl bootout "gui/$(id -u)/$agent" >/dev/null 2>&1 || :
565
+ rm -f "$HOME/Library/LaunchAgents/$agent.plist"
566
+ done
567
+ rm -rf "$HOME/Applications/Lanes.app" "$HOME/Applications/Lanes Upload.app" "$HOME/Applications/Lanes Helper.app"
568
+ else
569
+ units=${XDG_CONFIG_HOME:-$HOME/.config}/systemd/user
570
+ systemctl --user disable --now lanes-upload.path >/dev/null 2>&1 || :
571
+ systemctl --user disable --now lanes-helper.service >/dev/null 2>&1 || :
572
+ rm -f "$units/lanes-upload.path" "$units/lanes-upload.service" "$units/lanes-helper.service"
573
+ systemctl --user daemon-reload >/dev/null 2>&1 || :
574
+ rm -f "$HOME/.local/share/applications/lanes.desktop"
575
+ for main in "$HOME/.config/hypr/hyprland.lua" "$HOME/.config/hypr/hyprland.conf"; do
576
+ [ -f "$main" ] && grep -q -F -e 'require("hypr.lanes")' -e 'source = ~/.config/hypr/lanes.conf' "$main" || continue
577
+ cp -p "$main" "$main.pre-lanes-uninstall.$stamp"
578
+ grep -v -F -e 'lanes workspace server (remove this line' -e 'require("hypr.lanes")' -e 'source = ~/.config/hypr/lanes.conf' \
579
+ "$main.pre-lanes-uninstall.$stamp" > "$main" || :
580
+ say "Hyprland: removed the lanes line from $(tilde "$main") (backup kept)"
581
+ done
582
+ rm -f "$HOME/.config/hypr/lanes.lua" "$HOME/.config/hypr/lanes.conf"
583
+ command -v hyprctl >/dev/null 2>&1 && hyprctl reload >/dev/null 2>&1 || :
584
+ fi
585
+ if grep -q '^Include config.d/lanes.conf' "$HOME/.ssh/config" 2>/dev/null; then
586
+ cp -p "$HOME/.ssh/config" "$HOME/.ssh/config.pre-lanes-uninstall.$stamp"
587
+ grep -v -e '^Include config.d/lanes.conf' -e '^# lanes workspace server (added ' "$HOME/.ssh/config.pre-lanes-uninstall.$stamp" > "$HOME/.ssh/config" || :
588
+ chmod 600 "$HOME/.ssh/config"; say '~/.ssh/config: removed the Include line (backup kept)'
589
+ fi
590
+ rm -f "$HOME/.ssh/config.d/lanes.conf"
591
+ rm -rf "$share/client" "$state"
592
+ # A Linux account can run both roles. Keep server catalogs and any files
593
+ # the client did not create in the shared configuration directory.
594
+ rm -f "$config/device" "$config/ghostty.conf" "$config"/ghostty.*.conf \
595
+ "$config/client-site.conf" "$config/client-known-hosts" "$config/site.cache" \
596
+ "$config/upload-allow" "$config/uploads-dir" "$config/upload-watch.log"
597
+ rmdir "$config" 2>/dev/null || :
598
+ say 'stopped the upload agents (watch folder, upload helper service); removed the client, its settings and its logs'
599
+ say "kept: your SSH key, the known_hosts entry, and \"$HOME/Lanes Uploads\""
600
+ say 'Your view session on the server stays until an admin removes it (it holds no agents: lanes are never touched).'
601
+ }
602
+
603
+ case "$mode" in install) do_install ;; check) do_check ;; uninstall) do_uninstall ;; esac