@vibelord/lanes 1.1.1 → 1.3.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 (71) hide show
  1. package/README.md +36 -5
  2. package/bin/lanes-install.mjs +166 -13
  3. package/lib/plan.mjs +113 -3
  4. package/package.json +1 -1
  5. package/payload/lanes/README.md +47 -23
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +2753 -202
  8. package/payload/lanes/client/ghostty/lanes-palette-light.conf +19 -19
  9. package/payload/lanes/client/ghostty/lanes-palette.conf +19 -19
  10. package/payload/lanes/client/lanes-launch +289 -40
  11. package/payload/lanes/client/lanes-window +15 -3
  12. package/payload/lanes/container/hooks/claude-settings.json +1 -0
  13. package/payload/lanes/container/lane +147 -92
  14. package/payload/lanes/container/lane-edit +100 -0
  15. package/payload/lanes/container/lane-mcp +1150 -0
  16. package/payload/lanes/container/lane-signal +109 -29
  17. package/payload/lanes/container/lane_core.py +1318 -0
  18. package/payload/lanes/container/vimrc +31 -0
  19. package/payload/lanes/docs/ARCHITECTURE.md +793 -16
  20. package/payload/lanes/docs/DESIGN.md +127 -46
  21. package/payload/lanes/docs/INSTALL.md +175 -18
  22. package/payload/lanes/docs/RUNBOOK.md +968 -76
  23. package/payload/lanes/docs/SHARING.md +3 -2
  24. package/payload/lanes/docs/USERGUIDE.md +967 -150
  25. package/payload/lanes/holders.lock +5 -0
  26. package/payload/lanes/install/enable-docs.sh +78 -0
  27. package/payload/lanes/install/enable-web.sh +2 -0
  28. package/payload/lanes/install/host-install.sh +197 -7
  29. package/payload/lanes/install/services.py +148 -18
  30. package/payload/lanes/install.sh +68 -7
  31. package/payload/lanes/libexec/agent_state.py +74 -1
  32. package/payload/lanes/libexec/cursor_sync.py +6 -0
  33. package/payload/lanes/libexec/device_sync.py +47 -10
  34. package/payload/lanes/libexec/docs_store.py +995 -0
  35. package/payload/lanes/libexec/docsd.py +2010 -0
  36. package/payload/lanes/libexec/economy_poll.py +1 -0
  37. package/payload/lanes/libexec/enrold.py +41 -22
  38. package/payload/lanes/libexec/keys.py +54 -23
  39. package/payload/lanes/libexec/lanes_lib.py +620 -22
  40. package/payload/lanes/libexec/mdlite.py +575 -0
  41. package/payload/lanes/libexec/members.py +21 -2
  42. package/payload/lanes/libexec/notify.py +169 -29
  43. package/payload/lanes/libexec/registrations.py +216 -18
  44. package/payload/lanes/libexec/session_actions.py +182 -14
  45. package/payload/lanes/libexec/sidebar +11 -3
  46. package/payload/lanes/libexec/sidebar.py +2232 -365
  47. package/payload/lanes/libexec/statusd.py +231 -12
  48. package/payload/lanes/libexec/studio_host.py +8 -1
  49. package/payload/lanes/libexec/tokens.py +148 -35
  50. package/payload/lanes/libexec/tokens.sh +86 -2
  51. package/payload/lanes/libexec/update.py +1044 -0
  52. package/payload/lanes/libexec/uploads.py +538 -0
  53. package/payload/lanes/libexec/users.py +614 -0
  54. package/payload/lanes/libexec/view.sh +34 -6
  55. package/payload/lanes/systemd/lanes-docsd.service +29 -0
  56. package/payload/lanes/systemd/lanes-ensure.service +3 -0
  57. package/payload/lanes/systemd/lanes-gc.service +1 -1
  58. package/payload/lanes/systemd/lanes-update.service +17 -0
  59. package/payload/lanes/systemd/lanes-update.timer +17 -0
  60. package/payload/lanes/web/Caddyfile +24 -1
  61. package/payload/lanes/web/README.md +165 -28
  62. package/payload/lanes/web/browser.js +4 -8
  63. package/payload/lanes/web/docs.css +52 -0
  64. package/payload/lanes/web/docs.js +36 -0
  65. package/payload/lanes/web/mobile.css +17 -17
  66. package/payload/lanes/web/mobile.js +45 -32
  67. package/payload/lanes/web/upload.css +17 -0
  68. package/payload/lanes/web/upload.js +84 -0
  69. package/payload/lanes/workspaces.example/README.md +3 -1
  70. package/payload/lanes/zellij/config.kdl +106 -56
  71. package/payload/manifest.json +6 -3
@@ -16,3 +16,8 @@ glow 3.0.0 13e05e4b2acc18d2aee44291aefe6325b077ec321b631a0cfa780e8e3bc33f78 http
16
16
  # a locally built binary with `lanes studio-host install --from PATH [--sha256 HEX]` (also sha256-verified).
17
17
  # Template (fill from the release's two .sha256 sidecars, then uncomment):
18
18
  # throttle-rc node-<sha12> <tarball-sha256> https://github.com/bwllaming/bw-tools/releases/download/node/<sha12>/throttle-rc-x86_64-unknown-linux-gnu.tar.gz opt-in throttle-rc-x86_64-unknown-linux-gnu/throttle-rc <binary-sha256>
19
+ # mermaid 11.17.2 (the docs page's diagrams, plan W10 W6, LNS-107): the npm tarball, 17,796,365 B, whose sha512 is the
20
+ # registry's dist.integrity; the member is the classic-script (IIFE) build, 3,572,661 B, that web/docs.js loads with
21
+ # this digest as its SRI. Both sha256s measured 2026-10-08. install/enable-docs.sh installs it into
22
+ # ~/.local/share/lanes/vendor/; to bump it see docs/RUNBOOK.md "Mermaid pin".
23
+ mermaid 11.17.2 6ad2f42c3fc26bbf9e45cbb6d11898972573ea52b33a5f4ff51952899f950ffd https://registry.npmjs.org/mermaid/-/mermaid-11.17.2.tgz opt-in package/dist/mermaid.min.js 581ed7d74bd9048d0e3a91363927d72ef22942d7722546b27f7cc29e35390eb8
@@ -0,0 +1,78 @@
1
+ #!/bin/sh
2
+ # The docs page's pinned Mermaid (plan W10 W6, LNS-107; docs/RUNBOOK.md "Mermaid pin"). Every server install runs it
3
+ # right after lanes-docsd's restart and every update (--update-only) as the post-commit step `enable-docs` (W8, LNS-109:
4
+ # host-install.sh, libexec/update.py DOCS_STEP), never fatally; it runs by hand as well. As the workspace account, never
5
+ # root. Reads holders.lock's `mermaid` opt-in pin, downloads its npm tarball into a temporary
6
+ # directory, checks the tarball's sha256, extracts ONLY the pinned member (never a link), checks the member's sha256,
7
+ # and installs it as $LANES_SHARE/vendor/mermaid-<version>.min.js (0644, by rename) with the relative link
8
+ # mermaid.min.js -> that file. Caddy serves the directory at /lanes-assets/vendor/ (web/Caddyfile); web/docs.js
9
+ # loads the versioned name with the member digest as its SRI. Idempotent: an installed file whose digest is the pin's
10
+ # is kept and nothing is downloaded; one that differs is replaced from a verified download. Older versions stay.
11
+ # Exit status: 0 installed, or already present; 1 a prerequisite is missing or the download failed; 2 usage;
12
+ # 3 refused — holders.lock has no valid mermaid pin, a digest differs, or the member is missing or not a regular file.
13
+ # A refusal or failure installs nothing.
14
+ set -eu
15
+ repo=$(cd "$(dirname "$0")/.." && pwd)
16
+ usage='usage: enable-docs.sh [--help]'
17
+ case "$#:${1:-}" in
18
+ 0:) ;;
19
+ 1:-h|1:--help)
20
+ echo "$usage"
21
+ echo 'Installs the Mermaid that holders.lock pins (opt-in line `mermaid`) into ~/.local/share/lanes/vendor/'
22
+ echo '($LANES_SHARE/vendor), after checking the sha256 of the npm tarball and of its member.'
23
+ echo 'Exit status: 0 installed or already present; 1 a prerequisite is missing or the download failed;'
24
+ echo '2 usage; 3 refused (no valid pin, a sha256 mismatch, the member missing or not a regular file).'
25
+ exit 0 ;;
26
+ *) echo "$usage" >&2; exit 2 ;;
27
+ esac
28
+ [ "$(id -u)" != 0 ] || { echo 'Run as the workspace account, not root.' >&2; exit 1; }
29
+ for command in curl tar sha256sum install mktemp; do
30
+ command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
31
+ done
32
+ share=${LANES_SHARE:-$HOME/.local/share/lanes}
33
+ lock=$repo/holders.lock
34
+ refuse() { echo "enable-docs: refused, nothing was installed: $*" >&2; exit 3; }
35
+ hex64() { [ "${#1}" -eq 64 ] && case $1 in *[!0-9a-f]*) false ;; esac; }
36
+
37
+ # The pin: exactly one `mermaid` line of the 7-field opt-in grammar (holders.lock's header; studio_host.parse_lock).
38
+ pins=$(grep -v '^[[:space:]]*#' "$lock" | awk '$1 == "mermaid"') || :
39
+ [ -n "$pins" ] || refuse "$lock has no mermaid line"
40
+ [ "$(printf '%s\n' "$pins" | wc -l)" -eq 1 ] || refuse "$lock pins mermaid more than once"
41
+ # shellcheck disable=SC2086 # the line's words are the fields; no pathname expansion of them
42
+ set -f
43
+ set -- $pins
44
+ set +f
45
+ [ "$#" -eq 7 ] && [ "$5" = opt-in ] || refuse "$lock: the mermaid line is not 'name version sha256 url opt-in member sha256'"
46
+ version=$2 sha=$3 url=$4 member=$6 member_sha=$7
47
+ case $version in ''|*[!A-Za-z0-9._-]*) refuse "$lock: mermaid version '$version' is not a plain word" ;; esac
48
+ hex64 "$sha" || refuse "$lock: the mermaid tarball digest is not 64 lowercase hex"
49
+ hex64 "$member_sha" || refuse "$lock: the mermaid member digest is not 64 lowercase hex"
50
+ case $url in https://*) ;; *) refuse "$lock: the mermaid url is not https" ;; esac
51
+ case /$member/ in //*|*/../*|*/./*|*//*) refuse "$lock: member '$member' is not a plain relative path" ;; esac
52
+
53
+ vendor=$share/vendor
54
+ target=$vendor/mermaid-$version.min.js
55
+ if [ -f "$target" ] && [ ! -L "$target" ] && [ "$(sha256sum "$target" | cut -d' ' -f1)" = "$member_sha" ]; then
56
+ ln -sfn "mermaid-$version.min.js" "$vendor/mermaid.min.js"
57
+ echo "ok mermaid $version ($target)"
58
+ exit 0
59
+ fi
60
+
61
+ temporary=$(mktemp -d)
62
+ trap 'rm -rf "$temporary"' EXIT
63
+ trap 'exit 130' INT
64
+ trap 'exit 143' TERM HUP
65
+ curl -fsSL --retry 3 -o "$temporary/mermaid.tgz" "$url" || { echo "enable-docs: download failed: $url" >&2; exit 1; }
66
+ actual=$(sha256sum "$temporary/mermaid.tgz" | cut -d' ' -f1)
67
+ [ "$actual" = "$sha" ] || refuse "sha256 mismatch for the mermaid $version tarball: $actual, pinned $sha"
68
+ mkdir "$temporary/x"
69
+ tar -xzf "$temporary/mermaid.tgz" -C "$temporary/x" "$member" 2>/dev/null || refuse "the tarball has no $member"
70
+ file=$temporary/x/$member
71
+ [ -f "$file" ] && [ ! -L "$file" ] || refuse "$member in the tarball is not a regular file"
72
+ actual=$(sha256sum "$file" | cut -d' ' -f1)
73
+ [ "$actual" = "$member_sha" ] || refuse "sha256 mismatch for $member: $actual, pinned $member_sha"
74
+ mkdir -p "$vendor"
75
+ install -m 0644 "$file" "$vendor/.mermaid-$version.min.js.$$"
76
+ mv -f "$vendor/.mermaid-$version.min.js.$$" "$target"
77
+ ln -sfn "mermaid-$version.min.js" "$vendor/mermaid.min.js"
78
+ echo "installed $target (sha256 $member_sha)"
@@ -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,27 +1,170 @@
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 (right after lanes-docsd's, install/enable-docs.sh:
12
+ # the docs page's pinned Mermaid, a warning when it is skipped or refused, W10 W8), `lanes
13
+ # install-container` per container, `lanes reload-views`, `lanes ensure --if-layout-changed` — each step
14
+ # runs whatever the one before it returned (`lanes sync` exiting 1 — a board with an error — is a warning
15
+ # in the record, which leaves the result `ok`; never the end of the update).
16
+ # Every run is recorded in $LANES_STATE/update/last.json (DM-LANES-UPDATE-RECORD: result ok | partial | refused |
17
+ # failed, each phase and step with its exit status) and logged in full to update/last.log; the terminal or the journal
18
+ # gets one line per phase and step. A full install (no --update-only) records nothing; it runs enable-docs.sh right
19
+ # after lanes-docsd's restart, never fatally, and its summary names the Mermaid outcome.
20
+ # One install or update at a time: every run but --check first takes the run lock $LANES_STATE/update/.lock and holds
21
+ # it to its end (fd 9); a run that finds it held writes nothing — no log, no record — and exits 3.
22
+ # A full install enables lanes-update.timer with the other timers (plan W9 U3, LNS-91): `lanes update --timer`, 15 min
23
+ # after a boot and every 30 min, runs this --update-only for each newer npm release, with the npx the npm wrapper
24
+ # recorded in $LANES_STATE/update/npx (a checkout install is left alone: it updates with git pull). --update-only
25
+ # enables nothing, that timer included: a server set up before 1.2.0 and updated only with --update-only has it off
26
+ # until a full install or `systemctl --user enable --now lanes-update.timer` (`lanes doctor` warns; fix cycle 1 of U3).
3
27
  set -eu
4
28
  repo=$(cd "$(dirname "$0")/.." && pwd)
5
- start=1 check=0
29
+ start=1 check=0 update_only=0
6
30
  for argument in "$@"; do
7
31
  case "$argument" in
8
32
  --no-start) start=0 ;;
9
33
  --check) check=1 ;;
34
+ --update-only) update_only=1 ;;
10
35
  -h|--help)
11
- echo 'usage: ./install.sh --server [--check | --no-start]'
36
+ echo 'usage: ./install.sh --server [--check | --no-start | --update-only]'
12
37
  echo 'Installs pinned binaries and user services. --check changes nothing; --no-start only installs files.'
38
+ echo '--update-only: an update. After the switch to this release every step runs, whatever the one before it'
39
+ echo 'returned; the run is recorded in ~/.local/state/lanes/update/last.json and logged in update/last.log.'
40
+ echo 'It updates a server a full install set up: with no installed release it is refused. A full install'
41
+ echo 'enables lanes-update.timer, whose `lanes update --timer` runs it for each newer npm release;'
42
+ echo '--update-only enables nothing, that timer included (on a server set up before 1.2.0: run the full'
43
+ echo 'install once, or systemctl --user enable --now lanes-update.timer).'
44
+ echo 'Exit status of --update-only: 0 ok, 4 partial (installed; a step after the switch failed), 3 refused'
45
+ echo '(a prerequisite, no installed release or another install said no; nothing changed), 1 failed (nothing'
46
+ echo 'was installed). One install or update runs at a time: another one finding it running exits 3.'
13
47
  exit 0 ;;
14
48
  *) echo "unknown server option: $argument" >&2; exit 2 ;;
15
49
  esac
16
50
  done
51
+ if [ "$update_only" = 1 ] && [ "$start$check" != 10 ]; then
52
+ echo '--update-only restarts what it installs: it takes neither --check nor --no-start' >&2; exit 2
53
+ fi
17
54
  [ "$(uname -s)/$(uname -m)" = Linux/x86_64 ] || {
18
55
  echo 'The pinned server binaries require Linux x86_64. macOS/Linux desktops use ./install.sh --site FILE.' >&2; exit 1;
19
56
  }
20
57
  [ "$(id -u)" != 0 ] || { echo 'Run as the workspace user, not root.' >&2; exit 1; }
58
+ share=${LANES_SHARE:-$HOME/.local/share/lanes}
59
+ state=${LANES_STATE:-$HOME/.local/state/lanes}
60
+ # One install or update at a time (fix cycle 1 of W9 U2): the run lock, taken before anything is written — the update's
61
+ # log and record included — and held by this shell on fd 9 until it exits, however it ends. Two runs could otherwise
62
+ # truncate each other's log, run the post-commit twice over (every service restarted twice, two install-container
63
+ # copies into one volume) and race on update/last.json. A run that finds it held changes and records nothing: exit 3.
64
+ # No child of this shell may keep fd 9 (it would hold the lock after the run): those that can leave a process behind
65
+ # (`lanes sync`, libexec/update.py) get `9>&-`, and update.py runs each post-commit step with no descriptor but its
66
+ # stdio. --check changes nothing: no lock.
67
+ if [ "$check" = 0 ]; then
68
+ command -v python3 >/dev/null 2>&1 || { echo 'Missing prerequisite: python3 (see docs/INSTALL.md)' >&2; exit 1; }
69
+ mkdir -p "$state/update"
70
+ exec 9>>"$state/update/.lock"
71
+ lock_status=0
72
+ if [ "$update_only" = 1 ]; then
73
+ python3 "$repo/libexec/update.py" lock --state "$state" --fd 9 --mode update || lock_status=$?
74
+ else
75
+ python3 "$repo/libexec/update.py" lock --state "$state" --fd 9 --mode install >&2 || lock_status=$?
76
+ fi
77
+ case $lock_status in
78
+ 0) ;;
79
+ 3) exit 3 ;;
80
+ *) echo "could not take the run lock $state/update/.lock (exit $lock_status)" >&2; exit 1 ;;
81
+ esac
82
+ fi
83
+ # The phases of --update-only. `phase NAME`: the phase before it ended with exit 0 (reported and kept for the record);
84
+ # NAME runs next, under a `== NAME` line in the log. A full install only names them.
85
+ phase=preflight steps='' committed=0
86
+ phase() {
87
+ if [ "$update_only" = 1 ]; then
88
+ steps="$steps --step $phase=0"
89
+ printf 'ok %s\n' "$phase" >&3
90
+ printf '== %s\n' "$1"
91
+ fi
92
+ phase=$1
93
+ }
94
+ # --update-only, before the commit: whatever ends the run is recorded (`set -e`, an `exit`, a signal). refused: the
95
+ # preflight (no installed release among its checks), or install/services.py's refusal (exit 3: another install holds
96
+ # its lock, a link that is no symlink);
97
+ # failed: anything else — unless the flip happened before services.py failed (its command link), which is installed
98
+ # but not restarted: partial. After the commit the post-commit records its own outcome.
99
+ update_ended() {
100
+ status=$1
101
+ set +e
102
+ trap - EXIT HUP INT TERM
103
+ [ "$committed" = 0 ] || exit "$status"
104
+ result=failed code=1
105
+ case $phase in
106
+ preflight) result=refused code=3 ;;
107
+ commit)
108
+ if [ "$status" = 3 ]; then result=refused code=3
109
+ elif [ "$(readlink -e "$HOME/.local/share/lanes/current")" = "$tree" ] && [ "$before" != "$tree" ]; then
110
+ result=partial code=4
111
+ fi ;;
112
+ esac
113
+ printf 'FAIL %s (exit %s)\n' "$phase" "$status" >&3
114
+ # the last lines the failing phase wrote (after its `== NAME` line in the log)
115
+ awk '/^== /{n=0; next} {line[n++]=$0} END{for (i = (n > 5 ? n - 5 : 0); i < n; i++) print " " line[i]}' \
116
+ "$log" >&3
117
+ # shellcheck disable=SC2086 # $steps is `--step NAME=RC` words
118
+ python3 "$repo/libexec/update.py" record --result "$result" --state "$state" --tree "$tree" --before "$before" \
119
+ --log "$log" --started "$started" $steps --step "$phase=$status" 9>&- ||
120
+ printf 'could not write %s\n' "$state/update/last.json" >&3
121
+ case $result in
122
+ refused)
123
+ if [ -n "$before" ]; then printf 'refused: nothing changed; %s still runs — log %s\n' "$before" "$log" >&3
124
+ else printf 'refused: nothing changed; no release is installed here — log %s\n' "$log" >&3
125
+ fi ;;
126
+ partial) printf 'partial: %s is installed, nothing was restarted — log %s\n' "$tree" "$log" >&3 ;;
127
+ *) printf 'failed: nothing was installed; %s still runs — log %s\n' "${before:-the installed release}" "$log" >&3 ;;
128
+ esac
129
+ exit "$code"
130
+ }
131
+ if [ "$update_only" = 1 ]; then
132
+ tree=$(cd "$repo" && pwd -P) # as install/services.py resolves it for the link
133
+ log=$state/update/last.log
134
+ : > "$log"
135
+ # From here on the run's own output goes to the log (appended: the post-commit appends to it too); fd 3 is the
136
+ # terminal or the journal.
137
+ exec 3>&1 >>"$log" 2>&1
138
+ before=$(readlink -e "$HOME/.local/share/lanes/current" || :)
139
+ started=$(date -u +%Y-%m-%dT%H:%M:%SZ)
140
+ trap 'update_ended $?' EXIT
141
+ trap 'exit 129' HUP
142
+ trap 'exit 130' INT
143
+ trap 'exit 143' TERM
144
+ printf 'lanes update: %s -> %s (log %s)\n' "${before:-nothing installed}" "$tree" "$log" >&3
145
+ echo '== preflight'
146
+ # An update restarts what a full install enabled — statusd, the timers, the enrolment service — and enables
147
+ # nothing; with no installed release it would end `ok` on a server where none of that runs, or ever starts again
148
+ # after a reboot (fix cycle 1 of W9 U2). Refused, before anything is fetched or installed.
149
+ if [ -z "$before" ]; then
150
+ echo "no release is installed here: $HOME/.local/share/lanes/current names no tree"
151
+ echo 'an update only restarts what a server install set up: install it first, without --update-only'
152
+ exit 3
153
+ fi
154
+ fi
21
155
  for command in python3 curl tar sha256sum infocmp tic install systemctl docker tmux; do
22
156
  command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
23
157
  done
24
158
  python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else "Python 3.9+ is required")'
159
+ # --- docs store encryption at rest (W10 W5, LNS-106) ------------------------------------------------------------
160
+ # LANES_DOCS_AGE_RECIPIENT in lanes-docsd.service's drop-in encrypts each document's docs-store copy with `age -r`
161
+ # (default off; RUNBOOK "Docs service", encryption at rest). Set where no `age` binary is on PATH, every upload and
162
+ # publish would fail: refused here, before anything is installed, with libexec/docs_store.py's reason.
163
+ check_docs_age() {
164
+ python3 "$repo/libexec/docs_store.py" check-age || exit 1
165
+ }
166
+ check_docs_age
167
+ # --- end docs store encryption at rest -------------------------------------------------------------------------
25
168
  export XDG_RUNTIME_DIR="${XDG_RUNTIME_DIR:-/run/user/$(id -u)}"
26
169
  if [ "$start" = 1 ]; then
27
170
  systemctl --user show-environment >/dev/null
@@ -31,8 +174,7 @@ if [ "$check" = 1 ]; then
31
174
  echo 'Server prerequisites: ready'
32
175
  exit 0
33
176
  fi
34
- share=${LANES_SHARE:-$HOME/.local/share/lanes}
35
- state=${LANES_STATE:-$HOME/.local/state/lanes}
177
+ phase binaries
36
178
  mkdir -p "$share/bin" "$share/dl" "$state/flags"
37
179
  # The view server's PATH (bin/lanes clean_env): the Run pane that renders `lanes preview` sees this, never the
38
180
  # installer's PATH, so a glow in ~/.local/bin or ~/go/bin does not count as installed.
@@ -62,29 +204,77 @@ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url flag _res
62
204
  trap - EXIT HUP INT TERM
63
205
  echo "installed $target"
64
206
  done
207
+ phase terminfo
65
208
  if ! infocmp xterm-lanesview >/dev/null 2>&1; then
66
209
  infocmp -x xterm-256color | sed '1,3s/^xterm-256color|/xterm-lanesview|/' > "$share/dl/xterm-lanesview.ti"
67
210
  tic -x -o "$HOME/.terminfo" "$share/dl/xterm-lanesview.ti"
68
211
  fi
212
+ # Before the commit: nothing here depends on the release that is installed.
213
+ phase catalog
69
214
  [ -e "$state/registry.json" ] || touch "$state/flags/coexist"
70
- python3 "$repo/install/services.py"
71
215
  catalog=$HOME/.config/lanes/workspaces/catalog.json
72
216
  if [ ! -e "$catalog" ] && [ ! -d "$repo/workspaces" ] && [ -z "${LANES_WORKSPACES:-}" ]; then
73
217
  mkdir -p "$(dirname "$catalog")"
74
218
  printf '{"version":1,"workspaces":[]}\n' > "$catalog"
75
219
  fi
220
+ phase commit
221
+ # --install: the server installer is the one caller that makes its own tree the installed release (`current`);
222
+ # the enable steps re-render the release `current` names (install/services.py, DEF-1835).
223
+ python3 "$repo/install/services.py" --install
224
+ committed=1
225
+ if [ "$update_only" = 1 ]; then
226
+ phase post-commit
227
+ # The post-commit of the release just installed records the outcome itself (`partial` before its first step);
228
+ # its stdout is the terminal or the journal, its steps write to the log.
229
+ status=0
230
+ # shellcheck disable=SC2086 # $steps is `--step NAME=RC` words
231
+ python3 "$repo/libexec/update.py" post-commit --state "$state" --tree "$tree" --before "$before" --log "$log" \
232
+ --started "$started" $steps >&3 3>&- 9>&- || status=$?
233
+ case $status in
234
+ 0|4) exit "$status" ;;
235
+ *) printf 'partial: the post-commit ended with exit %s (log %s)\n' "$status" "$log" >&3; exit 4 ;;
236
+ esac
237
+ fi
238
+ mermaid="not installed (--no-start) — sh $repo/install/enable-docs.sh installs it"
76
239
  if [ "$start" = 1 ]; then
77
240
  systemctl --user daemon-reload
78
- "$repo/bin/lanes" sync
79
- systemctl --user enable --now lanes-statusd.service lanes-sync.timer lanes-ensure.timer lanes-gc.timer
241
+ # A failing sync (a board's container down, a registry it cannot read) is reported; the services still start.
242
+ "$repo/bin/lanes" sync 9>&- || echo 'warning: lanes sync failed (above); the services start anyway (run: lanes sync)' >&2
243
+ # lanes-update.timer (W9 U3): its OnBootSec is past on a running host, so it checks once now — refused while this
244
+ # install holds the run lock — then every 30 min. On a checkout install `lanes update` does nothing. Only this full
245
+ # install enables it: --update-only (above) never does, and `lanes doctor` names a timer that is off.
246
+ systemctl --user enable --now lanes-statusd.service lanes-sync.timer lanes-ensure.timer lanes-gc.timer \
247
+ lanes-update.timer
80
248
  # The enrolment service (libexec/enrold.py) runs the code of THIS install: enable, then (re)start it.
81
249
  systemctl --user enable lanes-enrold.service
82
250
  systemctl --user restart lanes-enrold.service
251
+ # The docs service (libexec/docsd.py, W10 W3) likewise; it refuses every request until `lanes enrol mode request`
252
+ # pins the Serve header check.
253
+ systemctl --user enable lanes-docsd.service
254
+ systemctl --user restart lanes-docsd.service
255
+ # --- the docs page's pinned Mermaid (W10 W8, LNS-109) ---------------------------------------------------------
256
+ # install/enable-docs.sh (RUNBOOK "Mermaid pin"), never fatal: the install goes on whatever it exits, and the
257
+ # summary below names the outcome — 0 ok (installed, or already present with the pinned digest: no download),
258
+ # 1 skipped (a prerequisite or the download), 3 refused (no valid pin, a digest mismatch: nothing installed), any
259
+ # other status failed. An update (--update-only) runs it as a post-commit step (libexec/update.py DOCS_STEP).
260
+ mermaid_rc=0
261
+ mermaid_said=$(sh "$repo/install/enable-docs.sh" 2>&1 9>&-) || mermaid_rc=$?
262
+ [ -z "$mermaid_said" ] || printf '%s\n' "$mermaid_said"
263
+ mermaid_reason=$(printf '%s\n' "$mermaid_said" | awk 'NF { line = $0 } END { print line }')
264
+ [ -n "$mermaid_reason" ] || mermaid_reason="exit $mermaid_rc, no output"
265
+ case $mermaid_rc in
266
+ 0) mermaid='ok' ;;
267
+ 1) mermaid="skipped ($mermaid_reason) — diagrams show as source until sh $repo/install/enable-docs.sh succeeds" ;;
268
+ 3) mermaid="refused ($mermaid_reason) — nothing was installed; see RUNBOOK \"Mermaid pin\"" ;;
269
+ *) mermaid="failed (exit $mermaid_rc: $mermaid_reason) — sh $repo/install/enable-docs.sh" ;;
270
+ esac
271
+ # --- end the docs page's pinned Mermaid -----------------------------------------------------------------------
83
272
  # A running web frontend reads the Caddyfile of the tree it was started from; an npm update stages a new tree,
84
273
  # so a reload would keep the old routes (/enrol/ included): restart it, only when it runs. Clients reconnect.
85
274
  systemctl --user try-restart lanes-mobile.service
86
275
  fi
87
276
  printf '\nServer installed from %s\n' "$repo"
277
+ echo "Mermaid for the docs page: $mermaid"
88
278
  echo 'Configure boards in ~/.config/lanes/workspaces/; see workspaces.example/README.md.'
89
279
  echo 'Next: lanes install-container CONTAINER; lanes sync; lanes doctor'
90
280
  echo 'Web access and host cleanup/guard timers remain opt-in; see docs/INSTALL.md.'
@@ -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 ValueError('installation paths must not contain control characters')
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 register(repo, home, share, state, workspaces=None, economy=None, notify=None, xdg=None, units_dir=None):
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
- links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
132
- for link, _ in links:
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 ValueError(f'{link} already exists and is not a symlink')
135
- settings = [('LANES_REPO', repo), ('LANES_SHARE', share), ('LANES_STATE', state)]
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(repo, command=True))
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
- for link, target in links:
174
- link.parent.mkdir(parents=True, exist_ok=True)
175
- if link.is_symlink():
176
- link.unlink()
177
- link.symlink_to(target)
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
- register(Path(__file__).resolve().parents[1], Path.home(),
182
- os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
183
- os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
184
- os.environ.get('LANES_WORKSPACES'), economy_settings(os.environ), notify_settings(os.environ),
185
- units_dir=manager_unit_dir(Path.home()))
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)