@vibelord/lanes 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (89) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +70 -0
  3. package/bin/lanes-install.mjs +158 -0
  4. package/lib/plan.mjs +65 -0
  5. package/package.json +37 -0
  6. package/payload/lanes/README.md +152 -0
  7. package/payload/lanes/VERSION +1 -0
  8. package/payload/lanes/bin/lanes +1035 -0
  9. package/payload/lanes/client/ghostty/common.conf +22 -0
  10. package/payload/lanes/client/ghostty/macos.conf +6 -0
  11. package/payload/lanes/client/ghostty/omarchy.conf +19 -0
  12. package/payload/lanes/client/install.sh +16 -0
  13. package/payload/lanes/client/lanes-launch +315 -0
  14. package/payload/lanes/client/lanes-upload +147 -0
  15. package/payload/lanes/client/lanes-window +36 -0
  16. package/payload/lanes/client/macos-helper.applescript +14 -0
  17. package/payload/lanes/client/omarchy/discover.sh +56 -0
  18. package/payload/lanes/client/omarchy/lanes.conf +5 -0
  19. package/payload/lanes/client/omarchy/lanes.desktop +9 -0
  20. package/payload/lanes/client/omarchy/lanes.lua +14 -0
  21. package/payload/lanes/client/ssh.stanza.in +54 -0
  22. package/payload/lanes/container/env.sh +16 -0
  23. package/payload/lanes/container/lane +268 -0
  24. package/payload/lanes/container/lane-run +34 -0
  25. package/payload/lanes/docs/ARCHITECTURE.md +93 -0
  26. package/payload/lanes/docs/HOST-GUARD.md +108 -0
  27. package/payload/lanes/docs/INSTALL.md +192 -0
  28. package/payload/lanes/docs/RUNBOOK.md +385 -0
  29. package/payload/lanes/docs/SHARING.md +124 -0
  30. package/payload/lanes/docs/ZMX-RING0.md +51 -0
  31. package/payload/lanes/docs/images/desktop.png +0 -0
  32. package/payload/lanes/docs/images/iphone.png +0 -0
  33. package/payload/lanes/holders/exec.sh +21 -0
  34. package/payload/lanes/holders/tmux.sh +111 -0
  35. package/payload/lanes/holders/zmx.sh +22 -0
  36. package/payload/lanes/holders.lock +5 -0
  37. package/payload/lanes/install/enable-phone.sh +43 -0
  38. package/payload/lanes/install/enable-web.sh +16 -0
  39. package/payload/lanes/install/host-install.sh +72 -0
  40. package/payload/lanes/install/services.py +61 -0
  41. package/payload/lanes/install.sh +603 -0
  42. package/payload/lanes/libexec/cursor_commit.py +159 -0
  43. package/payload/lanes/libexec/cursor_sync.py +166 -0
  44. package/payload/lanes/libexec/guard.py +231 -0
  45. package/payload/lanes/libexec/lanes_lib.py +610 -0
  46. package/payload/lanes/libexec/legacy_sessions.py +130 -0
  47. package/payload/lanes/libexec/session_actions.py +276 -0
  48. package/payload/lanes/libexec/sidebar +8 -0
  49. package/payload/lanes/libexec/sidebar.py +872 -0
  50. package/payload/lanes/libexec/statusd.py +764 -0
  51. package/payload/lanes/libexec/tmpscan.py +921 -0
  52. package/payload/lanes/libexec/view.sh +179 -0
  53. package/payload/lanes/scripts/adopt-sharing-candidate.py +109 -0
  54. package/payload/lanes/site/README.md +28 -0
  55. package/payload/lanes/site/beyondwork/known_hosts +1 -0
  56. package/payload/lanes/site/beyondwork/site.conf +11 -0
  57. package/payload/lanes/site/site.example.conf +10 -0
  58. package/payload/lanes/sudo/host-guard.sh +356 -0
  59. package/payload/lanes/sudo/one-time-root.sh +32 -0
  60. package/payload/lanes/systemd/lanes-ensure.service +7 -0
  61. package/payload/lanes/systemd/lanes-ensure.timer +10 -0
  62. package/payload/lanes/systemd/lanes-gc.service +7 -0
  63. package/payload/lanes/systemd/lanes-gc.timer +9 -0
  64. package/payload/lanes/systemd/lanes-guard.service +8 -0
  65. package/payload/lanes/systemd/lanes-guard.timer +10 -0
  66. package/payload/lanes/systemd/lanes-mobile.service +23 -0
  67. package/payload/lanes/systemd/lanes-statusd.service +15 -0
  68. package/payload/lanes/systemd/lanes-sync.service +10 -0
  69. package/payload/lanes/systemd/lanes-sync.timer +10 -0
  70. package/payload/lanes/systemd/lanes-tmpreap.service +11 -0
  71. package/payload/lanes/systemd/lanes-tmpreap.timer +13 -0
  72. package/payload/lanes/systemd/lanes-web.service +13 -0
  73. package/payload/lanes/systemd/lanes.slice +11 -0
  74. package/payload/lanes/web/Caddyfile +37 -0
  75. package/payload/lanes/web/README.md +35 -0
  76. package/payload/lanes/web/icon-192.png +0 -0
  77. package/payload/lanes/web/icon.svg +4 -0
  78. package/payload/lanes/web/index.html +30 -0
  79. package/payload/lanes/web/manifest.webmanifest +15 -0
  80. package/payload/lanes/web/mobile.css +301 -0
  81. package/payload/lanes/web/mobile.js +367 -0
  82. package/payload/lanes/workspaces.example/README.md +27 -0
  83. package/payload/lanes/workspaces.example/catalog.json +7 -0
  84. package/payload/lanes/workspaces.example/commit-targets.json +8 -0
  85. package/payload/lanes/workspaces.example/terminals.example.json +19 -0
  86. package/payload/lanes/workspaces.example/workspaces.product.json +15 -0
  87. package/payload/lanes/workspaces.example/workspaces.research.json +16 -0
  88. package/payload/lanes/zellij/config.kdl +360 -0
  89. package/payload/manifest.json +31 -0
@@ -0,0 +1,111 @@
1
+ #!/bin/sh
2
+ # Attach this terminal to an EXISTING in-container tmux lane, straight over its unix socket.
3
+ # usage: tmux.sh <container> <socket> <session>
4
+ #
5
+ # Guards (all mandatory, see docs/ARCHITECTURE.md):
6
+ # -N never start a tmux server (a plain attach on a missing socket would start a phantom one)
7
+ # env -i tmux's update-environment must not copy this host's SSH_* / DISPLAY into the lane
8
+ # init-pid the socket is reached through /proc/<container-init-pid>/root, verified to be a docker cgroup
9
+ # pty guard the lane's client-attached hook detaches any client whose tty NAME equals the lane's
10
+ # pane_tty; host and container pty numbers can collide, so re-wrap in a fresh pty on a match
11
+ # ignore-size while the old viewers coexist, never resize a live agent's window
12
+ set -u
13
+ state=${LANES_STATE:-$HOME/.local/state/lanes}
14
+ container=${1:-} socket=${2:-} session=${3:-}
15
+ fail() { echo "$2" >&2; exit "$1"; } # the view shows the last stderr line on its status screen
16
+ case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) fail 64 'invalid container' ;; esac
17
+ case "$socket" in ''|-*|*[!A-Za-z0-9_.-]*) fail 64 'invalid socket' ;; esac
18
+ # The session gate admits what TMUX itself can hold, not what a lane may be DECLARED with. statusd lists every
19
+ # live session, its name outside the declaration grammar or not (the `<name> (drain)` twins, a hand-made
20
+ # `Planning Notes 2`), and the sidebar offers each as a 👻 row — so a gate copied from
21
+ # `lanes_lib.TMUX_SESSION` refused `invalid session` (64) on exactly those rows and view.sh retried that refusal
22
+ # for ever under a name the lane itself answers to (DEF-891). MEASURED (tmux 3.4): `new-session -s` accepts
23
+ # `(`, `)`, `/`, `,`, `$`, a backtick, a quote and a leading `-`; only `:` and `.` are rewritten to `_`, so
24
+ # such a name simply has no session and fails below with the precise "no such tmux session". The name never
25
+ # reaches a shell: it is one argv element behind `-t "$target"` (an EXACT match, so a leading `-` is inert),
26
+ # and the pty re-wrap below hands it to `script` through the ENVIRONMENT, never inside a quoted command string.
27
+ # Control characters stay refused: no listing round-trips them.
28
+ case "$session" in ''|*[[:cntrl:]]*) fail 64 'invalid session' ;; esac
29
+ # Every target carries the WINDOW suffix `:`. A trailing `;` ends a tmux command in ANY argv element, so a bare
30
+ # `-t "=$session"` on a live session named `foo;` resolved `=foo`: MEASURED (tmux 3.4, scratch server) an
31
+ # `attach-session -t '=foo;'` put the client on `foo` — a lane the operator did not select — while with `foo`
32
+ # gone `has-session -t '=foo;'` failed "can't find session: foo" although `foo;` was listed. With the suffix
33
+ # the same three calls resolve `foo;` (`attach-session -t '=foo;:'` landed on `foo;`, `-f ignore-size` intact).
34
+ # `:` alone is the session's CURRENT window, which is what a plain attach selects anyway (DEF-917).
35
+ target="=$session:"
36
+
37
+ is_container_pid() {
38
+ [ "${1:-0}" -gt 0 ] 2>/dev/null && [ -r "/proc/$1/cgroup" ] && grep -q docker "/proc/$1/cgroup" 2>/dev/null
39
+ }
40
+ pid=$(cat "$state/containers/$container.pid" 2>/dev/null || echo 0)
41
+ if ! is_container_pid "$pid"; then
42
+ pid=$(docker inspect -f '{{.State.Pid}}' "$container" 2>/dev/null || echo 0)
43
+ is_container_pid "$pid" || fail 69 "container $container is not running"
44
+ fi
45
+ path=/proc/$pid/root/tmp/tmux-${LANES_CONTAINER_UID:-1000}/$socket
46
+ if [ ! -S "$path" ]; then
47
+ # statusd saw the server PROCESS alive while its socket file is gone = somebody emptied the container's /tmp.
48
+ # 75 (try again), not 69: the lane exists, so the view keeps its "THE LANE IS STILL RUNNING" headline.
49
+ if grep -q -x -F "$container/$socket" "$state/flags/sockets-lost" 2>/dev/null; then
50
+ fail 75 "the lane runs, but its tmux socket file was deleted (emptied /tmp?) — on the server: lanes sockets repair"
51
+ fi
52
+ fail 69 "tmux server '$socket' is not running in $container"
53
+ fi
54
+
55
+ # Viewer marker: a user-level terminfo alias of xterm-256color (install/host-install.sh). tmux 3.4 reads terminfo
56
+ # on the client side and exposes the name as #{client_termname}; `xterm*` keeps tmux's default xterm features.
57
+ # (#{client_cwd} is empty in tmux 3.4, so the working directory cannot serve as the marker.)
58
+ view_term=xterm-256color
59
+ if infocmp xterm-lanesview >/dev/null 2>&1; then view_term=xterm-lanesview; fi
60
+ lanes_tmux() {
61
+ env -i PATH=/usr/bin:/bin HOME="$HOME" TERM="$view_term" LANG=C.UTF-8 COLORTERM=truecolor \
62
+ tmux -N -u -2 -T RGB,hyperlinks -S "$path" "$@"
63
+ }
64
+ lanes_tmux has-session -t "$target" 2>/dev/null || fail 66 "lane $socket / $session is not provisioned (no such tmux session)"
65
+ # ... and the session that ANSWERED is the one asked for. `$<n>`, `@<n>` and `%<n>` are tmux OBJECT IDs, and
66
+ # neither the `=` exact-match prefix nor the `:` window suffix stops the id lookup: MEASURED (tmux 3.4, scratch
67
+ # server holding `plain`=$0, `other`=$1 and a session literally NAMED `$0`) `display-message -p -t '=$0:'`
68
+ # answered `plain|$0`, `has-session` and `list-clients` succeeded against `plain`, and a real
69
+ # `attach-session -f ignore-size -t '=$0:'` put the client on `plain` — a lane the operator did not select,
70
+ # whose editor clients the size pass below would then put into ignore-size. The gate above admits such a name
71
+ # on purpose (statusd lists every live session and the sidebar opens it, DEF-891), so the ANSWER is what is
72
+ # checked, exactly as the Host's channel preflight checks it (`studio_host::channel`, `LaneMissing`). DEF-968.
73
+ answering=$(lanes_tmux display-message -p -t "$target" '#{session_name}' 2>/dev/null || echo '')
74
+ [ "$answering" = "$session" ] || fail 66 "lane $socket / $session is not provisioned (that target resolves the session '$answering')"
75
+
76
+ flags=
77
+ if [ -e "$state/flags/coexist" ] && [ ! -e "$state/flags/sized.${LANES_WS:-}" ]; then flags=ignore-size; fi
78
+ if [ -e "$state/flags/readonly" ]; then flags=${flags:+$flags,}read-only; fi
79
+
80
+ my_tty=$(tty 2>/dev/null || echo none)
81
+ pane_tty=$(lanes_tmux display-message -p -t "$target" '#{pane_tty}' 2>/dev/null || echo unknown)
82
+ if [ "$my_tty" = "$pane_tty" ] && [ -z "${LANES_REWRAPPED:-}" ]; then
83
+ # Same pty NAME in two mount namespaces: move this viewer onto a fresh pty. The three arguments travel in
84
+ # the ENVIRONMENT and the command string is single-quoted, so the inner `sh -c` expands them as values: a
85
+ # session name carrying a `'` (which tmux accepts) used to close the quoting and run the rest as a command.
86
+ LANES_REWRAPPED=1 LANES_STATE="$state" LANES_WS="${LANES_WS:-}" \
87
+ LANES_SELF="$0" LANES_ARG_CONTAINER="$container" LANES_ARG_SOCKET="$socket" LANES_ARG_SESSION="$session" \
88
+ exec script -qfc 'exec "$LANES_SELF" "$LANES_ARG_CONTAINER" "$LANES_ARG_SOCKET" "$LANES_ARG_SESSION"' /dev/null
89
+ fi
90
+
91
+ # The lanes view owns the size of the lane it shows: put editor terminals (Cursor / VS Code, ad-hoc attaches)
92
+ # already on this lane into ignore-size now. statusd reconciles continuously and gives them the size back once
93
+ # no viewer shows the lane.
94
+ if [ -z "$flags" ] && [ "$view_term" = xterm-lanesview ]; then
95
+ lanes_tmux list-clients -t "$target" -F '#{client_name}|#{client_termname}|#{client_flags}' 2>/dev/null |
96
+ while IFS='|' read -r c_name c_term c_flags; do
97
+ [ "$c_term" = xterm-lanesview ] && continue
98
+ case ",$c_flags," in *,ignore-size,*) continue ;; esac
99
+ [ "$c_name" = "$my_tty" ] && continue
100
+ lanes_tmux refresh-client -t "$c_name" -f ignore-size 2>/dev/null || :
101
+ done
102
+ fi
103
+
104
+ # exec: the supervisor's child pid must BE the tmux client, so a lane switch (SIGTERM) detaches it cleanly
105
+ # instead of orphaning a client on this pty.
106
+ if [ -n "$flags" ]; then
107
+ exec env -i PATH=/usr/bin:/bin HOME="$HOME" TERM="$view_term" LANG=C.UTF-8 COLORTERM=truecolor \
108
+ tmux -N -u -2 -T RGB,hyperlinks -S "$path" attach-session -f "$flags" -t "$target"
109
+ fi
110
+ exec env -i PATH=/usr/bin:/bin HOME="$HOME" TERM="$view_term" LANG=C.UTF-8 COLORTERM=truecolor \
111
+ tmux -N -u -2 -T RGB,hyperlinks -S "$path" attach-session -t "$target"
@@ -0,0 +1,22 @@
1
+ #!/bin/sh
2
+ # Attach this terminal to an EXISTING zmx-held lane inside a container, straight over its unix socket.
3
+ # usage: zmx.sh <container> <lane-name> <zmx-version>
4
+ #
5
+ # * zmx has no attach-only flag: a missing session would be CREATED (here, on the host). Guard twice:
6
+ # the socket must exist, and the create-command is /bin/false so an accidental creation dies at once.
7
+ # * zmx has no IPC version handshake: run the very binary the lane's daemon runs (from the home volume).
8
+ # * sun_path is 108 bytes and the volume path alone is 74: reach the sockets through the short symlink
9
+ # ~/.lz/<container> -> <home volume>/.lz (zmx does not canonicalise paths).
10
+ # * ZMX_NO_DETACH_KEY: Ctrl-\ belongs to the agent; leaving a lane is the view's job.
11
+ set -u
12
+ container=${1:-} name=${2:-} version=${3:-}
13
+ fail() { echo "$2" >&2; exit "$1"; }
14
+ case "$container" in ''|-*|*[!A-Za-z0-9_.-]*) fail 64 'invalid container' ;; esac
15
+ case "$name" in ''|-*|*[!A-Za-z0-9_.-]*) fail 64 'invalid lane name' ;; esac
16
+ case "$version" in ''|*[!0-9.]*) fail 64 'invalid zmx version' ;; esac
17
+ link=$HOME/.lz/$container
18
+ zmx=$link/bin/zmx-$version
19
+ [ -x "$zmx" ] || fail 69 "zmx $version is not installed for $container"
20
+ [ -S "$link/s/$name" ] || fail 66 "lane $name is not running"
21
+ exec env -i PATH=/usr/bin:/bin HOME="$HOME" TERM=xterm-256color LANG=C.UTF-8 \
22
+ ZMX_DIR="$link/s" ZMX_NO_DETACH_KEY=1 "$zmx" attach "$name" /bin/false
@@ -0,0 +1,5 @@
1
+ # Pinned binaries. install/host-install.sh refuses anything whose sha256 differs.
2
+ # name version sha256(tarball) url
3
+ # zellij 0.45.1: tarball digest matches GitHub's asset digest; inner binary d006c521…19dd matches the published .sha256sum.
4
+ zellij 0.45.1 40bcc2e03f5d5ae8e054e39f676081fe12ab70871506996ba595834c3718eefc https://github.com/zellij-org/zellij/releases/download/v0.45.1/zellij-x86_64-unknown-linux-musl.tar.gz
5
+ zmx 0.8.1 dfd75720b942466f28870731cc86dbc07afa72fb8f3bd5eeb4ff707e4eecebe8 https://github.com/neurosnap/zmx/releases/download/v0.8.1/zmx-0.8.1-linux-x86_64.tar.gz
@@ -0,0 +1,43 @@
1
+ #!/bin/sh
2
+ # Key-authenticated client entry point and local server setup. Tokens stay in the caller's terminal.
3
+ set -eu
4
+ repo=$(cd "$(dirname "$0")/.." && pwd)
5
+ host= device=phone local=0 named=0
6
+ while [ "$#" -gt 0 ]; do
7
+ case "$1" in
8
+ --host) host=${2:?--host SSH_ALIAS}; shift ;;
9
+ --local) local=1 ;;
10
+ -h|--help)
11
+ echo 'usage: enable-phone.sh [--host SSH_ALIAS | --local] [device]'
12
+ echo 'Client: --host lanes uses the SSH key from ./install.sh. Server: --local.'
13
+ exit 0 ;;
14
+ -*) echo "unknown option: $1" >&2; exit 2 ;;
15
+ *) [ "$named" = 0 ] || { echo 'only one device name is allowed' >&2; exit 2; }; device=$1; named=1 ;;
16
+ esac
17
+ shift
18
+ done
19
+ case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
20
+ case "$host" in -*|*[!A-Za-z0-9_.@:-]*) echo 'invalid SSH host' >&2; exit 2 ;; esac
21
+ [ "$local" = 0 ] || [ -z "$host" ] || { echo '--local and --host are mutually exclusive' >&2; exit 2; }
22
+ if [ "$local" = 0 ] && [ "$(uname -s)" = Darwin ] && [ -z "$host" ]; then host=lanes; fi
23
+ [ -t 0 ] && [ -t 1 ] || { echo 'Run enable-phone.sh in your own terminal; it displays a private login token.' >&2; exit 64; }
24
+ if [ -n "$host" ]; then
25
+ exec ssh -t -o BatchMode=yes -o PreferredAuthentications=publickey -o ForwardAgent=no "$host" \
26
+ "exec sh \"\$HOME/.local/share/lanes/current/install/enable-phone.sh\" --local '$device'"
27
+ fi
28
+ command -v tailscale >/dev/null 2>&1 || { echo 'Install and connect Tailscale on the server first.' >&2; exit 1; }
29
+ sh "$repo/install/enable-web.sh" "$device"
30
+ if ! tailscale serve --bg --https=8443 http://127.0.0.1:8083; then
31
+ echo 'The server admin must allow this account to manage Tailscale Serve:' >&2
32
+ printf ' sudo tailscale set --operator=%s\n' "$(id -un)" >&2
33
+ echo 'Then run this command again. See docs/INSTALL.md for HTTPS setup.' >&2
34
+ exit 1
35
+ fi
36
+ dns=$(tailscale status --json | python3 -c 'import json,sys; print(json.load(sys.stdin)["Self"]["DNSName"].rstrip("."))')
37
+ [ -n "$dns" ] || { echo 'This node needs Tailscale MagicDNS and HTTPS enabled.' >&2; exit 1; }
38
+ printf '\nOn your phone, connect Tailscale and open:\n\n https://%s:8443/%s\n\n' "$dns" "$device"
39
+ printf 'The last line is NAME: TOKEN. Paste only TOKEN into the sign-in screen.\n'
40
+ printf 'In Safari, Share > Add to Home Screen installs the app.\n\n'
41
+ # Zellij 0.45.1 requires --create-token by itself; --token-name cannot accompany it.
42
+ exec "${LANES_SHARE:-$HOME/.local/share/lanes}/bin/zellij-0.45.1" \
43
+ --config-dir "$repo/zellij" --config "$repo/zellij/config.kdl" web --create-token
@@ -0,0 +1,16 @@
1
+ #!/bin/sh
2
+ # Enable loopback web services as the installed workspace user. Does not print a token.
3
+ set -eu
4
+ repo=$(cd "$(dirname "$0")/.." && pwd)
5
+ device=${1:-phone}
6
+ [ "$#" -le 1 ] || { echo 'usage: enable-web.sh [device]' >&2; exit 2; }
7
+ case "$device" in ''|-*|*[!A-Za-z0-9_.-]*) echo 'invalid device name' >&2; exit 2 ;; esac
8
+ command -v caddy >/dev/null 2>&1 || { echo 'Install Caddy first (see docs/INSTALL.md).' >&2; exit 1; }
9
+ export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
10
+ "$repo/bin/lanes" prepare "$device" --mobile
11
+ python3 "$repo/install/services.py"
12
+ systemctl --user daemon-reload
13
+ systemctl --user enable --now lanes-web.service lanes-mobile.service
14
+ systemctl --user is-active --quiet lanes-web.service lanes-mobile.service
15
+ printf '\nPhone frontend: 127.0.0.1:8083; Zellij: 127.0.0.1:8082 (loopback only).\n'
16
+ printf 'For private HTTPS and a login token, run in your terminal:\n sh "%s/install/enable-phone.sh" --local %s\n' "$repo" "$device"
@@ -0,0 +1,72 @@
1
+ #!/bin/sh
2
+ # Linux server install/update. Called by ./install.sh --server. Never runs as root.
3
+ set -eu
4
+ repo=$(cd "$(dirname "$0")/.." && pwd)
5
+ start=1 check=0
6
+ for argument in "$@"; do
7
+ case "$argument" in
8
+ --no-start) start=0 ;;
9
+ --check) check=1 ;;
10
+ -h|--help)
11
+ echo 'usage: ./install.sh --server [--check | --no-start]'
12
+ echo 'Installs pinned binaries and user services. --check changes nothing; --no-start only installs files.'
13
+ exit 0 ;;
14
+ *) echo "unknown server option: $argument" >&2; exit 2 ;;
15
+ esac
16
+ done
17
+ [ "$(uname -s)/$(uname -m)" = Linux/x86_64 ] || {
18
+ echo 'The pinned server binaries require Linux x86_64. macOS/Linux desktops use ./install.sh --site FILE.' >&2; exit 1;
19
+ }
20
+ [ "$(id -u)" != 0 ] || { echo 'Run as the workspace user, not root.' >&2; exit 1; }
21
+ for command in python3 curl tar sha256sum infocmp tic install systemctl docker tmux; do
22
+ command -v "$command" >/dev/null 2>&1 || { echo "Missing prerequisite: $command (see docs/INSTALL.md)" >&2; exit 1; }
23
+ done
24
+ python3 -c 'import sys; sys.exit(0 if sys.version_info >= (3, 9) else "Python 3.9+ is required")'
25
+ export XDG_RUNTIME_DIR=${XDG_RUNTIME_DIR:-/run/user/$(id -u)}
26
+ if [ "$start" = 1 ]; then
27
+ systemctl --user show-environment >/dev/null
28
+ python3 -c 'import subprocess; subprocess.run(["docker", "info", "--format", "{{.ServerVersion}}"], check=True, timeout=15)'
29
+ fi
30
+ if [ "$check" = 1 ]; then
31
+ echo 'Server prerequisites: ready'
32
+ exit 0
33
+ fi
34
+ share=${LANES_SHARE:-$HOME/.local/share/lanes}
35
+ state=${LANES_STATE:-$HOME/.local/state/lanes}
36
+ mkdir -p "$share/bin" "$share/dl" "$state/flags"
37
+ grep -v '^#' "$repo/holders.lock" | while read -r name version sha url; do
38
+ [ -n "$name" ] || continue
39
+ target=$share/bin/$name-$version
40
+ if [ -x "$target" ]; then echo "ok $name $version"; continue; fi
41
+ archive=$share/dl/$(basename "$url")
42
+ curl -fsSL --retry 3 -o "$archive" "$url"
43
+ actual=$(sha256sum "$archive" | cut -d' ' -f1)
44
+ [ "$actual" = "$sha" ] || { echo "sha256 mismatch for $name" >&2; exit 1; }
45
+ temporary=$(mktemp -d)
46
+ trap 'rm -rf "$temporary"' EXIT HUP INT TERM
47
+ tar -xzf "$archive" -C "$temporary"
48
+ install -m 0755 "$temporary/$name" "$target"
49
+ rm -rf "$temporary"
50
+ trap - EXIT HUP INT TERM
51
+ echo "installed $target"
52
+ done
53
+ if ! infocmp xterm-lanesview >/dev/null 2>&1; then
54
+ infocmp -x xterm-256color | sed '1,3s/^xterm-256color|/xterm-lanesview|/' > "$share/dl/xterm-lanesview.ti"
55
+ tic -x -o "$HOME/.terminfo" "$share/dl/xterm-lanesview.ti"
56
+ fi
57
+ [ -e "$state/registry.json" ] || touch "$state/flags/coexist"
58
+ python3 "$repo/install/services.py"
59
+ catalog=$HOME/.config/lanes/workspaces/catalog.json
60
+ if [ ! -e "$catalog" ] && [ ! -d "$repo/workspaces" ] && [ -z "${LANES_WORKSPACES:-}" ]; then
61
+ mkdir -p "$(dirname "$catalog")"
62
+ printf '{"version":1,"workspaces":[]}\n' > "$catalog"
63
+ fi
64
+ if [ "$start" = 1 ]; then
65
+ systemctl --user daemon-reload
66
+ "$repo/bin/lanes" sync
67
+ systemctl --user enable --now lanes-statusd.service lanes-sync.timer lanes-ensure.timer lanes-gc.timer
68
+ fi
69
+ printf '\nServer installed from %s\n' "$repo"
70
+ echo 'Configure boards in ~/.config/lanes/workspaces/; see workspaces.example/README.md.'
71
+ echo 'Next: lanes install-container CONTAINER; lanes sync; lanes doctor'
72
+ echo 'Web access and host cleanup/guard timers remain opt-in; see docs/INSTALL.md.'
@@ -0,0 +1,61 @@
1
+ #!/usr/bin/env python3
2
+ """Register this checkout's user services without starting or restarting them."""
3
+ import os
4
+ import shutil
5
+ from pathlib import Path
6
+ import tempfile
7
+
8
+
9
+ def unit_value(value, command=False):
10
+ value = str(value)
11
+ if any(c in value for c in '\r\n\0'):
12
+ raise ValueError('installation paths must not contain control characters')
13
+ value = value.replace('\\', '\\\\').replace('"', '\\"').replace('%', '%%')
14
+ return value.replace('$', '$$') if command else value
15
+
16
+
17
+ def register(repo, home, share, state, workspaces=None):
18
+ repo, home, share, state = (Path(p).resolve() for p in (repo, home, share, state))
19
+ current = home / '.local/share/lanes/current'
20
+ links = ((current, repo), (home / '.local/bin/lanes', repo / 'bin/lanes'))
21
+ for link, _ in links:
22
+ if link.exists() and not link.is_symlink():
23
+ raise ValueError(f'{link} already exists and is not a symlink')
24
+ settings = [('LANES_REPO', repo), ('LANES_SHARE', share), ('LANES_STATE', state)]
25
+ if workspaces:
26
+ settings.append(('LANES_WORKSPACES', Path(workspaces).resolve()))
27
+ environment = ''.join(f'Environment="{name}={unit_value(value)}"\n' for name, value in settings)
28
+ rendered = {}
29
+ for source in sorted((repo / 'systemd').iterdir()):
30
+ if source.name.startswith('.') or source.suffix not in ('.service', '.timer', '.slice') or not source.is_file():
31
+ continue
32
+ content = source.read_text().replace('@REPO@', unit_value(repo, command=True))
33
+ content = content.replace('@SHARE@', unit_value(share, command=True))
34
+ content = content.replace('@CADDY@', unit_value(shutil.which('caddy') or '/usr/bin/caddy', command=True))
35
+ if '[Service]\n' in content:
36
+ content = content.replace('[Service]\n', '[Service]\n' + environment, 1)
37
+ rendered[source.name] = '# Generated by lanes/install/services.py; re-run the server installer to update.\n' + content
38
+ units = home / '.config/systemd/user'
39
+ units.mkdir(parents=True, exist_ok=True)
40
+ for name, content in rendered.items():
41
+ fd, temporary = tempfile.mkstemp(prefix='.lanes-', dir=units)
42
+ try:
43
+ with os.fdopen(fd, 'w') as handle:
44
+ handle.write(content)
45
+ os.chmod(temporary, 0o644)
46
+ os.replace(temporary, units / name) # replace old symlinks, never write through them
47
+ finally:
48
+ if os.path.exists(temporary):
49
+ os.unlink(temporary)
50
+ for link, target in links:
51
+ link.parent.mkdir(parents=True, exist_ok=True)
52
+ if link.is_symlink():
53
+ link.unlink()
54
+ link.symlink_to(target)
55
+
56
+
57
+ if __name__ == '__main__':
58
+ register(Path(__file__).resolve().parents[1], Path.home(),
59
+ os.environ.get('LANES_SHARE', Path.home() / '.local/share/lanes'),
60
+ os.environ.get('LANES_STATE', Path.home() / '.local/state/lanes'),
61
+ os.environ.get('LANES_WORKSPACES'))