@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.
- package/LICENSE +21 -0
- package/README.md +70 -0
- package/bin/lanes-install.mjs +158 -0
- package/lib/plan.mjs +65 -0
- package/package.json +37 -0
- package/payload/lanes/README.md +152 -0
- package/payload/lanes/VERSION +1 -0
- package/payload/lanes/bin/lanes +1035 -0
- package/payload/lanes/client/ghostty/common.conf +22 -0
- package/payload/lanes/client/ghostty/macos.conf +6 -0
- package/payload/lanes/client/ghostty/omarchy.conf +19 -0
- package/payload/lanes/client/install.sh +16 -0
- package/payload/lanes/client/lanes-launch +315 -0
- package/payload/lanes/client/lanes-upload +147 -0
- package/payload/lanes/client/lanes-window +36 -0
- package/payload/lanes/client/macos-helper.applescript +14 -0
- package/payload/lanes/client/omarchy/discover.sh +56 -0
- package/payload/lanes/client/omarchy/lanes.conf +5 -0
- package/payload/lanes/client/omarchy/lanes.desktop +9 -0
- package/payload/lanes/client/omarchy/lanes.lua +14 -0
- package/payload/lanes/client/ssh.stanza.in +54 -0
- package/payload/lanes/container/env.sh +16 -0
- package/payload/lanes/container/lane +268 -0
- package/payload/lanes/container/lane-run +34 -0
- package/payload/lanes/docs/ARCHITECTURE.md +93 -0
- package/payload/lanes/docs/HOST-GUARD.md +108 -0
- package/payload/lanes/docs/INSTALL.md +192 -0
- package/payload/lanes/docs/RUNBOOK.md +385 -0
- package/payload/lanes/docs/SHARING.md +124 -0
- package/payload/lanes/docs/ZMX-RING0.md +51 -0
- package/payload/lanes/docs/images/desktop.png +0 -0
- package/payload/lanes/docs/images/iphone.png +0 -0
- package/payload/lanes/holders/exec.sh +21 -0
- package/payload/lanes/holders/tmux.sh +111 -0
- package/payload/lanes/holders/zmx.sh +22 -0
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-phone.sh +43 -0
- package/payload/lanes/install/enable-web.sh +16 -0
- package/payload/lanes/install/host-install.sh +72 -0
- package/payload/lanes/install/services.py +61 -0
- package/payload/lanes/install.sh +603 -0
- package/payload/lanes/libexec/cursor_commit.py +159 -0
- package/payload/lanes/libexec/cursor_sync.py +166 -0
- package/payload/lanes/libexec/guard.py +231 -0
- package/payload/lanes/libexec/lanes_lib.py +610 -0
- package/payload/lanes/libexec/legacy_sessions.py +130 -0
- package/payload/lanes/libexec/session_actions.py +276 -0
- package/payload/lanes/libexec/sidebar +8 -0
- package/payload/lanes/libexec/sidebar.py +872 -0
- package/payload/lanes/libexec/statusd.py +764 -0
- package/payload/lanes/libexec/tmpscan.py +921 -0
- package/payload/lanes/libexec/view.sh +179 -0
- package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
- package/payload/lanes/site/README.md +28 -0
- package/payload/lanes/site/beyondwork/known_hosts +1 -0
- package/payload/lanes/site/beyondwork/site.conf +11 -0
- package/payload/lanes/site/site.example.conf +10 -0
- package/payload/lanes/sudo/host-guard.sh +356 -0
- package/payload/lanes/sudo/one-time-root.sh +32 -0
- package/payload/lanes/systemd/lanes-ensure.service +7 -0
- package/payload/lanes/systemd/lanes-ensure.timer +10 -0
- package/payload/lanes/systemd/lanes-gc.service +7 -0
- package/payload/lanes/systemd/lanes-gc.timer +9 -0
- package/payload/lanes/systemd/lanes-guard.service +8 -0
- package/payload/lanes/systemd/lanes-guard.timer +10 -0
- package/payload/lanes/systemd/lanes-mobile.service +23 -0
- package/payload/lanes/systemd/lanes-statusd.service +15 -0
- package/payload/lanes/systemd/lanes-sync.service +10 -0
- package/payload/lanes/systemd/lanes-sync.timer +10 -0
- package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
- package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
- package/payload/lanes/systemd/lanes-web.service +13 -0
- package/payload/lanes/systemd/lanes.slice +11 -0
- package/payload/lanes/web/Caddyfile +37 -0
- package/payload/lanes/web/README.md +35 -0
- package/payload/lanes/web/icon-192.png +0 -0
- package/payload/lanes/web/icon.svg +4 -0
- package/payload/lanes/web/index.html +30 -0
- package/payload/lanes/web/manifest.webmanifest +15 -0
- package/payload/lanes/web/mobile.css +301 -0
- package/payload/lanes/web/mobile.js +367 -0
- package/payload/lanes/workspaces.example/README.md +27 -0
- package/payload/lanes/workspaces.example/catalog.json +7 -0
- package/payload/lanes/workspaces.example/commit-targets.json +8 -0
- package/payload/lanes/workspaces.example/terminals.example.json +19 -0
- package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
- package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
- package/payload/lanes/zellij/config.kdl +360 -0
- 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
|