@vibelord/lanes 1.1.0 → 1.2.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/README.md +36 -5
- package/bin/lanes-install.mjs +166 -13
- package/lib/plan.mjs +113 -3
- package/package.json +1 -1
- package/payload/lanes/README.md +47 -22
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2459 -164
- package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
- package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
- package/payload/lanes/client/lanes-launch +289 -40
- package/payload/lanes/client/lanes-window +15 -3
- package/payload/lanes/container/hooks/claude-settings.json +1 -0
- package/payload/lanes/container/lane +106 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1091 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1140 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +625 -15
- package/payload/lanes/docs/DESIGN.md +122 -44
- package/payload/lanes/docs/INSTALL.md +157 -17
- package/payload/lanes/docs/RUNBOOK.md +653 -81
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +792 -140
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +163 -7
- package/payload/lanes/install/services.py +148 -18
- package/payload/lanes/install.sh +68 -7
- package/payload/lanes/libexec/agent_state.py +74 -1
- package/payload/lanes/libexec/cursor_sync.py +6 -0
- package/payload/lanes/libexec/device_sync.py +47 -10
- package/payload/lanes/libexec/economy_poll.py +1 -0
- package/payload/lanes/libexec/enrold.py +41 -22
- package/payload/lanes/libexec/keys.py +54 -23
- package/payload/lanes/libexec/lanes_lib.py +620 -22
- package/payload/lanes/libexec/members.py +16 -2
- package/payload/lanes/libexec/notify.py +104 -24
- package/payload/lanes/libexec/registrations.py +216 -18
- package/payload/lanes/libexec/session_actions.py +182 -14
- package/payload/lanes/libexec/sidebar +11 -3
- package/payload/lanes/libexec/sidebar.py +2135 -361
- package/payload/lanes/libexec/statusd.py +137 -10
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +121 -30
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +972 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-ensure.service +3 -0
- package/payload/lanes/systemd/lanes-gc.service +1 -1
- package/payload/lanes/systemd/lanes-update.service +17 -0
- package/payload/lanes/systemd/lanes-update.timer +17 -0
- package/payload/lanes/web/README.md +207 -25
- package/payload/lanes/web/browser.js +3 -5
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +128 -76
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +5 -3
|
@@ -21,6 +21,8 @@ case "$device" in -*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;
|
|
|
21
21
|
command -v caddy >/dev/null 2>&1 || { echo 'Install Caddy first (see docs/INSTALL.md).' >&2; exit 1; }
|
|
22
22
|
export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
|
|
23
23
|
"$repo/bin/lanes" prepare "$device" "$layout"
|
|
24
|
+
# A re-render, never --install: the units of the release `current` names, every link left as it is — run from
|
|
25
|
+
# a tree an update moved past, this must not put the old release back (install/services.py, DEF-1835).
|
|
24
26
|
python3 "$repo/install/services.py"
|
|
25
27
|
systemctl --user daemon-reload
|
|
26
28
|
systemctl --user enable --now lanes-web.service lanes-mobile.service
|
|
@@ -1,23 +1,155 @@
|
|
|
1
1
|
#!/bin/sh
|
|
2
2
|
# Linux server install/update. Called by ./install.sh --server. Never runs as root.
|
|
3
|
+
#
|
|
4
|
+
# --update-only (plan W9 U2, LNS-90) is the update `npx @vibelord/lanes@<release> --server --update-only` runs (by
|
|
5
|
+
# hand, or by `lanes update` and its timer, plan W9 U3) on a server a full install set up. It goes through three phases:
|
|
6
|
+
# pre-commit preflight (prerequisites, the user manager, Docker, an installed release: an update restarts what the
|
|
7
|
+
# full install enabled and enables nothing, so with no `current` it is refused), the pinned binaries
|
|
8
|
+
# (sha256), terminfo, the catalog: a failure here leaves the installed release running exactly as it was;
|
|
9
|
+
# commit install/services.py --install: every unit, then current.previous, then the flip of `current` (W9 U1);
|
|
10
|
+
# post-commit libexec/update.py post-commit, from the tree just installed: daemon-reload, `lanes sync`, restart
|
|
11
|
+
# lanes-statusd, try-restart each optional service, `lanes install-container` per container,
|
|
12
|
+
# `lanes reload-views`, `lanes ensure --if-layout-changed` — each step runs whatever the one before it
|
|
13
|
+
# returned (`lanes sync` exiting 1 — a board with an error — is a warning in the record, which leaves
|
|
14
|
+
# the result `ok`; never the end of the update).
|
|
15
|
+
# Every run is recorded in $LANES_STATE/update/last.json (DM-LANES-UPDATE-RECORD: result ok | partial | refused |
|
|
16
|
+
# failed, each phase and step with its exit status) and logged in full to update/last.log; the terminal or the journal
|
|
17
|
+
# gets one line per phase and step. A full install (no --update-only) prints as before and records nothing.
|
|
18
|
+
# One install or update at a time: every run but --check first takes the run lock $LANES_STATE/update/.lock and holds
|
|
19
|
+
# it to its end (fd 9); a run that finds it held writes nothing — no log, no record — and exits 3.
|
|
20
|
+
# A full install enables lanes-update.timer with the other timers (plan W9 U3, LNS-91): `lanes update --timer`, 15 min
|
|
21
|
+
# after a boot and every 30 min, runs this --update-only for each newer npm release, with the npx the npm wrapper
|
|
22
|
+
# recorded in $LANES_STATE/update/npx (a checkout install is left alone: it updates with git pull). --update-only
|
|
23
|
+
# enables nothing, that timer included: a server set up before 1.2.0 and updated only with --update-only has it off
|
|
24
|
+
# until a full install or `systemctl --user enable --now lanes-update.timer` (`lanes doctor` warns; fix cycle 1 of U3).
|
|
3
25
|
set -eu
|
|
4
26
|
repo=$(cd "$(dirname "$0")/.." && pwd)
|
|
5
|
-
start=1 check=0
|
|
27
|
+
start=1 check=0 update_only=0
|
|
6
28
|
for argument in "$@"; do
|
|
7
29
|
case "$argument" in
|
|
8
30
|
--no-start) start=0 ;;
|
|
9
31
|
--check) check=1 ;;
|
|
32
|
+
--update-only) update_only=1 ;;
|
|
10
33
|
-h|--help)
|
|
11
|
-
echo 'usage: ./install.sh --server [--check | --no-start]'
|
|
34
|
+
echo 'usage: ./install.sh --server [--check | --no-start | --update-only]'
|
|
12
35
|
echo 'Installs pinned binaries and user services. --check changes nothing; --no-start only installs files.'
|
|
36
|
+
echo '--update-only: an update. After the switch to this release every step runs, whatever the one before it'
|
|
37
|
+
echo 'returned; the run is recorded in ~/.local/state/lanes/update/last.json and logged in update/last.log.'
|
|
38
|
+
echo 'It updates a server a full install set up: with no installed release it is refused. A full install'
|
|
39
|
+
echo 'enables lanes-update.timer, whose `lanes update --timer` runs it for each newer npm release;'
|
|
40
|
+
echo '--update-only enables nothing, that timer included (on a server set up before 1.2.0: run the full'
|
|
41
|
+
echo 'install once, or systemctl --user enable --now lanes-update.timer).'
|
|
42
|
+
echo 'Exit status of --update-only: 0 ok, 4 partial (installed; a step after the switch failed), 3 refused'
|
|
43
|
+
echo '(a prerequisite, no installed release or another install said no; nothing changed), 1 failed (nothing'
|
|
44
|
+
echo 'was installed). One install or update runs at a time: another one finding it running exits 3.'
|
|
13
45
|
exit 0 ;;
|
|
14
46
|
*) echo "unknown server option: $argument" >&2; exit 2 ;;
|
|
15
47
|
esac
|
|
16
48
|
done
|
|
49
|
+
if [ "$update_only" = 1 ] && [ "$start$check" != 10 ]; then
|
|
50
|
+
echo '--update-only restarts what it installs: it takes neither --check nor --no-start' >&2; exit 2
|
|
51
|
+
fi
|
|
17
52
|
[ "$(uname -s)/$(uname -m)" = Linux/x86_64 ] || {
|
|
18
53
|
echo 'The pinned server binaries require Linux x86_64. macOS/Linux desktops use ./install.sh --site FILE.' >&2; exit 1;
|
|
19
54
|
}
|
|
20
55
|
[ "$(id -u)" != 0 ] || { echo 'Run as the workspace user, not root.' >&2; exit 1; }
|
|
56
|
+
share=${LANES_SHARE:-$HOME/.local/share/lanes}
|
|
57
|
+
state=${LANES_STATE:-$HOME/.local/state/lanes}
|
|
58
|
+
# One install or update at a time (fix cycle 1 of W9 U2): the run lock, taken before anything is written — the update's
|
|
59
|
+
# log and record included — and held by this shell on fd 9 until it exits, however it ends. Two runs could otherwise
|
|
60
|
+
# truncate each other's log, run the post-commit twice over (every service restarted twice, two install-container
|
|
61
|
+
# copies into one volume) and race on update/last.json. A run that finds it held changes and records nothing: exit 3.
|
|
62
|
+
# No child of this shell may keep fd 9 (it would hold the lock after the run): those that can leave a process behind
|
|
63
|
+
# (`lanes sync`, libexec/update.py) get `9>&-`, and update.py runs each post-commit step with no descriptor but its
|
|
64
|
+
# stdio. --check changes nothing: no lock.
|
|
65
|
+
if [ "$check" = 0 ]; then
|
|
66
|
+
command -v python3 >/dev/null 2>&1 || { echo 'Missing prerequisite: python3 (see docs/INSTALL.md)' >&2; exit 1; }
|
|
67
|
+
mkdir -p "$state/update"
|
|
68
|
+
exec 9>>"$state/update/.lock"
|
|
69
|
+
lock_status=0
|
|
70
|
+
if [ "$update_only" = 1 ]; then
|
|
71
|
+
python3 "$repo/libexec/update.py" lock --state "$state" --fd 9 --mode update || lock_status=$?
|
|
72
|
+
else
|
|
73
|
+
python3 "$repo/libexec/update.py" lock --state "$state" --fd 9 --mode install >&2 || lock_status=$?
|
|
74
|
+
fi
|
|
75
|
+
case $lock_status in
|
|
76
|
+
0) ;;
|
|
77
|
+
3) exit 3 ;;
|
|
78
|
+
*) echo "could not take the run lock $state/update/.lock (exit $lock_status)" >&2; exit 1 ;;
|
|
79
|
+
esac
|
|
80
|
+
fi
|
|
81
|
+
# The phases of --update-only. `phase NAME`: the phase before it ended with exit 0 (reported and kept for the record);
|
|
82
|
+
# NAME runs next, under a `== NAME` line in the log. A full install only names them.
|
|
83
|
+
phase=preflight steps='' committed=0
|
|
84
|
+
phase() {
|
|
85
|
+
if [ "$update_only" = 1 ]; then
|
|
86
|
+
steps="$steps --step $phase=0"
|
|
87
|
+
printf 'ok %s\n' "$phase" >&3
|
|
88
|
+
printf '== %s\n' "$1"
|
|
89
|
+
fi
|
|
90
|
+
phase=$1
|
|
91
|
+
}
|
|
92
|
+
# --update-only, before the commit: whatever ends the run is recorded (`set -e`, an `exit`, a signal). refused: the
|
|
93
|
+
# preflight (no installed release among its checks), or install/services.py's refusal (exit 3: another install holds
|
|
94
|
+
# its lock, a link that is no symlink);
|
|
95
|
+
# failed: anything else — unless the flip happened before services.py failed (its command link), which is installed
|
|
96
|
+
# but not restarted: partial. After the commit the post-commit records its own outcome.
|
|
97
|
+
update_ended() {
|
|
98
|
+
status=$1
|
|
99
|
+
set +e
|
|
100
|
+
trap - EXIT HUP INT TERM
|
|
101
|
+
[ "$committed" = 0 ] || exit "$status"
|
|
102
|
+
result=failed code=1
|
|
103
|
+
case $phase in
|
|
104
|
+
preflight) result=refused code=3 ;;
|
|
105
|
+
commit)
|
|
106
|
+
if [ "$status" = 3 ]; then result=refused code=3
|
|
107
|
+
elif [ "$(readlink -e "$HOME/.local/share/lanes/current")" = "$tree" ] && [ "$before" != "$tree" ]; then
|
|
108
|
+
result=partial code=4
|
|
109
|
+
fi ;;
|
|
110
|
+
esac
|
|
111
|
+
printf 'FAIL %s (exit %s)\n' "$phase" "$status" >&3
|
|
112
|
+
# the last lines the failing phase wrote (after its `== NAME` line in the log)
|
|
113
|
+
awk '/^== /{n=0; next} {line[n++]=$0} END{for (i = (n > 5 ? n - 5 : 0); i < n; i++) print " " line[i]}' \
|
|
114
|
+
"$log" >&3
|
|
115
|
+
# shellcheck disable=SC2086 # $steps is `--step NAME=RC` words
|
|
116
|
+
python3 "$repo/libexec/update.py" record --result "$result" --state "$state" --tree "$tree" --before "$before" \
|
|
117
|
+
--log "$log" --started "$started" $steps --step "$phase=$status" 9>&- ||
|
|
118
|
+
printf 'could not write %s\n' "$state/update/last.json" >&3
|
|
119
|
+
case $result in
|
|
120
|
+
refused)
|
|
121
|
+
if [ -n "$before" ]; then printf 'refused: nothing changed; %s still runs — log %s\n' "$before" "$log" >&3
|
|
122
|
+
else printf 'refused: nothing changed; no release is installed here — log %s\n' "$log" >&3
|
|
123
|
+
fi ;;
|
|
124
|
+
partial) printf 'partial: %s is installed, nothing was restarted — log %s\n' "$tree" "$log" >&3 ;;
|
|
125
|
+
*) printf 'failed: nothing was installed; %s still runs — log %s\n' "${before:-the installed release}" "$log" >&3 ;;
|
|
126
|
+
esac
|
|
127
|
+
exit "$code"
|
|
128
|
+
}
|
|
129
|
+
if [ "$update_only" = 1 ]; then
|
|
130
|
+
tree=$(cd "$repo" && pwd -P) # as install/services.py resolves it for the link
|
|
131
|
+
log=$state/update/last.log
|
|
132
|
+
: > "$log"
|
|
133
|
+
# From here on the run's own output goes to the log (appended: the post-commit appends to it too); fd 3 is the
|
|
134
|
+
# terminal or the journal.
|
|
135
|
+
exec 3>&1 >>"$log" 2>&1
|
|
136
|
+
before=$(readlink -e "$HOME/.local/share/lanes/current" || :)
|
|
137
|
+
started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
|
|
138
|
+
trap 'update_ended $?' EXIT
|
|
139
|
+
trap 'exit 129' HUP
|
|
140
|
+
trap 'exit 130' INT
|
|
141
|
+
trap 'exit 143' TERM
|
|
142
|
+
printf 'lanes update: %s -> %s (log %s)\n' "${before:-nothing installed}" "$tree" "$log" >&3
|
|
143
|
+
echo '== preflight'
|
|
144
|
+
# An update restarts what a full install enabled — statusd, the timers, the enrolment service — and enables
|
|
145
|
+
# nothing; with no installed release it would end `ok` on a server where none of that runs, or ever starts again
|
|
146
|
+
# after a reboot (fix cycle 1 of W9 U2). Refused, before anything is fetched or installed.
|
|
147
|
+
if [ -z "$before" ]; then
|
|
148
|
+
echo "no release is installed here: $HOME/.local/share/lanes/current names no tree"
|
|
149
|
+
echo 'an update only restarts what a server install set up: install it first, without --update-only'
|
|
150
|
+
exit 3
|
|
151
|
+
fi
|
|
152
|
+
fi
|
|
21
153
|
for command in python3 curl tar sha256sum infocmp tic install systemctl docker tmux; do
|
|
22
154
|
command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
|
|
23
155
|
done
|
|
@@ -31,8 +163,7 @@ if [ "$check" = 1 ]; then
|
|
|
31
163
|
echo 'Server prerequisites: ready'
|
|
32
164
|
exit 0
|
|
33
165
|
fi
|
|
34
|
-
|
|
35
|
-
state=${LANES_STATE:-$HOME/.local/state/lanes}
|
|
166
|
+
phase binaries
|
|
36
167
|
mkdir -p "$share/bin" "$share/dl" "$state/flags"
|
|
37
168
|
# The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
|
|
38
169
|
# installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
|
|
@@ -62,21 +193,46 @@ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url flag _res
|
|
|
62
193
|
trap - EXIT HUP INT TERM
|
|
63
194
|
echo "installed $target"
|
|
64
195
|
done
|
|
196
|
+
phase terminfo
|
|
65
197
|
if ! infocmp xterm-lanesview >/dev/null 2>&1; then
|
|
66
198
|
infocmp -x xterm-256color | sed '1,3s/^xterm-256color|/xterm-lanesview|/' > "$share/dl/xterm-lanesview.ti"
|
|
67
199
|
tic -x -o "$HOME/.terminfo" "$share/dl/xterm-lanesview.ti"
|
|
68
200
|
fi
|
|
201
|
+
# Before the commit: nothing here depends on the release that is installed.
|
|
202
|
+
phase catalog
|
|
69
203
|
[ -e "$state/registry.json" ] || touch "$state/flags/coexist"
|
|
70
|
-
python3 "$repo/install/services.py"
|
|
71
204
|
catalog=$HOME/.config/lanes/workspaces/catalog.json
|
|
72
205
|
if [ ! -e "$catalog" ] && [ ! -d "$repo/workspaces" ] && [ -z "${LANES_WORKSPACES:-}" ]; then
|
|
73
206
|
mkdir -p "$(dirname "$catalog")"
|
|
74
207
|
printf '{"version":1,"workspaces":[]}\n' > "$catalog"
|
|
75
208
|
fi
|
|
209
|
+
phase commit
|
|
210
|
+
# --install: the server installer is the one caller that makes its own tree the installed release (`current`);
|
|
211
|
+
# the enable steps re-render the release `current` names (install/services.py, DEF-1835).
|
|
212
|
+
python3 "$repo/install/services.py" --install
|
|
213
|
+
committed=1
|
|
214
|
+
if [ "$update_only" = 1 ]; then
|
|
215
|
+
phase post-commit
|
|
216
|
+
# The post-commit of the release just installed records the outcome itself (`partial` before its first step);
|
|
217
|
+
# its stdout is the terminal or the journal, its steps write to the log.
|
|
218
|
+
status=0
|
|
219
|
+
# shellcheck disable=SC2086 # $steps is `--step NAME=RC` words
|
|
220
|
+
python3 "$repo/libexec/update.py" post-commit --state "$state" --tree "$tree" --before "$before" --log "$log" \
|
|
221
|
+
--started "$started" $steps >&3 3>&- 9>&- || status=$?
|
|
222
|
+
case $status in
|
|
223
|
+
0|4) exit "$status" ;;
|
|
224
|
+
*) printf 'partial: the post-commit ended with exit %s (log %s)\n' "$status" "$log" >&3; exit 4 ;;
|
|
225
|
+
esac
|
|
226
|
+
fi
|
|
76
227
|
if [ "$start" = 1 ]; then
|
|
77
228
|
systemctl --user daemon-reload
|
|
78
|
-
|
|
79
|
-
|
|
229
|
+
# A failing sync (a board's container down, a registry it cannot read) is reported; the services still start.
|
|
230
|
+
"$repo/bin/lanes" sync 9>&- || echo 'warning: lanes sync failed (above); the services start anyway (run: lanes sync)' >&2
|
|
231
|
+
# lanes-update.timer (W9 U3): its OnBootSec is past on a running host, so it checks once now — refused while this
|
|
232
|
+
# install holds the run lock — then every 30 min. On a checkout install `lanes update` does nothing. Only this full
|
|
233
|
+
# install enables it: --update-only (above) never does, and `lanes doctor` names a timer that is off.
|
|
234
|
+
systemctl --user enable --now lanes-statusd.service lanes-sync.timer lanes-ensure.timer lanes-gc.timer \
|
|
235
|
+
lanes-update.timer
|
|
80
236
|
# The enrolment service (libexec/enrold.py) runs the code of THIS install: enable, then (re)start it.
|
|
81
237
|
systemctl --user enable lanes-enrold.service
|
|
82
238
|
systemctl --user restart lanes-enrold.service
|
|
@@ -1,18 +1,52 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
|
-
"""Register this checkout's user services without starting or restarting them.
|
|
2
|
+
"""Register this checkout's user services without starting or restarting them.
|
|
3
|
+
|
|
4
|
+
python3 install/services.py --install the server installer (install/host-install.sh): this tree becomes `current`
|
|
5
|
+
python3 install/services.py a re-render (install/enable-web.sh): the units of the tree `current` names
|
|
6
|
+
|
|
7
|
+
The units, LANES_REPO and ~/.local/bin/lanes name the `current` link (~/.local/share/lanes/current), never the tree
|
|
8
|
+
it points at (plan W9 U1, LNS-89), and the link is moved last, in one rename(2): the update's commit point. Before it
|
|
9
|
+
every unit is written — naming the link, so its text does not change with the release and nothing runs differently
|
|
10
|
+
yet — and `current.previous` records the tree the link named (the rollback target, plan W9 U3). After it
|
|
11
|
+
~/.local/bin/lanes is made to point through the link. A failure before the flip leaves the installed release as it
|
|
12
|
+
was; services are never started or restarted here (install/host-install.sh does that).
|
|
13
|
+
|
|
14
|
+
Only the server installer moves `current` off a tree it names (`--install`, register(install=True)). Every other
|
|
15
|
+
caller re-renders: the enable steps (install/enable-web.sh, libexec/studio_host.py step 6) run from whatever tree
|
|
16
|
+
their shell found, and after an update that can be the old one — so a re-render renders the templates of the tree
|
|
17
|
+
`current` names and leaves every link as it is; it writes `current` only where none names a tree yet. Installs and
|
|
18
|
+
re-renders take one exclusive lock, ~/.local/share/lanes/.install.lock, and read `current` inside it, so neither acts
|
|
19
|
+
on a link the other has since moved (fix cycle 1 of W9 U1, DEF-1835)."""
|
|
20
|
+
import contextlib
|
|
21
|
+
import fcntl
|
|
3
22
|
import json
|
|
4
23
|
import os
|
|
24
|
+
import secrets
|
|
5
25
|
import shutil
|
|
6
26
|
import sys
|
|
7
27
|
import subprocess
|
|
28
|
+
import time
|
|
8
29
|
from pathlib import Path
|
|
9
30
|
import tempfile
|
|
10
31
|
|
|
11
32
|
|
|
33
|
+
class Refusal(Exception):
|
|
34
|
+
"""A refusal raised before anything is written; the command line exits 3 for it (plan W9 U2: host-install.sh
|
|
35
|
+
--update-only records `refused`). Each concrete refusal is also the ValueError or RuntimeError callers catch."""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class RefusedValue(Refusal, ValueError):
|
|
39
|
+
pass
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class RefusedLock(Refusal, RuntimeError):
|
|
43
|
+
pass
|
|
44
|
+
|
|
45
|
+
|
|
12
46
|
def unit_value(value, command=False):
|
|
13
47
|
value = str(value)
|
|
14
48
|
if any(c in value for c in '\r\n\0'):
|
|
15
|
-
raise
|
|
49
|
+
raise RefusedValue('installation paths must not contain control characters')
|
|
16
50
|
value = value.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%')
|
|
17
51
|
return value.replace('$', '$$') if command else value
|
|
18
52
|
|
|
@@ -115,8 +149,79 @@ def manager_unit_dir(home):
|
|
|
115
149
|
return default
|
|
116
150
|
|
|
117
151
|
|
|
118
|
-
def
|
|
152
|
+
def linked_tree(link):
|
|
153
|
+
"""The existing directory `link` names, resolved; None when `link` is no symlink, dangles (a pruned tree) or loops."""
|
|
154
|
+
if not link.is_symlink():
|
|
155
|
+
return None
|
|
156
|
+
try:
|
|
157
|
+
target = link.resolve(strict=True)
|
|
158
|
+
except (OSError, RuntimeError):
|
|
159
|
+
return None
|
|
160
|
+
return target if target.is_dir() else None
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def swap_link(link, target):
|
|
164
|
+
"""Point `link` at `target` in one rename(2): a new symlink made beside it (same directory, so the rename is
|
|
165
|
+
atomic), then os.replace over it. A reader sees the old link or the new one, never none — unlink-then-symlink left
|
|
166
|
+
a moment without `current` and, interrupted, no link at all."""
|
|
167
|
+
link.parent.mkdir(parents=True, exist_ok=True)
|
|
168
|
+
temporary = link.parent / f'.{link.name}.{os.getpid()}.{secrets.token_hex(4)}'
|
|
169
|
+
os.symlink(str(target), str(temporary))
|
|
170
|
+
try:
|
|
171
|
+
os.replace(str(temporary), str(link))
|
|
172
|
+
except BaseException:
|
|
173
|
+
try:
|
|
174
|
+
os.unlink(str(temporary))
|
|
175
|
+
except OSError:
|
|
176
|
+
pass
|
|
177
|
+
raise
|
|
178
|
+
|
|
179
|
+
|
|
180
|
+
# The install lock: beside the `current` link it guards (under the home, as the link is), held from the refusal check
|
|
181
|
+
# through the command link. Never removed: unlinking a flock file lets the next caller lock a new inode while a
|
|
182
|
+
# waiter still holds the old one. LOCK_WAIT bounds the wait — register itself runs no command and waits on nothing
|
|
183
|
+
# else, so a holder that keeps it longer is stuck, and an install says so rather than hang.
|
|
184
|
+
INSTALL_LOCK = '.install.lock'
|
|
185
|
+
LOCK_WAIT = 60
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def install_lock_path(home):
|
|
189
|
+
return Path(home) / '.local/share/lanes' / INSTALL_LOCK
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
@contextlib.contextmanager
|
|
193
|
+
def install_lock(home):
|
|
194
|
+
"""Hold the install lock (an exclusive flock(2), released when the descriptor closes — by a crash too)."""
|
|
195
|
+
path = install_lock_path(home)
|
|
196
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
197
|
+
descriptor = os.open(path, os.O_RDWR | os.O_CREAT | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600)
|
|
198
|
+
try:
|
|
199
|
+
deadline = time.monotonic() + LOCK_WAIT
|
|
200
|
+
while True:
|
|
201
|
+
try:
|
|
202
|
+
fcntl.flock(descriptor, fcntl.LOCK_EX | fcntl.LOCK_NB)
|
|
203
|
+
break
|
|
204
|
+
except BlockingIOError:
|
|
205
|
+
if time.monotonic() >= deadline:
|
|
206
|
+
raise RefusedLock(f'another install holds {path} (waited {LOCK_WAIT:g} s); nothing was changed — '
|
|
207
|
+
'run the install again once it has finished') from None
|
|
208
|
+
time.sleep(0.1)
|
|
209
|
+
yield
|
|
210
|
+
finally:
|
|
211
|
+
os.close(descriptor)
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def register(repo, home, share, state, workspaces=None, economy=None, notify=None, xdg=None, units_dir=None,
|
|
215
|
+
install=False):
|
|
216
|
+
"""Render every unit and point the links. install=True: the server installer (or a rollback) — `repo` becomes the
|
|
217
|
+
installed release. install=False: a re-render — when `current` names another existing tree, that tree's templates
|
|
218
|
+
are rendered and no link moves (DEF-1835); `current` is written only where it names no tree."""
|
|
119
219
|
repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
|
|
220
|
+
with install_lock(home):
|
|
221
|
+
_register(repo, home, share, state, workspaces, economy, notify, xdg, units_dir, install)
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def _register(repo, home, share, state, workspaces, economy, notify, xdg, units_dir, install):
|
|
120
225
|
if xdg is None:
|
|
121
226
|
ambient = {name: os.environ[name] for name in XDG_ROOTS if os.environ.get(name)}
|
|
122
227
|
xdg = recorded_xdg(state)
|
|
@@ -127,12 +232,24 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
|
|
|
127
232
|
f'{state / XDG_RECORD} ({xdg or "the defaults"}), not this shell\'s ({ambient or "none"}); re-run '
|
|
128
233
|
'install/enable-studio-host.sh from the shell whose roots should hold', file=sys.stderr)
|
|
129
234
|
xdg = {name: str(Path(value).resolve()) for name, value in xdg.items() if name in XDG_ROOTS and value}
|
|
235
|
+
# Under the home, never $XDG_DATA_HOME: lanes_lib.current_tree and the npm wrapper's prune (plan.mjs currentLink)
|
|
236
|
+
# read the link here (W9 U0, DEF-1783).
|
|
130
237
|
current = home / '.local/share/lanes/current'
|
|
131
|
-
|
|
132
|
-
|
|
238
|
+
previous = current.with_name('current.previous')
|
|
239
|
+
command = home / '.local/bin/lanes'
|
|
240
|
+
for link in (current, previous, command):
|
|
133
241
|
if link.exists() and not link.is_symlink():
|
|
134
|
-
raise
|
|
135
|
-
|
|
242
|
+
raise RefusedValue(f'{link} already exists and is not a symlink')
|
|
243
|
+
# Read under the install lock: an install that lands between this read and the flip below cannot exist.
|
|
244
|
+
before = linked_tree(current)
|
|
245
|
+
if not install and before is not None and before != repo:
|
|
246
|
+
print(f'services.py: {current} names {before}, not this tree ({repo}): the units are rendered from {before} '
|
|
247
|
+
'and no link moves — only the server installer installs a release (npx @vibelord/lanes --server, or '
|
|
248
|
+
'./install.sh --server from a checkout)', file=sys.stderr)
|
|
249
|
+
repo = before
|
|
250
|
+
# W9 U1: the units name the link. A unit's text then no longer changes with the release, and the flip below moves
|
|
251
|
+
# every unit (and the Caddyfile root LANES_REPO feeds) to the new tree at once.
|
|
252
|
+
settings = [('LANES_REPO', current), ('LANES_SHARE', share), ('LANES_STATE', state)]
|
|
136
253
|
if workspaces:
|
|
137
254
|
settings.append(('LANES_WORKSPACES', Path(workspaces).resolve()))
|
|
138
255
|
environment = ''.join(f'Environment="{name}={unit_value(value)}"\n' for name, value in settings)
|
|
@@ -147,7 +264,7 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
|
|
|
147
264
|
for source in sorted((repo / 'systemd').iterdir()):
|
|
148
265
|
if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
|
|
149
266
|
continue
|
|
150
|
-
content = source.read_text().replace('@REPO@', unit_value(
|
|
267
|
+
content = source.read_text().replace('@REPO@', unit_value(current, command=True))
|
|
151
268
|
content = content.replace('@SHARE@', unit_value(share, command=True))
|
|
152
269
|
content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
|
|
153
270
|
content = content.replace('@THROTTLE_RC@', unit_value(throttle_rc_path(share), command=True))
|
|
@@ -170,16 +287,29 @@ def register(repo, home, share, state, workspaces=None, economy=None, notify=Non
|
|
|
170
287
|
finally:
|
|
171
288
|
if os.path.exists(temporary):
|
|
172
289
|
os.unlink(temporary)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
290
|
+
# The commit point. The tree `current` named becomes `current.previous` when it still exists and is not the tree
|
|
291
|
+
# being installed (a re-install, or a re-render — which installs the tree `current` names — must not make the
|
|
292
|
+
# rollback target itself; a pruned tree is no target, so the last one recorded stays). Then the flip; then the
|
|
293
|
+
# command, through the link, so a later flip (an update, a rollback) moves it too.
|
|
294
|
+
if before is not None and before != repo:
|
|
295
|
+
swap_link(previous, before)
|
|
296
|
+
swap_link(current, repo)
|
|
297
|
+
swap_link(command, current / 'bin/lanes')
|
|
178
298
|
|
|
179
299
|
|
|
180
300
|
if __name__ == '__main__':
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
301
|
+
if sys.argv[1:] not in ([], ['--install']):
|
|
302
|
+
print('usage: services.py [--install]', file=sys.stderr)
|
|
303
|
+
sys.exit(2)
|
|
304
|
+
# A refusal changes nothing — another install holds the lock, `current`, `current.previous` or the command is no
|
|
305
|
+
# symlink, or a path cannot be written into a unit (each raised before the first write) — and exits 3, which
|
|
306
|
+
# host-install.sh --update-only records as `refused` (plan W9 U2). Any other failure keeps its traceback, exit 1.
|
|
307
|
+
try:
|
|
308
|
+
register(Path(__file__).resolve().parents[1], Path.home(),
|
|
309
|
+
os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
|
|
310
|
+
os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
|
|
311
|
+
os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ),
|
|
312
|
+
units_dir=manager_unit_dir(Path.home()), install=sys.argv[1:] == ['--install'])
|
|
313
|
+
except Refusal as error:
|
|
314
|
+
print(f'services.py: refused, nothing was changed: {error}', file=sys.stderr)
|
|
315
|
+
sys.exit(3)
|
package/payload/lanes/install.sh
CHANGED
|
@@ -4,6 +4,8 @@
|
|
|
4
4
|
#
|
|
5
5
|
# ./install.sh install or update the client on this machine
|
|
6
6
|
# ./install.sh --server install or update the Linux workspace server
|
|
7
|
+
# ./install.sh --server --update-only update it: every step after the switch runs whatever the one before it
|
|
8
|
+
# returned; the run is recorded in ~/.local/state/lanes/update/last.json (exit 0 · 4 · 3 · 1)
|
|
7
9
|
# ./install.sh --check verify terminal, SSH path, key and server (changes nothing)
|
|
8
10
|
# ./install.sh --uninstall remove what the installer added (your SSH key and ~/Lanes Uploads are kept)
|
|
9
11
|
#
|
|
@@ -41,9 +43,14 @@
|
|
|
41
43
|
# :8443/enrol/) authorizes it — at once for an invite code; without one, on a terminal, the installer offers to ask the
|
|
42
44
|
# site's members and waits up to 10 min (LANES_ENROL_WAIT) for an approval. The client never edits authorized_keys.
|
|
43
45
|
# No service, a refused code, or no code and no terminal: the public key is printed for the admin (exit 3), as before.
|
|
46
|
+
# Run by the npm wrapper with an invite, the installer first asks that service which release the server runs: a newer
|
|
47
|
+
# one than this installer ends the run before anything is installed or redeemed (exit 5), and the wrapper runs the
|
|
48
|
+
# server's release with the same options, which redeems the invite (LANES_NO_SELF_UPDATE=1: never asked).
|
|
44
49
|
#
|
|
45
50
|
# Exit status: 0 ready to use · 3 installed, but this machine's key is not authorized on the server yet ·
|
|
46
51
|
# 4 installed, but not in the tailnet, or the server is not reachable or did not answer in time (busy?) ·
|
|
52
|
+
# 5 nothing installed: the server runs a newer release (only when LANES_WRAPPER_REPLY names the npm
|
|
53
|
+
# wrapper's hand-over file; the wrapper then runs that release) ·
|
|
47
54
|
# 1 error
|
|
48
55
|
set -eu
|
|
49
56
|
here=$(cd "$(dirname "$0")" && pwd)
|
|
@@ -81,7 +88,7 @@ while [ $# -gt 0 ]; do
|
|
|
81
88
|
--palette) palette=1 ;;
|
|
82
89
|
--no-palette) palette=0 ;;
|
|
83
90
|
--yes|-y) assume_yes=1 ;;
|
|
84
|
-
-h|--help) sed -n '2,
|
|
91
|
+
-h|--help) sed -n '2,54p' "$0"; exit 0 ;;
|
|
85
92
|
*) echo "install.sh: unknown option $1 (see --help)" >&2; exit 1 ;;
|
|
86
93
|
esac
|
|
87
94
|
shift
|
|
@@ -1142,7 +1149,7 @@ report_key_taken() {
|
|
|
1142
1149
|
# and nobody at a terminal to ask: report_server's exit-3 text, unchanged — except a 409 (the device name or the key is
|
|
1143
1150
|
# already a member's), which names the re-run that joins instead (report_device_taken, report_key_taken). The code
|
|
1144
1151
|
# goes in a request body on stdin: never on a command line, in a file or in the output.
|
|
1145
|
-
enrol_file=$config/enrol-request enrol_caller= enrol_conflict= enrol_via=
|
|
1152
|
+
enrol_file=$config/enrol-request enrol_caller= enrol_conflict= enrol_via= enrol_base= enrol_resolve=
|
|
1146
1153
|
invite_problem() { # CODE -> why it is not an invite code for this profile, or nothing. Names the prefix at most.
|
|
1147
1154
|
ip_bad='not an invite code (letters, digits and dashes, like LN-xxxx-xxxxxxxxxxxx)'
|
|
1148
1155
|
case "$1" in ''|-*|*-|*--*|*[!A-Za-z0-9-]*) printf '%s' "$ip_bad"; return 0 ;; *-*) ;; *) printf '%s' "$ip_bad"; return 0 ;; esac
|
|
@@ -1169,14 +1176,18 @@ json_get() { # PATH -> the value at that path of $http_body (strings unquoted)
|
|
|
1169
1176
|
}
|
|
1170
1177
|
# -> enrol_base (scheme://host[:port]), enrol_host, enrol_port, enrol_ip (the tailnet address to use when this machine
|
|
1171
1178
|
# cannot resolve the name: an IPv4 LANES_SERVER_HOST); 1 + enrol_note when there is no service to ask.
|
|
1172
|
-
# LANES_ENROL_URL replaces the base (development and tests); the name and address logic stays the same.
|
|
1179
|
+
# LANES_ENROL_URL replaces the base (development and tests); the name and address logic stays the same. Called again in
|
|
1180
|
+
# the same run (the K3 question before the install, then enrol), it keeps the --resolve the first calls learned for the
|
|
1181
|
+
# same base: a name this machine cannot resolve is not tried again (each try costs up to curl's 5 s to connect).
|
|
1173
1182
|
enrol_target() {
|
|
1174
|
-
enrol_base
|
|
1183
|
+
et_known=$enrol_base
|
|
1184
|
+
enrol_base=${LANES_ENROL_URL:-} enrol_note=
|
|
1175
1185
|
if [ -z "$enrol_base" ]; then
|
|
1176
1186
|
if [ -z "$server_name" ]; then enrol_note='this site profile names no LANES_SERVER_NAME (its enrolment service answers under that name)'; return 1; fi
|
|
1177
1187
|
enrol_base=https://${server_name%.}:8443
|
|
1178
1188
|
fi
|
|
1179
1189
|
enrol_base=${enrol_base%/}
|
|
1190
|
+
[ "$enrol_base" = "$et_known" ] || enrol_resolve=
|
|
1180
1191
|
et_hostport=${enrol_base#*://}
|
|
1181
1192
|
case "$enrol_base" in http://*|https://*) ;; *) enrol_note='LANES_ENROL_URL is not an http(s) URL'; return 1 ;; esac
|
|
1182
1193
|
case "$et_hostport" in ''|*/*|*[!A-Za-z0-9.:-]*) enrol_note='LANES_ENROL_URL is not scheme://host[:port]'; return 1 ;; esac
|
|
@@ -1289,7 +1300,8 @@ enrol_site() { # GET /enrol/site -> 0, request_mode and enrol_caller; or 1 and
|
|
|
1289
1300
|
# (a server without the service hands /enrol/site to its web client: a page, not this document)
|
|
1290
1301
|
if ! enrol_json; then enrol_note="no enrolment service at $enrol_base/enrol/ (the answer is not JSON)"; return 1; fi
|
|
1291
1302
|
# lanes-enrold's /site always says request_mode, true or false: that is what tells it from any other JSON. Its
|
|
1292
|
-
# `version` is the server's Lanes release, not a wire number (wire v1 is additive only, and none is served)
|
|
1303
|
+
# `version` is the server's Lanes release, not a wire number (wire v1 is additive only, and none is served): only
|
|
1304
|
+
# enrol_preflight reads it, to hand an invite over to that release's installer; no admission ever depends on it.
|
|
1293
1305
|
request_mode=$(json_get /request_mode)
|
|
1294
1306
|
case "$request_mode" in
|
|
1295
1307
|
true|false) ;;
|
|
@@ -1298,6 +1310,55 @@ enrol_site() { # GET /enrol/site -> 0, request_mode and enrol_caller; or 1 and
|
|
|
1298
1310
|
# The tailnet login the service received (null outside request mode): the owner a request is filed under.
|
|
1299
1311
|
enrol_caller=$(enrol_clean "$(json_get /caller)")
|
|
1300
1312
|
}
|
|
1313
|
+
# ---- the server's release first (W9 K3, LNS-88) ---------------------------------------------------------------------
|
|
1314
|
+
# An invite is redeemed once, so the installer that redeems it should be the server's own release. Run by the npm
|
|
1315
|
+
# wrapper (which names a file in LANES_WRAPPER_REPLY) with an invite (--invite or LANES_INVITE), the installer asks the
|
|
1316
|
+
# enrolment service which release the server runs — /enrol/site's `version` — BEFORE anything is installed, sent or
|
|
1317
|
+
# redeemed. A newer one than this tree's VERSION ends the run: `update-to=<release>` into that file, one line, exit 5.
|
|
1318
|
+
# The wrapper then runs `npx --yes @vibelord/lanes@<release>` with the same options (the code moved to LANES_INVITE)
|
|
1319
|
+
# and LANES_NO_SELF_UPDATE=1, which skips this question: one hand-over at most. Anything else changes nothing and the
|
|
1320
|
+
# install goes on with this tree: no invite; LANES_NO_SELF_UPDATE (any value); a tailnet site this machine is not in
|
|
1321
|
+
# yet (one `tailscale status`, no login — the install's own Tailscale step follows); no service or no answer (one
|
|
1322
|
+
# bounded call, as enrol_call makes every call); an equal, older or unparseable release on either side. Without the
|
|
1323
|
+
# wrapper (a checkout's ./install.sh) a newer server is one line and the install goes on.
|
|
1324
|
+
release_key() { # RELEASE -> "MAJOR MINOR PATCH", nothing for what is no release
|
|
1325
|
+
# The rule `lanes hello` compares by (bin/lanes version_key): major.minor.patch, an optional -/+ suffix that is not
|
|
1326
|
+
# compared, at most 32 characters — and here each number at most 9 digits, so test(1) compares it exactly.
|
|
1327
|
+
case "$1" in ''|*[!A-Za-z0-9.+-]*) return 0 ;; esac
|
|
1328
|
+
[ "${#1}" -le 32 ] || return 0
|
|
1329
|
+
printf '%s\n' "$1" | sed -n 's/^\([0-9]\{1,9\}\)\.\([0-9]\{1,9\}\)\.\([0-9]\{1,9\}\)\([-+][A-Za-z0-9.]\{1,\}\)\{0,1\}$/\1 \2 \3/p'
|
|
1330
|
+
}
|
|
1331
|
+
release_newer() { # SERVER MINE -> 0 when both are releases and SERVER's major.minor.patch is the higher, as numbers
|
|
1332
|
+
rn_server=$(release_key "$1") rn_mine=$(release_key "$2")
|
|
1333
|
+
[ -n "$rn_server" ] && [ -n "$rn_mine" ] || return 1
|
|
1334
|
+
# shellcheck disable=SC2086
|
|
1335
|
+
set -- $rn_server $rn_mine # (digits only: no glob, no blank inside a number)
|
|
1336
|
+
if [ "$1" -ne "$4" ]; then [ "$1" -gt "$4" ]; return; fi
|
|
1337
|
+
if [ "$2" -ne "$5" ]; then [ "$2" -gt "$5" ]; return; fi
|
|
1338
|
+
[ "$3" -gt "$6" ]
|
|
1339
|
+
}
|
|
1340
|
+
enrol_preflight() {
|
|
1341
|
+
[ -n "$invite" ] && [ -z "${LANES_NO_SELF_UPDATE:-}" ] || return 0
|
|
1342
|
+
if tailnet_site; then
|
|
1343
|
+
ts=$(tailscale_bin) || return 0
|
|
1344
|
+
tailscale_look
|
|
1345
|
+
[ "$ts_state" = Running ] && [ "$ts_online" = true ] || return 0
|
|
1346
|
+
fi
|
|
1347
|
+
enrol_target && enrol_site || return 0
|
|
1348
|
+
pf_server=$(json_get /version)
|
|
1349
|
+
pf_mine=dev; if [ -r "$here/VERSION" ]; then pf_mine=$(tr -cd 'A-Za-z0-9._+-' < "$here/VERSION"); fi
|
|
1350
|
+
release_newer "$pf_server" "$pf_mine" || return 0
|
|
1351
|
+
if [ -z "${LANES_WRAPPER_REPLY:-}" ]; then
|
|
1352
|
+
say "server runs $pf_server; this installer is $pf_mine — going on with $pf_mine"
|
|
1353
|
+
return 0
|
|
1354
|
+
fi
|
|
1355
|
+
if ! { printf 'update-to=%s\n' "$pf_server" > "$LANES_WRAPPER_REPLY"; } 2>/dev/null; then
|
|
1356
|
+
warn "server runs $pf_server; this installer is $pf_mine, and the npm wrapper's hand-over file cannot be written — going on with $pf_mine"
|
|
1357
|
+
return 0
|
|
1358
|
+
fi
|
|
1359
|
+
say "server runs $pf_server: fetching that installer first (this one is $pf_mine; nothing is installed yet, and the invite is kept for it)"
|
|
1360
|
+
exit 5
|
|
1361
|
+
}
|
|
1301
1362
|
enrol_key() { # -> enrol_pubkey "ssh-ed25519 <base64>" (enrold writes its own comment); 1, said, for any other key
|
|
1302
1363
|
enrol_pubkey= kt= kb=
|
|
1303
1364
|
if [ -r "$key.pub" ]; then read -r kt kb _ < "$key.pub" || :; fi
|
|
@@ -1451,7 +1512,7 @@ enrol() {
|
|
|
1451
1512
|
do_install() {
|
|
1452
1513
|
command -v ssh >/dev/null 2>&1 || die 'OpenSSH (ssh) is required'
|
|
1453
1514
|
case "$os" in Darwin|Linux) ;; *) die "unsupported system: $os (macOS and Linux only)" ;; esac
|
|
1454
|
-
resolve_options; load_site; check_invite; src=$here/client
|
|
1515
|
+
resolve_options; load_site; check_invite; enrol_preflight; src=$here/client
|
|
1455
1516
|
head_ "lanes client $(cat "$here/VERSION" 2>/dev/null || echo dev) -> $server_user@$server_alias"
|
|
1456
1517
|
choose_device; choose_key; ensure_key
|
|
1457
1518
|
install_client; src=$target
|
|
@@ -1488,7 +1549,7 @@ do_install() {
|
|
|
1488
1549
|
say 'Open the window: lanes-window (Omarchy: Super+Shift+L · macOS: the Lanes app)'
|
|
1489
1550
|
say 'In any terminal: lanes-launch one lane only: lanes-launch lane <id> · list: lanes-launch ls'
|
|
1490
1551
|
say 'Phone or browser: lanes-launch web phone · lanes-launch web laptop (in your own terminal: a login token)'
|
|
1491
|
-
say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · close the window to detach'
|
|
1552
|
+
say 'Keys: Ctrl-Alt-←/→ board · Ctrl-Alt-↑/↓ lane · Ctrl-Alt-/ find · Ctrl-Alt-u upload box · Ctrl-Alt-l sidebar · close the window to detach'
|
|
1492
1553
|
exit 0 ;;
|
|
1493
1554
|
unauthorized) exit 3 ;;
|
|
1494
1555
|
*) exit 4 ;;
|