@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
package/payload/lanes/bin/lanes
CHANGED
|
@@ -6,10 +6,23 @@
|
|
|
6
6
|
lanes resolve <lane-id> exact target of one lane as JSON (contract for the web harness)
|
|
7
7
|
lanes status one-line-per-workspace health summary
|
|
8
8
|
lanes attach <device> [--client ID] [--mobile] [--viewports N]
|
|
9
|
-
[--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|16|none]
|
|
10
|
-
[--
|
|
9
|
+
[--ascii] [--glyphs unicode|ascii|emoji] [--theme dark|light|auto] [--color truecolor|256|16|none]
|
|
10
|
+
[--notify 1|0] [--dim 90|2] [--editor micro|hx|nvim|vim|auto] [--device-only] [--default-device NAME]
|
|
11
|
+
[--client-version V]
|
|
11
12
|
attach this terminal to the device's view session (creates it if needed); the look
|
|
12
|
-
flags
|
|
13
|
+
flags (--notify 0: no desktop notification; --dim 2: SGR 2 for 16-colour dim text)
|
|
14
|
+
are remembered and win over LANES_COLOR/THEME/GLYPHS/NOTIFY/DIM, which a session
|
|
15
|
+
created here inherits from this shell. On a device with an owner (a member record, its
|
|
16
|
+
machine's for a window like mac-2, or `lanes device claim`) --theme/--color/--glyphs/
|
|
17
|
+
--ascii go to that person's profile users/<login>.json, which every device they own
|
|
18
|
+
shares, and leave devices/<device>.json; --device-only keeps them in the device file,
|
|
19
|
+
for this device alone (marked as its own: only such a key wins over the profile
|
|
20
|
+
there, and the flag without --device-only hands it back). A look a device with an
|
|
21
|
+
owner recorded before profiles existed paints there only where the profile sets none
|
|
22
|
+
(`lanes user show` names it; `lanes device claim <device> --login <owner>` folds it
|
|
23
|
+
in). --notify/--dim/--editor, and every flag on a device without an owner, are
|
|
24
|
+
remembered in devices/<device>.json, which reaches its running sidebars in a tick.
|
|
25
|
+
--editor is the editor quick edit (Ctrl-Alt-e) starts on this device (auto: none chosen)
|
|
13
26
|
lanes-launch passes its machine's device and its version: one dim line says when that
|
|
14
27
|
client is older than this server (`lanes-launch update`)
|
|
15
28
|
lanes prepare <device> [--mobile|--desktop]
|
|
@@ -36,6 +49,11 @@
|
|
|
36
49
|
lanes open <lane-id> attach this terminal straight to one lane (no multiplexer in between)
|
|
37
50
|
lanes register <lane-id> [ws] add an unregistered session to a workspace without restarting it
|
|
38
51
|
use lanes ls --unregistered [--json] to find its ID
|
|
52
|
+
lanes unregister <lane-id> take a session added that way out of its workspace again (the sidebar's `u`): nothing
|
|
53
|
+
stops; a running one is listed under Unregistered with the same id and name (lanes
|
|
54
|
+
register undoes it); refused while tmux cannot check it. A record no workspace lists
|
|
55
|
+
any more is removed from registered-sessions.json. A lane declared in a repository's
|
|
56
|
+
terminals.json is refused with the file to edit
|
|
39
57
|
lanes workspaces print the board ids (what the client installer reads)
|
|
40
58
|
lanes workspaces add <id> --container <name> --repo <path> [--title T] [--session S] [--write]
|
|
41
59
|
add a board to the site catalog (catalog.json + a new workspaces.<id>.json whose one
|
|
@@ -45,6 +63,24 @@
|
|
|
45
63
|
a no-op, a difference is reported and exits 1. A repository belongs to one board: one
|
|
46
64
|
another board already declares (same container and folder) is refused, as cursor-sync
|
|
47
65
|
would rewrite its terminals.json for both. Then: install-container, sync, doctor
|
|
66
|
+
lanes workspaces hide|show|order <id…> [--device D | --login L]
|
|
67
|
+
arrange a person's boards on every device they own, in their profile users/<login>.json:
|
|
68
|
+
hide / show takes boards off their tabs / puts them back (a hide that would leave no
|
|
69
|
+
board is refused; show also takes off an id no board has any more), order puts the
|
|
70
|
+
boards named first, in that order, the rest following in catalog order (it replaces
|
|
71
|
+
the order). The person is the owner of --device D (a window like mac-2: its machine's),
|
|
72
|
+
else of $LANES_DEVICE, or --login L. Ids are the ones `lanes workspaces` prints. Exit 1
|
|
73
|
+
when the profile stayed busy (run it again)
|
|
74
|
+
lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]
|
|
75
|
+
the same person's pinned lanes (listed first in their section) and hidden lanes (left
|
|
76
|
+
out of their lists): pin takes a lane a board lists or a running unregistered session,
|
|
77
|
+
hide a registered lane only (an unregistered session that runs is always listed);
|
|
78
|
+
unpin and show also take a lane that left the boards
|
|
79
|
+
lanes here <device> show on <device> the lane its owner opened last on any of their devices (the profile's
|
|
80
|
+
last opened lane): its board's first viewport points at it and, when the view runs, the
|
|
81
|
+
board's tab comes to the front. Says why when it cannot (nothing opened yet, the lane
|
|
82
|
+
is gone, its board is hidden). Asked for, never automatic: `lanes-launch here` runs it
|
|
83
|
+
for the client's own device
|
|
48
84
|
lanes onboard <login> --container <name> [--workspace <id> --repo <path> [--title T]]
|
|
49
85
|
[--write] [--no-mcp]
|
|
50
86
|
provision a user's container and agents in one verb. Dry-run by default (every diff and
|
|
@@ -62,14 +98,54 @@
|
|
|
62
98
|
writing nothing: the agent could not read files this account writes. Prints a
|
|
63
99
|
key: value table (a stopped run names every step it did not reach: not-run) and keeps
|
|
64
100
|
~/.local/state/lanes/onboard/<login>-<UTC>.json. Re-running changes nothing that is
|
|
65
|
-
done. --no-mcp leaves out lane-mcp
|
|
101
|
+
done. --no-mcp leaves out lane-mcp and the agents' MCP entries (install-container's).
|
|
66
102
|
Exit 1 only when a step failed; an unknown container or a board that disagrees stops it
|
|
67
|
-
lanes layout <device> [--mobile] print the generated zellij layout
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
lanes
|
|
71
|
-
|
|
103
|
+
lanes layout <device> [--mobile] print the generated zellij layout (its tree files named through
|
|
104
|
+
~/.local/share/lanes/current while that link names this tree: a pane started from it
|
|
105
|
+
runs the release the link names then)
|
|
106
|
+
lanes rebuild <device> recreate a device's view session (agents are never touched). Waits up to 120 s while
|
|
107
|
+
another lanes process acts on the view sessions (the view-session lock,
|
|
108
|
+
~/.local/state/lanes/locks/sessions.lock), then exits 1 naming it
|
|
109
|
+
lanes upgrade-session [device…] sync catalog tabs and labels to live sessions without a rebuild; the owner's profile
|
|
110
|
+
orders the workspace tabs and its hidden boards' tabs are closed (lanes untouched)
|
|
111
|
+
lanes ensure [--if-layout-changed]
|
|
112
|
+
recreate any known device session that is missing (never when zellij did not answer:
|
|
113
|
+
an unknown session list is not an empty one). --if-layout-changed (the server update's
|
|
114
|
+
last step) also compares every running view with the layout this release generates
|
|
115
|
+
for it: when a tab the view still has would get other panes, the view is rebuilt
|
|
116
|
+
(`lanes rebuild`) if `zellij action list-clients` shows no client attached; with a
|
|
117
|
+
client — or no answer — it keeps running and gets ~/.local/state/lanes/clients/
|
|
118
|
+
<device>.update-ready, which its sidebars show as `update ready · reload (r)` (r
|
|
119
|
+
rebuilds it). Tabs `lanes sync` adds, renames or orders in a running view are no
|
|
120
|
+
change. Without the flag only a device with that file is compared, so
|
|
121
|
+
lanes-ensure.timer rebuilds it once its last client has left. The whole run holds the
|
|
122
|
+
view-session lock (as rebuild, attach/prepare/web's create, offboard and device forget
|
|
123
|
+
do): with the flag it waits up to 120 s for another holder, without it 10 s, then
|
|
124
|
+
skips the run (exit 0: the timer runs again in a minute). Exit 1 when a view could
|
|
125
|
+
not be recreated (the run stops there) or rebuilt (the other views are still done), or
|
|
126
|
+
the flag's wait ran out; 2 for any other argument
|
|
72
127
|
lanes reattach make every view re-attach its lane (after changing flags); agents untouched
|
|
128
|
+
lanes reload-views ask every running viewport to start again from the installed release at its next lane
|
|
129
|
+
switch or reconnect (SIGUSR2 to each view.sh that handles it; an attached lane client
|
|
130
|
+
is never dropped). A view.sh of an older release, which that signal would end, is left
|
|
131
|
+
alone and named: `lanes rebuild <device>` moves it. Sidebars swap by themselves. The
|
|
132
|
+
server update runs it after the switch (./install.sh --server --update-only)
|
|
133
|
+
lanes update [--now | --timer | --status | --rollback]
|
|
134
|
+
install the newest npm release on an npm-installed server (what lanes-update.timer runs
|
|
135
|
+
15 min after boot and every 30 min, as --timer): ask the registry which release the
|
|
136
|
+
dist-tag `latest` names (`lanes-stable` with channel=stable in
|
|
137
|
+
~/.config/lanes/update.conf) and, when it is newer than the installed one and not the one
|
|
138
|
+
a rollback left, run `npx --yes @vibelord/lanes@<release> --server --update-only` with the
|
|
139
|
+
npx the npm wrapper recorded at install (~/.local/state/lanes/update/npx). A last update
|
|
140
|
+
that ended partial gets its post-commit again first (the timer: once per release). A
|
|
141
|
+
check that answered less than 20 min ago is not repeated; --now asks anyway. One at a
|
|
142
|
+
time (~/.local/state/lanes/locks/update.lock). --rollback: install the release
|
|
143
|
+
~/.local/share/lanes/current.previous names again, in place (no network), run its
|
|
144
|
+
post-commit and mark the release left `bad`: no update installs it again. --status:
|
|
145
|
+
print ~/.local/state/lanes/update/last.json, where every check is recorded (`check`).
|
|
146
|
+
A checkout server is left alone (git pull). Exit 0 nothing to do or ok, 4 partial,
|
|
147
|
+
3 refused (another update running, a checkout, no installed release; 0 under --timer),
|
|
148
|
+
1 failed (no release from the registry, no recorded npx, the install failed), 2 usage
|
|
73
149
|
lanes upload <ws> | --active <device> [--paste]
|
|
74
150
|
read a tar stream on stdin into that board's uploads/ folder (the same folder Cursor
|
|
75
151
|
users drop files into); prints the in-container paths. --paste also types the path(s)
|
|
@@ -81,16 +157,39 @@
|
|
|
81
157
|
attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
|
|
82
158
|
lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
|
|
83
159
|
working plain `tmux -L S new -ADs N [-c D] CMD` recipe, keeping CMD. The old recipe is
|
|
84
|
-
kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559)
|
|
160
|
+
kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559). An
|
|
161
|
+
entry's displayName (the repository's own lane name) is carried unchanged
|
|
85
162
|
lanes cursor-commit [ws…] [--push]
|
|
86
163
|
commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
|
|
87
164
|
with git plumbing only (no working tree or index is touched). Dry-run without --push.
|
|
88
|
-
lanes hello [device]
|
|
165
|
+
lanes hello [device] [--client-version V]
|
|
166
|
+
what the client installer calls to prove the path, the key and the server version:
|
|
167
|
+
`ok device=D version=S boards=N`; a client that passes its release V, older than S
|
|
168
|
+
(major.minor.patch as numbers), gets ` update=S` appended — the release to update to
|
|
169
|
+
lanes version [--json] which release runs here, in one line: `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 ·
|
|
170
|
+
current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
|
|
171
|
+
stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
|
|
172
|
+
tree it does name), then the tree each long-running process recorded at its last start
|
|
173
|
+
(~/.local/state/lanes/run/<name>.tree: statusd, enrold, notify, economy, studio_host,
|
|
174
|
+
the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
|
|
175
|
+
started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
|
|
176
|
+
updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
|
|
177
|
+
restart that service. A stopped process keeps its last record. --json: {schema:
|
|
178
|
+
lanes.version/1, version, commit, tree, current, running{<name>: {tree, version,
|
|
179
|
+
started, current, changed}}, client_min (the release `lanes attach` stops telling
|
|
180
|
+
lanes-launch to update at)}. Exit 0
|
|
89
181
|
lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
|
|
90
182
|
lanes install-container <name> [--no-mcp]
|
|
91
|
-
install the `lane` contract (lane, lane-run, lane-signal,
|
|
92
|
-
|
|
93
|
-
|
|
183
|
+
install the `lane` contract (lane, lane_core.py, lane-run, lane-signal, lane-edit,
|
|
184
|
+
lane-mcp, vimrc, VERSION, hooks/) + pinned zmx into a container's home volume
|
|
185
|
+
(additive; installs no agent hook and no editor: lane-edit runs $LANES_EDITOR or the
|
|
186
|
+
first of micro hx nvim vim vi there) and add the Lanes MCP server to the agents'
|
|
187
|
+
configs: mcpServers.lanes in ~/.claude.json and [mcp_servers.lanes] in
|
|
188
|
+
~/.codex/config.toml, both starting /home/coder/.lz/bin/lane-mcp — only where missing,
|
|
189
|
+
the file kept as <file>.bak-<UTC> first, atomic (a `lanes` entry that starts something
|
|
190
|
+
else is left alone, exit 1; on a uid split nothing is written there, exit 1). --no-mcp
|
|
191
|
+
leaves out lane-mcp and the entries (lane-edit and its vimrc still ship) and removes
|
|
192
|
+
nothing an earlier install wrote. In the container: lane mcp install
|
|
94
193
|
lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
|
|
95
194
|
merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
|
|
96
195
|
(+ a top-level Codex notify when none is set); prints the diff, writes only with --write
|
|
@@ -98,7 +197,10 @@
|
|
|
98
197
|
--write into a --container whose home the agent could not read from this account (uid
|
|
99
198
|
not 1000, or a home volume not owned by 1000) is refused with the reason
|
|
100
199
|
lanes invariant save|check snapshot / verify every live pane's pid and window size
|
|
101
|
-
lanes gc truncate oversized zmx logs, drop stale dynamic resolve files
|
|
200
|
+
lanes gc truncate oversized zmx logs, drop stale dynamic resolve files, prune old staged trees:
|
|
201
|
+
each pkg root keeps its 3 newest and every tree current, current.previous, this
|
|
202
|
+
lanes, a view layout, a running daemon's run record or a process of this account
|
|
203
|
+
uses (none pruned when /proc cannot be read); `pruned <tree>` / `kept <tree>: <why>`
|
|
102
204
|
lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
|
|
103
205
|
(someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
|
|
104
206
|
lanes pressure host memory / io pressure, swap, per-container memory and /tmp use — plain files and
|
|
@@ -123,11 +225,32 @@
|
|
|
123
225
|
focused viewport (the first one when the sidebar has the focus) at the next / previous
|
|
124
226
|
lane of its board in registry order, wrapping, and wake its view.sh (SIGUSR1) — the
|
|
125
227
|
same write as Enter in the sidebar. Reads two zellij listings; moves no focus
|
|
228
|
+
lanes sidebar-key filter|upload --focused [--device D]
|
|
229
|
+
what Ctrl-Alt-/ and Ctrl-Alt-u run in a floating pane that closes on exit, after their
|
|
230
|
+
three lefts moved the focus to the sidebar: tell the sidebar of the active tab to start
|
|
231
|
+
its filter / arm its upload strip through views/<device>/<ws>.sidebar.cmd (it acts within
|
|
232
|
+
a tick, and before the next key it reads). Types nothing into any pane; on a tab without
|
|
233
|
+
a sidebar (Host, a tool tab), or when the sidebar does not have the focus, it does nothing
|
|
126
234
|
lanes preview --focused [--device D]
|
|
127
235
|
what Ctrl-Alt-m runs in a floating pane: the markdown files the focused lane named in
|
|
128
236
|
its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
|
|
129
237
|
newest first; one is shown at once, several give a numbered menu. glow (pinned in
|
|
130
238
|
holders.lock) in the device's dark/light theme, else less -R
|
|
239
|
+
lanes edit --focused [--device D]
|
|
240
|
+
what Ctrl-Alt-e runs in a floating pane named Edit: quick edit of the focused lane's
|
|
241
|
+
AGENTS.md, CLAUDE.md or .env.local. One Edit pane per view: when another is open it
|
|
242
|
+
gets the focus (same tab) or is named (another tab) and nothing opens. Otherwise the
|
|
243
|
+
focused tmux lane's git checkout (its working directory, else its folder; a worktree's
|
|
244
|
+
.git file leads to the main checkout), read through /proc/<init>/root: AGENTS.md and
|
|
245
|
+
CLAUDE.md of the checkout, of the main checkout too when the lane is in a linked
|
|
246
|
+
worktree, and the main checkout's .env.local. A regular file inside the checkout only
|
|
247
|
+
(a symbolic link is never offered); a missing one is shown `not present` (no file is
|
|
248
|
+
created). One key picks (1-5; Enter when one file is there; q or Esc closes), then
|
|
249
|
+
`docker exec -it -u coder -w <checkout> <container> ~/.lz/bin/lane-edit <file>` replaces
|
|
250
|
+
it (no shell, no docker inspect) with LANES_EDITOR = the device's --editor, else this
|
|
251
|
+
process's LANES_EDITOR, else none (lane-edit's own list). Refused: a container that is
|
|
252
|
+
not running, one without lane-edit (install-container), no checkout. Exit: the
|
|
253
|
+
editor's status, 0 closed or nothing to edit, 1 refused, 2 without --focused
|
|
131
254
|
lanes studio-host status [--json] [--check]
|
|
132
255
|
the co-located Throttle Studio Host (opt-in: install/enable-studio-host.sh): binary
|
|
133
256
|
digest, config mode, enrolment, units. Exit 0 only when it runs enrolled
|
|
@@ -141,17 +264,46 @@
|
|
|
141
264
|
the lanes per create-recipe class (idempotent / attach-only / null / external /
|
|
142
265
|
retired-path), lane identity, numeric session names, crowded `default` sockets and
|
|
143
266
|
orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
|
|
144
|
-
any record left in the retired legacy-sessions/ directory,
|
|
267
|
+
any record left in the retired legacy-sessions/ directory, the devices without an owner
|
|
268
|
+
(each keeps its look to itself: `lanes device claim`), and the browser/phone half:
|
|
145
269
|
lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
|
|
146
270
|
database the running lanes-web reads (✗ when it is not the one `lanes token` uses) and the
|
|
147
|
-
Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name)
|
|
148
|
-
|
|
271
|
+
Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
|
|
272
|
+
then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
|
|
273
|
+
agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
|
|
274
|
+
link: the rest compares with this tree); every lanes process that runs now has a run
|
|
275
|
+
record of the current code (✗ with its restart — a daemon or sidebar that runs with no
|
|
276
|
+
record started from an older release; a record whose process does not run is one line
|
|
277
|
+
of information); every device layout runs the current tree (✗ `lanes rebuild
|
|
278
|
+
<device>`; ⚠ when it names it by another path than `lanes rebuild` writes now); every
|
|
279
|
+
installed lanes unit's ExecStart runs it (✗ re-run the server installer); and the
|
|
280
|
+
updates (update/last.json): how long ago `lanes update` last checked and what came of
|
|
281
|
+
it, the last update's result (✗ partial or failed, ⚠ refused, a registry that gave no
|
|
282
|
+
release, lanes-update.timer not enabled — no timers.target.wants link: an update with
|
|
283
|
+
--update-only never enables it — or no check for 2 h: is it on?), a release a rollback
|
|
284
|
+
marked bad; a checkout server is one line of information
|
|
285
|
+
lanes device list | forget <name> | claim <device> --login L [--name N]
|
|
149
286
|
the devices `lanes ensure` recreates view sessions for, each with its owner's login
|
|
150
287
|
(from members/<device>.json, or the machine's for a window like mac-2, whenever that
|
|
151
288
|
record exists, so it agrees with `lanes keys list`; after a revoke the last one is
|
|
152
289
|
kept; blank for none); forget removes devices/<name>.json (a running view session is
|
|
153
|
-
left alone and is not brought back once it ends)
|
|
154
|
-
|
|
290
|
+
left alone and is not brought back once it ends); claim names the owner of a device no
|
|
291
|
+
member record speaks for (one from before enrolment): it writes `owner` into
|
|
292
|
+
devices/<device>.json and its windows' (mac-2, …), where every rewrite keeps it, then
|
|
293
|
+
folds their look (never a --device-only key) and seen marks into L's profile; a view L
|
|
294
|
+
owns already keeps the name it records unless --name replaces it. A member record
|
|
295
|
+
decides whenever one exists: a claim that disagrees is refused (`lanes keys owner`),
|
|
296
|
+
one that agrees only folds (a look the device recorded before profiles existed).
|
|
297
|
+
Exit 1 when the profile could not be written (the same claim finishes it)
|
|
298
|
+
lanes user list | show <login> [--json]
|
|
299
|
+
the people lanes knows — a profile under users/, or a device a member record or a claim
|
|
300
|
+
gives them — with their devices; show prints one profile (look, seen lanes, workspace
|
|
301
|
+
order and hidden boards, pinned and hidden lanes, last opened lane, quiet time, last
|
|
302
|
+
change; --json: the stored document and the devices) and every look one of the
|
|
303
|
+
person's devices keeps in its own file (--device-only, or from before the profile).
|
|
304
|
+
Read-only. Exit 1 when the profile file exists but this release does not read it (the
|
|
305
|
+
reason is printed)
|
|
306
|
+
lanes keys list | add <device> <pubkey-file|-> | revoke <device> | adopt [--dry-run] [--login LOGIN]
|
|
155
307
|
| owner <device> <login> [--name NAME] [--email ADDR]
|
|
156
308
|
who may log in, as a projection of ~/.local/state/lanes/members/<device>.json: one
|
|
157
309
|
`restrict,pty ssh-ed25519 <key> lanes:<device>` line per member between the
|
|
@@ -163,8 +315,11 @@
|
|
|
163
315
|
neither (exit 1 only when a line outside the block still lets it in); adopt moves
|
|
164
316
|
hand-added `lanes:<device>` lines into members/
|
|
165
317
|
and the block (first adopt keeps authorized_keys.bak-<UTC>) and re-renders the block;
|
|
318
|
+
--login names the owner of every key it moves in (owner null without it);
|
|
166
319
|
owner names the person behind a member (add/adopt record none) — the tailnet login
|
|
167
|
-
`/enrol/pending` must see to let that person approve enrolment requests
|
|
320
|
+
`/enrol/pending` must see to let that person approve enrolment requests. Whenever
|
|
321
|
+
adopt --login or owner names an owner, the views of those devices (and their windows)
|
|
322
|
+
are folded into that person's profile, as `lanes device claim` does
|
|
168
323
|
lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]
|
|
169
324
|
lanes invite list | revoke <id>
|
|
170
325
|
mint a one-time code LN-<id>-<secret> a new machine redeems at lanes-enrold (libexec/
|
|
@@ -191,12 +346,17 @@
|
|
|
191
346
|
views `lanes-launch web` opened for it), end its view session (no lane is touched).
|
|
192
347
|
Without --yes or off a terminal: the plan, exit 2
|
|
193
348
|
"""
|
|
349
|
+
import contextlib
|
|
194
350
|
import json
|
|
195
351
|
import os
|
|
352
|
+
import posixpath
|
|
196
353
|
import pwd
|
|
197
354
|
import re
|
|
198
355
|
import shutil
|
|
356
|
+
import signal
|
|
357
|
+
import stat
|
|
199
358
|
import sys
|
|
359
|
+
import tempfile
|
|
200
360
|
import time
|
|
201
361
|
from pathlib import Path
|
|
202
362
|
|
|
@@ -206,17 +366,28 @@ import lanes_lib as L # noqa: E402
|
|
|
206
366
|
SLOTS = ['main', 'b', 'c']
|
|
207
367
|
ZELLIJ_VERSION = '0.45.1'
|
|
208
368
|
ZELLIJ = L.SHARE / 'bin' / f'zellij-{ZELLIJ_VERSION}'
|
|
209
|
-
|
|
369
|
+
# A view session outlives the update that flips `current` (W9 U1): its zellij server keeps the argv and the environment
|
|
370
|
+
# it was started with, so the config it reads and the bare `lanes` its keybindings run (clean_env PATH) are named
|
|
371
|
+
# through L.CURRENT — the link for a staged tree it names — never this tree's resolved path.
|
|
372
|
+
ZELLIJ_BASE = [str(ZELLIJ), '--config-dir', str(L.CURRENT / 'zellij'), '--config', str(L.CURRENT / 'zellij/config.kdl')]
|
|
210
373
|
PROC = Path(os.environ.get('LANES_PROC_ROOT') or '/proc') # injectable for the unit tests
|
|
211
374
|
|
|
212
375
|
|
|
376
|
+
# The sidebar's look and notification switches (cockpit v3 P4, LNS-72, DEF-1577): forwarded when set, so the shell that
|
|
377
|
+
# runs `lanes attach|ensure|rebuild` decides them for the view sessions it creates. A device's recorded LOOK_KEYS still
|
|
378
|
+
# win over them (sidebar.resolve_modes / resolve_dim_notify).
|
|
379
|
+
LOOK_ENV = ('LANES_COLOR', 'LANES_THEME', 'LANES_GLYPHS', 'LANES_DIM', 'LANES_NOTIFY')
|
|
380
|
+
|
|
381
|
+
|
|
213
382
|
def clean_env(extra=None):
|
|
214
|
-
"""Environment for the view server: nothing from the ssh session leaks into panes.
|
|
383
|
+
"""Environment for the view server: nothing from the ssh session leaks into panes but the named keys. The Zellij
|
|
384
|
+
server create_session starts keeps it for the session's life, and every sidebar, view.sh and floating Run pane of
|
|
385
|
+
that session inherits it (zellij() and cmd_open use it too): LOOK_ENV is how LANES_NOTIFY=0 reaches a sidebar."""
|
|
215
386
|
env = {'HOME': str(Path.home()), 'USER': os.environ.get('USER', 'dev'), 'SHELL': '/bin/bash',
|
|
216
|
-
'PATH': f"{L.SHARE / 'bin'}:{L.
|
|
387
|
+
'PATH': f"{L.SHARE / 'bin'}:{L.CURRENT / 'bin'}:/usr/local/bin:/usr/bin:/bin",
|
|
217
388
|
'LANG': 'C.UTF-8', 'LANES_STATE': str(L.STATE), 'LANES_SHARE': str(L.SHARE), 'LANES_ZELLIJ': str(ZELLIJ)}
|
|
218
|
-
for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR')
|
|
219
|
-
if os.environ.get(key):
|
|
389
|
+
for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR') + LOOK_ENV:
|
|
390
|
+
if os.environ.get(key): # ZELLIJ_SOCKET_DIR: test isolation
|
|
220
391
|
env[key] = os.environ[key]
|
|
221
392
|
if 'XDG_RUNTIME_DIR' not in env and Path(f'/run/user/{os.getuid()}').is_dir():
|
|
222
393
|
env['XDG_RUNTIME_DIR'] = f'/run/user/{os.getuid()}'
|
|
@@ -230,11 +401,14 @@ def q(text):
|
|
|
230
401
|
|
|
231
402
|
# ------------------------------------------------------------------- registry
|
|
232
403
|
|
|
233
|
-
def
|
|
404
|
+
def sync_registry():
|
|
405
|
+
"""Rebuild and publish the registry, print the per-board table -> (boards with errors, [(container, why)] whose
|
|
406
|
+
`~/.lz/registry.json` was refused). `lanes sync` turns either into exit 1; a rename that publishes through it
|
|
407
|
+
raises the refusal (DEF-1796: that container's `lane open` keeps its previous projection)."""
|
|
234
408
|
with L.registry_lock():
|
|
235
409
|
previous = L.read_json(L.STATE / 'registry.json')
|
|
236
410
|
registry = L.build_registry(previous)
|
|
237
|
-
L.publish_registry(registry)
|
|
411
|
+
refused = L.publish_registry(registry) or []
|
|
238
412
|
total = sum(len(w['lanes']) for w in registry['workspaces'])
|
|
239
413
|
failed = [w for w in registry['workspaces'] if w['errors']]
|
|
240
414
|
for workspace in registry['workspaces']:
|
|
@@ -242,10 +416,18 @@ def cmd_sync(_args):
|
|
|
242
416
|
print(f"{mark} {workspace['id']:<18} {len(workspace['lanes']):>3} lanes {workspace['container']}")
|
|
243
417
|
for error in workspace['errors']:
|
|
244
418
|
print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
|
|
245
|
-
|
|
419
|
+
for container, why in refused: # DEF-1796: that container's `lane open` keeps its previous projection
|
|
420
|
+
print(f'! container {container}: {why}')
|
|
421
|
+
print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else '')
|
|
422
|
+
+ (f'; {len(refused)} container projection(s) not written' if refused else ''))
|
|
246
423
|
if any((L.STATE / 'devices').glob('*.json')):
|
|
247
424
|
cmd_upgrade_session([])
|
|
248
|
-
return
|
|
425
|
+
return failed, refused
|
|
426
|
+
|
|
427
|
+
|
|
428
|
+
def cmd_sync(_args):
|
|
429
|
+
failed, refused = sync_registry()
|
|
430
|
+
return 1 if failed or refused else 0
|
|
249
431
|
|
|
250
432
|
|
|
251
433
|
def cmd_register(args):
|
|
@@ -257,6 +439,54 @@ def cmd_register(args):
|
|
|
257
439
|
print(f"Registered {lane['name']} in {lane['workspace']}")
|
|
258
440
|
|
|
259
441
|
|
|
442
|
+
def cmd_unregister(args):
|
|
443
|
+
"""lanes unregister <lane-id> — the sidebar's `u` (cockpit v3 S7): take a session added with Add to workspace /
|
|
444
|
+
`lanes register` out of registered-sessions.json. Nothing is stopped; a running session is listed under
|
|
445
|
+
Unregistered again with the same id and name. A lane a repository's terminals.json declares is taken out of that
|
|
446
|
+
file (session_actions.unregister_lane: the Agent API's editor, M4, wired at the fan-in), then synced; its refusals
|
|
447
|
+
(DeclaredRefused) are said, and a lane of an SSH workspace says which file declares it. A record no workspace
|
|
448
|
+
lists (its workspace left the catalog, uses another container, or a repository declares the session) is removed
|
|
449
|
+
from the file all the same (S7 fix cycle 1, DEF-1826)."""
|
|
450
|
+
if len(args) != 1:
|
|
451
|
+
raise L.LanesError('Usage: lanes unregister <lane-id>')
|
|
452
|
+
import registrations
|
|
453
|
+
import session_actions
|
|
454
|
+
try:
|
|
455
|
+
workspace, lane = L.find_lane(L.load_registry(), args[0])
|
|
456
|
+
except L.LanesError as error:
|
|
457
|
+
workspace, lane, unknown = None, None, error
|
|
458
|
+
published = lane is not None and bool(lane.get('registered_session'))
|
|
459
|
+
if lane is not None and not published and session_actions.declared_in(lane):
|
|
460
|
+
if not lane.get('source'): # an SSH workspace's lane: catalog.json, which no editor of ours writes
|
|
461
|
+
raise L.LanesError(f"{lane['id']} is {session_actions.declared_text(lane)}")
|
|
462
|
+
done = session_actions.unregister_lane(lane['id'])
|
|
463
|
+
if done['changed']:
|
|
464
|
+
cmd_sync([])
|
|
465
|
+
print(f"Unregistered {lane['name']} from {workspace['id']} ({done['name']} out of {done['source']})")
|
|
466
|
+
return 0
|
|
467
|
+
if published or any(item['id'] == args[0] for item in registrations.load()['sessions']):
|
|
468
|
+
try:
|
|
469
|
+
record = registrations.unregister(args[0])
|
|
470
|
+
except registrations.Unpublished as error:
|
|
471
|
+
undo = error.record
|
|
472
|
+
if undo.get('identity'): # the record is out of the file: say how to put it back once it is listed
|
|
473
|
+
raise L.LanesError(f"{error} (undo once it is listed again: lanes register {undo['id']} "
|
|
474
|
+
f"{undo['workspace']})") from None
|
|
475
|
+
raise
|
|
476
|
+
if record.get('identity'):
|
|
477
|
+
print(f"Unregistered {record['name']} from {record['workspace']}; it is listed under Unregistered again "
|
|
478
|
+
f"(undo: lanes register {record['id']} {record['workspace']})")
|
|
479
|
+
elif published:
|
|
480
|
+
print(f"Unregistered {record['name']} from {record['workspace']}; no session runs under it now")
|
|
481
|
+
else:
|
|
482
|
+
print(f"Removed the registration of {record['name']} ({record['id']}) from {registrations.FILENAME}; "
|
|
483
|
+
"no workspace listed it, so nothing else changed")
|
|
484
|
+
return 0
|
|
485
|
+
if lane is None:
|
|
486
|
+
raise unknown
|
|
487
|
+
raise L.LanesError(f"{lane['id']} is not registered; nothing to unregister")
|
|
488
|
+
|
|
489
|
+
|
|
260
490
|
def cmd_ls(args):
|
|
261
491
|
registry = L.load_registry()
|
|
262
492
|
snapshot = L.read_json(L.STATE / 'status.json') or {}
|
|
@@ -321,7 +551,12 @@ def tab_layout(lines):
|
|
|
321
551
|
' }', ' }'] + lines + ['}']) + '\n'
|
|
322
552
|
|
|
323
553
|
|
|
324
|
-
def tab_specs(device, registry, mobile=False, viewports=1):
|
|
554
|
+
def tab_specs(device, registry, mobile=False, viewports=1, profile=None):
|
|
555
|
+
"""The tabs a view session has, in order: the workspace boards, then ssh workspaces and tools, then the boards whose
|
|
556
|
+
`position` is `before-host`, then Host. The boards are the ones the device's owner sees (cockpit v3 R4, LNS-77):
|
|
557
|
+
`profile` (users/<login>.json as users.read gives it, device_profile) hides `workspaces.hidden` and orders the rest
|
|
558
|
+
by `workspaces.order` (users.arrange: an unknown id is ignored, an unlisted board follows in catalog order, a hidden
|
|
559
|
+
list that would leave no board hides none); None — a device nobody owns — is the catalog order."""
|
|
325
560
|
result = []
|
|
326
561
|
def add(key, title, lines):
|
|
327
562
|
result.append({'key': key, 'title': title, 'lines': lines, 'layout': tab_layout(lines)})
|
|
@@ -333,20 +568,25 @@ def tab_specs(device, registry, mobile=False, viewports=1):
|
|
|
333
568
|
add(f"workspace/{workspace['id']}/{role}", title, mobile_workspace_tab(device, workspace, picker))
|
|
334
569
|
else:
|
|
335
570
|
add(f"workspace/{workspace['id']}/view", workspace['title'], workspace_tab_kdl(device, workspace, False, viewports))
|
|
336
|
-
|
|
571
|
+
users = users_module() if profile else None
|
|
572
|
+
boards = users.arrange(registry['workspaces'], profile) if users else registry['workspaces']
|
|
573
|
+
for workspace in boards:
|
|
337
574
|
if workspace.get('position') != 'before-host': board(workspace)
|
|
338
575
|
for workspace in registry.get('ssh_workspaces', []):
|
|
339
576
|
add('ssh/' + workspace['id'], workspace['title'], ssh_workspace_tab_kdl(workspace))
|
|
340
577
|
for tool in registry.get('tools', []):
|
|
341
578
|
add('tool/' + tool['view'], tool['tab'], tool_tab_kdl(device, tool, mobile=mobile))
|
|
342
|
-
for workspace in
|
|
579
|
+
for workspace in boards:
|
|
343
580
|
if workspace.get('position') == 'before-host': board(workspace)
|
|
344
581
|
add('host', 'Host', [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }'])
|
|
345
582
|
return result
|
|
346
583
|
|
|
347
584
|
|
|
348
|
-
def layout_kdl(device, mobile=False, viewports=1):
|
|
349
|
-
|
|
585
|
+
def layout_kdl(device, mobile=False, viewports=1, profile=None):
|
|
586
|
+
"""The view session's layout (create_session, `lanes layout`): tab_specs for the device's owner's profile — passed
|
|
587
|
+
by the caller, else read here (device_profile) — with the focus on the first tab."""
|
|
588
|
+
specs = tab_specs(device, L.load_registry(), mobile, viewports,
|
|
589
|
+
device_profile(device) if profile is None else profile)
|
|
350
590
|
lines = []
|
|
351
591
|
for index, spec in enumerate(specs):
|
|
352
592
|
tab = list(spec['lines'])
|
|
@@ -356,9 +596,58 @@ def layout_kdl(device, mobile=False, viewports=1):
|
|
|
356
596
|
return tab_layout(lines)
|
|
357
597
|
|
|
358
598
|
|
|
599
|
+
# A tab of a layout layout_kdl wrote: tab_layout indents every tab by four, and only tabs start ` tab name=`.
|
|
600
|
+
LAYOUT_TAB = re.compile(r' tab name=("(?:[^"\\]|\\.)*")')
|
|
601
|
+
|
|
602
|
+
|
|
603
|
+
def layout_parts(text):
|
|
604
|
+
"""(frame, tabs) of a layout layout_kdl wrote: `frame` the lines outside every tab (`layout {`, the
|
|
605
|
+
default_tab_template, the closing brace), `tabs` {title: [each tab of that title as text]}. The ` focus=true`
|
|
606
|
+
layout_kdl gives whichever tab comes first is left out of that tab's own line: which tab is first is the catalog's
|
|
607
|
+
order, which upgrade-session keeps in a running view without a rebuild."""
|
|
608
|
+
frame, tabs, block = [], {}, None
|
|
609
|
+
for line in text.splitlines():
|
|
610
|
+
match = LAYOUT_TAB.match(line)
|
|
611
|
+
if match:
|
|
612
|
+
try:
|
|
613
|
+
title = json.loads(match.group(1))
|
|
614
|
+
except ValueError:
|
|
615
|
+
title = match.group(1)
|
|
616
|
+
block = [line[:match.end()] + line[match.end():].replace(' focus=true', '', 1)]
|
|
617
|
+
tabs.setdefault(title, []).append(block)
|
|
618
|
+
elif block is not None and line.startswith(' '):
|
|
619
|
+
block.append(line)
|
|
620
|
+
else:
|
|
621
|
+
block = None
|
|
622
|
+
frame.append(line)
|
|
623
|
+
return frame, {title: ['\n'.join(block) for block in blocks] for title, blocks in tabs.items()}
|
|
624
|
+
|
|
625
|
+
|
|
626
|
+
def layout_changed(stored, generated):
|
|
627
|
+
"""Whether a view built from the layout `stored` (layouts/<device>.kdl, what create_session wrote; None when there
|
|
628
|
+
is none) runs differently from `generated` (layout_kdl now) in a way only a rebuild changes (W9 U6, LNS-94): the
|
|
629
|
+
frame differs (the tab template), or a tab whose title both have is, line for line, none of `generated`'s tabs of
|
|
630
|
+
that title (another tree path, another pane, size or argument — what a release changes). A tab only one of them has
|
|
631
|
+
and the order of the tabs are no change: `lanes sync` (upgrade-session, device_sync.reconcile) adds, renames and
|
|
632
|
+
orders the tabs of a running view, and never changes a tab's panes, so after a board was added or renamed the file
|
|
633
|
+
no longer names every tab a view shows. A missing or unreadable layout is a change: nothing says what the view
|
|
634
|
+
runs."""
|
|
635
|
+
if stored is None:
|
|
636
|
+
return True
|
|
637
|
+
frame, tabs = layout_parts(stored)
|
|
638
|
+
frame_now, tabs_now = layout_parts(generated)
|
|
639
|
+
return frame != frame_now or any(tab not in tabs_now[title]
|
|
640
|
+
for title, kept in tabs.items() if title in tabs_now for tab in kept)
|
|
641
|
+
|
|
642
|
+
|
|
643
|
+
# Every tree file a layout runs is named through L.CURRENT (W9 U1, LNS-89): the `current` link while it names this
|
|
644
|
+
# tree, so an update that flips the link moves every view built through it; this tree's own path for a checkout and
|
|
645
|
+
# for a staged tree the link has moved past. Readers match those commands by suffix (lanes_lib.tree_file_at), so a
|
|
646
|
+
# layout written before — naming its pkg/<version> tree — still identifies.
|
|
647
|
+
|
|
359
648
|
def mobile_workspace_tab(device, workspace, picker):
|
|
360
649
|
title = workspace['title'] + (' ☰' if picker else '')
|
|
361
|
-
command = str(L.
|
|
650
|
+
command = str(L.CURRENT / 'libexec' / ('sidebar' if picker else 'view.sh'))
|
|
362
651
|
args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
|
|
363
652
|
# The picker's sidebar pane has NO layout name: Zellij reports a non-empty pane name instead of the pane's own
|
|
364
653
|
# OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1531).
|
|
@@ -374,8 +663,9 @@ def stale_picker_layouts():
|
|
|
374
663
|
`upgrade-session` / `ensure` only add missing tabs and rename titles: a picker tab created from an older layout
|
|
375
664
|
keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1531).
|
|
376
665
|
`create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
|
|
666
|
+
The sidebar is recognised by its tree file, whichever tree path the layout names (W9 U1: pkg/<version> before,
|
|
667
|
+
`current` since, a checkout's own path).
|
|
377
668
|
"""
|
|
378
|
-
sidebar = str(L.REPO / 'libexec' / 'sidebar')
|
|
379
669
|
stale = []
|
|
380
670
|
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
381
671
|
if not (L.read_json(meta_file) or {}).get('mobile'):
|
|
@@ -384,13 +674,14 @@ def stale_picker_layouts():
|
|
|
384
674
|
text = (L.STATE / 'layouts' / f'{meta_file.stem}.kdl').read_text()
|
|
385
675
|
except OSError:
|
|
386
676
|
continue
|
|
387
|
-
if any('name="Lanes"' in line and
|
|
677
|
+
if any('name="Lanes"' in line and any(L.tree_file_at([command], 'libexec/sidebar') == 0
|
|
678
|
+
for command in layout_commands(line)) for line in text.splitlines()):
|
|
388
679
|
stale.append(meta_file.stem)
|
|
389
680
|
return stale
|
|
390
681
|
|
|
391
682
|
|
|
392
683
|
def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
|
|
393
|
-
sidebar, view = str(L.
|
|
684
|
+
sidebar, view = str(L.CURRENT / 'libexec/sidebar'), str(L.CURRENT / 'libexec/view.sh')
|
|
394
685
|
focus = ' focus=true' if focused else ''
|
|
395
686
|
lines = []
|
|
396
687
|
if mobile:
|
|
@@ -413,7 +704,7 @@ def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=Fals
|
|
|
413
704
|
|
|
414
705
|
def ssh_workspace_tab_kdl(workspace, indent=' '):
|
|
415
706
|
"""Main terminal on the left; up to two worker terminals stacked on the right."""
|
|
416
|
-
holder = str(L.
|
|
707
|
+
holder = str(L.CURRENT / 'holders/ssh.sh')
|
|
417
708
|
panes = workspace['panes']
|
|
418
709
|
lines = [f'{indent}tab name={q(workspace["title"])} split_direction="vertical" {{']
|
|
419
710
|
|
|
@@ -435,7 +726,7 @@ def ssh_workspace_tab_kdl(workspace, indent=' '):
|
|
|
435
726
|
def tool_tab_kdl(device, tool, indent=' ', mobile=False):
|
|
436
727
|
"""One full-size tab for a shared tool (the Throttle monitor). It is a view with a fixed target, so it starts
|
|
437
728
|
only when the tab is first shown and restarts by itself if the tool exits."""
|
|
438
|
-
view = str(L.
|
|
729
|
+
view = str(L.CURRENT / 'libexec/view.sh')
|
|
439
730
|
extra = ' "main" "--mobile"' if mobile else ''
|
|
440
731
|
return [f'{indent}tab name={q(tool["tab"])} {{',
|
|
441
732
|
f'{indent} pane borderless=true name={q(tool["name"])} command={q(view)} {{',
|
|
@@ -466,6 +757,26 @@ def live_sessions():
|
|
|
466
757
|
return names
|
|
467
758
|
|
|
468
759
|
|
|
760
|
+
# The view-session lock (L.sessions_lock, DEF-1792). Every caller that decides from live_sessions() (or devices/) and then
|
|
761
|
+
# creates, kills or deletes a view session holds it from that list to the last zellij call: `ensure`, `rebuild`,
|
|
762
|
+
# attach/prepare/web's create, offboard's end_view and `device forget`. A caller that must act waits SESSIONS_WAIT for
|
|
763
|
+
# another to finish (a rebuild is at most kill 30 s + delete 30 s + create 60 s, plus a list); one with a fallback only
|
|
764
|
+
# glances (SESSIONS_GLANCE): the minute timer's plain `ensure` skips its tick, `attach` attaches without creating.
|
|
765
|
+
SESSIONS_WAIT = 120
|
|
766
|
+
SESSIONS_GLANCE = 10
|
|
767
|
+
|
|
768
|
+
|
|
769
|
+
def hold_sessions(stack, timeout, doing):
|
|
770
|
+
"""Enter L.sessions_lock(timeout, doing) on `stack` (a contextlib.ExitStack) -> None when held, else the LanesError
|
|
771
|
+
that says why not (SessionsBusy: another lanes process held it for `timeout` s; a plain LanesError: the lock file
|
|
772
|
+
could not be opened), so a caller tells the lock it did not get from a failure of the work it does under it."""
|
|
773
|
+
try:
|
|
774
|
+
stack.enter_context(L.sessions_lock(timeout, doing))
|
|
775
|
+
except L.LanesError as error:
|
|
776
|
+
return error
|
|
777
|
+
return None
|
|
778
|
+
|
|
779
|
+
|
|
469
780
|
def device_name(value):
|
|
470
781
|
if not L.IDENT.match(value or '') or len(value) > 32:
|
|
471
782
|
raise L.LanesError('Invalid device name')
|
|
@@ -491,12 +802,16 @@ def create_session(device, mobile, viewports=1):
|
|
|
491
802
|
owner = device_owner(device, old)
|
|
492
803
|
if owner: # who the device belongs to (W8 A2): the member record's, else the one recorded (device_owner)
|
|
493
804
|
meta['owner'] = owner
|
|
494
|
-
# The look `lanes attach --ascii|--glyphs|--theme|--color` recorded (record_look) is the
|
|
495
|
-
# session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every
|
|
496
|
-
# device would flip back to the environment defaults within one tick of this rewrite
|
|
805
|
+
# The look `lanes attach --ascii|--glyphs|--theme|--color|--notify|--dim|--editor` recorded (record_look) is the
|
|
806
|
+
# operator's choice, not session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every
|
|
807
|
+
# sidebar of the device would flip back to the environment defaults within one tick of this rewrite — and the
|
|
808
|
+
# `device_only` list that marks the device's own (--device-only) keys with them.
|
|
497
809
|
for key, allowed in LOOK_KEYS.values():
|
|
498
810
|
if old.get(key) in allowed:
|
|
499
811
|
meta[key] = old[key]
|
|
812
|
+
users = users_module()
|
|
813
|
+
if users and users.device_only(old):
|
|
814
|
+
meta = users.mark_device_only(meta, users.device_only(old))
|
|
500
815
|
L.atomic_write(meta_file, json.dumps(meta) + '\n')
|
|
501
816
|
command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
|
|
502
817
|
if shutil.which('systemd-run'):
|
|
@@ -510,6 +825,15 @@ def create_session(device, mobile, viewports=1):
|
|
|
510
825
|
if rc != 0:
|
|
511
826
|
raise L.LanesError(f'Could not create session {device}: '
|
|
512
827
|
+ ('no answer in 60 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
|
|
828
|
+
# The session runs this release's layout now: nothing `lanes ensure --if-layout-changed` stamped is pending (W9 U6)
|
|
829
|
+
update_ready_path(device).unlink(missing_ok=True)
|
|
830
|
+
|
|
831
|
+
|
|
832
|
+
def update_ready_path(device):
|
|
833
|
+
"""clients/<device>.update-ready (DM-LANES-UPDATE-READY, W9 U6): written by `lanes ensure --if-layout-changed` when
|
|
834
|
+
this release lays out the running view `device` differently while a client is attached (or zellij did not say);
|
|
835
|
+
its sidebars offer `update ready · reload (r)` while it exists; create_session removes it."""
|
|
836
|
+
return L.STATE / 'clients' / f'{device}.update-ready'
|
|
513
837
|
|
|
514
838
|
|
|
515
839
|
def device_owner(device, meta):
|
|
@@ -527,6 +851,15 @@ def device_owner(device, meta):
|
|
|
527
851
|
return members.owner_for((device, meta.get('base')), meta.get('owner'))
|
|
528
852
|
|
|
529
853
|
|
|
854
|
+
def users_module():
|
|
855
|
+
"""libexec/users.py, or None when it cannot be loaded — for create_session, which must never raise (`lanes ensure`
|
|
856
|
+
recreates views through it every minute)."""
|
|
857
|
+
try:
|
|
858
|
+
return libexec_module('users')
|
|
859
|
+
except L.LanesError:
|
|
860
|
+
return None
|
|
861
|
+
|
|
862
|
+
|
|
530
863
|
def base_device(device, default):
|
|
531
864
|
"""The machine a view belongs to: `mac-2` / `mac-w2`, opened by the client whose default device is `mac`, share
|
|
532
865
|
mac's upload helper (one helper service per machine); every other device is its own base."""
|
|
@@ -567,12 +900,20 @@ def viewports_arg(args, default=1):
|
|
|
567
900
|
return default
|
|
568
901
|
|
|
569
902
|
|
|
570
|
-
|
|
571
|
-
|
|
903
|
+
# `256` since cockpit v3 P2 (LNS-70): the sidebar paints the xterm-256 set (tokens.XTERM256, indices 16-255). `notify`
|
|
904
|
+
# and `dim` since P4 (LNS-72, DEF-1577): LANES_NOTIFY's and LANES_DIM's per-device path, in the variables' own words
|
|
905
|
+
# (`dim` is the SGR of the 16-colour dim: 90 or the escape hatch 2).
|
|
906
|
+
LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '256', '16', 'none')),
|
|
907
|
+
'--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji')), '--notify': ('notify', ('1', '0')),
|
|
908
|
+
'--dim': ('dim', ('90', '2')),
|
|
909
|
+
# The editor quick edit (`lanes edit`, W9 E2) hands lane-edit as LANES_EDITOR; `auto` takes the choice back.
|
|
910
|
+
# Per device, like notify/dim: never a profile key (users.LOOK), never read by a sidebar.
|
|
911
|
+
'--editor': ('editor', ('micro', 'hx', 'nvim', 'vim', 'auto'))}
|
|
572
912
|
|
|
573
913
|
|
|
574
914
|
def look_args(args):
|
|
575
|
-
"""The look `lanes attach` was asked for: {'glyphs'|'theme'|'color': value}, validated
|
|
915
|
+
"""The look `lanes attach` was asked for: {'glyphs'|'theme'|'color'|'notify'|'dim'|'editor': value}, validated
|
|
916
|
+
(spec §2.4)."""
|
|
576
917
|
look = {}
|
|
577
918
|
if '--ascii' in args:
|
|
578
919
|
look['glyphs'] = 'ascii'
|
|
@@ -586,20 +927,107 @@ def look_args(args):
|
|
|
586
927
|
|
|
587
928
|
|
|
588
929
|
def record_look(device, args):
|
|
589
|
-
"""`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
930
|
+
"""`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C|--notify N|--dim D|--editor E [--device-only]`:
|
|
931
|
+
remembered where the device's sidebars read it (cockpit v3 R2, LNS-75).
|
|
932
|
+
* A device with an owner (profile_for) shares theme, color and glyphs with every device that person owns: they
|
|
933
|
+
are written to users/<login>.json (users.update, a 1 s lock) and leave devices/<device>.json — a key left
|
|
934
|
+
there would sit beneath the profile for this device. `--device-only` keeps them in the device file instead, for
|
|
935
|
+
this device alone, and names them in its `device_only` list (users.mark_device_only, DEF-1782): only a key that
|
|
936
|
+
list names wins over the profile there (sidebar.layered_look) and survives a fold (users.migrate). The same
|
|
937
|
+
flag without --device-only takes its key off the list again. A profile write that is dropped (its lock busy, or
|
|
938
|
+
a file this release does not write) records none of them anywhere — the device keeps what it had, its marks
|
|
939
|
+
included — and says so on stderr.
|
|
940
|
+
* notify and dim are per-device switches (P4), and so is editor (W9 E2: read by `lanes edit` at each Ctrl-Alt-e,
|
|
941
|
+
never by a sidebar): always the device file, never on the list. A device without an owner records every flag in
|
|
942
|
+
its file; a device that never used --device-only has no list, so its file is byte for byte what it was before
|
|
943
|
+
R2.
|
|
944
|
+
devices/<device>.json is the file every sidebar of the device re-reads when it changes (its reload() stamp), so a
|
|
945
|
+
RUNNING session follows a device-file change within one tick.
|
|
946
|
+
Returns what was recorded, in the flags' order: empty when nothing was asked — or when the device has no file yet,
|
|
947
|
+
because `ensure` builds a session for every devices/*.json and a look flag must never create that stub on its
|
|
948
|
+
own (nor, for an unknown device, a profile)."""
|
|
593
949
|
look = look_args(args)
|
|
594
950
|
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
595
951
|
meta = L.read_json(meta_file) if meta_file.exists() else None
|
|
596
952
|
if not look or not isinstance(meta, dict):
|
|
597
953
|
return {}
|
|
598
|
-
|
|
599
|
-
|
|
954
|
+
alone = '--device-only' in args
|
|
955
|
+
login = None if alone else profile_for(device)
|
|
956
|
+
users = libexec_module('users')
|
|
957
|
+
shared = {}
|
|
958
|
+
if login:
|
|
959
|
+
shared = {key: value for key, value in look.items() if key in users.LOOK}
|
|
960
|
+
if shared and not users.update(login, device, lambda profile: profile['look'].update(shared)):
|
|
961
|
+
words = ', '.join(f'{key} {value}' for key, value in shared.items())
|
|
962
|
+
print(f"lanes: the look ({words}) was not saved: {login}'s profile was busy or cannot be written "
|
|
963
|
+
f'(`lanes user show {login}` says why); nothing changed — attach again, or add --device-only to keep '
|
|
964
|
+
f'it on {device} alone', file=sys.stderr)
|
|
965
|
+
look = {key: value for key, value in look.items() if key not in shared}
|
|
966
|
+
shared = {}
|
|
967
|
+
on_device = {key: value for key, value in look.items() if key not in shared}
|
|
968
|
+
updated = {key: value for key, value in meta.items() if key not in shared}
|
|
969
|
+
updated.update(on_device)
|
|
970
|
+
marked, asked = set(users.device_only(meta)), set(look) & set(users.LOOK)
|
|
971
|
+
marked = marked | asked if alone else marked - asked
|
|
972
|
+
if marked or 'device_only' in meta:
|
|
973
|
+
updated = users.mark_device_only(updated, marked)
|
|
974
|
+
if updated != meta:
|
|
975
|
+
L.atomic_write(meta_file, json.dumps(updated) + '\n')
|
|
600
976
|
return look
|
|
601
977
|
|
|
602
978
|
|
|
979
|
+
def profile_for(device):
|
|
980
|
+
"""The login whose users/<login>.json profile `device` follows (cockpit v3 R2, LNS-75): its owner exactly as
|
|
981
|
+
device_owner derives it — the member record of the device or of the machine it is a window of whenever one exists,
|
|
982
|
+
else the owner devices/<device>.json records (`lanes device claim`) — through users.login_for. None for a device
|
|
983
|
+
nobody owns, or a name that is not a device: it keeps its look and seen marks to itself. Never raises."""
|
|
984
|
+
try:
|
|
985
|
+
return libexec_module('users').login_for(device)
|
|
986
|
+
except L.LanesError:
|
|
987
|
+
return None
|
|
988
|
+
|
|
989
|
+
|
|
990
|
+
def device_profile(device):
|
|
991
|
+
"""The profile `device` follows (users.read of profile_for's login), or None: a device nobody owns, no profile yet,
|
|
992
|
+
or a file this release does not read. What tab_specs arranges a view's workspace tabs by (cockpit v3 R4, LNS-77)
|
|
993
|
+
for create_session and `lanes upgrade-session`. Never raises: `lanes ensure` runs both every minute."""
|
|
994
|
+
login = profile_for(device)
|
|
995
|
+
users = users_module() if login else None
|
|
996
|
+
return users.read(login) if users else None
|
|
997
|
+
|
|
998
|
+
|
|
999
|
+
def windows_of(devices):
|
|
1000
|
+
"""{window: its devices/<window>.json} for each view whose recorded `base` is one of `devices` (mac-2 and mac-w2
|
|
1001
|
+
are mac's windows: base_device), the devices themselves excluded."""
|
|
1002
|
+
found = {}
|
|
1003
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
1004
|
+
meta = L.read_json(meta_file)
|
|
1005
|
+
if (isinstance(meta, dict) and meta.get('base') in devices and meta_file.stem not in devices
|
|
1006
|
+
and L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32):
|
|
1007
|
+
found[meta_file.stem] = meta
|
|
1008
|
+
return found
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def fold_profile(login, devices):
|
|
1012
|
+
"""Fold the views of `devices` and their windows that `login` owns now into `login`'s profile (users.migrate over
|
|
1013
|
+
exactly those: look keys the profile lacks from the most recently attached, seen marks union-max, then their look
|
|
1014
|
+
keys leave the device files) -> 0, or 1 when the profile write was dropped (said on stderr, with the claim that
|
|
1015
|
+
finishes it; no device file was touched). Devices with no view and no seen marks have nothing to fold: 0, silent.
|
|
1016
|
+
What `lanes device claim`, `lanes keys adopt --login` and `lanes keys owner` end with."""
|
|
1017
|
+
users = libexec_module('users')
|
|
1018
|
+
wanted = set(devices) | set(windows_of(set(devices)))
|
|
1019
|
+
owned = [found['device'] for found in users.owned_devices(login) if found['device'] in wanted]
|
|
1020
|
+
if not owned:
|
|
1021
|
+
return 0
|
|
1022
|
+
if users.migrate(login, devices=owned) is None:
|
|
1023
|
+
print(f"⚠ {login}'s profile was not updated: its lock was busy, or its file under users/ is not one this "
|
|
1024
|
+
f'release writes (`lanes user show {login}` says why); {", ".join(owned)} keep their own look until '
|
|
1025
|
+
f'`lanes device claim {owned[0]} --login {login}` runs again', file=sys.stderr)
|
|
1026
|
+
return 1
|
|
1027
|
+
print(f'profile {login}: folded in {", ".join(owned)} — `lanes user show {login}`')
|
|
1028
|
+
return 0
|
|
1029
|
+
|
|
1030
|
+
|
|
603
1031
|
def cmd_layout(args):
|
|
604
1032
|
print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
|
|
605
1033
|
return 0
|
|
@@ -607,7 +1035,7 @@ def cmd_layout(args):
|
|
|
607
1035
|
|
|
608
1036
|
def cmd_attach(args):
|
|
609
1037
|
device = device_name(args[0])
|
|
610
|
-
look_args(args) # a bad --theme/--color/--glyphs refuses here, before any session is created
|
|
1038
|
+
look_args(args) # a bad --theme/--color/--glyphs/--editor refuses here, before any session is created
|
|
611
1039
|
# Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
|
|
612
1040
|
# device session and bring that workspace's tab to the front.
|
|
613
1041
|
tab = None
|
|
@@ -632,15 +1060,22 @@ def cmd_attach(args):
|
|
|
632
1060
|
raise L.LanesError(f'{ZELLIJ} is not installed: run install/host-install.sh')
|
|
633
1061
|
if not (L.STATE / 'registry.json').exists():
|
|
634
1062
|
cmd_sync([])
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
# so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
|
|
640
|
-
print(f'lanes: {error}', file=sys.stderr)
|
|
1063
|
+
with contextlib.ExitStack() as stack:
|
|
1064
|
+
# Only a glance at the view-session lock (DEF-1792): a person is waiting. Not taken in time is unknown, not
|
|
1065
|
+
# missing — the holder may be rebuilding this very view — so this creates nothing and just attaches.
|
|
1066
|
+
refused = hold_sessions(stack, SESSIONS_GLANCE, f'lanes attach {device}')
|
|
641
1067
|
missing = False
|
|
642
|
-
|
|
643
|
-
|
|
1068
|
+
if refused:
|
|
1069
|
+
print(f'lanes: {refused}; attaching without creating {device}', file=sys.stderr)
|
|
1070
|
+
else:
|
|
1071
|
+
try:
|
|
1072
|
+
missing = device not in live_sessions()
|
|
1073
|
+
except L.LanesError as error:
|
|
1074
|
+
# Unknown is not missing: create nothing and just try to attach. If the session really is gone, zellij
|
|
1075
|
+
# says so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
|
|
1076
|
+
print(f'lanes: {error}', file=sys.stderr)
|
|
1077
|
+
if missing:
|
|
1078
|
+
create_session(device, '--mobile' in args, viewports_arg(args))
|
|
644
1079
|
# Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
|
|
645
1080
|
record_base(device, args)
|
|
646
1081
|
record_look(device, args)
|
|
@@ -704,14 +1139,22 @@ ENV_HOOK = '[ -r "$HOME/.lz/env.sh" ] && . "$HOME/.lz/env.sh" # lanes-env: temp
|
|
|
704
1139
|
|
|
705
1140
|
def hook_env(home):
|
|
706
1141
|
"""Make env.sh take effect for NEW shells: the disk temp dir it points at, and ONE marker-guarded line appended to
|
|
707
|
-
the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo.
|
|
1142
|
+
the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo.
|
|
1143
|
+
A dotfile (or ~/.tmp) that is a symbolic link is left alone and named: this runs on the host, where a link the
|
|
1144
|
+
container's home holds resolves outside the container (an absolute `/home/coder/dotfiles/.bashrc` is the HOST
|
|
1145
|
+
account's file; DEF-1798)."""
|
|
708
1146
|
tmp = home / '.tmp'
|
|
709
|
-
if
|
|
1147
|
+
if tmp.is_symlink():
|
|
1148
|
+
print(f'! {tmp} is a symbolic link — left alone')
|
|
1149
|
+
elif not tmp.exists():
|
|
710
1150
|
tmp.mkdir(mode=0o700)
|
|
711
1151
|
tmp.chmod(0o700)
|
|
712
|
-
login = home / '.bash_profile' if (home / '.bash_profile')
|
|
713
|
-
targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if (home / '.zshrc')
|
|
1152
|
+
login = home / '.bash_profile' if os.path.lexists(home / '.bash_profile') else home / '.profile'
|
|
1153
|
+
targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if os.path.lexists(home / '.zshrc') else [])
|
|
714
1154
|
for path in targets:
|
|
1155
|
+
if path.is_symlink():
|
|
1156
|
+
print(f'! {path} is a symbolic link — not changed; add this line to the file it points at by hand: {ENV_HOOK}')
|
|
1157
|
+
continue
|
|
715
1158
|
text = path.read_text(errors='replace') if path.exists() else ''
|
|
716
1159
|
if 'lanes-env' in text:
|
|
717
1160
|
continue
|
|
@@ -720,44 +1163,130 @@ def hook_env(home):
|
|
|
720
1163
|
|
|
721
1164
|
|
|
722
1165
|
INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
|
|
1166
|
+
# lane_core.py first: `lane` imports it, so no moment has a new `lane` beside an old (or no) lane_core.py.
|
|
1167
|
+
CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit')
|
|
1168
|
+
|
|
1169
|
+
|
|
1170
|
+
def lane_core():
|
|
1171
|
+
"""container/lane_core.py, the in-container contract's shared helpers, as a module: the agents' MCP entries are
|
|
1172
|
+
written from the host through the same code `lane mcp install` runs in the container."""
|
|
1173
|
+
module = sys.modules.get('lane_core')
|
|
1174
|
+
if module is None:
|
|
1175
|
+
import importlib.util
|
|
1176
|
+
spec = importlib.util.spec_from_file_location('lane_core', str(L.REPO / 'container' / 'lane_core.py'))
|
|
1177
|
+
module = importlib.util.module_from_spec(spec)
|
|
1178
|
+
spec.loader.exec_module(module)
|
|
1179
|
+
sys.modules['lane_core'] = module
|
|
1180
|
+
return module
|
|
1181
|
+
|
|
1182
|
+
|
|
1183
|
+
def container_dir(home, *parts):
|
|
1184
|
+
"""<home>/<parts…> as a real directory, created when missing; LanesError when it, or a component of it below the
|
|
1185
|
+
container home, is a symbolic link. The container owns its home and this runs on the HOST: a link planted there
|
|
1186
|
+
resolves outside the container (DEF-1798, the DEF-1796 class)."""
|
|
1187
|
+
path = Path(home).joinpath(*parts)
|
|
1188
|
+
core = lane_core()
|
|
1189
|
+
try:
|
|
1190
|
+
core.no_links(home, path)
|
|
1191
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1192
|
+
core.no_links(home, path)
|
|
1193
|
+
except core.LaneError as error:
|
|
1194
|
+
raise L.LanesError(f'{error}: nothing installed through it — remove the link in the container, then run again')
|
|
1195
|
+
return path
|
|
1196
|
+
|
|
1197
|
+
|
|
1198
|
+
def install_file(source, target):
|
|
1199
|
+
"""Copy `source` (its bytes and mode) to `target` without following anything at the target: an O_EXCL temp file
|
|
1200
|
+
beside it, renamed over whatever the name holds — a planted symbolic link is replaced, never written through
|
|
1201
|
+
(DEF-1798), and a `lane open` starting mid-install runs the old file or the new one, never a half-written one.
|
|
1202
|
+
The directory must be a real one (container_dir)."""
|
|
1203
|
+
fd, tmp = tempfile.mkstemp(prefix=f'.{target.name}.', dir=str(target.parent))
|
|
1204
|
+
try:
|
|
1205
|
+
with os.fdopen(fd, 'wb') as handle, open(source, 'rb') as data:
|
|
1206
|
+
shutil.copyfileobj(data, handle)
|
|
1207
|
+
shutil.copystat(source, tmp)
|
|
1208
|
+
os.replace(tmp, target)
|
|
1209
|
+
except BaseException:
|
|
1210
|
+
try:
|
|
1211
|
+
os.unlink(tmp)
|
|
1212
|
+
except OSError:
|
|
1213
|
+
pass
|
|
1214
|
+
raise
|
|
1215
|
+
|
|
1216
|
+
|
|
1217
|
+
def install_mcp_entries(home):
|
|
1218
|
+
"""`mcpServers.lanes` in <home>/.claude.json and `[mcp_servers.lanes]` in <home>/.codex/config.toml, both starting
|
|
1219
|
+
/home/coder/.lz/bin/lane-mcp, the Codex table also passing it LANE_ID, LANE_WS, TMUX and TMUX_PANE in `env_vars`
|
|
1220
|
+
(Codex clears an MCP server's environment) (lane_core.mcp_apply: written only where missing, `.bak-<UTC>` first,
|
|
1221
|
+
atomic, the file's own mode) -> exit code. Refused on a uid split (the agent could not read a file this account
|
|
1222
|
+
creates: DEF-1727's rule); a file left alone (not valid JSON/TOML, a `lanes` entry that starts something else)
|
|
1223
|
+
is said and makes the exit 1, while the other agent still gets its entry."""
|
|
1224
|
+
split = uid_split(home)
|
|
1225
|
+
if split:
|
|
1226
|
+
print(f'! MCP entries not written: {split} — the agent could not read what this account would write; run '
|
|
1227
|
+
f'`lanes install-container` from a host account with uid {L.CONTAINER_UID}, or `~/.lz/bin/lane mcp '
|
|
1228
|
+
f'install --write` inside the container')
|
|
1229
|
+
return 1
|
|
1230
|
+
code = 0
|
|
1231
|
+
for result in lane_core().mcp_apply(home):
|
|
1232
|
+
if result['status'] == 'refused':
|
|
1233
|
+
print(f"! {result['path']}: {result['why']}")
|
|
1234
|
+
code = 1
|
|
1235
|
+
continue
|
|
1236
|
+
if result['backup']:
|
|
1237
|
+
print(f"backup {result['backup']}")
|
|
1238
|
+
print(f"MCP ({result['agent']}): " + (f"wrote {result['path']}" if result['status'] == 'added'
|
|
1239
|
+
else f"{result['path']} already starts lane-mcp"))
|
|
1240
|
+
return code
|
|
723
1241
|
|
|
724
1242
|
|
|
725
1243
|
def cmd_install_container(args):
|
|
726
1244
|
names = [word for word in args if word != '--no-mcp']
|
|
727
1245
|
if len(names) != 1 or names[0].startswith('-'):
|
|
728
1246
|
raise L.LanesError(INSTALL_CONTAINER_USAGE)
|
|
729
|
-
|
|
730
|
-
# nothing to leave out yet: the flag is accepted so that `lanes onboard --no-mcp` keeps its meaning when it ships.
|
|
1247
|
+
no_mcp = '--no-mcp' in args # leaves out lane-mcp and the agents' MCP entries; removes nothing already there
|
|
731
1248
|
container = names[0]
|
|
732
1249
|
info = L.container_info(container)
|
|
733
1250
|
if not info.get('home'):
|
|
734
1251
|
raise L.LanesError(f'{container}: home volume not found')
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
for
|
|
739
|
-
|
|
1252
|
+
home = Path(info['home'])
|
|
1253
|
+
root = home / '.lz'
|
|
1254
|
+
# Every directory is a real one and every file lands by rename (DEF-1798): nothing is written through a link.
|
|
1255
|
+
for sub in ('bin', 's', 'meta', 'state', 'hooks'):
|
|
1256
|
+
container_dir(home, '.lz', sub)
|
|
1257
|
+
for name in CONTRACT_FILES:
|
|
1258
|
+
if not (no_mcp and name == 'lane-mcp'):
|
|
1259
|
+
install_file(L.REPO / 'container' / name, root / 'bin' / name)
|
|
1260
|
+
install_file(L.REPO / 'VERSION', root / 'VERSION') # what lane-mcp reports as its version
|
|
1261
|
+
# lane-edit (a CONTRACT_FILES member) is what `lanes edit` (Ctrl-Alt-e) execs in the container; vim and nvim start
|
|
1262
|
+
# there with -u ~/.lz/vimrc (W9 E3). Both are ours and replaced on every install, by rename like the rest, whatever
|
|
1263
|
+
# --no-mcp says. ~/.lz/tmp/, where that vim keeps its swap files, is created by lane-edit as the container's own
|
|
1264
|
+
# user (0700), never here.
|
|
1265
|
+
install_file(L.REPO / 'container/vimrc', root / 'vimrc')
|
|
740
1266
|
# The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
|
|
741
1267
|
# per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
|
|
742
|
-
(root / 'hooks').mkdir(exist_ok=True)
|
|
743
1268
|
for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
|
|
744
|
-
|
|
1269
|
+
install_file(source, root / 'hooks' / source.name)
|
|
745
1270
|
env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
|
|
746
1271
|
if env_file.exists():
|
|
747
|
-
|
|
748
|
-
hook_env(
|
|
1272
|
+
install_file(env_file, root / 'env.sh')
|
|
1273
|
+
hook_env(home)
|
|
749
1274
|
for binary in sorted((L.SHARE / 'bin').glob('zmx-*')):
|
|
750
1275
|
target = root / 'bin' / binary.name
|
|
751
|
-
if not
|
|
752
|
-
|
|
1276
|
+
if not os.path.lexists(target): # a running lane's daemon keeps executing its own version: never overwrite
|
|
1277
|
+
install_file(binary, target)
|
|
753
1278
|
link = Path.home() / '.lz' / container
|
|
754
1279
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
755
1280
|
if link.is_symlink() or link.exists():
|
|
756
1281
|
link.unlink()
|
|
757
1282
|
link.symlink_to(root)
|
|
758
|
-
print(f
|
|
759
|
-
+ (f' and {root}/env.sh' if env_file.exists() else '')
|
|
760
|
-
|
|
1283
|
+
print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
|
|
1284
|
+
f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
|
|
1285
|
+
+ f'; host link {link} -> {root}')
|
|
1286
|
+
if no_mcp:
|
|
1287
|
+
print('--no-mcp: lane-mcp and the agents\' MCP entries left out (anything an earlier install wrote is kept)')
|
|
1288
|
+
return 0
|
|
1289
|
+
return install_mcp_entries(home)
|
|
761
1290
|
|
|
762
1291
|
|
|
763
1292
|
HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
|
|
@@ -914,20 +1443,31 @@ def cmd_hooks(args):
|
|
|
914
1443
|
|
|
915
1444
|
def cmd_rebuild(args):
|
|
916
1445
|
device = device_name(args[0])
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
#
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1446
|
+
with contextlib.ExitStack() as stack:
|
|
1447
|
+
# From the list to the create, under the view-session lock (DEF-1792): between this kill and this create no
|
|
1448
|
+
# `lanes ensure` may find the device missing and create it as well. Re-entered when `ensure` rebuilds.
|
|
1449
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes rebuild {device}')
|
|
1450
|
+
if refused:
|
|
1451
|
+
raise L.LanesError(f'{refused}: {device} was NOT rebuilt (no lane was touched); run `lanes rebuild {device}` '
|
|
1452
|
+
'again')
|
|
1453
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json') or {}
|
|
1454
|
+
if device in live_sessions():
|
|
1455
|
+
# Attached clients see an ordinary exit (0) when their session is killed: leave a stamp so that
|
|
1456
|
+
# lanes-launch (`lanes rebuilt <device>`) reconnects instead of closing the window.
|
|
1457
|
+
L.atomic_write(L.STATE / 'clients' / f'{device}.rebuilt', f'{time.time()}\n')
|
|
1458
|
+
if zellij(['kill-session', device], 30)[0] is None:
|
|
1459
|
+
raise L.LanesError(f'zellij did not answer in 30 s: {device} was NOT rebuilt (no lane was touched)')
|
|
1460
|
+
zellij(['delete-session', '--force', device], 30)
|
|
1461
|
+
create_session(device, bool(meta.get('mobile')) or '--mobile' in args,
|
|
1462
|
+
viewports_arg(args, int(meta.get('viewports') or 1)))
|
|
926
1463
|
print(f'rebuilt view session {device}; no lane was touched')
|
|
927
1464
|
return 0
|
|
928
1465
|
|
|
929
1466
|
|
|
930
1467
|
def cmd_upgrade_session(args):
|
|
1468
|
+
"""Bring each running view session (the named devices, else every device) in line with the catalog and with its
|
|
1469
|
+
owner's profile (device_sync.reconcile): add missing tabs, rename titles, order them, and close the tab of a
|
|
1470
|
+
workspace the profile hides (cockpit v3 R4, LNS-77) — only a tab whose panes identify that workspace."""
|
|
931
1471
|
import device_sync
|
|
932
1472
|
registry = L.load_registry()
|
|
933
1473
|
live = live_sessions()
|
|
@@ -939,7 +1479,10 @@ def cmd_upgrade_session(args):
|
|
|
939
1479
|
meta = L.read_json(L.STATE / 'devices' / (device + '.json')) or {}
|
|
940
1480
|
mobile, viewports = bool(meta.get('mobile')), int(meta.get('viewports') or 1)
|
|
941
1481
|
try:
|
|
942
|
-
|
|
1482
|
+
specs = tab_specs(device, registry, mobile, viewports, device_profile(device))
|
|
1483
|
+
shown = {spec['key'].split('/')[1] for spec in specs if spec['key'].startswith('workspace/')}
|
|
1484
|
+
hidden = [workspace['id'] for workspace in registry['workspaces'] if workspace['id'] not in shown]
|
|
1485
|
+
for change in device_sync.reconcile(device, registry, specs, mobile, zellij, hidden=hidden):
|
|
943
1486
|
print(f'{device}: {change}')
|
|
944
1487
|
except (L.LanesError, ValueError, KeyError) as error:
|
|
945
1488
|
failures.append(str(error))
|
|
@@ -962,26 +1505,198 @@ def cmd_reattach(_args):
|
|
|
962
1505
|
return 0
|
|
963
1506
|
|
|
964
1507
|
|
|
965
|
-
def
|
|
1508
|
+
def catches_signal(pid, signum):
|
|
1509
|
+
"""Whether process `pid` belongs to this account and has a handler installed for `signum` (the SigCgt mask of
|
|
1510
|
+
/proc/<pid>/status). A signal a process neither catches nor ignores takes its default action, which for SIGUSR2 is
|
|
1511
|
+
to end it: reload-views signals only a view.sh whose `trap … USR2` is in place."""
|
|
1512
|
+
try:
|
|
1513
|
+
fields = dict(line.split(':', 1) for line in (PROC / str(pid) / 'status').read_text().splitlines()
|
|
1514
|
+
if ':' in line)
|
|
1515
|
+
uid, mask = int(fields['Uid'].split()[0]), int(fields['SigCgt'].strip(), 16)
|
|
1516
|
+
except (OSError, KeyError, ValueError, IndexError):
|
|
1517
|
+
return False
|
|
1518
|
+
return uid == os.getuid() and bool(mask >> (signum - 1) & 1)
|
|
1519
|
+
|
|
1520
|
+
|
|
1521
|
+
# The first release whose view.sh traps SIGUSR2 (cockpit v3 U5, shipped in 1.2.0 — plan D3). A view.sh of an older
|
|
1522
|
+
# release has no such trap: the signal's default action ENDS it (RUNBOOK, U5), and with it the viewport.
|
|
1523
|
+
VIEW_RELOAD_SINCE = (1, 2, 0)
|
|
1524
|
+
|
|
1525
|
+
|
|
1526
|
+
def view_tree(pid, words):
|
|
1527
|
+
"""The lanes tree the viewport process `pid` runs (`words`: its command line). First the tree of the view.sh file
|
|
1528
|
+
its shell holds open (/proc/<pid>/fd/*: dash and bash keep the script open while it runs, and the kernel names the
|
|
1529
|
+
file it RESOLVED at start, so a `…/current/libexec/view.sh` start names the tree `current` pointed at then, not the
|
|
1530
|
+
one an update has flipped it to since); else, with no readable fd, the resolved tree of the view.sh path in its
|
|
1531
|
+
command line. None when neither is a lanes tree file."""
|
|
1532
|
+
suffix = '/libexec/view.sh'
|
|
1533
|
+
try:
|
|
1534
|
+
fds = sorted((PROC / str(pid) / 'fd').iterdir())
|
|
1535
|
+
except OSError:
|
|
1536
|
+
fds = []
|
|
1537
|
+
for fd in fds:
|
|
1538
|
+
try:
|
|
1539
|
+
target = os.readlink(fd)
|
|
1540
|
+
except OSError:
|
|
1541
|
+
continue
|
|
1542
|
+
if target.startswith('/') and target.endswith(suffix): # a pruned tree reads `… (deleted)`: no match
|
|
1543
|
+
return Path(target[:-len(suffix)])
|
|
1544
|
+
at = L.tree_file_at(words, 'libexec/view.sh')
|
|
1545
|
+
return Path(os.path.realpath(words[at])).parent.parent if at is not None else None
|
|
1546
|
+
|
|
1547
|
+
|
|
1548
|
+
def view_reloads(tree):
|
|
1549
|
+
"""Whether a view.sh of `tree` handles SIGUSR2: its release (`<tree>/VERSION`, lanes_lib.tree_version, compared as
|
|
1550
|
+
major.minor.patch by version_key) is VIEW_RELOAD_SINCE or later. No tree, no VERSION or one that is no release
|
|
1551
|
+
('dev') is never assumed to: the signal would end a view that does not."""
|
|
1552
|
+
key = version_key(L.tree_version(tree) or '') if tree is not None else None
|
|
1553
|
+
return key is not None and key >= VIEW_RELOAD_SINCE
|
|
1554
|
+
|
|
1555
|
+
|
|
1556
|
+
def cmd_reload_views(args):
|
|
1557
|
+
"""SIGUSR2 to every running viewport (`views/<device>/<ws>.<slot>.pid`) whose process is a view.sh of a lanes tree
|
|
1558
|
+
(`L.tree_file_at`) that handles it: such a view.sh starts again from the tree `current` names once its lane client
|
|
1559
|
+
next exits (W9 U5), never dropping an attached client. One that does not handle it — a view.sh of a release before
|
|
1560
|
+
that — would be ENDED by the signal (SIGUSR2's default action), so it is left alone and counted: it keeps its
|
|
1561
|
+
release until `lanes rebuild <device>`. Two checks, both required: the release of the tree the process runs
|
|
1562
|
+
(view_tree: the script file it holds open, else its command line's) is VIEW_RELOAD_SINCE or later (view_reloads),
|
|
1563
|
+
and the process has a SIGUSR2 handler installed now (catches_signal: the trap has run). Sidebars swap by themselves
|
|
1564
|
+
(W9 U5). A stale pid file, a recycled pid or another account's process is skipped. Exit 0; 2 for any argument."""
|
|
1565
|
+
if args:
|
|
1566
|
+
print('usage: lanes reload-views')
|
|
1567
|
+
return 2
|
|
1568
|
+
asked, older = 0, {}
|
|
1569
|
+
for pid_file in sorted((L.STATE / 'views').glob('*/*.pid')):
|
|
1570
|
+
try:
|
|
1571
|
+
pid = int(pid_file.read_text().strip())
|
|
1572
|
+
words = [word.decode(errors='replace') for word in (PROC / str(pid) / 'cmdline').read_bytes().split(b'\0')]
|
|
1573
|
+
except (OSError, ValueError):
|
|
1574
|
+
continue
|
|
1575
|
+
if pid <= 1 or L.tree_file_at(words, 'libexec/view.sh') is None:
|
|
1576
|
+
continue
|
|
1577
|
+
if not view_reloads(view_tree(pid, words)) or not catches_signal(pid, signal.SIGUSR2):
|
|
1578
|
+
older.setdefault(pid_file.parent.name, []).append(pid)
|
|
1579
|
+
continue
|
|
1580
|
+
try:
|
|
1581
|
+
os.kill(pid, signal.SIGUSR2)
|
|
1582
|
+
except OSError:
|
|
1583
|
+
continue
|
|
1584
|
+
asked += 1
|
|
1585
|
+
print(f"reload-views: asked {asked} view{'' if asked == 1 else 's'} to restart on the installed release "
|
|
1586
|
+
'(each at its next lane switch or reconnect)')
|
|
1587
|
+
if older:
|
|
1588
|
+
count = sum(len(pids) for pids in older.values())
|
|
1589
|
+
print(f"reload-views: {count} view{'' if count == 1 else 's'} of {', '.join(sorted(older))} "
|
|
1590
|
+
f"{'runs' if count == 1 else 'run'} a view.sh that cannot reload in place (an older release): "
|
|
1591
|
+
"`lanes rebuild <device>` moves "
|
|
1592
|
+
f"{'it' if count == 1 else 'them'}")
|
|
1593
|
+
return 0
|
|
1594
|
+
|
|
1595
|
+
|
|
1596
|
+
ENSURE_USAGE = 'usage: lanes ensure [--if-layout-changed]'
|
|
1597
|
+
|
|
1598
|
+
|
|
1599
|
+
def view_clients(device):
|
|
1600
|
+
"""How many clients are attached to the view session `device`: the rows `zellij action list-clients` prints under
|
|
1601
|
+
its header (`CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND` on 0.45.1; a row starts with the client id — the sidebar's
|
|
1602
|
+
poll_mobile_view reads it the same way). None when zellij did not answer (no answer in 10 s, an error, a session
|
|
1603
|
+
gone since the list): an unknown list is never an empty one."""
|
|
1604
|
+
rc, out, _err = zellij(['-s', device, 'action', 'list-clients'], 10)
|
|
1605
|
+
if rc != 0:
|
|
1606
|
+
return None
|
|
1607
|
+
return sum(1 for line in out.splitlines() if re.match(r'^\s*\d+\s', line))
|
|
1608
|
+
|
|
1609
|
+
|
|
1610
|
+
def refresh_view(device, meta):
|
|
1611
|
+
"""`lanes ensure --if-layout-changed` for the running view `device` (W9 U6, LNS-94): nothing while its layout file
|
|
1612
|
+
is what this release generates (a stamp left from before — a rollback — is dropped); otherwise rebuilt when zellij
|
|
1613
|
+
answers that no client is attached (cmd_rebuild, which writes the reconnect stamp and the new layout, and drops the
|
|
1614
|
+
stamp through create_session), else stamped once (update_ready_path; never rewritten, so its time is when the
|
|
1615
|
+
change was first found attached). Raises LanesError when the rebuild failed."""
|
|
1616
|
+
stamp = update_ready_path(device)
|
|
1617
|
+
try:
|
|
1618
|
+
stored = (L.STATE / 'layouts' / f'{device}.kdl').read_text()
|
|
1619
|
+
except (OSError, UnicodeDecodeError):
|
|
1620
|
+
stored = None
|
|
1621
|
+
if not layout_changed(stored, layout_kdl(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))):
|
|
1622
|
+
if stamp.exists():
|
|
1623
|
+
stamp.unlink(missing_ok=True)
|
|
1624
|
+
print(f'{device}: its layout is current again; dropped {stamp.name}')
|
|
1625
|
+
return
|
|
1626
|
+
clients = view_clients(device)
|
|
1627
|
+
if clients == 0:
|
|
1628
|
+
print(f'{device}: its layout changed and no client is attached: rebuilding it')
|
|
1629
|
+
cmd_rebuild([device])
|
|
1630
|
+
return
|
|
1631
|
+
if not stamp.exists():
|
|
1632
|
+
L.atomic_write(stamp, f'{time.time()}\n')
|
|
1633
|
+
attached = 'zellij did not say which clients are attached' if clients is None else (
|
|
1634
|
+
f"{clients} client{'' if clients == 1 else 's'} attached")
|
|
1635
|
+
print(f'{device}: its layout changed; {attached}: left running, its sidebars offer `update ready · reload (r)` '
|
|
1636
|
+
f'({stamp.name})')
|
|
1637
|
+
|
|
1638
|
+
|
|
1639
|
+
def cmd_ensure(args):
|
|
1640
|
+
if any(arg != '--if-layout-changed' for arg in args):
|
|
1641
|
+
print(ENSURE_USAGE)
|
|
1642
|
+
return 2
|
|
1643
|
+
every = '--if-layout-changed' in args
|
|
1644
|
+
with contextlib.ExitStack() as stack:
|
|
1645
|
+
# One pass under the view-session lock (DEF-1792), from the list to the last tab sync: no other ensure, rebuild
|
|
1646
|
+
# or create acts on a session between what this run listed and what it does. The update's run waits for one
|
|
1647
|
+
# that holds it; the minute timer's only glances, and skips its tick — the holder is acting on the views now.
|
|
1648
|
+
refused = hold_sessions(stack, SESSIONS_WAIT if every else SESSIONS_GLANCE,
|
|
1649
|
+
'lanes ensure --if-layout-changed' if every else 'lanes ensure')
|
|
1650
|
+
if isinstance(refused, L.SessionsBusy) and not every:
|
|
1651
|
+
print(f'ensure: skipped: {refused}; nothing was recreated (lanes-ensure.timer runs again in a minute)')
|
|
1652
|
+
return 0
|
|
1653
|
+
if refused:
|
|
1654
|
+
raise L.LanesError(f'{refused}: no view was recreated, compared or rebuilt')
|
|
1655
|
+
return ensure_views(every)
|
|
1656
|
+
|
|
1657
|
+
|
|
1658
|
+
def ensure_views(every):
|
|
1659
|
+
"""cmd_ensure's pass, under the view-session lock: recreate each device with no live session, compare the running
|
|
1660
|
+
ones (every one with `every`, else only a device the update stamped), then sync the tabs of every live view."""
|
|
966
1661
|
# live_sessions() raises when zellij did not answer: then this run ends here and recreates nothing (the timer
|
|
967
1662
|
# tries again in a minute). "Missing" is only ever concluded from a list zellij actually gave.
|
|
968
1663
|
live = live_sessions()
|
|
1664
|
+
failures = []
|
|
969
1665
|
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
970
1666
|
device = meta_file.stem
|
|
1667
|
+
meta = L.read_json(meta_file) or {}
|
|
1668
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
971
1669
|
if device not in live:
|
|
972
|
-
meta = L.read_json(meta_file) or {}
|
|
973
1670
|
create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
|
|
974
1671
|
print(f'recreated view session {device}')
|
|
975
|
-
|
|
1672
|
+
elif every or update_ready_path(device).exists():
|
|
1673
|
+
# The update asks for every view; the minute timer only finishes what an update left (W9 U6). Before the
|
|
1674
|
+
# tab sync, so a rebuilt view is reconciled like any other; one device's failure never stops the next.
|
|
1675
|
+
try:
|
|
1676
|
+
refresh_view(device, meta)
|
|
1677
|
+
except L.LanesError as error:
|
|
1678
|
+
failures.append(f'{device}: {error}')
|
|
1679
|
+
if not failures:
|
|
1680
|
+
return cmd_upgrade_session([])
|
|
1681
|
+
try:
|
|
1682
|
+
cmd_upgrade_session([])
|
|
1683
|
+
except L.LanesError as error:
|
|
1684
|
+
failures.append(str(error))
|
|
1685
|
+
raise L.LanesError('; '.join(failures))
|
|
976
1686
|
|
|
977
1687
|
|
|
978
|
-
def prepare_view(device, mobile, viewports=1, explicit=False):
|
|
1688
|
+
def prepare_view(device, mobile, viewports=1, explicit=False, doing='lanes prepare'):
|
|
979
1689
|
"""Create the device's view session unless it runs -> 'created' | 'reused'. A running view is never rebuilt:
|
|
980
1690
|
a mobile request refuses a desktop view, and an EXPLICIT desktop request (`--desktop`, `lanes web`) refuses a
|
|
981
|
-
mobile one. Without a flag, `prepare` keeps reusing whatever layout runs.
|
|
982
|
-
|
|
983
|
-
|
|
984
|
-
|
|
1691
|
+
mobile one. Without a flag, `prepare` keeps reusing whatever layout runs. Under the view-session lock (DEF-1792),
|
|
1692
|
+
waited for like `lanes rebuild` waits; `doing` names the command to a process that waits for it."""
|
|
1693
|
+
with contextlib.ExitStack() as stack:
|
|
1694
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'{doing} {device}')
|
|
1695
|
+
if refused:
|
|
1696
|
+
raise L.LanesError(f'{refused}: {device} was not prepared; try again shortly')
|
|
1697
|
+
if device not in live_sessions():
|
|
1698
|
+
create_session(device, mobile, viewports)
|
|
1699
|
+
return 'created'
|
|
985
1700
|
running_mobile = bool((L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'))
|
|
986
1701
|
if mobile and not running_mobile:
|
|
987
1702
|
raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
|
|
@@ -1232,7 +1947,7 @@ def cmd_web(args):
|
|
|
1232
1947
|
prepare = ['lanes', 'prepare', device, flag] + (['--viewports', str(viewports)] if viewports != 1 else [])
|
|
1233
1948
|
state = 'planned'
|
|
1234
1949
|
if not print_only:
|
|
1235
|
-
state = prepare_view(device, mobile, viewports, explicit=explicit)
|
|
1950
|
+
state = prepare_view(device, mobile, viewports, explicit=explicit, doing='lanes web')
|
|
1236
1951
|
record_base(device, args)
|
|
1237
1952
|
rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
|
|
1238
1953
|
if rc != 0:
|
|
@@ -1544,18 +2259,22 @@ def cmd_upload_result(args):
|
|
|
1544
2259
|
|
|
1545
2260
|
|
|
1546
2261
|
WORKSPACES_USAGE = ('usage: lanes workspaces | lanes workspaces add <id> --container <name> --repo <path> '
|
|
1547
|
-
'[--title T] [--session S] [--write]'
|
|
2262
|
+
'[--title T] [--session S] [--write] | lanes workspaces hide|show|order <id…> '
|
|
2263
|
+
'[--device D | --login L]')
|
|
1548
2264
|
|
|
1549
2265
|
|
|
1550
2266
|
def cmd_workspaces(args):
|
|
1551
2267
|
"""`lanes workspaces` prints the board ids (the client installer reads them); `lanes workspaces add` appends a
|
|
1552
2268
|
board to the site catalog and writes its manifest (libexec/site_edit.py). Dry run (the printed diff) unless
|
|
1553
2269
|
--write; an id already in the catalog is verified and never rewritten (exit 1 when it disagrees); a repository
|
|
1554
|
-
another board already declares is refused (LanesError, before any write).
|
|
2270
|
+
another board already declares is refused (LanesError, before any write). `hide|show|order` arrange the boards in
|
|
2271
|
+
a person's profile (cmd_workspaces_profile, cockpit v3 R6)."""
|
|
1555
2272
|
if not args:
|
|
1556
2273
|
for workspace in L.load_registry()['workspaces']:
|
|
1557
2274
|
print(workspace['id'])
|
|
1558
2275
|
return 0
|
|
2276
|
+
if args[0] in ARRANGE_WORKSPACES:
|
|
2277
|
+
return cmd_workspaces_profile(args)
|
|
1559
2278
|
if args[0] != 'add' or len(args) < 2 or args[1].startswith('-'):
|
|
1560
2279
|
raise L.LanesError(WORKSPACES_USAGE)
|
|
1561
2280
|
opts, rest = {}, list(args[2:])
|
|
@@ -1819,18 +2538,44 @@ def cmd_onboard(args):
|
|
|
1819
2538
|
print(f'backup {backup}')
|
|
1820
2539
|
return ('changed' if write else 'planned'), f"{board} in {result['catalog']}"
|
|
1821
2540
|
|
|
2541
|
+
mcp_before = {}
|
|
2542
|
+
|
|
2543
|
+
def mcp_files():
|
|
2544
|
+
return [home() / lane_core().MCP_FILES[agent] for agent in lane_core().MCP_AGENTS]
|
|
2545
|
+
|
|
1822
2546
|
def contract_step():
|
|
1823
2547
|
root = home() / '.lz'
|
|
1824
2548
|
if not write:
|
|
1825
2549
|
print(f'would install the lane contract into {root} and link {Path.home() / ".lz" / container}')
|
|
1826
2550
|
return 'planned', str(root)
|
|
2551
|
+
for path in mcp_files(): # what the lane-mcp step compares against: install-container writes the entries
|
|
2552
|
+
mcp_before[path] = lane_core().read_regular(path, lane_core().EDIT_MAX) # never a link or a FIFO
|
|
1827
2553
|
cmd_install_container([container] + (['--no-mcp'] if no_mcp else []))
|
|
1828
2554
|
return 'ok', str(root)
|
|
1829
2555
|
|
|
1830
2556
|
def mcp_step():
|
|
2557
|
+
"""install-container (the contract step) wrote the agents' MCP entries; this step plans them again and says
|
|
2558
|
+
what that changed. A dry run prints the diffs install-container would apply."""
|
|
1831
2559
|
if no_mcp:
|
|
1832
2560
|
return 'skipped', '--no-mcp: left out'
|
|
1833
|
-
|
|
2561
|
+
plan = lane_core().mcp_plan(home())
|
|
2562
|
+
files = ', '.join(str(item['path']) for item in plan)
|
|
2563
|
+
refused = '; '.join(f"{item['path']}: {item['why']}" for item in plan if item['why'])
|
|
2564
|
+
pending = [str(item['path']) for item in plan if not item['why'] and item['after'] != item['before']]
|
|
2565
|
+
if not write:
|
|
2566
|
+
for item in plan:
|
|
2567
|
+
if item['why']:
|
|
2568
|
+
print(f"! {item['path']}: {item['why']}")
|
|
2569
|
+
elif item['after'] == item['before']:
|
|
2570
|
+
print(f"{item['path']}: the lanes MCP server is already there")
|
|
2571
|
+
else:
|
|
2572
|
+
sys.stdout.write(lane_core().mcp_diff(item))
|
|
2573
|
+
return ('warn', refused) if refused else (('planned', files) if pending else ('unchanged', files))
|
|
2574
|
+
if refused or pending:
|
|
2575
|
+
return 'warn', refused or f"not written: {', '.join(pending)}"
|
|
2576
|
+
changed = [str(path) for path in mcp_files()
|
|
2577
|
+
if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
|
|
2578
|
+
return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
|
|
1834
2579
|
|
|
1835
2580
|
def hooks_step(agent):
|
|
1836
2581
|
def step():
|
|
@@ -1851,7 +2596,7 @@ def cmd_onboard(args):
|
|
|
1851
2596
|
return 'planned', 'lanes sync'
|
|
1852
2597
|
if cmd_sync([]) == 0:
|
|
1853
2598
|
return 'ok', 'registry published'
|
|
1854
|
-
return 'warn', 'a board has registry errors (listed above)'
|
|
2599
|
+
return 'warn', 'a board has registry errors or a container refused its projection (listed above)'
|
|
1855
2600
|
|
|
1856
2601
|
cursor = {}
|
|
1857
2602
|
|
|
@@ -1933,10 +2678,10 @@ def cmd_rebuilt(args):
|
|
|
1933
2678
|
return 0 if time.time() - stamp < 60 else 1
|
|
1934
2679
|
|
|
1935
2680
|
|
|
1936
|
-
def server_version():
|
|
1937
|
-
"""
|
|
1938
|
-
|
|
1939
|
-
return
|
|
2681
|
+
def server_version(repo=None):
|
|
2682
|
+
"""A tree's release (default this server's, L.REPO/VERSION), as `lanes hello` and `lanes version` report it; 'dev'
|
|
2683
|
+
when the file is absent, empty or unreadable."""
|
|
2684
|
+
return L.tree_version(repo if repo is not None else L.REPO) or 'dev'
|
|
1940
2685
|
|
|
1941
2686
|
|
|
1942
2687
|
VERSION_RE = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:[-+][A-Za-z0-9.]+)?')
|
|
@@ -1949,6 +2694,23 @@ def version_key(text):
|
|
|
1949
2694
|
return tuple(int(part) for part in match.groups()) if match else None
|
|
1950
2695
|
|
|
1951
2696
|
|
|
2697
|
+
def client_older(client, server):
|
|
2698
|
+
"""Whether the release `client` names is older than `server`: both parse (version_key) and the client's
|
|
2699
|
+
major.minor.patch is the lower, compared as numbers (1.9.3 is older than 1.10.0; 1.1.0-rc.1 is not older than
|
|
2700
|
+
1.1.0). `dev`, a missing value or client text that is no version is never older. The one comparison behind both
|
|
2701
|
+
server answers to an older client: `lanes attach`'s notice (client_notice) and `lanes hello`'s update= (W9 K1)."""
|
|
2702
|
+
client_key, server_key = version_key(client), version_key(server)
|
|
2703
|
+
return client_key is not None and server_key is not None and client_key < server_key
|
|
2704
|
+
|
|
2705
|
+
|
|
2706
|
+
def client_min(server):
|
|
2707
|
+
"""The release a lanes-launch must be at for this server not to tell it to update: the server's major.minor.patch
|
|
2708
|
+
(client_notice compares exactly that — a client below it gets the notice, one at it none); None for a server
|
|
2709
|
+
whose version is no release ('dev'), which tells no client to update."""
|
|
2710
|
+
key = version_key(server)
|
|
2711
|
+
return '.'.join(str(part) for part in key) if key else None
|
|
2712
|
+
|
|
2713
|
+
|
|
1952
2714
|
# The newest published release whose lanes-launch passes `--client` but no `--client-version` (@vibelord/lanes 1.0.4).
|
|
1953
2715
|
# A client without the flag is this release or an earlier one, so it is older only than a server past it. A fixed fact
|
|
1954
2716
|
# about what was published: bumping lanes/VERSION never moves it.
|
|
@@ -1966,21 +2728,205 @@ def client_notice(args):
|
|
|
1966
2728
|
return None
|
|
1967
2729
|
if '--client-version' in args:
|
|
1968
2730
|
client = args[args.index('--client-version') + 1] if '--client-version' in args[:-1] else ''
|
|
1969
|
-
|
|
1970
|
-
|
|
2731
|
+
return (f'lanes client {client} is older than the server ({server}) — run lanes-launch update'
|
|
2732
|
+
if client_older(client, server) else None)
|
|
1971
2733
|
if '--client' in args and version_key(server) > LAST_FLAGLESS_CLIENT:
|
|
1972
2734
|
return f'lanes client is older than the server ({server}) — update it: npx @vibelord/lanes@latest'
|
|
1973
2735
|
return None
|
|
1974
2736
|
|
|
1975
2737
|
|
|
1976
2738
|
def cmd_hello(args):
|
|
1977
|
-
|
|
2739
|
+
"""`lanes hello [device] [--client-version V]`: one line, exit 0 — `ok device=<device> version=<server>
|
|
2740
|
+
boards=<n>`, what the client installer reads to prove the SSH path, the key and the server's release. With
|
|
2741
|
+
`--client-version V` older than this server (client_older: major.minor.patch as numbers) the line ends
|
|
2742
|
+
` update=<server>` (W9 K1, LNS-86): the exact release, as this tree's VERSION names it, that a client updates to
|
|
2743
|
+
(lanes-launch's self-update, W9 K2, installs exactly that and never @latest). An equal or newer client, `dev`
|
|
2744
|
+
on either side, text that is no version, or no flag at all: the line every earlier release printed. The hint is
|
|
2745
|
+
appended, so a reader of the first four fields (install.sh check_server, lanes-launch web) reads them as before.
|
|
2746
|
+
The flag and its value may stand anywhere; the device is the first other argument."""
|
|
2747
|
+
rest, client = list(args), None
|
|
2748
|
+
if '--client-version' in rest:
|
|
2749
|
+
at = rest.index('--client-version')
|
|
2750
|
+
client = rest[at + 1] if at + 1 < len(rest) else None
|
|
2751
|
+
del rest[at:at + 2]
|
|
2752
|
+
device = resolve_device(rest[0], args) if rest else '-'
|
|
1978
2753
|
version = server_version()
|
|
1979
2754
|
boards = len(L.load_registry()['workspaces']) if (L.STATE / 'registry.json').exists() else 0
|
|
1980
|
-
|
|
2755
|
+
update = f' update={version}' if client_older(client, version) else ''
|
|
2756
|
+
print(f'ok device={device} version={version} boards={boards}{update}')
|
|
1981
2757
|
return 0
|
|
1982
2758
|
|
|
1983
2759
|
|
|
2760
|
+
# ------------------------------------------------------------------ version (W9 V1, LNS-83)
|
|
2761
|
+
# `lanes version` answers "which release runs here, and what still runs an older one": this CLI's tree, the tree the
|
|
2762
|
+
# `current` link names (install/services.py writes it; the units run from it after W9 U1), and the tree each
|
|
2763
|
+
# long-running process recorded when it last started (L.record_tree, W9 U0). `current` is read from the link ITSELF:
|
|
2764
|
+
# L.CURRENT collapses to this tree's own path whenever the link names another tree, so realpath(L.CURRENT) is this tree
|
|
2765
|
+
# by construction and could never show a stale CLI. A checkout updated in place (`git pull`, USERGUIDE Update) keeps its
|
|
2766
|
+
# path, so a record names the current tree before and after the update: the time of the tree's code says which starts
|
|
2767
|
+
# came before it (tree_code_mtime).
|
|
2768
|
+
|
|
2769
|
+
# The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
|
|
2770
|
+
# then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
|
|
2771
|
+
RUN_ORDER = ('statusd', 'enrold', 'notify', 'economy', 'studio_host', 'sidebar')
|
|
2772
|
+
VERSION_SCHEMA = 'lanes.version/1'
|
|
2773
|
+
COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
|
|
2774
|
+
# A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
|
|
2775
|
+
# record_tree process starts from libexec/ or bin/lanes and imports libexec/*.py).
|
|
2776
|
+
TREE_CODE = ('bin', 'libexec')
|
|
2777
|
+
|
|
2778
|
+
|
|
2779
|
+
def utc(seconds):
|
|
2780
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(seconds))
|
|
2781
|
+
|
|
2782
|
+
|
|
2783
|
+
def tree_code_mtime(tree):
|
|
2784
|
+
"""The newest modification time (epoch seconds) of a tree's code: its VERSION and every regular file directly in
|
|
2785
|
+
bin/ and libexec/ — dotfiles (editor swap files), __pycache__ and every other folder (docs/, web/) are not code. None
|
|
2786
|
+
when there is none (a pruned tree). `git pull` writes each file it changes with the time of the pull, and the npm
|
|
2787
|
+
wrapper re-stages a version (other bytes) as a fresh copy, so a time later than a run record says that process
|
|
2788
|
+
started before its tree's code changed — a checkout updated in place keeps its path. A pull that only deletes files
|
|
2789
|
+
changes no file's time and is not seen."""
|
|
2790
|
+
tree = Path(tree)
|
|
2791
|
+
paths = [tree / 'VERSION']
|
|
2792
|
+
for folder in TREE_CODE:
|
|
2793
|
+
try:
|
|
2794
|
+
with os.scandir(tree / folder) as entries:
|
|
2795
|
+
paths.extend(Path(entry.path) for entry in entries if not entry.name.startswith('.'))
|
|
2796
|
+
except OSError:
|
|
2797
|
+
continue
|
|
2798
|
+
newest = None
|
|
2799
|
+
for path in paths:
|
|
2800
|
+
try:
|
|
2801
|
+
if path.is_file():
|
|
2802
|
+
mtime = path.stat().st_mtime
|
|
2803
|
+
newest = mtime if newest is None else max(newest, mtime)
|
|
2804
|
+
except OSError:
|
|
2805
|
+
continue
|
|
2806
|
+
return newest
|
|
2807
|
+
|
|
2808
|
+
|
|
2809
|
+
def run_records(state, reference):
|
|
2810
|
+
"""{name: {tree, version, started, current, changed}} for each `<state>/run/<name>.tree`, RUN_ORDER first. `tree`
|
|
2811
|
+
is the record's one absolute path (None for a record that names none), `version` that tree's VERSION now (None once
|
|
2812
|
+
the tree is pruned), `started` the record's mtime in UTC (the process's last start), `changed` the time of the tree's
|
|
2813
|
+
newest code (tree_code_mtime) when it is LATER than that start — the process runs code from before it — else None,
|
|
2814
|
+
and `current` whether the process runs the current code: its tree resolves to `reference` and `changed` is None. A
|
|
2815
|
+
record outlives its process: it says what the last start ran, never that it still runs."""
|
|
2816
|
+
found, code_times = {}, {}
|
|
2817
|
+
try:
|
|
2818
|
+
paths = sorted((Path(state) / L.RUN_DIR).glob('*.tree'))
|
|
2819
|
+
except OSError:
|
|
2820
|
+
paths = []
|
|
2821
|
+
for path in paths:
|
|
2822
|
+
name = path.name[:-len('.tree')]
|
|
2823
|
+
if not L.IDENT.match(name): # never a name record_tree writes
|
|
2824
|
+
continue
|
|
2825
|
+
try:
|
|
2826
|
+
if not path.is_file():
|
|
2827
|
+
continue
|
|
2828
|
+
lines = path.read_text().splitlines()
|
|
2829
|
+
started = path.stat().st_mtime
|
|
2830
|
+
except (OSError, ValueError): # ValueError: undecodable bytes
|
|
2831
|
+
continue
|
|
2832
|
+
tree = lines[0] if len(lines) == 1 and lines[0].startswith('/') else None
|
|
2833
|
+
if tree is not None and tree not in code_times:
|
|
2834
|
+
code_times[tree] = tree_code_mtime(tree)
|
|
2835
|
+
code = code_times.get(tree)
|
|
2836
|
+
changed = code if code is not None and code > started else None # a start AT the code's time is after it
|
|
2837
|
+
found[name] = {'tree': tree, 'version': L.tree_version(tree) if tree else None, 'started': utc(started),
|
|
2838
|
+
'current': tree is not None and os.path.realpath(tree) == reference and changed is None,
|
|
2839
|
+
'changed': utc(changed) if changed is not None else None}
|
|
2840
|
+
order = [name for name in RUN_ORDER if name in found] + sorted(set(found) - set(RUN_ORDER))
|
|
2841
|
+
return {name: found[name] for name in order}
|
|
2842
|
+
|
|
2843
|
+
|
|
2844
|
+
def version_report(repo=None, state=None, home=None):
|
|
2845
|
+
"""The `lanes version --json` document (schema lanes.version/1): {schema, version, commit, tree, current, running,
|
|
2846
|
+
client_min}. `version` is the tree's release ('dev' without one, as `lanes hello`), `commit` the npm wrapper's
|
|
2847
|
+
stamp (None for a checkout), `tree` the resolved tree this CLI runs, `current` the resolved target of
|
|
2848
|
+
~/.local/share/lanes/current (None without the link), `running` run_records() compared with `current` — or with
|
|
2849
|
+
`tree` when there is no link — and with the time of each tree's code, and `client_min` the release attach stops
|
|
2850
|
+
telling lanes-launch to update at."""
|
|
2851
|
+
repo = Path(repo) if repo is not None else L.REPO
|
|
2852
|
+
try:
|
|
2853
|
+
link = L.current_link(home)
|
|
2854
|
+
current = os.path.realpath(link) if os.path.lexists(link) else None
|
|
2855
|
+
except (KeyError, RuntimeError): # no HOME and no passwd entry: no link to read
|
|
2856
|
+
current = None
|
|
2857
|
+
version = server_version(repo)
|
|
2858
|
+
return {'schema': VERSION_SCHEMA, 'version': version, 'commit': (L.tree_stamp(repo) or {}).get('commit'),
|
|
2859
|
+
'tree': str(repo), 'current': current,
|
|
2860
|
+
'running': run_records(state if state is not None else L.STATE,
|
|
2861
|
+
current if current is not None else os.path.realpath(repo)),
|
|
2862
|
+
'client_min': client_min(version)}
|
|
2863
|
+
|
|
2864
|
+
|
|
2865
|
+
def plain(text):
|
|
2866
|
+
"""Text for a terminal: every character that is not printable (escape sequences included) becomes `?`."""
|
|
2867
|
+
return ''.join(char if char.isprintable() else '?' for char in str(text))
|
|
2868
|
+
|
|
2869
|
+
|
|
2870
|
+
def tree_shown(path, home=None):
|
|
2871
|
+
"""A tree as `lanes version` prints it: `pkg/<version>` for an npm-staged tree, `~/…` under the home, else the
|
|
2872
|
+
path."""
|
|
2873
|
+
path = Path(path)
|
|
2874
|
+
if path.parent.name == 'pkg':
|
|
2875
|
+
return plain(f'pkg/{path.name}')
|
|
2876
|
+
try:
|
|
2877
|
+
return plain(f'~/{path.relative_to(Path(home) if home is not None else Path.home())}')
|
|
2878
|
+
except (ValueError, KeyError, RuntimeError):
|
|
2879
|
+
return plain(path)
|
|
2880
|
+
|
|
2881
|
+
|
|
2882
|
+
def record_said(record, reference, home=None, past=False):
|
|
2883
|
+
"""What a record that does not run the current code says, as `lanes version` and doctor print it after the name:
|
|
2884
|
+
`started before its tree changed` when its tree is `reference` (updated in place after that start), else `runs`
|
|
2885
|
+
(`ran` with `past`) its tree's release — or the tree once that is pruned, or `?` for a record that names none."""
|
|
2886
|
+
if record['tree'] is not None and os.path.realpath(record['tree']) == reference:
|
|
2887
|
+
return 'started before its tree changed'
|
|
2888
|
+
ran = (plain(record['version']) if record['version'] else
|
|
2889
|
+
tree_shown(record['tree'], home) if record['tree'] else '?')
|
|
2890
|
+
return f"{'ran' if past else 'runs'} {ran}"
|
|
2891
|
+
|
|
2892
|
+
|
|
2893
|
+
def version_text(report, home=None):
|
|
2894
|
+
"""The one line `lanes version` prints, e.g. `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 · current ✓ · statusd runs
|
|
2895
|
+
1.1.0 ✗ · enrold notify ✓`: each record that does not run the current code on its own — `runs <version>` (or its
|
|
2896
|
+
tree once that is pruned, or `?` for a record that names no tree) when its tree is another one, `started before its
|
|
2897
|
+
tree changed` when it is the current tree, updated in place after that start — then the current ones together."""
|
|
2898
|
+
commit = COMMIT_RE.fullmatch(report['commit'] or '')
|
|
2899
|
+
parts = [f"lanes {plain(report['version'])}" + (f' ({commit.group(1)}{commit.group(2) or ""})' if commit else ''),
|
|
2900
|
+
f"tree {tree_shown(report['tree'], home)}"]
|
|
2901
|
+
current = report['current']
|
|
2902
|
+
parts.append('no current link' if current is None else 'current ✓' if current == os.path.realpath(report['tree'])
|
|
2903
|
+
else f'current {tree_shown(current, home)} ✗')
|
|
2904
|
+
reference = current if current is not None else os.path.realpath(report['tree'])
|
|
2905
|
+
running = report['running']
|
|
2906
|
+
for name, record in running.items():
|
|
2907
|
+
if not record['current']:
|
|
2908
|
+
parts.append(f'{plain(name)} {record_said(record, reference, home)} ✗')
|
|
2909
|
+
fresh = [plain(name) for name, record in running.items() if record['current']]
|
|
2910
|
+
if fresh:
|
|
2911
|
+
parts.append(' '.join(fresh) + ' ✓')
|
|
2912
|
+
return ' · '.join(parts)
|
|
2913
|
+
|
|
2914
|
+
|
|
2915
|
+
def cmd_version(args):
|
|
2916
|
+
if any(arg != '--json' for arg in args):
|
|
2917
|
+
print('usage: lanes version [--json]')
|
|
2918
|
+
return 2
|
|
2919
|
+
report = version_report()
|
|
2920
|
+
print(json.dumps(report, indent=1, ensure_ascii=False) if '--json' in args else version_text(report))
|
|
2921
|
+
return 0
|
|
2922
|
+
|
|
2923
|
+
|
|
2924
|
+
def cmd_update(args):
|
|
2925
|
+
"""`lanes update [--now | --timer | --status | --rollback]` (W9 U3, LNS-91): libexec/update.py owns the flow, its
|
|
2926
|
+
refusals and its exit statuses; releases are compared as `lanes hello` compares them (version_key)."""
|
|
2927
|
+
return libexec_module('update').update_main(args, version_key)
|
|
2928
|
+
|
|
2929
|
+
|
|
1984
2930
|
def cmd_client_config(_args):
|
|
1985
2931
|
"""Site facts a client may want after its first connection (e.g. the public fallback address), so that they
|
|
1986
2932
|
need not be written into the distributed package. KEY=VALUE lines, LANES_* keys only."""
|
|
@@ -2030,9 +2976,227 @@ def cmd_gc(_args):
|
|
|
2030
2976
|
if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
|
|
2031
2977
|
stale.unlink()
|
|
2032
2978
|
print(f'removed stale {stale.name}')
|
|
2979
|
+
gc_trees()
|
|
2033
2980
|
return 0
|
|
2034
2981
|
|
|
2035
2982
|
|
|
2983
|
+
# ------------------------------------------------------------------ staged trees (plan W9 U4, LNS-92)
|
|
2984
|
+
# The npm wrapper stages each release at <pkg root>/<version>. Since W9 U4 it prunes only where the `current` link is
|
|
2985
|
+
# absent — a client, where nothing runs from an old tree (npm/lanes/lib/plan.mjs prunable; DEF-1768) — and a server's
|
|
2986
|
+
# trees are pruned here, by `lanes gc` (the daily lanes-gc.timer), where what still uses one can be seen.
|
|
2987
|
+
TREE_KEEP = 3 # the newest staged trees each pkg root keeps, as the wrapper keeps them on a client
|
|
2988
|
+
PRUNING = '.pruning-' # a tree being removed is first renamed to .pruning-<version>-<pid>-<ns> in its root
|
|
2989
|
+
DELETED = ' (deleted)' # what readlink(2) of a /proc link appends once the file it names is gone
|
|
2990
|
+
|
|
2991
|
+
|
|
2992
|
+
def home_shown(path, home):
|
|
2993
|
+
"""A path as `lanes gc` prints it: `~/…` under the home, else whole (two roots may both be called pkg)."""
|
|
2994
|
+
for base in (Path(home), Path(home).resolve()):
|
|
2995
|
+
try:
|
|
2996
|
+
return plain(f'~/{Path(path).relative_to(base)}')
|
|
2997
|
+
except ValueError:
|
|
2998
|
+
continue
|
|
2999
|
+
return plain(path)
|
|
3000
|
+
|
|
3001
|
+
|
|
3002
|
+
def pkg_roots(home):
|
|
3003
|
+
"""The folders a server's staged trees are in, resolved, each once and existing only, in this order: the folder of
|
|
3004
|
+
the tree `current` names, of the tree `current.previous` names and of the tree this lanes runs from, each when it is
|
|
3005
|
+
a staged tree (L.staged_tree); then the folder the npm wrapper stages in from this environment (lanes-install.mjs
|
|
3006
|
+
pkgRoot: an absolute $LANES_PKG_ROOT, else $XDG_DATA_HOME/lanes/pkg) and its default, ~/.local/share/lanes/pkg.
|
|
3007
|
+
A timer's environment holds neither variable: the first three find a tree staged elsewhere."""
|
|
3008
|
+
link = L.current_link(home)
|
|
3009
|
+
folders = []
|
|
3010
|
+
for tree in (link, link.with_name('current.previous'), L.REPO):
|
|
3011
|
+
try:
|
|
3012
|
+
resolved = Path(tree).resolve(strict=True)
|
|
3013
|
+
except (OSError, RuntimeError):
|
|
3014
|
+
continue
|
|
3015
|
+
if L.staged_tree(resolved):
|
|
3016
|
+
folders.append(resolved.parent)
|
|
3017
|
+
own, data = os.environ.get('LANES_PKG_ROOT', ''), os.environ.get('XDG_DATA_HOME', '')
|
|
3018
|
+
if own:
|
|
3019
|
+
folders += [Path(own)] if own.startswith('/') else []
|
|
3020
|
+
elif data.startswith('/'):
|
|
3021
|
+
folders.append(Path(data) / 'lanes/pkg')
|
|
3022
|
+
folders.append(Path(home) / '.local/share/lanes/pkg')
|
|
3023
|
+
roots = []
|
|
3024
|
+
for folder in folders:
|
|
3025
|
+
try:
|
|
3026
|
+
resolved = folder.resolve(strict=True)
|
|
3027
|
+
except (OSError, RuntimeError):
|
|
3028
|
+
continue
|
|
3029
|
+
if resolved.is_dir() and resolved not in roots:
|
|
3030
|
+
roots.append(resolved)
|
|
3031
|
+
return roots
|
|
3032
|
+
|
|
3033
|
+
|
|
3034
|
+
def staged_entries(root):
|
|
3035
|
+
"""[(tree, mtime)] of the trees the npm wrapper staged directly in `root`, newest first (ties: the higher name
|
|
3036
|
+
first): a directory, never a link, whose name does not start with `.` (the wrapper's own `.staging-*`, a tree being
|
|
3037
|
+
pruned) and which holds the wrapper's stamp — a regular file that reads as a JSON object (L.tree_stamp), as the
|
|
3038
|
+
wrapper's own prune asks of it. A checkout, or anything else that is there, never is one."""
|
|
3039
|
+
found = []
|
|
3040
|
+
try:
|
|
3041
|
+
with os.scandir(root) as entries:
|
|
3042
|
+
for entry in entries:
|
|
3043
|
+
stamp = os.path.join(entry.path, L.PACKAGE_STAMP)
|
|
3044
|
+
try:
|
|
3045
|
+
if entry.name.startswith('.') or not entry.is_dir(follow_symlinks=False) \
|
|
3046
|
+
or os.path.islink(stamp) or L.tree_stamp(entry.path) is None:
|
|
3047
|
+
continue
|
|
3048
|
+
found.append((Path(root) / entry.name, entry.stat(follow_symlinks=False).st_mtime))
|
|
3049
|
+
except OSError:
|
|
3050
|
+
continue
|
|
3051
|
+
except OSError:
|
|
3052
|
+
return []
|
|
3053
|
+
return sorted(found, key=lambda each: (each[1], each[0].name), reverse=True)
|
|
3054
|
+
|
|
3055
|
+
|
|
3056
|
+
def proc_link(path):
|
|
3057
|
+
try:
|
|
3058
|
+
return os.readlink(path)
|
|
3059
|
+
except OSError:
|
|
3060
|
+
return None
|
|
3061
|
+
|
|
3062
|
+
|
|
3063
|
+
def process_paths(proc=None):
|
|
3064
|
+
"""What this account's processes use now, read from /proc (`proc`, default PROC) — best effort: a process that
|
|
3065
|
+
ends or denies a read mid-scan is skipped -> ({path: pid}, {RUN_SCRIPTS name}), or None when /proc cannot be listed.
|
|
3066
|
+
The paths are every absolute path a process of this uid uses: its exe, its cwd and each file it holds open
|
|
3067
|
+
(/proc/<pid>/exe, cwd, fd/*), as the kernel resolved them when the process opened them — so a script started
|
|
3068
|
+
through `current` before a flip still names its tree: bash and dash read their script through an open descriptor —
|
|
3069
|
+
and each absolute word of its argv (or the value of an `--option=/path` word), as written and resolved now; the
|
|
3070
|
+
lowest pid that uses a path is the one named. The names are the daemons among the processes (run_name)."""
|
|
3071
|
+
proc = Path(proc) if proc is not None else PROC
|
|
3072
|
+
try:
|
|
3073
|
+
pids = sorted((name for name in os.listdir(proc) if name.isdigit()), key=int)
|
|
3074
|
+
except OSError:
|
|
3075
|
+
return None
|
|
3076
|
+
uid, paths, names = os.geteuid(), {}, set()
|
|
3077
|
+
for pid in pids:
|
|
3078
|
+
base = proc / pid
|
|
3079
|
+
try:
|
|
3080
|
+
if base.stat().st_uid != uid:
|
|
3081
|
+
continue
|
|
3082
|
+
fds = os.listdir(base / 'fd')
|
|
3083
|
+
except OSError:
|
|
3084
|
+
continue
|
|
3085
|
+
used = [proc_link(base / 'exe'), proc_link(base / 'cwd')] + [proc_link(base / 'fd' / fd) for fd in fds]
|
|
3086
|
+
try:
|
|
3087
|
+
raw = (base / 'cmdline').read_bytes()
|
|
3088
|
+
except OSError:
|
|
3089
|
+
raw = b''
|
|
3090
|
+
words = [os.fsdecode(word) for word in (raw[:-1] if raw.endswith(b'\0') else raw).split(b'\0')] if raw else []
|
|
3091
|
+
name = run_name(words)
|
|
3092
|
+
if name is not None:
|
|
3093
|
+
names.add(name)
|
|
3094
|
+
for word in words:
|
|
3095
|
+
for part in (word, word.partition('=')[2]):
|
|
3096
|
+
if part.startswith('/'):
|
|
3097
|
+
used += [part, os.path.realpath(part)]
|
|
3098
|
+
for path in used:
|
|
3099
|
+
path = path[:-len(DELETED)] if path and path.endswith(DELETED) else path
|
|
3100
|
+
if path and path.startswith('/'):
|
|
3101
|
+
paths.setdefault(os.path.normpath(path), int(pid))
|
|
3102
|
+
return paths, names
|
|
3103
|
+
|
|
3104
|
+
|
|
3105
|
+
def staged_tree_plan(home=None, proc=None):
|
|
3106
|
+
"""[(tree, reason)] for every staged tree of pkg_roots, root by root and newest first: why `lanes gc` keeps it, or
|
|
3107
|
+
None when it is pruned. Kept: the TREE_KEEP newest of each root (by mtime, as the wrapper counts them on a client),
|
|
3108
|
+
and any older one that `current` or `current.previous` (the rollback target) names, that this lanes runs from,
|
|
3109
|
+
that a view layout (`layouts/<device>.kdl`) names — a release before W9 U1 wrote `pkg/<version>` into one, and the
|
|
3110
|
+
view keeps running it until it is rebuilt — that a running daemon's run record names (run/<name>.tree: one started
|
|
3111
|
+
through `current` before the last flip, whose argv now resolves to the new tree), or that a process of this account
|
|
3112
|
+
uses (process_paths, read only when an older tree has no other reason). When /proc cannot be listed no older tree
|
|
3113
|
+
is pruned. The units are not read: since W9 U1 each names `current`, and every install that moves the link renders
|
|
3114
|
+
them all."""
|
|
3115
|
+
home = Path(home) if home is not None else Path.home()
|
|
3116
|
+
link = L.current_link(home)
|
|
3117
|
+
named = {}
|
|
3118
|
+
for each, reason in ((link, 'current names it'),
|
|
3119
|
+
(link.with_name('current.previous'), 'current.previous names it (the rollback target)'),
|
|
3120
|
+
(L.REPO, 'this lanes runs from it')):
|
|
3121
|
+
if os.path.lexists(each):
|
|
3122
|
+
named.setdefault(os.path.realpath(each), reason)
|
|
3123
|
+
layouts = []
|
|
3124
|
+
for kdl in sorted((L.STATE / 'layouts').glob('*.kdl')):
|
|
3125
|
+
try:
|
|
3126
|
+
text = kdl.read_text()
|
|
3127
|
+
except (OSError, ValueError):
|
|
3128
|
+
continue
|
|
3129
|
+
said = f'the view layout of {plain(kdl.stem)} names it'
|
|
3130
|
+
layouts.append((said, text))
|
|
3131
|
+
for tree in layout_trees(text):
|
|
3132
|
+
named.setdefault(os.path.realpath(tree), said)
|
|
3133
|
+
plan = []
|
|
3134
|
+
for root in pkg_roots(home):
|
|
3135
|
+
for index, (tree, _mtime) in enumerate(staged_entries(root)):
|
|
3136
|
+
forms = {str(tree), os.path.realpath(tree)}
|
|
3137
|
+
plan.append([tree, forms, f'one of the {TREE_KEEP} newest' if index < TREE_KEEP else
|
|
3138
|
+
next((named[form] for form in forms if form in named), None)
|
|
3139
|
+
or next((said for said, text in layouts if any(form + '/' in text for form in forms)), None)])
|
|
3140
|
+
if not all(reason for _tree, _forms, reason in plan):
|
|
3141
|
+
scan = process_paths(proc)
|
|
3142
|
+
paths, live = scan if scan is not None else ({}, set())
|
|
3143
|
+
records = {}
|
|
3144
|
+
for name, record in run_records(L.STATE, '').items():
|
|
3145
|
+
if name in live and record['tree']:
|
|
3146
|
+
records.setdefault(os.path.realpath(record['tree']), f'{name} runs from it (run/{name}.tree)')
|
|
3147
|
+
for entry in plan:
|
|
3148
|
+
tree, forms, reason = entry
|
|
3149
|
+
if reason is None:
|
|
3150
|
+
entry[2] = ('the process table could not be read' if scan is None else
|
|
3151
|
+
next((records[form] for form in forms if form in records), None)
|
|
3152
|
+
or next((f'process {pid} uses it' for path, pid in paths.items()
|
|
3153
|
+
if any(path == form or path.startswith(form + '/') for form in forms)), None))
|
|
3154
|
+
return [(tree, reason) for tree, _forms, reason in plan]
|
|
3155
|
+
|
|
3156
|
+
|
|
3157
|
+
def gc_trees(home=None, proc=None):
|
|
3158
|
+
"""`lanes gc`'s staged-tree sweep (plan W9 U4): what an earlier sweep left half-removed (`.pruning-*`) goes first;
|
|
3159
|
+
then each tree staged_tree_plan gives no reason to keep is renamed to `.pruning-<version>-…` — one rename(2), so no
|
|
3160
|
+
half-removed tree is ever left under its version name, which the wrapper would reuse by its stamp — and removed.
|
|
3161
|
+
One line for each tree past the newest: `pruned <tree>`, or `kept <tree>: <reason>`; `skip <tree>: <error>`."""
|
|
3162
|
+
try:
|
|
3163
|
+
home = Path(home) if home is not None else Path.home()
|
|
3164
|
+
except (KeyError, RuntimeError): # no HOME and no passwd entry: no link and no default root to read
|
|
3165
|
+
return
|
|
3166
|
+
for root in pkg_roots(home):
|
|
3167
|
+
try:
|
|
3168
|
+
with os.scandir(root) as entries:
|
|
3169
|
+
remains = sorted(entry.path for entry in entries
|
|
3170
|
+
if entry.name.startswith(PRUNING) and entry.is_dir(follow_symlinks=False))
|
|
3171
|
+
except OSError:
|
|
3172
|
+
remains = []
|
|
3173
|
+
for path in remains:
|
|
3174
|
+
try:
|
|
3175
|
+
shutil.rmtree(path)
|
|
3176
|
+
print(f'pruned {home_shown(path, home)} (left by an earlier lanes gc)')
|
|
3177
|
+
except OSError as error:
|
|
3178
|
+
print(f'skip {home_shown(path, home)}: {error.strerror or error}')
|
|
3179
|
+
for tree, reason in staged_tree_plan(home, proc):
|
|
3180
|
+
shown = home_shown(tree, home)
|
|
3181
|
+
if reason == f'one of the {TREE_KEEP} newest':
|
|
3182
|
+
continue
|
|
3183
|
+
if reason is not None:
|
|
3184
|
+
print(f'kept {shown}: {reason}')
|
|
3185
|
+
continue
|
|
3186
|
+
aside = tree.with_name(f'{PRUNING}{tree.name}-{os.getpid()}-{time.time_ns()}')
|
|
3187
|
+
try:
|
|
3188
|
+
os.rename(tree, aside)
|
|
3189
|
+
except OSError as error:
|
|
3190
|
+
print(f'skip {shown}: {error.strerror or error}')
|
|
3191
|
+
continue
|
|
3192
|
+
try:
|
|
3193
|
+
shutil.rmtree(aside)
|
|
3194
|
+
except OSError as error:
|
|
3195
|
+
print(f'skip {shown}: {error.strerror or error} (left as {aside.name}; the next lanes gc removes it)')
|
|
3196
|
+
continue
|
|
3197
|
+
print(f'pruned {shown}')
|
|
3198
|
+
|
|
3199
|
+
|
|
2036
3200
|
def cmd_tmp(args):
|
|
2037
3201
|
return libexec_module('tmpscan').run(args) # verbs, options and the usage text are tmpscan's
|
|
2038
3202
|
|
|
@@ -2046,7 +3210,364 @@ def cmd_studio_host(args):
|
|
|
2046
3210
|
|
|
2047
3211
|
|
|
2048
3212
|
def cmd_keys(args):
|
|
2049
|
-
|
|
3213
|
+
# verbs, refusals and exit codes are keys.py's; a verb that names an owner (adopt --login, owner) ends by folding
|
|
3214
|
+
# the views of the devices it named into that person's profile (cockpit v3 R2), and a dropped fold is its exit 1
|
|
3215
|
+
return libexec_module('keys').main(args, on_owned=fold_profile)
|
|
3216
|
+
|
|
3217
|
+
|
|
3218
|
+
USER_USAGE = 'usage: lanes user list | lanes user show <login> [--json]'
|
|
3219
|
+
|
|
3220
|
+
|
|
3221
|
+
def shown(text):
|
|
3222
|
+
"""A file name for a terminal line: as it is when printable, else Python's escaped form (no control character from
|
|
3223
|
+
a directory listing reaches the terminal)."""
|
|
3224
|
+
return text if text.isprintable() else ascii(text)
|
|
3225
|
+
|
|
3226
|
+
|
|
3227
|
+
def shown_utc(epoch):
|
|
3228
|
+
"""A time as `lanes user show` and `lanes here` print it: `2026-10-07 09:46:40Z` (utc() is the ISO form the JSON
|
|
3229
|
+
of `lanes version` carries)."""
|
|
3230
|
+
return time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime(epoch))
|
|
3231
|
+
|
|
3232
|
+
|
|
3233
|
+
def look_words(look):
|
|
3234
|
+
"""`theme dark · glyphs ascii`: a validated look (users.LOOK keys and values) in LOOK order."""
|
|
3235
|
+
return ' · '.join(f'{key} {look[key]}' for key in ('theme', 'color', 'glyphs') if key in look)
|
|
3236
|
+
|
|
3237
|
+
|
|
3238
|
+
def device_looks(users, devices):
|
|
3239
|
+
"""{device: {'device_only': {…}, 'unfolded': {…}}} for each of `devices` whose devices/<device>.json keeps a
|
|
3240
|
+
look of its own (users.device_only / users.unfolded: validated values only), for `lanes user show` (DEF-1782):
|
|
3241
|
+
the keys that paint on that device whatever — or wherever — the profile says. Read-only."""
|
|
3242
|
+
looks = {}
|
|
3243
|
+
for device in devices:
|
|
3244
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
3245
|
+
own, early = users.device_only(meta), users.unfolded(meta)
|
|
3246
|
+
if own or early:
|
|
3247
|
+
looks[device] = {'device_only': own, 'unfolded': early}
|
|
3248
|
+
return looks
|
|
3249
|
+
|
|
3250
|
+
|
|
3251
|
+
def profile_lines(profile):
|
|
3252
|
+
"""[(label, value)] of one profile as `lanes user show` prints it (every value validated by users.normalize)."""
|
|
3253
|
+
look, seen = profile['look'], profile['seen']
|
|
3254
|
+
last, quiet = profile['last_opened'], profile['notify'].get('quiet_s')
|
|
3255
|
+
by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
|
|
3256
|
+
return [('look', look_words(look) or 'nothing set (the device file, the environment and the defaults decide)'),
|
|
3257
|
+
('seen', f"{len(seen)} lane{'s' if len(seen) != 1 else ''}" if seen else 'none'),
|
|
3258
|
+
('workspaces', f"order {' '.join(profile['workspaces']['order']) or '-'} · "
|
|
3259
|
+
f"hidden {' '.join(profile['workspaces']['hidden']) or '-'}"),
|
|
3260
|
+
('lanes', f"pinned {' '.join(profile['lanes']['pins']) or '-'} · "
|
|
3261
|
+
f"hidden {' '.join(profile['lanes']['hidden']) or '-'}"),
|
|
3262
|
+
('last opened', f"{last['lane']} on {last['device']} at {shown_utc(last['at'])}" if last else 'none'),
|
|
3263
|
+
('notify', f'quiet {quiet} s' if quiet is not None else 'default'),
|
|
3264
|
+
('updated', f"{shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'never')]
|
|
3265
|
+
|
|
3266
|
+
|
|
3267
|
+
def cmd_user(args):
|
|
3268
|
+
"""`lanes user list | show <login> [--json]` (cockpit v3 R2, LNS-75): the people lanes knows and one person's
|
|
3269
|
+
profile (users/<login>.json, libexec/users.py). A person is a login with a profile, or the owner of a device
|
|
3270
|
+
(users.device_logins: a view, a member record or seen marks, owned as device_owner derives it). show also names
|
|
3271
|
+
every look a device of the person keeps in its own devices/<device>.json (device_looks: --device-only keys, and
|
|
3272
|
+
keys from before the profile, with the claim that folds them in; DEF-1782) — why one device paints differently.
|
|
3273
|
+
Read-only: neither verb writes a file. Every value printed was validated (users.normalize, members.validate_owner,
|
|
3274
|
+
users.device_only/unfolded); a file name that is no profile is printed escaped."""
|
|
3275
|
+
users = libexec_module('users')
|
|
3276
|
+
if args == ['list']:
|
|
3277
|
+
profiles, problems = users.load_all()
|
|
3278
|
+
people = {profile['login']: {'profile': profile, 'devices': [], 'name': profile['name']}
|
|
3279
|
+
for profile in profiles}
|
|
3280
|
+
for device, login in users.device_logins().items():
|
|
3281
|
+
if login:
|
|
3282
|
+
person = people.setdefault(login, {'profile': None, 'devices': [], 'name': None})
|
|
3283
|
+
person['devices'].append(device)
|
|
3284
|
+
if not person['name']:
|
|
3285
|
+
person['name'] = (users.owner_of(device) or {}).get('name')
|
|
3286
|
+
for login, person in sorted(people.items()):
|
|
3287
|
+
profile = person['profile']
|
|
3288
|
+
if profile is None:
|
|
3289
|
+
state = 'no profile yet'
|
|
3290
|
+
else:
|
|
3291
|
+
by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
|
|
3292
|
+
state = f"profile updated {shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'profile'
|
|
3293
|
+
print(f"{login:<32} {person['name'] or '-':<24} {', '.join(person['devices']) or '-':<32} {state}")
|
|
3294
|
+
if not people:
|
|
3295
|
+
print(f'no users: no profile in {users.users_dir()} and no device with an owner — '
|
|
3296
|
+
'`lanes device claim <device> --login <login>` gives a device one')
|
|
3297
|
+
for name, reason in problems:
|
|
3298
|
+
print(f'⚠ users/{shown(name)}: {reason} — not read and never overwritten (fix or remove the file)',
|
|
3299
|
+
file=sys.stderr)
|
|
3300
|
+
return 0
|
|
3301
|
+
rest = [word for word in args[1:] if word != '--json']
|
|
3302
|
+
if args[:1] != ['show'] or len(rest) != 1 or len(args) - len(rest) > 2:
|
|
3303
|
+
raise L.LanesError(USER_USAGE)
|
|
3304
|
+
login = rest[0]
|
|
3305
|
+
profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
|
|
3306
|
+
devices = [device for device, owner in users.device_logins().items() if owner == login]
|
|
3307
|
+
if profile is None and problem is None and not devices:
|
|
3308
|
+
raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
|
|
3309
|
+
looks = device_looks(users, devices)
|
|
3310
|
+
if '--json' in args:
|
|
3311
|
+
print(json.dumps({'login': login, 'devices': devices, 'device_looks': looks, 'profile': profile,
|
|
3312
|
+
'problem': problem}, indent=2, ensure_ascii=False))
|
|
3313
|
+
return 1 if problem else 0
|
|
3314
|
+
name = profile['name'] if profile else next(
|
|
3315
|
+
(owner['name'] for owner in map(users.owner_of, devices) if owner and owner.get('name')), None)
|
|
3316
|
+
lines = [('login', login)] + ([('name', name)] if name else []) + [('devices', ', '.join(devices) or '-')]
|
|
3317
|
+
for device, look in looks.items():
|
|
3318
|
+
if look['unfolded']:
|
|
3319
|
+
lines.append(('device look', f"{device}: {look_words(look['unfolded'])} — from before the profile: "
|
|
3320
|
+
'painted only where the profile sets none; '
|
|
3321
|
+
f'`lanes device claim {device} --login {login}` folds it in'))
|
|
3322
|
+
if look['device_only']:
|
|
3323
|
+
lines.append(('device look', f"{device}: {look_words(look['device_only'])} — --device-only: wins over "
|
|
3324
|
+
f'the profile on {device}; the same flag without --device-only hands it back'))
|
|
3325
|
+
if problem:
|
|
3326
|
+
lines.append(('profile', f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or '
|
|
3327
|
+
'remove the file)'))
|
|
3328
|
+
elif profile is None:
|
|
3329
|
+
lines.append(('profile', f'none yet — `lanes device claim <device> --login {login}` or a look flag on '
|
|
3330
|
+
'`lanes attach` starts it'))
|
|
3331
|
+
else:
|
|
3332
|
+
lines += profile_lines(profile) + [('file', str(users.path(login)))]
|
|
3333
|
+
for label, value in lines:
|
|
3334
|
+
print(f'{label:<12} {value}')
|
|
3335
|
+
return 1 if problem else 0
|
|
3336
|
+
|
|
3337
|
+
|
|
3338
|
+
# ------------------------------------------------------------------ arrangement (cockpit v3 R6)
|
|
3339
|
+
# A person's boards and lanes are arranged once for every device they own: `lanes workspaces hide|show|order` and
|
|
3340
|
+
# `lanes lane pin|unpin|hide|show` write the `workspaces` and `lanes` lists of their profile users/<login>.json
|
|
3341
|
+
# (DM-LANES-USER-PROFILE) through users.update — its 1 s lock and merge, never a file this release does not read — and
|
|
3342
|
+
# `lanes here <device>` shows on that device the lane they opened last anywhere (`last_opened`). The person is the owner
|
|
3343
|
+
# of --device D (profile_for, as `lanes attach` derives it; else $LANES_DEVICE's) or --login L. Readers: the tabs of
|
|
3344
|
+
# every device of the person (workspace order and hidden boards, cockpit v3 R4) and their sidebars (pinned and hidden
|
|
3345
|
+
# lanes, R5). Nothing follows the person from device to device by itself: `lanes here` is asked for (v1).
|
|
3346
|
+
|
|
3347
|
+
ARRANGE_WORKSPACES = ('hide', 'show', 'order')
|
|
3348
|
+
LANE_VERBS = ('pin', 'unpin', 'hide', 'show')
|
|
3349
|
+
LANE_USAGE = 'usage: lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]'
|
|
3350
|
+
HERE_USAGE = 'usage: lanes here <device>'
|
|
3351
|
+
|
|
3352
|
+
|
|
3353
|
+
def target_args(args, usage):
|
|
3354
|
+
"""The words of `args` and the person they arrange -> (words, device, login): `--device D` or `--login L`, at most
|
|
3355
|
+
one of them and each once, anywhere among the words; any other option is the usage."""
|
|
3356
|
+
words, options, rest = [], {}, list(args)
|
|
3357
|
+
while rest:
|
|
3358
|
+
word = rest.pop(0)
|
|
3359
|
+
if word in ('--device', '--login'):
|
|
3360
|
+
if not rest or options:
|
|
3361
|
+
raise L.LanesError(usage)
|
|
3362
|
+
options[word] = rest.pop(0)
|
|
3363
|
+
elif word.startswith('-'):
|
|
3364
|
+
raise L.LanesError(usage)
|
|
3365
|
+
else:
|
|
3366
|
+
words.append(word)
|
|
3367
|
+
return words, options.get('--device'), options.get('--login')
|
|
3368
|
+
|
|
3369
|
+
|
|
3370
|
+
def profile_target(users, device, login, usage):
|
|
3371
|
+
"""The login whose profile an arranging verb writes. --login L: a person lanes knows (a profile, or a device they
|
|
3372
|
+
own — `lanes user list`). Else the device --device names, or $LANES_DEVICE: its owner (profile_for; a window such
|
|
3373
|
+
as mac-2 is its machine's owner's). The device only names the person: the write is the CLI's, stamped
|
|
3374
|
+
`cli:<command>` (arrange), never that device's — a verb run over SSH is nobody at the desk, and the push notifier
|
|
3375
|
+
must not mute that person's pushes for it (W9 review F3). Refused, never echoing an invalid value: no person
|
|
3376
|
+
named, a device nobody owns, an unknown login, or a profile file this release does not read (it is never
|
|
3377
|
+
overwritten)."""
|
|
3378
|
+
if login is None:
|
|
3379
|
+
device = device if device is not None else os.environ.get('LANES_DEVICE') or None
|
|
3380
|
+
if device is None:
|
|
3381
|
+
raise L.LanesError(f'{usage} — name the device (--device D, or LANES_DEVICE) or the person (--login L)')
|
|
3382
|
+
device = device_name(device)
|
|
3383
|
+
login = profile_for(device)
|
|
3384
|
+
if not login:
|
|
3385
|
+
raise L.LanesError(f'{device} has no owner, so it follows no profile: `lanes device claim {device} --login '
|
|
3386
|
+
'<login>` names one, or name the person with --login <login>')
|
|
3387
|
+
profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
|
|
3388
|
+
if problem:
|
|
3389
|
+
raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
|
|
3390
|
+
'the file); nothing changed')
|
|
3391
|
+
if device is None and profile is None and login not in users.device_logins().values():
|
|
3392
|
+
raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
|
|
3393
|
+
return login
|
|
3394
|
+
|
|
3395
|
+
|
|
3396
|
+
def arrange(users, login, command, change):
|
|
3397
|
+
"""users.update(login, `cli:<command>`, change) -> 0, then the profile row it changed is printed by the caller; 1
|
|
3398
|
+
when the write was dropped (the lock stayed busy for users.LOCK_S, or the store cannot be written), said on
|
|
3399
|
+
stderr. A refusal `change` raises under the lock propagates, and nothing is written. The stamp is the CLI writer
|
|
3400
|
+
(users.cli_writer), never a device: see profile_target."""
|
|
3401
|
+
if users.update(login, users.cli_writer(command), change):
|
|
3402
|
+
return 0
|
|
3403
|
+
print(f"lanes: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why); nothing "
|
|
3404
|
+
'changed — run it again', file=sys.stderr)
|
|
3405
|
+
return 1
|
|
3406
|
+
|
|
3407
|
+
|
|
3408
|
+
def arranged(users, login, label, groups):
|
|
3409
|
+
"""Print what an arranging verb did — `<words> for <login>: <ids>` per (words, ids) group with ids — and the
|
|
3410
|
+
profile's row `label` as `lanes user show` prints it."""
|
|
3411
|
+
for words, ids in groups:
|
|
3412
|
+
if ids:
|
|
3413
|
+
print(f"{words} for {login}: {' '.join(ids)}")
|
|
3414
|
+
profile = users.read(login) or users.empty(login)
|
|
3415
|
+
print(next(f'{name:<12} {value}' for name, value in profile_lines(profile) if name == label))
|
|
3416
|
+
|
|
3417
|
+
|
|
3418
|
+
def cmd_workspaces_profile(args):
|
|
3419
|
+
"""`lanes workspaces hide|show|order <id…> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's board
|
|
3420
|
+
arrangement, on every device they own. hide adds boards to `workspaces.hidden` — refused when no board would stay
|
|
3421
|
+
visible (decided under the profile's lock); show takes ids off it, also one no board has any more (no registry is
|
|
3422
|
+
read); order REPLACES `workspaces.order` with the ids given (each once; the boards it leaves out follow in catalog
|
|
3423
|
+
order, as the reader places them). hide and order take the board ids `lanes workspaces` prints: an SSH workspace or
|
|
3424
|
+
a tool tab is the site's, not arranged per person. -> 0, 1 when the write was dropped (arrange)."""
|
|
3425
|
+
words, device, login = target_args(args, WORKSPACES_USAGE)
|
|
3426
|
+
verb, ids = words[0], words[1:]
|
|
3427
|
+
if not ids:
|
|
3428
|
+
raise L.LanesError(WORKSPACES_USAGE)
|
|
3429
|
+
users = libexec_module('users')
|
|
3430
|
+
for board in ids:
|
|
3431
|
+
users.check_workspace(board) # never echoes an invalid value
|
|
3432
|
+
if verb == 'order':
|
|
3433
|
+
twice = next((board for index, board in enumerate(ids) if board in ids[:index]), None)
|
|
3434
|
+
if twice:
|
|
3435
|
+
raise L.LanesError(f'{twice} is named twice; nothing changed')
|
|
3436
|
+
ids = list(dict.fromkeys(ids))
|
|
3437
|
+
boards = [] if verb == 'show' else [workspace['id'] for workspace in L.load_registry()['workspaces']]
|
|
3438
|
+
unknown = next((board for board in ids if verb != 'show' and board not in boards), None)
|
|
3439
|
+
if unknown:
|
|
3440
|
+
raise L.LanesError(f'no board {unknown} (lanes workspaces lists them); nothing changed')
|
|
3441
|
+
login = profile_target(users, device, login, WORKSPACES_USAGE)
|
|
3442
|
+
groups = []
|
|
3443
|
+
|
|
3444
|
+
def change(profile):
|
|
3445
|
+
hidden, order = profile['workspaces']['hidden'], profile['workspaces']['order']
|
|
3446
|
+
if verb == 'order':
|
|
3447
|
+
follow = '(the other boards follow in catalog order)'
|
|
3448
|
+
groups[:] = [('order', ids + [follow]) if order != ids else ('order was already', ids)]
|
|
3449
|
+
profile['workspaces']['order'] = list(ids)
|
|
3450
|
+
return
|
|
3451
|
+
if verb == 'hide':
|
|
3452
|
+
new = [board for board in ids if board not in hidden]
|
|
3453
|
+
if len(hidden) + len(new) > users.MAX_WORKSPACES:
|
|
3454
|
+
raise L.LanesError(f'a profile keeps at most {users.MAX_WORKSPACES} hidden boards; nothing changed')
|
|
3455
|
+
if not [board for board in boards if board not in hidden + new]:
|
|
3456
|
+
raise L.LanesError('that would hide every board: at least one stays visible (`lanes workspaces show '
|
|
3457
|
+
'<id>` shows one again); nothing changed')
|
|
3458
|
+
hidden.extend(new)
|
|
3459
|
+
groups[:] = [('hidden', new), ('already hidden', [board for board in ids if board not in new])]
|
|
3460
|
+
return
|
|
3461
|
+
shown = [board for board in ids if board in hidden]
|
|
3462
|
+
profile['workspaces']['hidden'] = [board for board in hidden if board not in shown]
|
|
3463
|
+
groups[:] = [('shown again', shown), ('not hidden', [board for board in ids if board not in shown])]
|
|
3464
|
+
|
|
3465
|
+
code = arrange(users, login, 'workspaces', change)
|
|
3466
|
+
if code == 0:
|
|
3467
|
+
arranged(users, login, 'workspaces', groups)
|
|
3468
|
+
return code
|
|
3469
|
+
|
|
3470
|
+
|
|
3471
|
+
def cmd_lane(args):
|
|
3472
|
+
"""`lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's
|
|
3473
|
+
`lanes.pins` / `lanes.hidden`, on every device they own. pin takes a lane the boards list (registered, or a live
|
|
3474
|
+
unregistered session); hide only a registered one — the sidebar always lists an unregistered session that runs, so
|
|
3475
|
+
hiding it would be a silent no-op; unpin and show take any lane id off the list, also one that left the boards. At
|
|
3476
|
+
most users.MAX_LANES per list (refused beyond, never truncated). -> 0, 1 when the write was dropped (arrange)."""
|
|
3477
|
+
words, device, login = target_args(args, LANE_USAGE)
|
|
3478
|
+
if len(words) != 2 or words[0] not in LANE_VERBS:
|
|
3479
|
+
raise L.LanesError(LANE_USAGE)
|
|
3480
|
+
verb, lane_id = words
|
|
3481
|
+
users = libexec_module('users')
|
|
3482
|
+
users.check_lane(lane_id) # never echoes an invalid value
|
|
3483
|
+
if verb in ('pin', 'hide'):
|
|
3484
|
+
registry = L.load_registry()
|
|
3485
|
+
L.find_lane(registry, lane_id) # Unknown lane: <id>
|
|
3486
|
+
registered = any(lane['id'] == lane_id for workspace in registry['workspaces'] for lane in workspace['lanes'])
|
|
3487
|
+
if verb == 'hide' and not registered:
|
|
3488
|
+
raise L.LanesError(f'{lane_id} is not registered in a workspace: an unregistered session that runs is always '
|
|
3489
|
+
f'listed — `lanes register {lane_id}` adds it to its workspace first; nothing changed')
|
|
3490
|
+
login = profile_target(users, device, login, LANE_USAGE)
|
|
3491
|
+
key = 'pins' if verb in ('pin', 'unpin') else 'hidden'
|
|
3492
|
+
adds = verb in ('pin', 'hide')
|
|
3493
|
+
said = {'pin': ('pinned', 'already pinned'), 'unpin': ('unpinned', 'not pinned'),
|
|
3494
|
+
'hide': ('hidden', 'already hidden'), 'show': ('shown again', 'not hidden')}[verb]
|
|
3495
|
+
groups = []
|
|
3496
|
+
|
|
3497
|
+
def change(profile):
|
|
3498
|
+
listed = profile['lanes'][key]
|
|
3499
|
+
changed = (lane_id not in listed) if adds else (lane_id in listed)
|
|
3500
|
+
if changed and adds:
|
|
3501
|
+
if len(listed) >= users.MAX_LANES:
|
|
3502
|
+
raise L.LanesError(f"a profile keeps at most {users.MAX_LANES} {'pinned' if key == 'pins' else key} "
|
|
3503
|
+
f'lanes and {login}\'s has that many: {"unpin" if key == "pins" else "show"} one '
|
|
3504
|
+
'first; nothing changed')
|
|
3505
|
+
listed.append(lane_id)
|
|
3506
|
+
elif changed:
|
|
3507
|
+
listed.remove(lane_id)
|
|
3508
|
+
groups[:] = [(said[0] if changed else said[1], [lane_id])]
|
|
3509
|
+
|
|
3510
|
+
code = arrange(users, login, 'lane', change)
|
|
3511
|
+
if code == 0:
|
|
3512
|
+
arranged(users, login, 'lanes', groups)
|
|
3513
|
+
return code
|
|
3514
|
+
|
|
3515
|
+
|
|
3516
|
+
def cmd_here(args):
|
|
3517
|
+
"""`lanes here <device>` (cockpit v3 R6, LNS-79; `lanes-launch here` from a client): show on `device` the lane its
|
|
3518
|
+
owner opened last on any of their devices — the profile's `last_opened`, which a sidebar's Enter and `lanes
|
|
3519
|
+
next|prev` write when they open a lane with an agent — exactly as those do: its board's first viewport is pointed
|
|
3520
|
+
at it (lanes_lib.show_lane: the target file, SIGUSR1 to a running view.sh), and a lane with an agent is marked seen
|
|
3521
|
+
on the device and in the profile (mark_profile_seen: the mark and `last_opened` now name this device). When a view
|
|
3522
|
+
was woken, the board's tab is brought to the front (one bounded `zellij action go-to-tab-name`, as `lanes attach
|
|
3523
|
+
<workspace>` does). Says why it opens nothing (LanesError, exit 1): an unknown device, one nobody owns, no lane
|
|
3524
|
+
opened yet, a lane no board lists any more, or a board the person hides (cockpit v3 R4). Never follows by itself.
|
|
3525
|
+
-> 0."""
|
|
3526
|
+
if len(args) != 1 or args[0].startswith('-'):
|
|
3527
|
+
raise L.LanesError(HERE_USAGE)
|
|
3528
|
+
device = device_name(args[0])
|
|
3529
|
+
if not isinstance(L.read_json(L.STATE / 'devices' / f'{device}.json'), dict):
|
|
3530
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list)')
|
|
3531
|
+
login = profile_for(device)
|
|
3532
|
+
if not login:
|
|
3533
|
+
raise L.LanesError(f'{device} has no owner, so there is no profile to follow: `lanes device claim {device} '
|
|
3534
|
+
'--login <login>` names one')
|
|
3535
|
+
users = libexec_module('users')
|
|
3536
|
+
profile, problem = users.inspect(login)
|
|
3537
|
+
if problem:
|
|
3538
|
+
raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
|
|
3539
|
+
'the file)')
|
|
3540
|
+
last = (profile or {}).get('last_opened')
|
|
3541
|
+
if not last:
|
|
3542
|
+
raise L.LanesError(f'nothing to open: {login} has opened no lane with an agent yet (its profile records the last '
|
|
3543
|
+
'one opened)')
|
|
3544
|
+
when = f"on {last['device']} at {shown_utc(last['at'])}"
|
|
3545
|
+
try:
|
|
3546
|
+
workspace, lane = L.find_lane(L.load_registry(), last['lane'])
|
|
3547
|
+
except L.LanesError:
|
|
3548
|
+
raise L.LanesError(f"{last['lane']}, the lane {login} opened last ({when}), is not listed any more; nothing to "
|
|
3549
|
+
'open') from None
|
|
3550
|
+
if workspace['id'] in profile['workspaces']['hidden']:
|
|
3551
|
+
raise L.LanesError(f"{lane['name']} is on {workspace['title']}, which {login} hides on every device: `lanes "
|
|
3552
|
+
f"workspaces show {workspace['id']} --device {device}` shows it again")
|
|
3553
|
+
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
|
|
3554
|
+
agent = (status.get(lane['id']) or {}).get('agent') if isinstance(status, dict) else None
|
|
3555
|
+
if isinstance(agent, dict) and agent:
|
|
3556
|
+
seen = L.record_seen(L.STATE / 'clients' / f'{device}.seen.json', lane['id'], agent.get('since'), set(status))
|
|
3557
|
+
mark_profile_seen(device, lane['id'], seen[lane['id']], 'here')
|
|
3558
|
+
woken = L.show_lane(str(L.STATE / 'views' / device), workspace['id'], 'main', lane['id'])
|
|
3559
|
+
print(f"{device}: {lane['name']} ({workspace['title']}) — the lane {login} opened last, {when}")
|
|
3560
|
+
if lane['id'] in profile['lanes']['hidden']:
|
|
3561
|
+
print(f"{lane['name']} is hidden from your lists: `lanes lane show {lane['id']} --device {device}` lists it "
|
|
3562
|
+
'again')
|
|
3563
|
+
if not woken:
|
|
3564
|
+
print(f"{device}'s view of {workspace['title']} is not running: it shows {lane['name']} when it starts")
|
|
3565
|
+
return 0
|
|
3566
|
+
rc, out, err = zellij(['-s', device, 'action', 'go-to-tab-name', workspace['title']], 10)
|
|
3567
|
+
if rc != 0:
|
|
3568
|
+
reason = 'no answer in 10 s' if rc is None else (err.strip() or out.strip() or f'exit {rc}')
|
|
3569
|
+
print(f"the {workspace['title']} tab was not brought to the front ({reason}); Ctrl-Alt-←/→ reaches it")
|
|
3570
|
+
return 0
|
|
2050
3571
|
|
|
2051
3572
|
|
|
2052
3573
|
def cmd_invite(args):
|
|
@@ -2083,18 +3604,25 @@ def revoke_web_token(device, windows=()):
|
|
|
2083
3604
|
|
|
2084
3605
|
def end_view(device):
|
|
2085
3606
|
"""Offboard step 4 -> (status, sentence): end the view session `device` the way `lanes rebuild` does (kill, then
|
|
2086
|
-
delete the serialized session) without its reconnect stamp and without recreating it
|
|
2087
|
-
|
|
2088
|
-
|
|
2089
|
-
|
|
2090
|
-
|
|
2091
|
-
|
|
2092
|
-
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
3607
|
+
delete the serialized session) without its reconnect stamp and without recreating it, under the view-session lock
|
|
3608
|
+
(DEF-1792). No lane is touched."""
|
|
3609
|
+
with contextlib.ExitStack() as stack:
|
|
3610
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes offboard {device}')
|
|
3611
|
+
if refused:
|
|
3612
|
+
return 'failed', f'the view session {device} was not ended ({refused}); `lanes offboard {device} --yes` again'
|
|
3613
|
+
try:
|
|
3614
|
+
live = live_sessions()
|
|
3615
|
+
except L.LanesError as error:
|
|
3616
|
+
return 'failed', (f'whether the view session {device} runs is unknown ({error}); '
|
|
3617
|
+
f'`lanes offboard {device} --yes` again')
|
|
3618
|
+
if device not in live:
|
|
3619
|
+
return 'none', f'no view session {device} is running'
|
|
3620
|
+
rc = zellij(['kill-session', device], 30)[0]
|
|
3621
|
+
if rc != 0:
|
|
3622
|
+
return 'failed', (f'the view session {device} did not end ('
|
|
3623
|
+
+ ('no answer in 30 s' if rc is None else f'exit {rc}')
|
|
3624
|
+
+ f'); `lanes offboard {device} --yes` again')
|
|
3625
|
+
zellij(['delete-session', '--force', device], 30)
|
|
2098
3626
|
return 'done', f'ended view session {device} (no lane was touched)'
|
|
2099
3627
|
|
|
2100
3628
|
|
|
@@ -2232,14 +3760,118 @@ def socket_report(containers, live_by_container):
|
|
|
2232
3760
|
return warnings
|
|
2233
3761
|
|
|
2234
3762
|
|
|
3763
|
+
DEVICE_USAGE = 'usage: lanes device list | lanes device forget <name> | lanes device claim <device> --login L [--name N]'
|
|
3764
|
+
|
|
3765
|
+
|
|
3766
|
+
def claim_args(rest):
|
|
3767
|
+
"""[device, --login L (, --name N)] in any order of the two options, each once, --login required ->
|
|
3768
|
+
(device, login, name). Anything else is the usage."""
|
|
3769
|
+
if len(rest) not in (3, 5):
|
|
3770
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3771
|
+
options = {}
|
|
3772
|
+
for flag, value in zip(rest[1::2], rest[2::2]):
|
|
3773
|
+
if flag not in ('--login', '--name') or flag in options:
|
|
3774
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3775
|
+
options[flag] = value
|
|
3776
|
+
if '--login' not in options:
|
|
3777
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3778
|
+
return rest[0], options['--login'], options.get('--name')
|
|
3779
|
+
|
|
3780
|
+
|
|
3781
|
+
def deciding_member(members, device, meta):
|
|
3782
|
+
"""(name, owner or None) of the member record that decides who owns `device` — members/<device>.json, else
|
|
3783
|
+
members/<base>.json (members.owner_for's order) — or None while neither exists. A record that exists but does not
|
|
3784
|
+
load is refused here (owner_for would keep the recorded owner beside it, but a claim must not be written while it
|
|
3785
|
+
is there)."""
|
|
3786
|
+
base = meta.get('base')
|
|
3787
|
+
for name in [device] + ([base] if isinstance(base, str) and base != device else []):
|
|
3788
|
+
if not L.IDENT.match(name) or len(name) > 32: # a corrupt base names no member file
|
|
3789
|
+
continue
|
|
3790
|
+
try:
|
|
3791
|
+
document = members.read(name)
|
|
3792
|
+
except L.LanesError as error:
|
|
3793
|
+
raise L.LanesError(f'{error} — while it exists it, not a claim, decides who owns {device}: fix or remove it '
|
|
3794
|
+
'first; nothing changed') from None
|
|
3795
|
+
if document is not None:
|
|
3796
|
+
return name, members.valid_owner(document.get('owner'))
|
|
3797
|
+
return None
|
|
3798
|
+
|
|
3799
|
+
|
|
3800
|
+
def claimed_owner(before, owner, renamed):
|
|
3801
|
+
"""The owner a claim records on a view whose valid recorded owner is `before` (or None): `owner` ({login, name,
|
|
3802
|
+
email: null}) for a view another login or nobody holds; for the login it records already, that record itself — its
|
|
3803
|
+
name and email kept — with only the name replaced when the claim gave --name (`renamed`). So a claim run again
|
|
3804
|
+
without --name, as fold_profile's repair hint words it, changes nothing that is done."""
|
|
3805
|
+
if before and before['login'] == owner['login']:
|
|
3806
|
+
return dict(before, name=owner['name']) if renamed else before
|
|
3807
|
+
return owner
|
|
3808
|
+
|
|
3809
|
+
|
|
3810
|
+
def owner_words(owner):
|
|
3811
|
+
"""`login (name)` — or the login alone — of a validated owner, or `none`."""
|
|
3812
|
+
if not owner:
|
|
3813
|
+
return 'none'
|
|
3814
|
+
return owner['login'] + (f" ({owner['name']})" if owner['name'] else '')
|
|
3815
|
+
|
|
3816
|
+
|
|
3817
|
+
def cmd_device_claim(rest):
|
|
3818
|
+
"""`lanes device claim <device> --login L [--name N]` (cockpit v3 R2, LNS-75): name the owner of a view no member
|
|
3819
|
+
record speaks for — a device from before enrolment (W8) — so it follows L's profile. Writes `owner` into
|
|
3820
|
+
devices/<device>.json, where device_owner keeps it across every rewrite (create_session, record_base) while no
|
|
3821
|
+
member record exists, and into each of its windows' files (views whose recorded base is the device) that have no
|
|
3822
|
+
member record of their own; then folds those views into L's profile (fold_profile). A member record decides
|
|
3823
|
+
ownership whenever one exists (DM-LANES-DEVICE-OWNER): a claim that disagrees with it is refused with the verb that
|
|
3824
|
+
changes it, one that agrees writes no owner and only folds. A view L already owns keeps its recorded name and email
|
|
3825
|
+
unless --name replaces the name (claimed_owner), so re-running a claim — with or without --name — changes nothing
|
|
3826
|
+
that is done and finishes a fold that was dropped -> 0, or 1 when the profile could not be written."""
|
|
3827
|
+
device, login, name = claim_args(rest)
|
|
3828
|
+
device = device_name(device)
|
|
3829
|
+
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
3830
|
+
meta = L.read_json(meta_file) if meta_file.exists() else None
|
|
3831
|
+
if not isinstance(meta, dict):
|
|
3832
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list): a claim names the owner of a view '
|
|
3833
|
+
'lanes keeps')
|
|
3834
|
+
members = libexec_module('members')
|
|
3835
|
+
owner = members.validate_owner({'login': login, 'name': name})
|
|
3836
|
+
login = owner['login']
|
|
3837
|
+
decided = deciding_member(members, device, meta)
|
|
3838
|
+
if decided:
|
|
3839
|
+
record, current = decided
|
|
3840
|
+
if not current or current['login'] != login:
|
|
3841
|
+
who = current['login'] if current else 'nobody: a key added by hand'
|
|
3842
|
+
raise L.LanesError(f'members/{record}.json decides who owns {device} ({who}); a claim never overrides a '
|
|
3843
|
+
f'member record — `lanes keys owner {record} {login}` names its owner; nothing changed')
|
|
3844
|
+
print(f"{device} is already {login}'s: members/{record}.json names {login}")
|
|
3845
|
+
else:
|
|
3846
|
+
before = members.valid_owner(meta.get('owner'))
|
|
3847
|
+
after = claimed_owner(before, owner, name is not None)
|
|
3848
|
+
if before == after:
|
|
3849
|
+
print(f"{device} is already owned by {login} ("
|
|
3850
|
+
+ (f"{before['name']}, " if before['name'] else '') + f'devices/{device}.json)')
|
|
3851
|
+
else:
|
|
3852
|
+
L.atomic_write(meta_file, json.dumps(dict(meta, owner=after)) + '\n')
|
|
3853
|
+
print(f'owner of {device}: {owner_words(before)} -> {owner_words(after)}')
|
|
3854
|
+
for window, window_meta in windows_of({device}).items():
|
|
3855
|
+
before = members.valid_owner(window_meta.get('owner'))
|
|
3856
|
+
after = claimed_owner(before, owner, name is not None)
|
|
3857
|
+
if (members.members_dir() / f'{window}.json').exists():
|
|
3858
|
+
print(f'kept {window} (a window of {device}): members/{window}.json decides who owns it')
|
|
3859
|
+
elif not decided and before != after:
|
|
3860
|
+
L.atomic_write(L.STATE / 'devices' / f'{window}.json', json.dumps(dict(window_meta, owner=after)) + '\n')
|
|
3861
|
+
print(f'owner of {window} (a window of {device}): {owner_words(before)} -> {owner_words(after)}')
|
|
3862
|
+
return fold_profile(login, [device])
|
|
3863
|
+
|
|
3864
|
+
|
|
2235
3865
|
def cmd_device(args):
|
|
2236
|
-
"""lanes device list |
|
|
2237
|
-
recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes
|
|
2238
|
-
view session is never ended here. list prints the owner's login as the next rewrite
|
|
2239
|
-
member record's whenever one exists, so it agrees with `lanes keys list`; else the
|
|
2240
|
-
for none — a devices/*.json or members/*.json value never reaches the terminal
|
|
2241
|
-
|
|
3866
|
+
"""lanes device list | forget <name> | claim <device> --login L [--name N]: the devices/<name>.json files `lanes
|
|
3867
|
+
ensure` (lanes-ensure.timer) recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes
|
|
3868
|
+
that file only: a running view session is never ended here. list prints the owner's login as the next rewrite
|
|
3869
|
+
records it (device_owner: the member record's whenever one exists, so it agrees with `lanes keys list`; else the
|
|
3870
|
+
validated recorded one), blank for none — a devices/*.json or members/*.json value never reaches the terminal
|
|
3871
|
+
unchecked. claim: cmd_device_claim."""
|
|
2242
3872
|
devices = L.STATE / 'devices'
|
|
3873
|
+
if args[:1] == ['claim']:
|
|
3874
|
+
return cmd_device_claim(args[1:])
|
|
2243
3875
|
if args == ['list']:
|
|
2244
3876
|
try:
|
|
2245
3877
|
live = live_sessions()
|
|
@@ -2257,17 +3889,23 @@ def cmd_device(args):
|
|
|
2257
3889
|
+ (f" base {base}" if base else ''))
|
|
2258
3890
|
return 0
|
|
2259
3891
|
if len(args) != 2 or args[0] != 'forget':
|
|
2260
|
-
raise L.LanesError(
|
|
3892
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
2261
3893
|
device = device_name(args[1])
|
|
2262
3894
|
meta_file = devices / f'{device}.json'
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
3895
|
+
with contextlib.ExitStack() as stack:
|
|
3896
|
+
# Under the view-session lock (DEF-1792): a `lanes ensure` pass that listed this device before the unlink and
|
|
3897
|
+
# recreated its view after it would write devices/<device>.json again (create_session), undoing the forget.
|
|
3898
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes device forget {device}')
|
|
3899
|
+
if refused:
|
|
3900
|
+
raise L.LanesError(f'{refused}: {device} was not forgotten')
|
|
3901
|
+
if not meta_file.exists():
|
|
3902
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list)')
|
|
3903
|
+
meta = L.read_json(meta_file) or {}
|
|
3904
|
+
try:
|
|
3905
|
+
live = device in live_sessions()
|
|
3906
|
+
except L.LanesError:
|
|
3907
|
+
live = None
|
|
3908
|
+
meta_file.unlink()
|
|
2271
3909
|
kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
|
|
2272
3910
|
print(f'forgot {device} ({kind}): `lanes ensure` (lanes-ensure.timer) no longer recreates its view session, and '
|
|
2273
3911
|
'`lanes sync` / `upgrade-session` no longer update it')
|
|
@@ -2352,6 +3990,268 @@ def web_doctor():
|
|
|
2352
3990
|
return lines
|
|
2353
3991
|
|
|
2354
3992
|
|
|
3993
|
+
# ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
|
|
3994
|
+
# `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
|
|
3995
|
+
# uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
|
|
3996
|
+
# record whose process runs now is a fault (a stopped economy poller keeps its last one); a lanes process that runs
|
|
3997
|
+
# with NO record started from a release before the records (W9 U0) — what a server install that only `enable --now`s
|
|
3998
|
+
# statusd leaves running. Layouts and units are judged by the tree they resolve to; a layout that names the current tree
|
|
3999
|
+
# by another path than `lanes rebuild` writes now (a pinned pkg/<version> written before W9 U1, which writes through
|
|
4000
|
+
# `current`) is a warning: it runs the current tree, but the next update leaves its views behind.
|
|
4001
|
+
|
|
4002
|
+
# record_tree names by the script their process runs, in the argv Lanes starts it with (every release so far): the
|
|
4003
|
+
# units' `/usr/bin/python3 -S -E <tree>/libexec/<script> …` and the sidebar wrapper's `python3 -S -E
|
|
4004
|
+
# <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
|
|
4005
|
+
# hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
|
|
4006
|
+
# records a oneshot step (install/enable-studio-host.sh), never a running process.
|
|
4007
|
+
RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'notify': 'notify.py', 'economy': 'economy_poll.py',
|
|
4008
|
+
'sidebar': 'sidebar.py'}
|
|
4009
|
+
RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
|
|
4010
|
+
RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
|
|
4011
|
+
# The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
|
|
4012
|
+
# its first argument itself (`sys.argv[1] == '--once'`); notify and economy_poll use argparse, which takes the option
|
|
4013
|
+
# anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
|
|
4014
|
+
RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
|
|
4015
|
+
RUN_FIRST_ARGUMENT = {'statusd'}
|
|
4016
|
+
RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'notify': 'lanes-notify.service',
|
|
4017
|
+
'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
|
|
4018
|
+
LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
|
|
4019
|
+
LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
|
|
4020
|
+
UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
|
|
4021
|
+
CURRENT_SHOWN = '~/' + L.CURRENT_LINK
|
|
4022
|
+
|
|
4023
|
+
|
|
4024
|
+
def run_name(argv):
|
|
4025
|
+
"""The RUN_SCRIPTS name of the daemon a process with this `argv` is, or None. It is one only in the shape Lanes
|
|
4026
|
+
starts it: argv[0] a python3 interpreter, then exactly RUN_FLAGS, then an absolute `<tree>/libexec/<script>`, and
|
|
4027
|
+
no one-shot among the script's arguments, read as that script reads them."""
|
|
4028
|
+
if len(argv) < 4 or not RUN_INTERPRETER.fullmatch(os.path.basename(argv[0])) or argv[1:3] != RUN_FLAGS \
|
|
4029
|
+
or not os.path.isabs(argv[3]):
|
|
4030
|
+
return None
|
|
4031
|
+
name = next((name for name, script in RUN_SCRIPTS.items() if argv[3].endswith('/libexec/' + script)), None)
|
|
4032
|
+
args, oneshots = argv[4:], RUN_ONESHOTS.get(name, ())
|
|
4033
|
+
if name in RUN_FIRST_ARGUMENT:
|
|
4034
|
+
oneshot = bool(args) and args[0] in oneshots
|
|
4035
|
+
else:
|
|
4036
|
+
oneshot = any(len(word) > 2 and flag.startswith(word) for word in args for flag in oneshots)
|
|
4037
|
+
return None if oneshot else name
|
|
4038
|
+
|
|
4039
|
+
|
|
4040
|
+
def lanes_processes(table):
|
|
4041
|
+
"""The RUN_SCRIPTS names of this account's daemons that run now, from a `ps -eo pid=,comm=,args=` table. A row that
|
|
4042
|
+
names a /libexec/<script> is only a candidate: its argv is read from /proc/<pid>/cmdline — the words as exec'd, a
|
|
4043
|
+
tree path with a space intact, where the table joins them with spaces — and judged by run_name, for a /proc/<pid>
|
|
4044
|
+
that is this account's (gone since the sweep: none)."""
|
|
4045
|
+
found = set()
|
|
4046
|
+
for line in table.splitlines():
|
|
4047
|
+
pid, _, rest = line.strip().partition(' ')
|
|
4048
|
+
if not pid.isdigit() or not any(f'/libexec/{script}' in rest for script in RUN_SCRIPTS.values()):
|
|
4049
|
+
continue
|
|
4050
|
+
try:
|
|
4051
|
+
if (PROC / pid).stat().st_uid != os.geteuid():
|
|
4052
|
+
continue
|
|
4053
|
+
cmdline = (PROC / pid / 'cmdline').read_bytes()
|
|
4054
|
+
except OSError:
|
|
4055
|
+
continue
|
|
4056
|
+
words = cmdline[:-1] if cmdline.endswith(b'\0') else cmdline # each word NUL-terminated
|
|
4057
|
+
name = run_name([os.fsdecode(word) for word in words.split(b'\0')])
|
|
4058
|
+
if name is not None:
|
|
4059
|
+
found.add(name)
|
|
4060
|
+
return found
|
|
4061
|
+
|
|
4062
|
+
|
|
4063
|
+
def current_doctor(report, home=None):
|
|
4064
|
+
"""(mark, text): `current` names a tree that exists, whose VERSION agrees with its staged `<version>` directory name
|
|
4065
|
+
(an npm-staged tree, L.staged_tree: `pkg/<version>`, or `$LANES_PKG_ROOT/<version>` with the stamp) and its npm
|
|
4066
|
+
stamp, and that this lanes runs."""
|
|
4067
|
+
tree, current = os.path.realpath(report['tree']), report['current']
|
|
4068
|
+
if current is None:
|
|
4069
|
+
return ('⚠', f'no {CURRENT_SHOWN} link (the server installer writes it): layouts, units and run records are '
|
|
4070
|
+
f'compared with this tree, {tree_shown(tree, home)}')
|
|
4071
|
+
shown = tree_shown(current, home)
|
|
4072
|
+
if not os.path.isdir(current):
|
|
4073
|
+
return ('✗', f'current → {shown} does not exist — re-run the server installer')
|
|
4074
|
+
version = L.tree_version(current)
|
|
4075
|
+
if version is None:
|
|
4076
|
+
return ('✗', f'current → {shown} has no VERSION — re-run the server installer')
|
|
4077
|
+
named = Path(current).name if L.staged_tree(current) else version
|
|
4078
|
+
stamped = (L.tree_stamp(current) or {}).get('version') or version
|
|
4079
|
+
if (named, stamped) != (version, version):
|
|
4080
|
+
return ('✗', f'current → {shown}, but its VERSION says {plain(version)}'
|
|
4081
|
+
+ (f' and its npm stamp {plain(stamped)}' if stamped != version else '')
|
|
4082
|
+
+ ' — re-run the server installer')
|
|
4083
|
+
if current != tree:
|
|
4084
|
+
return ('✗', f'current → {shown} ({plain(version)}), but this lanes runs {tree_shown(tree, home)} '
|
|
4085
|
+
f'({plain(report["version"])}) — run {CURRENT_SHOWN}/bin/lanes; when ~/.local/bin/lanes is this '
|
|
4086
|
+
'one, re-run the server installer')
|
|
4087
|
+
return ('✓', f'current → {shown} ({plain(version)}), the release this lanes runs')
|
|
4088
|
+
|
|
4089
|
+
|
|
4090
|
+
def records_doctor(report, table, reference, home=None):
|
|
4091
|
+
"""[(mark, text)]: every lanes process that runs has a record of the current code. A record whose process is not
|
|
4092
|
+
running is information, never a fault; without a process table (`table` None) every daemon's counts."""
|
|
4093
|
+
running = report['running']
|
|
4094
|
+
live = lanes_processes(table) if table is not None else set(RUN_SCRIPTS)
|
|
4095
|
+
faults, stopped, restart = [], [], []
|
|
4096
|
+
for name, record in running.items():
|
|
4097
|
+
if record['current']:
|
|
4098
|
+
continue
|
|
4099
|
+
if name in live:
|
|
4100
|
+
faults.append(f'{plain(name)} {record_said(record, reference, home)}')
|
|
4101
|
+
restart.append(name)
|
|
4102
|
+
else:
|
|
4103
|
+
stopped.append(f'{plain(name)} {record_said(record, reference, home, past=True)}')
|
|
4104
|
+
if table is not None:
|
|
4105
|
+
for name in [name for name in RUN_ORDER if name in live and name not in running]:
|
|
4106
|
+
faults.append(f'{name} runs without a run record (started by an older release)')
|
|
4107
|
+
restart.append(name)
|
|
4108
|
+
lines = []
|
|
4109
|
+
if faults:
|
|
4110
|
+
units = [RUN_UNITS[name] for name in RUN_ORDER if name in restart and name in RUN_UNITS]
|
|
4111
|
+
hints = ([f"systemctl --user restart {' '.join(units)}"] if units else []) + \
|
|
4112
|
+
(['lanes rebuild <device>'] if 'sidebar' in restart else [])
|
|
4113
|
+
lines.append(('✗', 'processes run the current release — ' + '; '.join(faults)
|
|
4114
|
+
+ ('' if table is not None else ' (the process table was not read: every older record counts)')
|
|
4115
|
+
+ (' — ' + '; '.join(hints) if hints else '')))
|
|
4116
|
+
else:
|
|
4117
|
+
fresh = [plain(name) for name, record in running.items() if record['current']]
|
|
4118
|
+
lines.append(('✓', 'processes run the current release' + (': ' + ' '.join(fresh) if fresh else '')))
|
|
4119
|
+
if stopped:
|
|
4120
|
+
lines.append(('', 'not running now, so not judged (a record stays until its process starts again): '
|
|
4121
|
+
+ '; '.join(stopped)))
|
|
4122
|
+
return lines
|
|
4123
|
+
|
|
4124
|
+
|
|
4125
|
+
def layout_commands(text):
|
|
4126
|
+
"""The pane commands of a generated layout's text, in order, as q() wrote them (a JSON string after `command=`);
|
|
4127
|
+
one that does not decode is skipped."""
|
|
4128
|
+
commands = []
|
|
4129
|
+
for match in LAYOUT_COMMAND.finditer(text):
|
|
4130
|
+
try:
|
|
4131
|
+
commands.append(json.loads(match.group(1)))
|
|
4132
|
+
except ValueError:
|
|
4133
|
+
continue
|
|
4134
|
+
return commands
|
|
4135
|
+
|
|
4136
|
+
|
|
4137
|
+
def layout_trees(text):
|
|
4138
|
+
"""The trees a generated layout runs: each pane command naming one of LAYOUT_FILES, without that file."""
|
|
4139
|
+
trees = []
|
|
4140
|
+
for command in layout_commands(text):
|
|
4141
|
+
for name in LAYOUT_FILES:
|
|
4142
|
+
if command.endswith('/' + name) and command[:-len(name) - 1] not in trees:
|
|
4143
|
+
trees.append(command[:-len(name) - 1])
|
|
4144
|
+
return trees
|
|
4145
|
+
|
|
4146
|
+
|
|
4147
|
+
def layout_tree_now():
|
|
4148
|
+
"""The tree path `lanes rebuild` writes into a layout now, read from the generator itself: L.CURRENT (W9 U1) —
|
|
4149
|
+
the `current` link for a staged tree it names, else this tree's own path."""
|
|
4150
|
+
return layout_trees('\n'.join(workspace_tab_kdl('probe', {'id': 'probe', 'title': 'probe'})))[0]
|
|
4151
|
+
|
|
4152
|
+
|
|
4153
|
+
def layouts_doctor(reference, home=None):
|
|
4154
|
+
"""(mark, text): each device's layout (devices/<device>.json with a layouts/<device>.kdl) runs the current tree
|
|
4155
|
+
(✗ with `lanes rebuild <device>`), through the path `lanes rebuild` writes now (⚠ when it names the current tree by
|
|
4156
|
+
another path). A layout that runs no tree file (or none at all) says nothing."""
|
|
4157
|
+
now, faults, pinned, fresh = layout_tree_now(), [], {}, []
|
|
4158
|
+
for meta in sorted((L.STATE / 'devices').glob('*.json')):
|
|
4159
|
+
device = plain(meta.stem)
|
|
4160
|
+
try:
|
|
4161
|
+
trees = layout_trees((L.STATE / 'layouts' / f'{meta.stem}.kdl').read_text())
|
|
4162
|
+
except (OSError, ValueError):
|
|
4163
|
+
continue
|
|
4164
|
+
if not trees:
|
|
4165
|
+
continue
|
|
4166
|
+
other = [tree for tree in trees if os.path.realpath(tree) != reference]
|
|
4167
|
+
if other:
|
|
4168
|
+
ran = [plain(L.tree_version(tree) or tree_shown(tree, home)) for tree in other]
|
|
4169
|
+
faults.append((device, f"{device} runs {', '.join(ran)}"))
|
|
4170
|
+
continue
|
|
4171
|
+
fresh.append(device)
|
|
4172
|
+
for tree in trees:
|
|
4173
|
+
if tree != now:
|
|
4174
|
+
pinned.setdefault(tree_shown(tree, home), []).append(device)
|
|
4175
|
+
if faults:
|
|
4176
|
+
return ('✗', 'view layouts run the current release — ' + '; '.join(said for _device, said in faults)
|
|
4177
|
+
+ ' — ' + '; '.join(f'lanes rebuild {device}' for device, _said in faults))
|
|
4178
|
+
if pinned:
|
|
4179
|
+
devices = list(dict.fromkeys(device for named in pinned.values() for device in named))
|
|
4180
|
+
return ('⚠', '; '.join(f"view layouts of {' '.join(named)} name {shown} by its own path"
|
|
4181
|
+
for shown, named in pinned.items())
|
|
4182
|
+
+ f', not as lanes rebuild writes them now ({tree_shown(now, home)}): the next update leaves those '
|
|
4183
|
+
'views on it — ' + '; '.join(f'lanes rebuild {device}' for device in devices))
|
|
4184
|
+
return ('✓', 'view layouts run the current release' + (': ' + ' '.join(fresh) if fresh else ''))
|
|
4185
|
+
|
|
4186
|
+
|
|
4187
|
+
def unit_dirs(home=None):
|
|
4188
|
+
"""Where install/services.py writes the units: the user manager's $XDG_CONFIG_HOME/systemd/user (this shell's
|
|
4189
|
+
XDG_CONFIG_HOME stands in for it: doctor never asks systemctl here) and ~/.config/systemd/user."""
|
|
4190
|
+
home = Path(home) if home is not None else Path.home()
|
|
4191
|
+
xdg = os.environ.get('XDG_CONFIG_HOME', '')
|
|
4192
|
+
folders = ([Path(xdg) / 'systemd/user'] if xdg.startswith('/') else []) + [home / '.config/systemd/user']
|
|
4193
|
+
return list(dict.fromkeys(folders))
|
|
4194
|
+
|
|
4195
|
+
|
|
4196
|
+
def unit_trees(home=None):
|
|
4197
|
+
"""{unit: tree or None} for each lanes unit of this release whose template's ExecStart runs a tree file
|
|
4198
|
+
(`"@REPO@/<file>"`) and that is installed (the first of unit_dirs that has it): the tree its ExecStart names,
|
|
4199
|
+
unescaped as services.unit_value escaped it, or None when it names no `<tree>/<file>` (written by hand)."""
|
|
4200
|
+
found = {}
|
|
4201
|
+
for template in sorted((L.REPO / 'systemd').glob('lanes*.service')):
|
|
4202
|
+
entry = next((UNIT_ENTRY.search(line) for line in template.read_text().splitlines()
|
|
4203
|
+
if line.startswith('ExecStart=') and UNIT_ENTRY.search(line)), None)
|
|
4204
|
+
if entry is None:
|
|
4205
|
+
continue
|
|
4206
|
+
pattern = re.compile(r'"((?:[^"\\]|\\.)*)/' + re.escape(entry.group(1)) + '"')
|
|
4207
|
+
for folder in unit_dirs(home):
|
|
4208
|
+
try:
|
|
4209
|
+
text = (folder / template.name).read_text()
|
|
4210
|
+
except (OSError, ValueError):
|
|
4211
|
+
continue
|
|
4212
|
+
match = next((pattern.search(line) for line in text.splitlines()
|
|
4213
|
+
if line.startswith('ExecStart=') and pattern.search(line)), None)
|
|
4214
|
+
found[template.name] = (re.sub(r'\\(.)', r'\1', match.group(1).replace('$$', '$').replace('%%', '%'))
|
|
4215
|
+
if match else None)
|
|
4216
|
+
break
|
|
4217
|
+
return found
|
|
4218
|
+
|
|
4219
|
+
|
|
4220
|
+
def units_doctor(reference, home=None):
|
|
4221
|
+
"""(mark, text): every installed lanes unit's ExecStart runs the current tree (✗: re-run the server installer)."""
|
|
4222
|
+
trees = unit_trees(home)
|
|
4223
|
+
if not trees:
|
|
4224
|
+
shown = ' or '.join(tree_shown(folder, home) for folder in unit_dirs(home))
|
|
4225
|
+
return ('⚠', f'lanes units not checked: none in {shown} (the server installer writes them)')
|
|
4226
|
+
other = {}
|
|
4227
|
+
for unit, tree in trees.items():
|
|
4228
|
+
if tree is None:
|
|
4229
|
+
other.setdefault(None, []).append(unit)
|
|
4230
|
+
elif os.path.realpath(tree) != reference:
|
|
4231
|
+
other.setdefault(plain(L.tree_version(tree) or tree_shown(tree, home)), []).append(unit)
|
|
4232
|
+
if not other:
|
|
4233
|
+
return ('✓', f'lanes units run the current release ({len(trees)} units)')
|
|
4234
|
+
said = []
|
|
4235
|
+
for ran, units in other.items():
|
|
4236
|
+
names, one = ' '.join(units[:3]) + (' …' if len(units) > 3 else ''), len(units) == 1
|
|
4237
|
+
said.append(f"{names} name{'s' if one else ''} no lanes tree" if ran is None else
|
|
4238
|
+
f"{len(units)} unit{'' if one else 's'} run{'s' if one else ''} {ran} ({names})")
|
|
4239
|
+
return ('✗', 'lanes units run the current release — ' + '; '.join(said) + ' — re-run the server installer')
|
|
4240
|
+
|
|
4241
|
+
|
|
4242
|
+
def release_doctor(table, home=None):
|
|
4243
|
+
"""`lanes doctor`'s release lines -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information) (W9 V3, LNS-85): `current`
|
|
4244
|
+
and its VERSION, the run records against the processes in `table` (`ps -eo pid=,comm=,args=`; None when ps did
|
|
4245
|
+
not answer), the device layouts, the units, then the updates (W9 U3: libexec/update.py doctor_line, with the unit
|
|
4246
|
+
folders it looks for lanes-update.timer's enable link in; no line without an installed release). Read-only: files
|
|
4247
|
+
and the one process table doctor already swept."""
|
|
4248
|
+
report = version_report(home=home)
|
|
4249
|
+
reference = report['current'] or os.path.realpath(report['tree'])
|
|
4250
|
+
updates = libexec_module('update').doctor_line(L.STATE, home, units=unit_dirs(home))
|
|
4251
|
+
return [current_doctor(report, home), *records_doctor(report, table, reference, home),
|
|
4252
|
+
layouts_doctor(reference, home), units_doctor(reference, home), *([updates] if updates else [])]
|
|
4253
|
+
|
|
4254
|
+
|
|
2355
4255
|
def cmd_doctor(_args):
|
|
2356
4256
|
problems = 0
|
|
2357
4257
|
|
|
@@ -2449,6 +4349,18 @@ def cmd_doctor(_args):
|
|
|
2449
4349
|
print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
|
|
2450
4350
|
except (OSError, L.LanesError) as error:
|
|
2451
4351
|
check(False, 'zellij reachable', str(error))
|
|
4352
|
+
# Cockpit v3 R2 (LNS-75): a device nobody owns follows no profile — its look and seen marks stay its own. Owner as
|
|
4353
|
+
# device_owner derives it (a member record, its machine's for a window, or a recorded claim); a warning, never a ✗.
|
|
4354
|
+
ownerless = []
|
|
4355
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
4356
|
+
meta = L.read_json(meta_file)
|
|
4357
|
+
if (L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32
|
|
4358
|
+
and not device_owner(meta_file.stem, meta if isinstance(meta, dict) else {})):
|
|
4359
|
+
ownerless.append(meta_file.stem)
|
|
4360
|
+
if ownerless:
|
|
4361
|
+
print(f'⚠ devices without an owner: {", ".join(ownerless[:8])}{" …" if len(ownerless) > 8 else ""} — each keeps '
|
|
4362
|
+
'its look and seen marks to itself; `lanes device claim <device> --login <login>` names one (`lanes keys '
|
|
4363
|
+
'owner <device> <login>` when members/ holds its key)')
|
|
2452
4364
|
stale_pickers = stale_picker_layouts()
|
|
2453
4365
|
check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
|
|
2454
4366
|
'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
|
|
@@ -2458,6 +4370,13 @@ def cmd_doctor(_args):
|
|
|
2458
4370
|
print(f'⚠ {text}')
|
|
2459
4371
|
else:
|
|
2460
4372
|
check(mark == '✓', text)
|
|
4373
|
+
# The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
|
|
4374
|
+
# device layouts, the units.
|
|
4375
|
+
for mark, text in release_doctor(table if rc == 0 else None):
|
|
4376
|
+
if mark in ('⚠', ''):
|
|
4377
|
+
print(f'{mark or " "} {text}')
|
|
4378
|
+
else:
|
|
4379
|
+
check(mark == '✓', text)
|
|
2461
4380
|
print('PRESSURE')
|
|
2462
4381
|
health = L.compute_health()
|
|
2463
4382
|
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
@@ -2472,7 +4391,11 @@ def cmd_rename(args):
|
|
|
2472
4391
|
Rename a lane's display name.
|
|
2473
4392
|
- Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
|
|
2474
4393
|
- Registered sessions (adopted): writes the record's name in registered-sessions.json and publishes.
|
|
2475
|
-
- Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry.
|
|
4394
|
+
- Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry. Two layers
|
|
4395
|
+
name such a lane (LNS-33): this host override wins over the container's own `lanes.terminals[<name>]
|
|
4396
|
+
.displayName` in terminals.json, which rename never writes; renaming back to the lane's default (that
|
|
4397
|
+
container name when declared, else the terminal's title) removes the override. The id never changes. A
|
|
4398
|
+
container name another lane of the board answers to is not that lane's default: the reader drops it.
|
|
2476
4399
|
"""
|
|
2477
4400
|
if len(args) < 2:
|
|
2478
4401
|
raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
|
|
@@ -2512,8 +4435,11 @@ def cmd_rename(args):
|
|
|
2512
4435
|
if not lane.get('repo'):
|
|
2513
4436
|
raise L.LanesError('No repository for this lane; set displayNames in the workspace manifest manually')
|
|
2514
4437
|
registry_name = lane.get('registry_name') or lane.get('name', '')
|
|
2515
|
-
SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name
|
|
2516
|
-
|
|
4438
|
+
SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name,
|
|
4439
|
+
declared_display=lane.get('declared_display'))
|
|
4440
|
+
_failed, refused = sync_registry() # rebuild registry so all consumers see the new name
|
|
4441
|
+
if refused: # saved and published, except where a container refused its own `~/.lz/registry.json` (DEF-1796)
|
|
4442
|
+
raise L.ProjectionRefused('Rename', refused)
|
|
2517
4443
|
print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
|
|
2518
4444
|
return 0
|
|
2519
4445
|
|
|
@@ -2555,11 +4481,9 @@ def preview_renderer():
|
|
|
2555
4481
|
return 'less -R (no glow on the view server PATH; ./install.sh --server installs the pinned one)'
|
|
2556
4482
|
|
|
2557
4483
|
|
|
2558
|
-
def
|
|
2559
|
-
"""(
|
|
2560
|
-
|
|
2561
|
-
marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed."""
|
|
2562
|
-
import shlex
|
|
4484
|
+
def view_listings(device):
|
|
4485
|
+
"""(tabs, panes): `zellij action list-tabs -j` and `list-panes -j -a` of `device`'s view session. Raises when either
|
|
4486
|
+
did not answer or is not JSON: an unknown listing is never an empty one."""
|
|
2563
4487
|
listings = []
|
|
2564
4488
|
for action in ('list-tabs', 'list-panes'):
|
|
2565
4489
|
rc, out, err = zellij(['-s', device, 'action', action, '-j'] + (['-a'] if action == 'list-panes' else []), 10)
|
|
@@ -2569,10 +4493,26 @@ def focused_view(device):
|
|
|
2569
4493
|
listings.append(json.loads(out))
|
|
2570
4494
|
except ValueError:
|
|
2571
4495
|
raise L.LanesError(f'zellij answered {action} for {device} with something that is not JSON')
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
4496
|
+
return listings[0], listings[1]
|
|
4497
|
+
|
|
4498
|
+
|
|
4499
|
+
def active_tab(tabs):
|
|
4500
|
+
"""The tab_id list-tabs marks active, else None (a session no client is attached to marks none, measured)."""
|
|
4501
|
+
return next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
|
|
4502
|
+
|
|
4503
|
+
|
|
4504
|
+
def active_tab_panes(device, listings=None):
|
|
4505
|
+
"""[(kind, (view device, workspace, slot), focused)] for the board panes of `device`'s active tab, TILED only (a
|
|
4506
|
+
floating pane — the Run pane asking — is never one): kind `view` (a viewport's view.sh; its slot) or `sidebar`
|
|
4507
|
+
(slot 'main'). Two bounded zellij listings (view_listings), or `listings` when the caller already holds them. While
|
|
4508
|
+
a floating pane holds the focus, zellij 0.45.1 still marks the tiled pane under it focused (measured 2026-10-06,
|
|
4509
|
+
cockpit v3 S4), which is what `focused` reports."""
|
|
4510
|
+
import shlex
|
|
4511
|
+
tabs, panes = listings or view_listings(device)
|
|
4512
|
+
active = active_tab(tabs)
|
|
4513
|
+
# By tree file, never by this CLI's tree path (W9 U1): the panes may run the pkg/<version> tree of a layout built
|
|
4514
|
+
# before, the `current` link of one built since, or a checkout (lanes_lib.tree_file_at).
|
|
4515
|
+
found = []
|
|
2576
4516
|
for pane in panes:
|
|
2577
4517
|
if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('is_floating') or pane.get('tab_id') != active:
|
|
2578
4518
|
continue
|
|
@@ -2580,17 +4520,29 @@ def focused_view(device):
|
|
|
2580
4520
|
args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
|
|
2581
4521
|
except ValueError:
|
|
2582
4522
|
continue
|
|
2583
|
-
for script in (
|
|
2584
|
-
|
|
4523
|
+
for script in ('libexec/view.sh', 'libexec/sidebar'):
|
|
4524
|
+
at = L.tree_file_at(args, script)
|
|
4525
|
+
tail = args[at + 1:] if at is not None else []
|
|
2585
4526
|
if len(tail) < 2 or not all(L.IDENT.match(part) for part in tail[:2]):
|
|
2586
4527
|
continue
|
|
2587
|
-
if script ==
|
|
4528
|
+
if script == 'libexec/view.sh':
|
|
2588
4529
|
slot = tail[2] if len(tail) > 2 and tail[2] in SLOTS else 'main'
|
|
2589
|
-
|
|
4530
|
+
found.append(('view', (tail[0], tail[1], slot), bool(pane.get('is_focused'))))
|
|
2590
4531
|
else:
|
|
2591
|
-
|
|
4532
|
+
found.append(('sidebar', (tail[0], tail[1], 'main'), bool(pane.get('is_focused'))))
|
|
4533
|
+
return found
|
|
4534
|
+
|
|
4535
|
+
|
|
4536
|
+
def focused_view(device, listings=None):
|
|
4537
|
+
"""(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
|
|
4538
|
+
the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
|
|
4539
|
+
marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed. `listings`:
|
|
4540
|
+
view_listings(device) when the caller already holds them (`lanes edit` reads them once, for its guard first)."""
|
|
4541
|
+
panes = active_tab_panes(device, listings)
|
|
4542
|
+
views = [(not focused, SLOTS.index(where[2]), where) for kind, where, focused in panes if kind == 'view']
|
|
2592
4543
|
if views:
|
|
2593
4544
|
return min(views)[2]
|
|
4545
|
+
boards = [where for kind, where, _focused in panes if kind == 'sidebar']
|
|
2594
4546
|
if boards:
|
|
2595
4547
|
return boards[0]
|
|
2596
4548
|
raise L.LanesError('the focused tab shows no board (Host or a tool tab): nothing to preview')
|
|
@@ -2707,7 +4659,8 @@ def cmd_preview(args):
|
|
|
2707
4659
|
# closes when it exits. They used to walk the focus left three times and type J/K into whatever pane that reached
|
|
2708
4660
|
# (the sidebar, or — on a tab without one — the lane itself). Now nothing is typed and no focus moves: the viewport
|
|
2709
4661
|
# zellij marks focused is pointed at the neighbouring lane exactly as the sidebar's open_lane points it
|
|
2710
|
-
# (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it
|
|
4662
|
+
# (lanes_lib.show_lane), and a lane with an agent is marked seen on the device as open_lane marks it — and, on a device
|
|
4663
|
+
# with an owner, in that person's profile too (cockpit v3 R3, mark_profile_seen).
|
|
2711
4664
|
|
|
2712
4665
|
|
|
2713
4666
|
def step_lane(args, step):
|
|
@@ -2738,12 +4691,34 @@ def step_lane(args, step):
|
|
|
2738
4691
|
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
|
|
2739
4692
|
agent = (status.get(lane_id) or {}).get('agent') if isinstance(status, dict) else None
|
|
2740
4693
|
if isinstance(agent, dict) and agent:
|
|
2741
|
-
L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
|
|
4694
|
+
seen = L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
|
|
4695
|
+
mark_profile_seen(view_device, lane_id, seen[lane_id], verb)
|
|
2742
4696
|
if not L.show_lane(str(L.STATE / 'views' / view_device), workspace, slot, lane_id):
|
|
2743
4697
|
print('view not running yet')
|
|
2744
4698
|
return 0
|
|
2745
4699
|
|
|
2746
4700
|
|
|
4701
|
+
def mark_profile_seen(device, lane_id, at, verb):
|
|
4702
|
+
"""`lanes next|prev` opened a lane with an agent on `device` (cockpit v3 R3, LNS-76): on a device with an owner the
|
|
4703
|
+
mark and `last_opened` {lane, device, at} also go to that person's profile (users.update as the device, its 1 s
|
|
4704
|
+
lock), as the sidebar's mark_seen writes them, so their other devices leave Done on the next tick. The device's own
|
|
4705
|
+
seen file was already written. A dropped write (a busy or unreadable profile) is said on stderr and changes nothing
|
|
4706
|
+
else: the lane is still shown, and seen on this device. -> False only for that drop."""
|
|
4707
|
+
login = profile_for(device)
|
|
4708
|
+
if not login:
|
|
4709
|
+
return True
|
|
4710
|
+
users = libexec_module('users')
|
|
4711
|
+
|
|
4712
|
+
def mark(profile):
|
|
4713
|
+
profile['seen'][lane_id] = max(at, profile['seen'].get(lane_id, at))
|
|
4714
|
+
profile['last_opened'] = {'lane': lane_id, 'device': device, 'at': at}
|
|
4715
|
+
if users.update(login, device, mark):
|
|
4716
|
+
return True
|
|
4717
|
+
print(f"lanes {verb}: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why) — "
|
|
4718
|
+
f'{lane_id} is seen on {device} only', file=sys.stderr)
|
|
4719
|
+
return False
|
|
4720
|
+
|
|
4721
|
+
|
|
2747
4722
|
def cmd_next(args):
|
|
2748
4723
|
return step_lane(args, +1)
|
|
2749
4724
|
|
|
@@ -2752,13 +4727,333 @@ def cmd_prev(args):
|
|
|
2752
4727
|
return step_lane(args, -1)
|
|
2753
4728
|
|
|
2754
4729
|
|
|
2755
|
-
|
|
4730
|
+
# ------------------------------------------------------------------ sidebar-key
|
|
4731
|
+
# Ctrl-Alt-/ and Ctrl-Alt-u (zellij/config.kdl, cockpit v3 S4) walk the focus left to the sidebar, then run
|
|
4732
|
+
# `lanes sidebar-key filter|upload --focused` in a floating pane that closes when it exits. They used to end in
|
|
4733
|
+
# WriteChars "/" / "U", which typed into whatever pane held the focus by then — on a tab without a sidebar (Host,
|
|
4734
|
+
# Throttle, the phone's view tab) the pane itself, a shell or an agent's input. Now nothing is typed: the key is a
|
|
4735
|
+
# file, views/<device>/<ws>.sidebar.cmd (DM-LANES-SIDEBAR-CMD, lanes_lib.send_sidebar_key), written only for a sidebar
|
|
4736
|
+
# on the active tab that holds the focus; that sidebar acts on it within a tick and before the next key it reads.
|
|
4737
|
+
|
|
4738
|
+
|
|
4739
|
+
def cmd_sidebar_key(args):
|
|
4740
|
+
key = args[0] if args else ''
|
|
4741
|
+
if key not in L.SIDEBAR_KEYS or '--focused' not in args[1:]:
|
|
4742
|
+
print('usage: lanes sidebar-key filter|upload --focused [--device D] (Ctrl-Alt-/ and Ctrl-Alt-u run this)')
|
|
4743
|
+
return 2
|
|
4744
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
4745
|
+
try:
|
|
4746
|
+
device = device_name(device)
|
|
4747
|
+
sidebars = [(where, focused) for kind, where, focused in active_tab_panes(device) if kind == 'sidebar']
|
|
4748
|
+
if not sidebars:
|
|
4749
|
+
raise L.LanesError('this tab has no sidebar (Host or a tool tab): nothing to do')
|
|
4750
|
+
(view_device, workspace, _slot), focused = sidebars[0]
|
|
4751
|
+
if not focused:
|
|
4752
|
+
raise L.LanesError('the sidebar does not have the focus (it was in a floating pane): nothing to do')
|
|
4753
|
+
except L.LanesError as error:
|
|
4754
|
+
print(f'lanes sidebar-key: {error}')
|
|
4755
|
+
return 1
|
|
4756
|
+
L.send_sidebar_key(L.STATE / 'views' / view_device, workspace, key)
|
|
4757
|
+
return 0
|
|
4758
|
+
|
|
4759
|
+
|
|
4760
|
+
# ------------------------------------------------------------------ edit
|
|
4761
|
+
# Ctrl-Alt-e (zellij/config.kdl, W9 E1, LNS-95) runs `lanes edit --focused` in a floating pane named EDIT_PANE. Zellij
|
|
4762
|
+
# has no single-instance Run (LaunchOrFocus is for plugins only) and catches Ctrl-Alt-e in every unlocked mode,
|
|
4763
|
+
# whichever pane has the focus (the open editor's included), so each press opens one more pane. Two editors on one
|
|
4764
|
+
# file lose work: the one quit last writes its stale buffer over the other's save, and only vim and nvim warn (swap
|
|
4765
|
+
# files). So the verb is its own guard, before it resolves anything: one Edit pane per view session (DEF-1793).
|
|
4766
|
+
|
|
4767
|
+
EDIT_PANE = 'Edit' # the binding's `name`; test_server_cli EditWiring pins the two to one value
|
|
4768
|
+
|
|
4769
|
+
|
|
4770
|
+
def edit_pane(pane):
|
|
4771
|
+
"""Whether a list-panes entry is an Edit pane: a live terminal pane zellij started as `lanes edit` (it keeps the
|
|
4772
|
+
command it started, renamed or embedded since), or a floating one named EDIT_PANE (zellij keeps a given name over
|
|
4773
|
+
the title its program sets: measured 0.45.1, an OSC 2 title left `title` "Edit"). A TILED pane titled Edit is
|
|
4774
|
+
not one: a pane without a given name shows whatever title its program set."""
|
|
4775
|
+
if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('exited'):
|
|
4776
|
+
return False
|
|
4777
|
+
words = str(pane.get('terminal_command') or '').split() # zellij joins the Run argv with spaces (measured)
|
|
4778
|
+
if len(words) >= 2 and Path(words[0]).name == 'lanes' and words[1] == 'edit':
|
|
4779
|
+
return True
|
|
4780
|
+
return bool(pane.get('is_floating')) and pane.get('title') == EDIT_PANE
|
|
4781
|
+
|
|
4782
|
+
|
|
4783
|
+
def edit_pane_open(device, tabs, panes):
|
|
4784
|
+
"""The Edit pane already open in `device`'s view session that this process does not run in, or None; one in the
|
|
4785
|
+
active tab first. This process's own pane is ZELLIJ_PANE_ID (zellij gives each pane it starts its bare terminal
|
|
4786
|
+
id, measured 0.45.1) when it runs in that session; run by hand, or with --device from another session, every
|
|
4787
|
+
Edit pane counts. Every other one counts whichever is older: two presses at once both stand down (press again)
|
|
4788
|
+
rather than both open an editor."""
|
|
4789
|
+
own = os.environ.get('ZELLIJ_PANE_ID', '') if os.environ.get('ZELLIJ_SESSION_NAME') == device else ''
|
|
4790
|
+
own = own.removeprefix('terminal_')
|
|
4791
|
+
others = [pane for pane in (panes if isinstance(panes, list) else [])
|
|
4792
|
+
if edit_pane(pane) and (not own or str(pane.get('id')) != own)]
|
|
4793
|
+
active = active_tab(tabs)
|
|
4794
|
+
others.sort(key=lambda pane: active is None or pane.get('tab_id') != active)
|
|
4795
|
+
return others[0] if others else None
|
|
4796
|
+
|
|
4797
|
+
|
|
4798
|
+
def edit_defer(device, tabs, pane):
|
|
4799
|
+
"""Hand this press to the Edit pane already open. In the active tab it gets the focus and this pane closes (exit
|
|
4800
|
+
0, nothing said); in another tab, or when zellij will not focus it, it is named and the pane waits for Enter
|
|
4801
|
+
(exit 1). Either way nothing opens."""
|
|
4802
|
+
active = active_tab(tabs)
|
|
4803
|
+
if active is not None and pane.get('tab_id') == active:
|
|
4804
|
+
rc, _out, err = zellij(['-s', device, 'action', 'focus-pane-id', f"terminal_{pane.get('id')}"], 5)
|
|
4805
|
+
if rc == 0:
|
|
4806
|
+
return 0
|
|
4807
|
+
where = f'in this tab, and zellij did not focus it ({err.strip() or "no answer"})'
|
|
4808
|
+
else:
|
|
4809
|
+
name = next((t.get('name') for t in tabs if isinstance(t, dict) and t.get('tab_id') == pane.get('tab_id')), '')
|
|
4810
|
+
where = f'in tab "{name}"' if name else 'in another tab'
|
|
4811
|
+
print(f'An Edit pane is already open {where}. One per view: quit that editor, then press Ctrl-Alt-e again.')
|
|
4812
|
+
preview_hold()
|
|
4813
|
+
return 1
|
|
4814
|
+
|
|
4815
|
+
|
|
4816
|
+
# Past the guard (W9 E2, LNS-96): the focused lane as `lanes preview` finds it (tmux lanes; the container by the
|
|
4817
|
+
# registry's name and the init pid statusd recorded in containers.json: no docker inspect), the git checkout its
|
|
4818
|
+
# working directory is in (lanes_lib.repo_roots, through /proc/<init>/root), the files below offered in a one-key
|
|
4819
|
+
# picker painted with the sidebar's tokens, and the editor started INSIDE the container as uid 1000 (`coder`) by
|
|
4820
|
+
# execvp, argv only: `docker exec -it -u coder -w <checkout> ... <container> ~/.lz/bin/lane-edit <path>` (E3, LNS-97).
|
|
4821
|
+
# No file is created. `.env.local` is named by its label, never by its path, in anything printed.
|
|
4822
|
+
|
|
4823
|
+
EDIT_FILES = ('AGENTS.md', 'CLAUDE.md') # the checkout's own pair, then the main checkout's when it is another one
|
|
4824
|
+
EDIT_ENV_FILE = '.env.local' # the main checkout's only
|
|
4825
|
+
EDIT_MAIN = ' (main checkout)' # the label suffix of a main-checkout file when the lane is in a worktree
|
|
4826
|
+
LANE_EDIT = L.CONTAINER_HOME + '/.lz/bin/lane-edit' # where cmd_install_container copies container/lane-edit
|
|
4827
|
+
EDIT_QUIT = (b'', b'q', b'Q', b'\x1b', b'\x03', b'\x04') # end of input, q, Esc, Ctrl-C, Ctrl-D (raw mode)
|
|
4828
|
+
|
|
4829
|
+
|
|
4830
|
+
def edit_candidate(root, checkout, name, label):
|
|
4831
|
+
"""{label, root, container_path, present} for the file `name` of the container checkout `checkout`. Present only
|
|
4832
|
+
as a regular file that is no symbolic link, inside the checkout once both are resolved in the container
|
|
4833
|
+
(lanes_lib.container_realpath), on a path without control characters. Any error reads as not present and is never
|
|
4834
|
+
printed (it would carry the path)."""
|
|
4835
|
+
path, base = posixpath.join(checkout, name), checkout
|
|
4836
|
+
present = False
|
|
4837
|
+
if not L.CONTROL_CHARS.search(path):
|
|
4838
|
+
try:
|
|
4839
|
+
base = L.container_realpath(root, checkout)
|
|
4840
|
+
path = posixpath.join(L.container_realpath(root, posixpath.dirname(path)), name)
|
|
4841
|
+
except L.LanesError:
|
|
4842
|
+
base = None
|
|
4843
|
+
found = L.container_lstat(root, path) if base else None
|
|
4844
|
+
present = bool(found and stat.S_ISREG(found.st_mode) and path.startswith(base.rstrip('/') + '/'))
|
|
4845
|
+
return {'label': label, 'root': base or checkout, 'container_path': path, 'present': present}
|
|
4846
|
+
|
|
4847
|
+
|
|
4848
|
+
def edit_candidates(roots, root):
|
|
4849
|
+
"""The files quick edit offers for (worktree root, main checkout), in picker order: AGENTS.md and CLAUDE.md of the
|
|
4850
|
+
worktree, the main checkout's pair when it is another folder, then the main checkout's .env.local."""
|
|
4851
|
+
worktree, main = roots
|
|
4852
|
+
other = main != worktree
|
|
4853
|
+
rows = [(worktree, name, name) for name in EDIT_FILES]
|
|
4854
|
+
rows += [(main, name, name + EDIT_MAIN) for name in EDIT_FILES] if other else []
|
|
4855
|
+
rows.append((main, EDIT_ENV_FILE, EDIT_ENV_FILE + (EDIT_MAIN if other else '')))
|
|
4856
|
+
return [edit_candidate(root, checkout, name, label) for checkout, name, label in rows]
|
|
4857
|
+
|
|
4858
|
+
|
|
4859
|
+
def edit_roots(lane, board, root):
|
|
4860
|
+
"""repo_roots of the lane's working directory (statusd's pane cwd), else of its repository folder, else of its
|
|
4861
|
+
board's cwd: an agent that cd'd out of the checkout still edits its lane's files. The first error when none is in
|
|
4862
|
+
a checkout."""
|
|
4863
|
+
status = ((L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}).get(lane['id']) or {}
|
|
4864
|
+
tried, first = [], None
|
|
4865
|
+
for cwd in (status.get('cwd'), lane.get('folder'), board.get('cwd')):
|
|
4866
|
+
if not cwd or cwd in tried:
|
|
4867
|
+
continue
|
|
4868
|
+
tried.append(cwd)
|
|
4869
|
+
try:
|
|
4870
|
+
return L.repo_roots(cwd, root)
|
|
4871
|
+
except L.LanesError as error:
|
|
4872
|
+
first = first or error
|
|
4873
|
+
raise first or L.LanesError('the lane has no usable working directory')
|
|
4874
|
+
|
|
4875
|
+
|
|
4876
|
+
def edit_look(device):
|
|
4877
|
+
"""(SGR table, glyph set) the picker paints with: the device's sidebar look — sidebar.resolve_modes over
|
|
4878
|
+
devices/<device>.json and this pane's environment, then tokens.sgr_table / glyph_set — in the variant its theme
|
|
4879
|
+
names, else the one its sidebar published (clients/<device>.theme), else dark."""
|
|
4880
|
+
tokens = libexec_module('tokens')
|
|
4881
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
4882
|
+
color, theme, glyphs = libexec_module('sidebar').resolve_modes(os.environ, meta if isinstance(meta, dict) else {})
|
|
4883
|
+
if theme not in ('dark', 'light'):
|
|
4884
|
+
try:
|
|
4885
|
+
theme = (L.STATE / 'clients' / f'{device}.theme').read_text().strip()
|
|
4886
|
+
except OSError:
|
|
4887
|
+
theme = ''
|
|
4888
|
+
return tokens.sgr_table(color, theme if theme in ('dark', 'light') else 'dark'), tokens.glyph_set(glyphs)
|
|
4889
|
+
|
|
4890
|
+
|
|
4891
|
+
def edit_picker(name, roots, candidates, tone, glyphs):
|
|
4892
|
+
"""The picker: the lane and its checkout(s), one numbered row per candidate (a missing one dim, `not present`, its
|
|
4893
|
+
number never taken), and the keys. Nothing present: the rows and `Nothing to edit in …` instead of the keys."""
|
|
4894
|
+
reset, dim, sep = tone['reset'], tone['ink-dim'], f" {glyphs['marks']['sep']} "
|
|
4895
|
+
worktree, main = roots
|
|
4896
|
+
lines = [f"{tone['ink']}{tone['bold']}Edit{sep}{plain(name)}{reset}", f'{dim} {plain(worktree)}{reset}']
|
|
4897
|
+
if main != worktree:
|
|
4898
|
+
lines.append(f'{dim} main checkout {plain(main)}{reset}')
|
|
4899
|
+
lines.append('')
|
|
4900
|
+
for number, row in enumerate(candidates, 1):
|
|
4901
|
+
if row['present']:
|
|
4902
|
+
lines.append(f" {tone['accent']}{number}{reset} {tone['ink']}{plain(row['label'])}{reset}")
|
|
4903
|
+
else:
|
|
4904
|
+
lines.append(f"{dim} {number} {plain(row['label'])} not present{reset}")
|
|
4905
|
+
numbers = [str(number) for number, row in enumerate(candidates, 1) if row['present']]
|
|
4906
|
+
lines.append('')
|
|
4907
|
+
if not numbers:
|
|
4908
|
+
lines.append(f'{dim}Nothing to edit in {plain(worktree)}: lanes edit creates no file{reset}')
|
|
4909
|
+
else:
|
|
4910
|
+
keys = f"{numbers[0]} or {glyphs['marks']['enter']}" if len(numbers) == 1 else ' '.join(numbers)
|
|
4911
|
+
lines.append(f'{dim}{keys} edit{sep}q or Esc close{reset}')
|
|
4912
|
+
return '\n'.join(lines) + '\n'
|
|
4913
|
+
|
|
4914
|
+
|
|
4915
|
+
@contextlib.contextmanager
|
|
4916
|
+
def key_reader(stream=None):
|
|
4917
|
+
"""A read() of one key byte (b'' at the end of input). A terminal takes raw input for the duration (one byte, no
|
|
4918
|
+
echo, no Enter needed, Ctrl-C a byte) and is restored after; anything else (a pipe, a test's StringIO) is read as it
|
|
4919
|
+
is. The switch is TCSANOW, never tty.setraw's TCSAFLUSH: a key already typed (while the pane started, or while the
|
|
4920
|
+
picker painted) is the first key read, not discarded (DEF-1836). Output processing stays on (setraw clears OPOST),
|
|
4921
|
+
so the caller paints the picker inside the block: once it shows, the terminal is raw."""
|
|
4922
|
+
stream = stream or sys.stdin
|
|
4923
|
+
try:
|
|
4924
|
+
fd = stream.fileno()
|
|
4925
|
+
terminal = os.isatty(fd)
|
|
4926
|
+
except (AttributeError, OSError, ValueError):
|
|
4927
|
+
terminal = False
|
|
4928
|
+
if not terminal:
|
|
4929
|
+
def read():
|
|
4930
|
+
key = stream.read(1)
|
|
4931
|
+
return key.encode() if isinstance(key, str) else (key or b'')
|
|
4932
|
+
yield read
|
|
4933
|
+
return
|
|
4934
|
+
import termios
|
|
4935
|
+
saved = termios.tcgetattr(fd)
|
|
4936
|
+
raw = termios.tcgetattr(fd) # tty.setraw's input, control and local flags; its oflag and `when` are not taken
|
|
4937
|
+
raw[0] &= ~(termios.BRKINT | termios.ICRNL | termios.INPCK | termios.ISTRIP | termios.IXON)
|
|
4938
|
+
raw[2] = raw[2] & ~(termios.CSIZE | termios.PARENB) | termios.CS8
|
|
4939
|
+
raw[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
|
|
4940
|
+
raw[6][termios.VMIN], raw[6][termios.VTIME] = 1, 0
|
|
4941
|
+
try:
|
|
4942
|
+
termios.tcsetattr(fd, termios.TCSANOW, raw)
|
|
4943
|
+
yield lambda: os.read(fd, 1)
|
|
4944
|
+
finally:
|
|
4945
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
4946
|
+
|
|
4947
|
+
|
|
4948
|
+
def pick_key(candidates, read):
|
|
4949
|
+
"""The index of the candidate one key picks, or None to close. `1`…`5` take that row when it is present; Enter takes
|
|
4950
|
+
the only present one (and nothing when several are); q, Esc, Ctrl-C, Ctrl-D and the end of input close; every
|
|
4951
|
+
other key (a missing row's number included) is ignored."""
|
|
4952
|
+
present = [index for index, row in enumerate(candidates) if row.get('present')]
|
|
4953
|
+
while True:
|
|
4954
|
+
key = read()
|
|
4955
|
+
if key in EDIT_QUIT:
|
|
4956
|
+
return None
|
|
4957
|
+
if key in (b'\r', b'\n') and len(present) == 1:
|
|
4958
|
+
return present[0]
|
|
4959
|
+
if len(key) == 1 and key.isdigit() and int(key) - 1 in present:
|
|
4960
|
+
return int(key) - 1
|
|
4961
|
+
|
|
4962
|
+
|
|
4963
|
+
def edit_editor(device):
|
|
4964
|
+
"""The LANES_EDITOR lane-edit gets: the device's `lanes attach --editor` choice, else this process's LANES_EDITOR
|
|
4965
|
+
(without control characters), else empty (lane-edit then takes the first of micro hx nvim vim vi). The Ctrl-Alt-e
|
|
4966
|
+
pane runs with the view server's environment, which clean_env builds without LANES_EDITOR: there the device's
|
|
4967
|
+
choice is the one that counts; the variable serves a `lanes edit` run by hand."""
|
|
4968
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
4969
|
+
chosen = meta.get('editor') if isinstance(meta, dict) else None
|
|
4970
|
+
if chosen in LOOK_KEYS['--editor'][1] and chosen != 'auto':
|
|
4971
|
+
return chosen
|
|
4972
|
+
value = os.environ.get('LANES_EDITOR', '')
|
|
4973
|
+
return '' if L.CONTROL_CHARS.search(value) else value
|
|
4974
|
+
|
|
4975
|
+
|
|
4976
|
+
def edit_argv(container, root, path, editor, label):
|
|
4977
|
+
"""`docker exec` of lane-edit on `path` in `container`, as uid 1000 in `root`, argv only (no shell)."""
|
|
4978
|
+
return ['docker', 'exec', '-it', '-u', 'coder', '-w', root, '-e', 'TERM=xterm-256color', '-e', 'COLORTERM=truecolor',
|
|
4979
|
+
'-e', 'LANG=C.UTF-8', '-e', f'LANES_EDITOR={editor}', '-e', f'LANES_EDIT_LABEL={label}', container, LANE_EDIT,
|
|
4980
|
+
path]
|
|
4981
|
+
|
|
4982
|
+
|
|
4983
|
+
def edit_container(lane):
|
|
4984
|
+
"""(container, /proc/<init>/root) of a running container, from the registry's name and containers.json's pid;
|
|
4985
|
+
refused when it is not running or lane-edit is not installed there."""
|
|
4986
|
+
container = lane.get('container') or ''
|
|
4987
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
|
|
4988
|
+
root = PROC / str(info.get('pid') or 0) / 'root'
|
|
4989
|
+
try:
|
|
4990
|
+
running = bool(info.get('pid')) and stat.S_ISDIR(os.stat(root).st_mode)
|
|
4991
|
+
except FileNotFoundError:
|
|
4992
|
+
running = False
|
|
4993
|
+
except OSError as error:
|
|
4994
|
+
raise L.LanesError(f'container {container}: its files cannot be read through /proc ({error.strerror})')
|
|
4995
|
+
if not running:
|
|
4996
|
+
raise L.LanesError(f'container {container} is not running')
|
|
4997
|
+
found = L.container_lstat(root, L.container_realpath(root, LANE_EDIT))
|
|
4998
|
+
if not (found and stat.S_ISREG(found.st_mode)):
|
|
4999
|
+
raise L.LanesError(f'lane-edit is not installed in {container}: run lanes install-container {container}')
|
|
5000
|
+
return container, root
|
|
5001
|
+
|
|
5002
|
+
|
|
5003
|
+
def cmd_edit(args):
|
|
5004
|
+
if '--focused' not in args:
|
|
5005
|
+
print('usage: lanes edit --focused [--device D] (the Ctrl-Alt-e floating pane runs this)')
|
|
5006
|
+
return 2
|
|
5007
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
5008
|
+
try:
|
|
5009
|
+
device = device_name(device)
|
|
5010
|
+
tabs, panes = view_listings(device)
|
|
5011
|
+
other = edit_pane_open(device, tabs, panes)
|
|
5012
|
+
if other is not None:
|
|
5013
|
+
return edit_defer(device, tabs, other)
|
|
5014
|
+
view_device, workspace, slot = focused_view(device, (tabs, panes))
|
|
5015
|
+
board, lane = L.find_lane(L.load_registry(), lane_shown(view_device, workspace, slot))
|
|
5016
|
+
if lane.get('holder') != 'tmux':
|
|
5017
|
+
raise L.LanesError(f"{lane['name']} is a {lane.get('holder')} lane: quick edit opens tmux lanes only")
|
|
5018
|
+
container, root = edit_container(lane)
|
|
5019
|
+
roots = edit_roots(lane, board, root)
|
|
5020
|
+
candidates = edit_candidates(roots, root)
|
|
5021
|
+
tone, glyphs = edit_look(device)
|
|
5022
|
+
except L.LanesError as error:
|
|
5023
|
+
print(f'lanes edit: {error}')
|
|
5024
|
+
preview_hold()
|
|
5025
|
+
return 1
|
|
5026
|
+
picker = edit_picker(lane['name'], roots, candidates, tone, glyphs)
|
|
5027
|
+
if not any(row['present'] for row in candidates):
|
|
5028
|
+
print(picker, end='', flush=True)
|
|
5029
|
+
preview_hold()
|
|
5030
|
+
return 0
|
|
5031
|
+
with key_reader() as read: # raw before the picker shows: a key pressed once it is on screen is never lost
|
|
5032
|
+
print(picker, end='', flush=True)
|
|
5033
|
+
choice = pick_key(candidates, read)
|
|
5034
|
+
if choice is None:
|
|
5035
|
+
return 0
|
|
5036
|
+
row = candidates[choice]
|
|
5037
|
+
argv = edit_argv(container, row['root'], row['container_path'], edit_editor(device), row['label'])
|
|
5038
|
+
try:
|
|
5039
|
+
os.execvp(argv[0], argv)
|
|
5040
|
+
except OSError as error: # its text names docker, never the file; the label says which file it was
|
|
5041
|
+
print(f"lanes edit: could not start docker for {row['label']}: {error.strerror or 'failed'}")
|
|
5042
|
+
preview_hold()
|
|
5043
|
+
return 1
|
|
5044
|
+
return 0 # only reached when execvp is a test double
|
|
5045
|
+
|
|
5046
|
+
|
|
5047
|
+
COMMANDS = {'rebuilt': cmd_rebuilt, 'hello': cmd_hello, 'client-config': cmd_client_config, 'cursor-commit': cmd_cursor_commit, 'upgrade-session': cmd_upgrade_session, 'upload-requests': cmd_upload_requests, 'upload-result': cmd_upload_result, 'upload': cmd_upload, 'workspaces': cmd_workspaces, 'cursor-sync': cmd_cursor_sync, 'reattach': cmd_reattach, 'reload-views': cmd_reload_views, 'gc': cmd_gc, 'install-container': cmd_install_container, 'open': cmd_open, 'sync': cmd_sync, 'ls': cmd_ls, 'resolve': cmd_resolve, 'status': cmd_status, 'layout': cmd_layout,
|
|
2756
5048
|
'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
|
|
2757
5049
|
'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
|
|
2758
|
-
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, '
|
|
2759
|
-
'
|
|
5050
|
+
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
|
|
5051
|
+
'hooks': cmd_hooks, 'economy': cmd_economy,
|
|
5052
|
+
'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
|
|
5053
|
+
'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
|
|
2760
5054
|
'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
|
|
2761
|
-
'token': cmd_token, 'onboard': cmd_onboard
|
|
5055
|
+
'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
|
|
5056
|
+
'version': cmd_version, 'update': cmd_update}
|
|
2762
5057
|
|
|
2763
5058
|
if __name__ == '__main__':
|
|
2764
5059
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|