@vibelord/lanes 1.1.1 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 -23
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +2753 -202
- 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 +147 -92
- package/payload/lanes/container/lane-edit +100 -0
- package/payload/lanes/container/lane-mcp +1150 -0
- package/payload/lanes/container/lane-signal +109 -29
- package/payload/lanes/container/lane_core.py +1318 -0
- package/payload/lanes/container/vimrc +31 -0
- package/payload/lanes/docs/ARCHITECTURE.md +793 -16
- package/payload/lanes/docs/DESIGN.md +127 -46
- package/payload/lanes/docs/INSTALL.md +175 -18
- package/payload/lanes/docs/RUNBOOK.md +968 -76
- package/payload/lanes/docs/SHARING.md +3 -2
- package/payload/lanes/docs/USERGUIDE.md +967 -150
- package/payload/lanes/holders.lock +5 -0
- package/payload/lanes/install/enable-docs.sh +78 -0
- package/payload/lanes/install/enable-web.sh +2 -0
- package/payload/lanes/install/host-install.sh +197 -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/docs_store.py +995 -0
- package/payload/lanes/libexec/docsd.py +2010 -0
- 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/mdlite.py +575 -0
- package/payload/lanes/libexec/members.py +21 -2
- package/payload/lanes/libexec/notify.py +169 -29
- 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 +2232 -365
- package/payload/lanes/libexec/statusd.py +231 -12
- package/payload/lanes/libexec/studio_host.py +8 -1
- package/payload/lanes/libexec/tokens.py +148 -35
- package/payload/lanes/libexec/tokens.sh +86 -2
- package/payload/lanes/libexec/update.py +1044 -0
- package/payload/lanes/libexec/uploads.py +538 -0
- package/payload/lanes/libexec/users.py +614 -0
- package/payload/lanes/libexec/view.sh +34 -6
- package/payload/lanes/systemd/lanes-docsd.service +29 -0
- 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/Caddyfile +24 -1
- package/payload/lanes/web/README.md +165 -28
- package/payload/lanes/web/browser.js +4 -8
- package/payload/lanes/web/docs.css +52 -0
- package/payload/lanes/web/docs.js +36 -0
- package/payload/lanes/web/mobile.css +17 -17
- package/payload/lanes/web/mobile.js +45 -32
- package/payload/lanes/web/upload.css +17 -0
- package/payload/lanes/web/upload.js +84 -0
- package/payload/lanes/workspaces.example/README.md +3 -1
- package/payload/lanes/zellij/config.kdl +106 -56
- package/payload/manifest.json +6 -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,18 +98,65 @@
|
|
|
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)
|
|
76
|
-
into the lane that device is looking at. `lanes workspaces` lists board ids.
|
|
152
|
+
into the lane that device is looking at. `lanes workspaces` lists board ids. Each file
|
|
153
|
+
also goes to the docs store with a sha256 receipt (stderr `# docs:` lines), for the
|
|
154
|
+
login that owns the device; the Docs page's upload (POST /up) lands the same way.
|
|
155
|
+
lanes docs push <id> the hook for pushing one docs-store document to the data service's publication surface
|
|
156
|
+
— NOT BUILT: it prints the document's receipt (receipt.json: id, sha256, bytes, url,
|
|
157
|
+
at; looked up by id in every login's store, for the operator), says `not configured`
|
|
158
|
+
on stderr and exits 2. It sends nothing and writes nothing. Exit 1: no document with
|
|
159
|
+
that id, an id that is not one, or another argument (this usage)
|
|
77
160
|
lanes cursor-sync [ws…] [--write] [--diff] [--canonicalize] [--identity]
|
|
78
161
|
make Cursor/VS Code terminals open exactly the board's lanes through `lane open`
|
|
79
162
|
(dry-run by default: prints the git-style diff; --write applies with a backup).
|
|
@@ -81,16 +164,39 @@
|
|
|
81
164
|
attach-only, missing script) with the canonical one whose `tmux new -e` flags give the
|
|
82
165
|
lane LANE_ID/LANE_WS and the lanes/<ws>/<id> trace; --identity adds those flags to a
|
|
83
166
|
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)
|
|
167
|
+
kept as createPrevious. Only these -e flags give a lane its identity (DEF-1559). An
|
|
168
|
+
entry's displayName (the repository's own lane name) is carried unchanged
|
|
85
169
|
lanes cursor-commit [ws…] [--push]
|
|
86
170
|
commit the normalized terminals.json on top of each repo's REMOTE branch tip and push it,
|
|
87
171
|
with git plumbing only (no working tree or index is touched). Dry-run without --push.
|
|
88
|
-
lanes hello [device]
|
|
172
|
+
lanes hello [device] [--client-version V]
|
|
173
|
+
what the client installer calls to prove the path, the key and the server version:
|
|
174
|
+
`ok device=D version=S boards=N`; a client that passes its release V, older than S
|
|
175
|
+
(major.minor.patch as numbers), gets ` update=S` appended — the release to update to
|
|
176
|
+
lanes version [--json] which release runs here, in one line: `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 ·
|
|
177
|
+
current ✓ · statusd runs 1.1.0 ✗ · enrold notify ✓` — this tree's VERSION and the npm
|
|
178
|
+
stamp's commit, the tree, whether ~/.local/share/lanes/current names it (✗ with the
|
|
179
|
+
tree it does name), then the tree each long-running process recorded at its last start
|
|
180
|
+
(~/.local/state/lanes/run/<name>.tree: statusd, enrold, docsd, notify, economy, studio_host,
|
|
181
|
+
the newest sidebar), ✗ for one that is not the current tree (`runs 1.1.0 ✗`) or that
|
|
182
|
+
started before its tree's code (VERSION, bin/, libexec/) last changed — a checkout
|
|
183
|
+
updated in place by `git pull` keeps its path (`started before its tree changed ✗`) —
|
|
184
|
+
restart that service. A stopped process keeps its last record. --json: {schema:
|
|
185
|
+
lanes.version/1, version, commit, tree, current, running{<name>: {tree, version,
|
|
186
|
+
started, current, changed}}, client_min (the release `lanes attach` stops telling
|
|
187
|
+
lanes-launch to update at)}. Exit 0
|
|
89
188
|
lanes client-config site facts for clients (KEY=VALUE from ~/.config/lanes/site.conf on the server)
|
|
90
189
|
lanes install-container <name> [--no-mcp]
|
|
91
|
-
install the `lane` contract (lane, lane-run, lane-signal,
|
|
92
|
-
|
|
93
|
-
|
|
190
|
+
install the `lane` contract (lane, lane_core.py, lane-run, lane-signal, lane-edit,
|
|
191
|
+
lane-mcp, vimrc, VERSION, hooks/) + pinned zmx into a container's home volume
|
|
192
|
+
(additive; installs no agent hook and no editor: lane-edit runs $LANES_EDITOR or the
|
|
193
|
+
first of micro hx nvim vim vi there) and add the Lanes MCP server to the agents'
|
|
194
|
+
configs: mcpServers.lanes in ~/.claude.json and [mcp_servers.lanes] in
|
|
195
|
+
~/.codex/config.toml, both starting /home/coder/.lz/bin/lane-mcp — only where missing,
|
|
196
|
+
the file kept as <file>.bak-<UTC> first, atomic (a `lanes` entry that starts something
|
|
197
|
+
else is left alone, exit 1; on a uid split nothing is written there, exit 1). --no-mcp
|
|
198
|
+
leaves out lane-mcp and the entries (lane-edit and its vimrc still ship) and removes
|
|
199
|
+
nothing an earlier install wrote. In the container: lane mcp install
|
|
94
200
|
lanes hooks install --agent claude|codex (--container <name> | --home <dir>) [--dry-run | --write]
|
|
95
201
|
merge the lane-signal hooks into that home's ~/.claude/settings.json or ~/.codex/hooks.json
|
|
96
202
|
(+ a top-level Codex notify when none is set); prints the diff, writes only with --write
|
|
@@ -98,7 +204,10 @@
|
|
|
98
204
|
--write into a --container whose home the agent could not read from this account (uid
|
|
99
205
|
not 1000, or a home volume not owned by 1000) is refused with the reason
|
|
100
206
|
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
|
|
207
|
+
lanes gc truncate oversized zmx logs, drop stale dynamic resolve files, prune old staged trees:
|
|
208
|
+
each pkg root keeps its 3 newest and every tree current, current.previous, this
|
|
209
|
+
lanes, a view layout, a running daemon's run record or a process of this account
|
|
210
|
+
uses (none pruned when /proc cannot be read); `pruned <tree>` / `kept <tree>: <why>`
|
|
102
211
|
lanes sockets [check|repair] lanes that run but cannot be reached: tmux servers whose socket file was deleted
|
|
103
212
|
(someone emptied the container's /tmp); repair = tmux's SIGUSR1, nothing restarts
|
|
104
213
|
lanes pressure host memory / io pressure, swap, per-container memory and /tmp use — plain files and
|
|
@@ -123,11 +232,32 @@
|
|
|
123
232
|
focused viewport (the first one when the sidebar has the focus) at the next / previous
|
|
124
233
|
lane of its board in registry order, wrapping, and wake its view.sh (SIGUSR1) — the
|
|
125
234
|
same write as Enter in the sidebar. Reads two zellij listings; moves no focus
|
|
235
|
+
lanes sidebar-key filter|upload --focused [--device D]
|
|
236
|
+
what Ctrl-Alt-/ and Ctrl-Alt-u run in a floating pane that closes on exit, after their
|
|
237
|
+
three lefts moved the focus to the sidebar: tell the sidebar of the active tab to start
|
|
238
|
+
its filter / arm its upload strip through views/<device>/<ws>.sidebar.cmd (it acts within
|
|
239
|
+
a tick, and before the next key it reads). Types nothing into any pane; on a tab without
|
|
240
|
+
a sidebar (Host, a tool tab), or when the sidebar does not have the focus, it does nothing
|
|
126
241
|
lanes preview --focused [--device D]
|
|
127
242
|
what Ctrl-Alt-m runs in a floating pane: the markdown files the focused lane named in
|
|
128
243
|
its last 300 lines (guarded tmux capture, resolved against its cwd, existing ones only),
|
|
129
244
|
newest first; one is shown at once, several give a numbered menu. glow (pinned in
|
|
130
245
|
holders.lock) in the device's dark/light theme, else less -R
|
|
246
|
+
lanes edit --focused [--device D]
|
|
247
|
+
what Ctrl-Alt-e runs in a floating pane named Edit: quick edit of the focused lane's
|
|
248
|
+
AGENTS.md, CLAUDE.md or .env.local. One Edit pane per view: when another is open it
|
|
249
|
+
gets the focus (same tab) or is named (another tab) and nothing opens. Otherwise the
|
|
250
|
+
focused tmux lane's git checkout (its working directory, else its folder; a worktree's
|
|
251
|
+
.git file leads to the main checkout), read through /proc/<init>/root: AGENTS.md and
|
|
252
|
+
CLAUDE.md of the checkout, of the main checkout too when the lane is in a linked
|
|
253
|
+
worktree, and the main checkout's .env.local. A regular file inside the checkout only
|
|
254
|
+
(a symbolic link is never offered); a missing one is shown `not present` (no file is
|
|
255
|
+
created). One key picks (1-5; Enter when one file is there; q or Esc closes), then
|
|
256
|
+
`docker exec -it -u coder -w <checkout> <container> ~/.lz/bin/lane-edit <file>` replaces
|
|
257
|
+
it (no shell, no docker inspect) with LANES_EDITOR = the device's --editor, else this
|
|
258
|
+
process's LANES_EDITOR, else none (lane-edit's own list). Refused: a container that is
|
|
259
|
+
not running, one without lane-edit (install-container), no checkout. Exit: the
|
|
260
|
+
editor's status, 0 closed or nothing to edit, 1 refused, 2 without --focused
|
|
131
261
|
lanes studio-host status [--json] [--check]
|
|
132
262
|
the co-located Throttle Studio Host (opt-in: install/enable-studio-host.sh): binary
|
|
133
263
|
digest, config mode, enrolment, units. Exit 0 only when it runs enrolled
|
|
@@ -141,17 +271,51 @@
|
|
|
141
271
|
the lanes per create-recipe class (idempotent / attach-only / null / external /
|
|
142
272
|
retired-path), lane identity, numeric session names, crowded `default` sockets and
|
|
143
273
|
orphan tmux socket files (report only; in a container `lane gc --sockets` removes them),
|
|
144
|
-
any record left in the retired legacy-sessions/ directory,
|
|
274
|
+
any record left in the retired legacy-sessions/ directory, the devices without an owner
|
|
275
|
+
(each keeps its look to itself: `lanes device claim`), and the browser/phone half:
|
|
145
276
|
lanes-web / lanes-mobile units (⚠ not enabled, ✗ enabled but not running), the login-token
|
|
146
277
|
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
|
-
|
|
278
|
+
Tailscale Serve https:8443 mapping (⚠ not checked on a host without a Tailscale name);
|
|
279
|
+
the docs service: lanes-docsd enabled, active and answering on its loopback port (⚠
|
|
280
|
+
not enabled, ✗ enabled but not running or not answering), the pin gate (⚠ until `lanes
|
|
281
|
+
enrol mode request`), the store (documents, bytes, docs.log), the Mermaid pin (⚠ not
|
|
282
|
+
installed: install/enable-docs.sh; ✗ not the pinned build) and the age switch (✗ set
|
|
283
|
+
without an age binary);
|
|
284
|
+
then the release that runs: ~/.local/share/lanes/current names a tree whose VERSION
|
|
285
|
+
agrees with its pkg/<version> name and npm stamp and that this lanes runs (⚠ without the
|
|
286
|
+
link: the rest compares with this tree); every lanes process that runs now has a run
|
|
287
|
+
record of the current code (✗ with its restart — a daemon or sidebar that runs with no
|
|
288
|
+
record started from an older release; a record whose process does not run is one line
|
|
289
|
+
of information); every device layout runs the current tree (✗ `lanes rebuild
|
|
290
|
+
<device>`; ⚠ when it names it by another path than `lanes rebuild` writes now); every
|
|
291
|
+
installed lanes unit's ExecStart runs it (✗ re-run the server installer); and the
|
|
292
|
+
updates (update/last.json): how long ago `lanes update` last checked and what came of
|
|
293
|
+
it, the last update's result (✗ partial or failed, ⚠ refused, a registry that gave no
|
|
294
|
+
release, lanes-update.timer not enabled — no timers.target.wants link: an update with
|
|
295
|
+
--update-only never enables it — or no check for 2 h: is it on?), a release a rollback
|
|
296
|
+
marked bad; a checkout server is one line of information
|
|
297
|
+
lanes device list | forget <name> | claim <device> --login L [--name N]
|
|
149
298
|
the devices `lanes ensure` recreates view sessions for, each with its owner's login
|
|
150
299
|
(from members/<device>.json, or the machine's for a window like mac-2, whenever that
|
|
151
300
|
record exists, so it agrees with `lanes keys list`; after a revoke the last one is
|
|
152
301
|
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
|
-
|
|
302
|
+
left alone and is not brought back once it ends); claim names the owner of a device no
|
|
303
|
+
member record speaks for (one from before enrolment): it writes `owner` into
|
|
304
|
+
devices/<device>.json and its windows' (mac-2, …), where every rewrite keeps it, then
|
|
305
|
+
folds their look (never a --device-only key) and seen marks into L's profile; a view L
|
|
306
|
+
owns already keeps the name it records unless --name replaces it. A member record
|
|
307
|
+
decides whenever one exists: a claim that disagrees is refused (`lanes keys owner`),
|
|
308
|
+
one that agrees only folds (a look the device recorded before profiles existed).
|
|
309
|
+
Exit 1 when the profile could not be written (the same claim finishes it)
|
|
310
|
+
lanes user list | show <login> [--json]
|
|
311
|
+
the people lanes knows — a profile under users/, or a device a member record or a claim
|
|
312
|
+
gives them — with their devices; show prints one profile (look, seen lanes, workspace
|
|
313
|
+
order and hidden boards, pinned and hidden lanes, last opened lane, quiet time, last
|
|
314
|
+
change; --json: the stored document and the devices) and every look one of the
|
|
315
|
+
person's devices keeps in its own file (--device-only, or from before the profile).
|
|
316
|
+
Read-only. Exit 1 when the profile file exists but this release does not read it (the
|
|
317
|
+
reason is printed)
|
|
318
|
+
lanes keys list | add <device> <pubkey-file|-> | revoke <device> | adopt [--dry-run] [--login LOGIN]
|
|
155
319
|
| owner <device> <login> [--name NAME] [--email ADDR]
|
|
156
320
|
who may log in, as a projection of ~/.local/state/lanes/members/<device>.json: one
|
|
157
321
|
`restrict,pty ssh-ed25519 <key> lanes:<device>` line per member between the
|
|
@@ -163,8 +327,11 @@
|
|
|
163
327
|
neither (exit 1 only when a line outside the block still lets it in); adopt moves
|
|
164
328
|
hand-added `lanes:<device>` lines into members/
|
|
165
329
|
and the block (first adopt keeps authorized_keys.bak-<UTC>) and re-renders the block;
|
|
330
|
+
--login names the owner of every key it moves in (owner null without it);
|
|
166
331
|
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
|
|
332
|
+
`/enrol/pending` must see to let that person approve enrolment requests. Whenever
|
|
333
|
+
adopt --login or owner names an owner, the views of those devices (and their windows)
|
|
334
|
+
are folded into that person's profile, as `lanes device claim` does
|
|
168
335
|
lanes invite <name> [--email ADDR] [--expires 7d] [--workspaces a,b] [--tailscale-link URL] [--md]
|
|
169
336
|
lanes invite list | revoke <id>
|
|
170
337
|
mint a one-time code LN-<id>-<secret> a new machine redeems at lanes-enrold (libexec/
|
|
@@ -191,12 +358,17 @@
|
|
|
191
358
|
views `lanes-launch web` opened for it), end its view session (no lane is touched).
|
|
192
359
|
Without --yes or off a terminal: the plan, exit 2
|
|
193
360
|
"""
|
|
361
|
+
import contextlib
|
|
194
362
|
import json
|
|
195
363
|
import os
|
|
364
|
+
import posixpath
|
|
196
365
|
import pwd
|
|
197
366
|
import re
|
|
198
367
|
import shutil
|
|
368
|
+
import signal
|
|
369
|
+
import stat
|
|
199
370
|
import sys
|
|
371
|
+
import tempfile
|
|
200
372
|
import time
|
|
201
373
|
from pathlib import Path
|
|
202
374
|
|
|
@@ -206,17 +378,28 @@ import lanes_lib as L # noqa: E402
|
|
|
206
378
|
SLOTS = ['main', 'b', 'c']
|
|
207
379
|
ZELLIJ_VERSION = '0.45.1'
|
|
208
380
|
ZELLIJ = L.SHARE / 'bin' / f'zellij-{ZELLIJ_VERSION}'
|
|
209
|
-
|
|
381
|
+
# A view session outlives the update that flips `current` (W9 U1): its zellij server keeps the argv and the environment
|
|
382
|
+
# it was started with, so the config it reads and the bare `lanes` its keybindings run (clean_env PATH) are named
|
|
383
|
+
# through L.CURRENT — the link for a staged tree it names — never this tree's resolved path.
|
|
384
|
+
ZELLIJ_BASE = [str(ZELLIJ), '--config-dir', str(L.CURRENT / 'zellij'), '--config', str(L.CURRENT / 'zellij/config.kdl')]
|
|
210
385
|
PROC = Path(os.environ.get('LANES_PROC_ROOT') or '/proc') # injectable for the unit tests
|
|
211
386
|
|
|
212
387
|
|
|
388
|
+
# The sidebar's look and notification switches (cockpit v3 P4, LNS-72, DEF-1577): forwarded when set, so the shell that
|
|
389
|
+
# runs `lanes attach|ensure|rebuild` decides them for the view sessions it creates. A device's recorded LOOK_KEYS still
|
|
390
|
+
# win over them (sidebar.resolve_modes / resolve_dim_notify).
|
|
391
|
+
LOOK_ENV = ('LANES_COLOR', 'LANES_THEME', 'LANES_GLYPHS', 'LANES_DIM', 'LANES_NOTIFY')
|
|
392
|
+
|
|
393
|
+
|
|
213
394
|
def clean_env(extra=None):
|
|
214
|
-
"""Environment for the view server: nothing from the ssh session leaks into panes.
|
|
395
|
+
"""Environment for the view server: nothing from the ssh session leaks into panes but the named keys. The Zellij
|
|
396
|
+
server create_session starts keeps it for the session's life, and every sidebar, view.sh and floating Run pane of
|
|
397
|
+
that session inherits it (zellij() and cmd_open use it too): LOOK_ENV is how LANES_NOTIFY=0 reaches a sidebar."""
|
|
215
398
|
env = {'HOME': str(Path.home()), 'USER': os.environ.get('USER', 'dev'), 'SHELL': '/bin/bash',
|
|
216
|
-
'PATH': f"{L.SHARE / 'bin'}:{L.
|
|
399
|
+
'PATH': f"{L.SHARE / 'bin'}:{L.CURRENT / 'bin'}:/usr/local/bin:/usr/bin:/bin",
|
|
217
400
|
'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):
|
|
401
|
+
for key in ('LANES_WORKSPACES', 'XDG_RUNTIME_DIR', 'DBUS_SESSION_BUS_ADDRESS', 'ZELLIJ_SOCKET_DIR') + LOOK_ENV:
|
|
402
|
+
if os.environ.get(key): # ZELLIJ_SOCKET_DIR: test isolation
|
|
220
403
|
env[key] = os.environ[key]
|
|
221
404
|
if 'XDG_RUNTIME_DIR' not in env and Path(f'/run/user/{os.getuid()}').is_dir():
|
|
222
405
|
env['XDG_RUNTIME_DIR'] = f'/run/user/{os.getuid()}'
|
|
@@ -230,11 +413,14 @@ def q(text):
|
|
|
230
413
|
|
|
231
414
|
# ------------------------------------------------------------------- registry
|
|
232
415
|
|
|
233
|
-
def
|
|
416
|
+
def sync_registry():
|
|
417
|
+
"""Rebuild and publish the registry, print the per-board table -> (boards with errors, [(container, why)] whose
|
|
418
|
+
`~/.lz/registry.json` was refused). `lanes sync` turns either into exit 1; a rename that publishes through it
|
|
419
|
+
raises the refusal (DEF-1796: that container's `lane open` keeps its previous projection)."""
|
|
234
420
|
with L.registry_lock():
|
|
235
421
|
previous = L.read_json(L.STATE / 'registry.json')
|
|
236
422
|
registry = L.build_registry(previous)
|
|
237
|
-
L.publish_registry(registry)
|
|
423
|
+
refused = L.publish_registry(registry) or []
|
|
238
424
|
total = sum(len(w['lanes']) for w in registry['workspaces'])
|
|
239
425
|
failed = [w for w in registry['workspaces'] if w['errors']]
|
|
240
426
|
for workspace in registry['workspaces']:
|
|
@@ -242,10 +428,18 @@ def cmd_sync(_args):
|
|
|
242
428
|
print(f"{mark} {workspace['id']:<18} {len(workspace['lanes']):>3} lanes {workspace['container']}")
|
|
243
429
|
for error in workspace['errors']:
|
|
244
430
|
print(f' ⚠ {error}' + (' (kept previous lanes)' if workspace.get('stale') else ''))
|
|
245
|
-
|
|
431
|
+
for container, why in refused: # DEF-1796: that container's `lane open` keeps its previous projection
|
|
432
|
+
print(f'! container {container}: {why}')
|
|
433
|
+
print(f"{total} lanes in {len(registry['workspaces'])} workspaces" + (f'; {len(failed)} with errors' if failed else '')
|
|
434
|
+
+ (f'; {len(refused)} container projection(s) not written' if refused else ''))
|
|
246
435
|
if any((L.STATE / 'devices').glob('*.json')):
|
|
247
436
|
cmd_upgrade_session([])
|
|
248
|
-
return
|
|
437
|
+
return failed, refused
|
|
438
|
+
|
|
439
|
+
|
|
440
|
+
def cmd_sync(_args):
|
|
441
|
+
failed, refused = sync_registry()
|
|
442
|
+
return 1 if failed or refused else 0
|
|
249
443
|
|
|
250
444
|
|
|
251
445
|
def cmd_register(args):
|
|
@@ -257,6 +451,54 @@ def cmd_register(args):
|
|
|
257
451
|
print(f"Registered {lane['name']} in {lane['workspace']}")
|
|
258
452
|
|
|
259
453
|
|
|
454
|
+
def cmd_unregister(args):
|
|
455
|
+
"""lanes unregister <lane-id> — the sidebar's `u` (cockpit v3 S7): take a session added with Add to workspace /
|
|
456
|
+
`lanes register` out of registered-sessions.json. Nothing is stopped; a running session is listed under
|
|
457
|
+
Unregistered again with the same id and name. A lane a repository's terminals.json declares is taken out of that
|
|
458
|
+
file (session_actions.unregister_lane: the Agent API's editor, M4, wired at the fan-in), then synced; its refusals
|
|
459
|
+
(DeclaredRefused) are said, and a lane of an SSH workspace says which file declares it. A record no workspace
|
|
460
|
+
lists (its workspace left the catalog, uses another container, or a repository declares the session) is removed
|
|
461
|
+
from the file all the same (S7 fix cycle 1, DEF-1826)."""
|
|
462
|
+
if len(args) != 1:
|
|
463
|
+
raise L.LanesError('Usage: lanes unregister <lane-id>')
|
|
464
|
+
import registrations
|
|
465
|
+
import session_actions
|
|
466
|
+
try:
|
|
467
|
+
workspace, lane = L.find_lane(L.load_registry(), args[0])
|
|
468
|
+
except L.LanesError as error:
|
|
469
|
+
workspace, lane, unknown = None, None, error
|
|
470
|
+
published = lane is not None and bool(lane.get('registered_session'))
|
|
471
|
+
if lane is not None and not published and session_actions.declared_in(lane):
|
|
472
|
+
if not lane.get('source'): # an SSH workspace's lane: catalog.json, which no editor of ours writes
|
|
473
|
+
raise L.LanesError(f"{lane['id']} is {session_actions.declared_text(lane)}")
|
|
474
|
+
done = session_actions.unregister_lane(lane['id'])
|
|
475
|
+
if done['changed']:
|
|
476
|
+
cmd_sync([])
|
|
477
|
+
print(f"Unregistered {lane['name']} from {workspace['id']} ({done['name']} out of {done['source']})")
|
|
478
|
+
return 0
|
|
479
|
+
if published or any(item['id'] == args[0] for item in registrations.load()['sessions']):
|
|
480
|
+
try:
|
|
481
|
+
record = registrations.unregister(args[0])
|
|
482
|
+
except registrations.Unpublished as error:
|
|
483
|
+
undo = error.record
|
|
484
|
+
if undo.get('identity'): # the record is out of the file: say how to put it back once it is listed
|
|
485
|
+
raise L.LanesError(f"{error} (undo once it is listed again: lanes register {undo['id']} "
|
|
486
|
+
f"{undo['workspace']})") from None
|
|
487
|
+
raise
|
|
488
|
+
if record.get('identity'):
|
|
489
|
+
print(f"Unregistered {record['name']} from {record['workspace']}; it is listed under Unregistered again "
|
|
490
|
+
f"(undo: lanes register {record['id']} {record['workspace']})")
|
|
491
|
+
elif published:
|
|
492
|
+
print(f"Unregistered {record['name']} from {record['workspace']}; no session runs under it now")
|
|
493
|
+
else:
|
|
494
|
+
print(f"Removed the registration of {record['name']} ({record['id']}) from {registrations.FILENAME}; "
|
|
495
|
+
"no workspace listed it, so nothing else changed")
|
|
496
|
+
return 0
|
|
497
|
+
if lane is None:
|
|
498
|
+
raise unknown
|
|
499
|
+
raise L.LanesError(f"{lane['id']} is not registered; nothing to unregister")
|
|
500
|
+
|
|
501
|
+
|
|
260
502
|
def cmd_ls(args):
|
|
261
503
|
registry = L.load_registry()
|
|
262
504
|
snapshot = L.read_json(L.STATE / 'status.json') or {}
|
|
@@ -321,7 +563,12 @@ def tab_layout(lines):
|
|
|
321
563
|
' }', ' }'] + lines + ['}']) + '\n'
|
|
322
564
|
|
|
323
565
|
|
|
324
|
-
def tab_specs(device, registry, mobile=False, viewports=1):
|
|
566
|
+
def tab_specs(device, registry, mobile=False, viewports=1, profile=None):
|
|
567
|
+
"""The tabs a view session has, in order: the workspace boards, then ssh workspaces and tools, then the boards whose
|
|
568
|
+
`position` is `before-host`, then Host. The boards are the ones the device's owner sees (cockpit v3 R4, LNS-77):
|
|
569
|
+
`profile` (users/<login>.json as users.read gives it, device_profile) hides `workspaces.hidden` and orders the rest
|
|
570
|
+
by `workspaces.order` (users.arrange: an unknown id is ignored, an unlisted board follows in catalog order, a hidden
|
|
571
|
+
list that would leave no board hides none); None — a device nobody owns — is the catalog order."""
|
|
325
572
|
result = []
|
|
326
573
|
def add(key, title, lines):
|
|
327
574
|
result.append({'key': key, 'title': title, 'lines': lines, 'layout': tab_layout(lines)})
|
|
@@ -333,20 +580,25 @@ def tab_specs(device, registry, mobile=False, viewports=1):
|
|
|
333
580
|
add(f"workspace/{workspace['id']}/{role}", title, mobile_workspace_tab(device, workspace, picker))
|
|
334
581
|
else:
|
|
335
582
|
add(f"workspace/{workspace['id']}/view", workspace['title'], workspace_tab_kdl(device, workspace, False, viewports))
|
|
336
|
-
|
|
583
|
+
users = users_module() if profile else None
|
|
584
|
+
boards = users.arrange(registry['workspaces'], profile) if users else registry['workspaces']
|
|
585
|
+
for workspace in boards:
|
|
337
586
|
if workspace.get('position') != 'before-host': board(workspace)
|
|
338
587
|
for workspace in registry.get('ssh_workspaces', []):
|
|
339
588
|
add('ssh/' + workspace['id'], workspace['title'], ssh_workspace_tab_kdl(workspace))
|
|
340
589
|
for tool in registry.get('tools', []):
|
|
341
590
|
add('tool/' + tool['view'], tool['tab'], tool_tab_kdl(device, tool, mobile=mobile))
|
|
342
|
-
for workspace in
|
|
591
|
+
for workspace in boards:
|
|
343
592
|
if workspace.get('position') == 'before-host': board(workspace)
|
|
344
593
|
add('host', 'Host', [' tab name="Host" {', ' pane command="/bin/bash" {', ' args "-l"', ' }', ' }'])
|
|
345
594
|
return result
|
|
346
595
|
|
|
347
596
|
|
|
348
|
-
def layout_kdl(device, mobile=False, viewports=1):
|
|
349
|
-
|
|
597
|
+
def layout_kdl(device, mobile=False, viewports=1, profile=None):
|
|
598
|
+
"""The view session's layout (create_session, `lanes layout`): tab_specs for the device's owner's profile — passed
|
|
599
|
+
by the caller, else read here (device_profile) — with the focus on the first tab."""
|
|
600
|
+
specs = tab_specs(device, L.load_registry(), mobile, viewports,
|
|
601
|
+
device_profile(device) if profile is None else profile)
|
|
350
602
|
lines = []
|
|
351
603
|
for index, spec in enumerate(specs):
|
|
352
604
|
tab = list(spec['lines'])
|
|
@@ -356,9 +608,58 @@ def layout_kdl(device, mobile=False, viewports=1):
|
|
|
356
608
|
return tab_layout(lines)
|
|
357
609
|
|
|
358
610
|
|
|
611
|
+
# A tab of a layout layout_kdl wrote: tab_layout indents every tab by four, and only tabs start ` tab name=`.
|
|
612
|
+
LAYOUT_TAB = re.compile(r' tab name=("(?:[^"\\]|\\.)*")')
|
|
613
|
+
|
|
614
|
+
|
|
615
|
+
def layout_parts(text):
|
|
616
|
+
"""(frame, tabs) of a layout layout_kdl wrote: `frame` the lines outside every tab (`layout {`, the
|
|
617
|
+
default_tab_template, the closing brace), `tabs` {title: [each tab of that title as text]}. The ` focus=true`
|
|
618
|
+
layout_kdl gives whichever tab comes first is left out of that tab's own line: which tab is first is the catalog's
|
|
619
|
+
order, which upgrade-session keeps in a running view without a rebuild."""
|
|
620
|
+
frame, tabs, block = [], {}, None
|
|
621
|
+
for line in text.splitlines():
|
|
622
|
+
match = LAYOUT_TAB.match(line)
|
|
623
|
+
if match:
|
|
624
|
+
try:
|
|
625
|
+
title = json.loads(match.group(1))
|
|
626
|
+
except ValueError:
|
|
627
|
+
title = match.group(1)
|
|
628
|
+
block = [line[:match.end()] + line[match.end():].replace(' focus=true', '', 1)]
|
|
629
|
+
tabs.setdefault(title, []).append(block)
|
|
630
|
+
elif block is not None and line.startswith(' '):
|
|
631
|
+
block.append(line)
|
|
632
|
+
else:
|
|
633
|
+
block = None
|
|
634
|
+
frame.append(line)
|
|
635
|
+
return frame, {title: ['\n'.join(block) for block in blocks] for title, blocks in tabs.items()}
|
|
636
|
+
|
|
637
|
+
|
|
638
|
+
def layout_changed(stored, generated):
|
|
639
|
+
"""Whether a view built from the layout `stored` (layouts/<device>.kdl, what create_session wrote; None when there
|
|
640
|
+
is none) runs differently from `generated` (layout_kdl now) in a way only a rebuild changes (W9 U6, LNS-94): the
|
|
641
|
+
frame differs (the tab template), or a tab whose title both have is, line for line, none of `generated`'s tabs of
|
|
642
|
+
that title (another tree path, another pane, size or argument — what a release changes). A tab only one of them has
|
|
643
|
+
and the order of the tabs are no change: `lanes sync` (upgrade-session, device_sync.reconcile) adds, renames and
|
|
644
|
+
orders the tabs of a running view, and never changes a tab's panes, so after a board was added or renamed the file
|
|
645
|
+
no longer names every tab a view shows. A missing or unreadable layout is a change: nothing says what the view
|
|
646
|
+
runs."""
|
|
647
|
+
if stored is None:
|
|
648
|
+
return True
|
|
649
|
+
frame, tabs = layout_parts(stored)
|
|
650
|
+
frame_now, tabs_now = layout_parts(generated)
|
|
651
|
+
return frame != frame_now or any(tab not in tabs_now[title]
|
|
652
|
+
for title, kept in tabs.items() if title in tabs_now for tab in kept)
|
|
653
|
+
|
|
654
|
+
|
|
655
|
+
# Every tree file a layout runs is named through L.CURRENT (W9 U1, LNS-89): the `current` link while it names this
|
|
656
|
+
# tree, so an update that flips the link moves every view built through it; this tree's own path for a checkout and
|
|
657
|
+
# for a staged tree the link has moved past. Readers match those commands by suffix (lanes_lib.tree_file_at), so a
|
|
658
|
+
# layout written before — naming its pkg/<version> tree — still identifies.
|
|
659
|
+
|
|
359
660
|
def mobile_workspace_tab(device, workspace, picker):
|
|
360
661
|
title = workspace['title'] + (' ☰' if picker else '')
|
|
361
|
-
command = str(L.
|
|
662
|
+
command = str(L.CURRENT / 'libexec' / ('sidebar' if picker else 'view.sh'))
|
|
362
663
|
args = f'{q(device)} {q(workspace["id"])}' + ('' if picker else ' "main" "--mobile"')
|
|
363
664
|
# The picker's sidebar pane has NO layout name: Zellij reports a non-empty pane name instead of the pane's own
|
|
364
665
|
# OSC 2 title, and that title (the attention counts) is what the phone reads (DEF-1531).
|
|
@@ -374,8 +675,9 @@ def stale_picker_layouts():
|
|
|
374
675
|
`upgrade-session` / `ensure` only add missing tabs and rename titles: a picker tab created from an older layout
|
|
375
676
|
keeps its pane name, which Zellij reports instead of the OSC 2 counts, until `lanes rebuild <device>` (DEF-1531).
|
|
376
677
|
`create_session` is the only writer of layouts/<device>.kdl, so the file is the layout the session was built from.
|
|
678
|
+
The sidebar is recognised by its tree file, whichever tree path the layout names (W9 U1: pkg/<version> before,
|
|
679
|
+
`current` since, a checkout's own path).
|
|
377
680
|
"""
|
|
378
|
-
sidebar = str(L.REPO / 'libexec' / 'sidebar')
|
|
379
681
|
stale = []
|
|
380
682
|
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
381
683
|
if not (L.read_json(meta_file) or {}).get('mobile'):
|
|
@@ -384,13 +686,14 @@ def stale_picker_layouts():
|
|
|
384
686
|
text = (L.STATE / 'layouts' / f'{meta_file.stem}.kdl').read_text()
|
|
385
687
|
except OSError:
|
|
386
688
|
continue
|
|
387
|
-
if any('name="Lanes"' in line and
|
|
689
|
+
if any('name="Lanes"' in line and any(L.tree_file_at([command], 'libexec/sidebar') == 0
|
|
690
|
+
for command in layout_commands(line)) for line in text.splitlines()):
|
|
388
691
|
stale.append(meta_file.stem)
|
|
389
692
|
return stale
|
|
390
693
|
|
|
391
694
|
|
|
392
695
|
def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=False):
|
|
393
|
-
sidebar, view = str(L.
|
|
696
|
+
sidebar, view = str(L.CURRENT / 'libexec/sidebar'), str(L.CURRENT / 'libexec/view.sh')
|
|
394
697
|
focus = ' focus=true' if focused else ''
|
|
395
698
|
lines = []
|
|
396
699
|
if mobile:
|
|
@@ -413,7 +716,7 @@ def workspace_tab_kdl(device, workspace, mobile=False, viewports=1, focused=Fals
|
|
|
413
716
|
|
|
414
717
|
def ssh_workspace_tab_kdl(workspace, indent=' '):
|
|
415
718
|
"""Main terminal on the left; up to two worker terminals stacked on the right."""
|
|
416
|
-
holder = str(L.
|
|
719
|
+
holder = str(L.CURRENT / 'holders/ssh.sh')
|
|
417
720
|
panes = workspace['panes']
|
|
418
721
|
lines = [f'{indent}tab name={q(workspace["title"])} split_direction="vertical" {{']
|
|
419
722
|
|
|
@@ -435,7 +738,7 @@ def ssh_workspace_tab_kdl(workspace, indent=' '):
|
|
|
435
738
|
def tool_tab_kdl(device, tool, indent=' ', mobile=False):
|
|
436
739
|
"""One full-size tab for a shared tool (the Throttle monitor). It is a view with a fixed target, so it starts
|
|
437
740
|
only when the tab is first shown and restarts by itself if the tool exits."""
|
|
438
|
-
view = str(L.
|
|
741
|
+
view = str(L.CURRENT / 'libexec/view.sh')
|
|
439
742
|
extra = ' "main" "--mobile"' if mobile else ''
|
|
440
743
|
return [f'{indent}tab name={q(tool["tab"])} {{',
|
|
441
744
|
f'{indent} pane borderless=true name={q(tool["name"])} command={q(view)} {{',
|
|
@@ -466,9 +769,38 @@ def live_sessions():
|
|
|
466
769
|
return names
|
|
467
770
|
|
|
468
771
|
|
|
772
|
+
# The view-session lock (L.sessions_lock, DEF-1792). Every caller that decides from live_sessions() (or devices/) and then
|
|
773
|
+
# creates, kills or deletes a view session holds it from that list to the last zellij call: `ensure`, `rebuild`,
|
|
774
|
+
# attach/prepare/web's create, offboard's end_view and `device forget`. A caller that must act waits SESSIONS_WAIT for
|
|
775
|
+
# 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
|
|
776
|
+
# glances (SESSIONS_GLANCE): the minute timer's plain `ensure` skips its tick, `attach` attaches without creating.
|
|
777
|
+
SESSIONS_WAIT = 120
|
|
778
|
+
SESSIONS_GLANCE = 10
|
|
779
|
+
|
|
780
|
+
|
|
781
|
+
def hold_sessions(stack, timeout, doing):
|
|
782
|
+
"""Enter L.sessions_lock(timeout, doing) on `stack` (a contextlib.ExitStack) -> None when held, else the LanesError
|
|
783
|
+
that says why not (SessionsBusy: another lanes process held it for `timeout` s; a plain LanesError: the lock file
|
|
784
|
+
could not be opened), so a caller tells the lock it did not get from a failure of the work it does under it."""
|
|
785
|
+
try:
|
|
786
|
+
stack.enter_context(L.sessions_lock(timeout, doing))
|
|
787
|
+
except L.LanesError as error:
|
|
788
|
+
return error
|
|
789
|
+
return None
|
|
790
|
+
|
|
791
|
+
|
|
792
|
+
# The web front's own path roots (web/Caddyfile): a view session is served at /<device>, so a device of one of these
|
|
793
|
+
# names would sit on a route a service owns — the enrolment service (/enrol/*), the docs service (/lanes-docs/*) and
|
|
794
|
+
# the shell's assets (/lanes-assets/*). Compared casefolded: Caddy's path matcher ignores case.
|
|
795
|
+
RESERVED_DEVICES = ('enrol', 'lanes-assets', 'lanes-docs')
|
|
796
|
+
|
|
797
|
+
|
|
469
798
|
def device_name(value):
|
|
470
799
|
if not L.IDENT.match(value or '') or len(value) > 32:
|
|
471
800
|
raise L.LanesError('Invalid device name')
|
|
801
|
+
if value.casefold() in RESERVED_DEVICES:
|
|
802
|
+
raise L.LanesError(f'Invalid device name: {value} is a path the web front reserves '
|
|
803
|
+
f'({", ".join(RESERVED_DEVICES)})')
|
|
472
804
|
return value
|
|
473
805
|
|
|
474
806
|
|
|
@@ -491,12 +823,16 @@ def create_session(device, mobile, viewports=1):
|
|
|
491
823
|
owner = device_owner(device, old)
|
|
492
824
|
if owner: # who the device belongs to (W8 A2): the member record's, else the one recorded (device_owner)
|
|
493
825
|
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
|
|
826
|
+
# The look `lanes attach --ascii|--glyphs|--theme|--color|--notify|--dim|--editor` recorded (record_look) is the
|
|
827
|
+
# operator's choice, not session state: a recreation (ensure after a reboot, rebuild) keeps every valid one, or every
|
|
828
|
+
# sidebar of the device would flip back to the environment defaults within one tick of this rewrite — and the
|
|
829
|
+
# `device_only` list that marks the device's own (--device-only) keys with them.
|
|
497
830
|
for key, allowed in LOOK_KEYS.values():
|
|
498
831
|
if old.get(key) in allowed:
|
|
499
832
|
meta[key] = old[key]
|
|
833
|
+
users = users_module()
|
|
834
|
+
if users and users.device_only(old):
|
|
835
|
+
meta = users.mark_device_only(meta, users.device_only(old))
|
|
500
836
|
L.atomic_write(meta_file, json.dumps(meta) + '\n')
|
|
501
837
|
command = ZELLIJ_BASE + ['--layout', str(layout_file), 'attach', '--create-background', device]
|
|
502
838
|
if shutil.which('systemd-run'):
|
|
@@ -510,6 +846,15 @@ def create_session(device, mobile, viewports=1):
|
|
|
510
846
|
if rc != 0:
|
|
511
847
|
raise L.LanesError(f'Could not create session {device}: '
|
|
512
848
|
+ ('no answer in 60 s' if rc is None else err.strip() or out.strip() or f'exit {rc}'))
|
|
849
|
+
# The session runs this release's layout now: nothing `lanes ensure --if-layout-changed` stamped is pending (W9 U6)
|
|
850
|
+
update_ready_path(device).unlink(missing_ok=True)
|
|
851
|
+
|
|
852
|
+
|
|
853
|
+
def update_ready_path(device):
|
|
854
|
+
"""clients/<device>.update-ready (DM-LANES-UPDATE-READY, W9 U6): written by `lanes ensure --if-layout-changed` when
|
|
855
|
+
this release lays out the running view `device` differently while a client is attached (or zellij did not say);
|
|
856
|
+
its sidebars offer `update ready · reload (r)` while it exists; create_session removes it."""
|
|
857
|
+
return L.STATE / 'clients' / f'{device}.update-ready'
|
|
513
858
|
|
|
514
859
|
|
|
515
860
|
def device_owner(device, meta):
|
|
@@ -527,6 +872,15 @@ def device_owner(device, meta):
|
|
|
527
872
|
return members.owner_for((device, meta.get('base')), meta.get('owner'))
|
|
528
873
|
|
|
529
874
|
|
|
875
|
+
def users_module():
|
|
876
|
+
"""libexec/users.py, or None when it cannot be loaded — for create_session, which must never raise (`lanes ensure`
|
|
877
|
+
recreates views through it every minute)."""
|
|
878
|
+
try:
|
|
879
|
+
return libexec_module('users')
|
|
880
|
+
except L.LanesError:
|
|
881
|
+
return None
|
|
882
|
+
|
|
883
|
+
|
|
530
884
|
def base_device(device, default):
|
|
531
885
|
"""The machine a view belongs to: `mac-2` / `mac-w2`, opened by the client whose default device is `mac`, share
|
|
532
886
|
mac's upload helper (one helper service per machine); every other device is its own base."""
|
|
@@ -567,12 +921,20 @@ def viewports_arg(args, default=1):
|
|
|
567
921
|
return default
|
|
568
922
|
|
|
569
923
|
|
|
570
|
-
|
|
571
|
-
|
|
924
|
+
# `256` since cockpit v3 P2 (LNS-70): the sidebar paints the xterm-256 set (tokens.XTERM256, indices 16-255). `notify`
|
|
925
|
+
# and `dim` since P4 (LNS-72, DEF-1577): LANES_NOTIFY's and LANES_DIM's per-device path, in the variables' own words
|
|
926
|
+
# (`dim` is the SGR of the 16-colour dim: 90 or the escape hatch 2).
|
|
927
|
+
LOOK_KEYS = {'--theme': ('theme', ('dark', 'light', 'auto')), '--color': ('color', ('truecolor', '256', '16', 'none')),
|
|
928
|
+
'--glyphs': ('glyphs', ('unicode', 'ascii', 'emoji')), '--notify': ('notify', ('1', '0')),
|
|
929
|
+
'--dim': ('dim', ('90', '2')),
|
|
930
|
+
# The editor quick edit (`lanes edit`, W9 E2) hands lane-edit as LANES_EDITOR; `auto` takes the choice back.
|
|
931
|
+
# Per device, like notify/dim: never a profile key (users.LOOK), never read by a sidebar.
|
|
932
|
+
'--editor': ('editor', ('micro', 'hx', 'nvim', 'vim', 'auto'))}
|
|
572
933
|
|
|
573
934
|
|
|
574
935
|
def look_args(args):
|
|
575
|
-
"""The look `lanes attach` was asked for: {'glyphs'|'theme'|'color': value}, validated
|
|
936
|
+
"""The look `lanes attach` was asked for: {'glyphs'|'theme'|'color'|'notify'|'dim'|'editor': value}, validated
|
|
937
|
+
(spec §2.4)."""
|
|
576
938
|
look = {}
|
|
577
939
|
if '--ascii' in args:
|
|
578
940
|
look['glyphs'] = 'ascii'
|
|
@@ -586,20 +948,107 @@ def look_args(args):
|
|
|
586
948
|
|
|
587
949
|
|
|
588
950
|
def record_look(device, args):
|
|
589
|
-
"""`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
951
|
+
"""`lanes attach DEVICE --ascii|--glyphs M|--theme T|--color C|--notify N|--dim D|--editor E [--device-only]`:
|
|
952
|
+
remembered where the device's sidebars read it (cockpit v3 R2, LNS-75).
|
|
953
|
+
* A device with an owner (profile_for) shares theme, color and glyphs with every device that person owns: they
|
|
954
|
+
are written to users/<login>.json (users.update, a 1 s lock) and leave devices/<device>.json — a key left
|
|
955
|
+
there would sit beneath the profile for this device. `--device-only` keeps them in the device file instead, for
|
|
956
|
+
this device alone, and names them in its `device_only` list (users.mark_device_only, DEF-1782): only a key that
|
|
957
|
+
list names wins over the profile there (sidebar.layered_look) and survives a fold (users.migrate). The same
|
|
958
|
+
flag without --device-only takes its key off the list again. A profile write that is dropped (its lock busy, or
|
|
959
|
+
a file this release does not write) records none of them anywhere — the device keeps what it had, its marks
|
|
960
|
+
included — and says so on stderr.
|
|
961
|
+
* notify and dim are per-device switches (P4), and so is editor (W9 E2: read by `lanes edit` at each Ctrl-Alt-e,
|
|
962
|
+
never by a sidebar): always the device file, never on the list. A device without an owner records every flag in
|
|
963
|
+
its file; a device that never used --device-only has no list, so its file is byte for byte what it was before
|
|
964
|
+
R2.
|
|
965
|
+
devices/<device>.json is the file every sidebar of the device re-reads when it changes (its reload() stamp), so a
|
|
966
|
+
RUNNING session follows a device-file change within one tick.
|
|
967
|
+
Returns what was recorded, in the flags' order: empty when nothing was asked — or when the device has no file yet,
|
|
968
|
+
because `ensure` builds a session for every devices/*.json and a look flag must never create that stub on its
|
|
969
|
+
own (nor, for an unknown device, a profile)."""
|
|
593
970
|
look = look_args(args)
|
|
594
971
|
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
595
972
|
meta = L.read_json(meta_file) if meta_file.exists() else None
|
|
596
973
|
if not look or not isinstance(meta, dict):
|
|
597
974
|
return {}
|
|
598
|
-
|
|
599
|
-
|
|
975
|
+
alone = '--device-only' in args
|
|
976
|
+
login = None if alone else profile_for(device)
|
|
977
|
+
users = libexec_module('users')
|
|
978
|
+
shared = {}
|
|
979
|
+
if login:
|
|
980
|
+
shared = {key: value for key, value in look.items() if key in users.LOOK}
|
|
981
|
+
if shared and not users.update(login, device, lambda profile: profile['look'].update(shared)):
|
|
982
|
+
words = ', '.join(f'{key} {value}' for key, value in shared.items())
|
|
983
|
+
print(f"lanes: the look ({words}) was not saved: {login}'s profile was busy or cannot be written "
|
|
984
|
+
f'(`lanes user show {login}` says why); nothing changed — attach again, or add --device-only to keep '
|
|
985
|
+
f'it on {device} alone', file=sys.stderr)
|
|
986
|
+
look = {key: value for key, value in look.items() if key not in shared}
|
|
987
|
+
shared = {}
|
|
988
|
+
on_device = {key: value for key, value in look.items() if key not in shared}
|
|
989
|
+
updated = {key: value for key, value in meta.items() if key not in shared}
|
|
990
|
+
updated.update(on_device)
|
|
991
|
+
marked, asked = set(users.device_only(meta)), set(look) & set(users.LOOK)
|
|
992
|
+
marked = marked | asked if alone else marked - asked
|
|
993
|
+
if marked or 'device_only' in meta:
|
|
994
|
+
updated = users.mark_device_only(updated, marked)
|
|
995
|
+
if updated != meta:
|
|
996
|
+
L.atomic_write(meta_file, json.dumps(updated) + '\n')
|
|
600
997
|
return look
|
|
601
998
|
|
|
602
999
|
|
|
1000
|
+
def profile_for(device):
|
|
1001
|
+
"""The login whose users/<login>.json profile `device` follows (cockpit v3 R2, LNS-75): its owner exactly as
|
|
1002
|
+
device_owner derives it — the member record of the device or of the machine it is a window of whenever one exists,
|
|
1003
|
+
else the owner devices/<device>.json records (`lanes device claim`) — through users.login_for. None for a device
|
|
1004
|
+
nobody owns, or a name that is not a device: it keeps its look and seen marks to itself. Never raises."""
|
|
1005
|
+
try:
|
|
1006
|
+
return libexec_module('users').login_for(device)
|
|
1007
|
+
except L.LanesError:
|
|
1008
|
+
return None
|
|
1009
|
+
|
|
1010
|
+
|
|
1011
|
+
def device_profile(device):
|
|
1012
|
+
"""The profile `device` follows (users.read of profile_for's login), or None: a device nobody owns, no profile yet,
|
|
1013
|
+
or a file this release does not read. What tab_specs arranges a view's workspace tabs by (cockpit v3 R4, LNS-77)
|
|
1014
|
+
for create_session and `lanes upgrade-session`. Never raises: `lanes ensure` runs both every minute."""
|
|
1015
|
+
login = profile_for(device)
|
|
1016
|
+
users = users_module() if login else None
|
|
1017
|
+
return users.read(login) if users else None
|
|
1018
|
+
|
|
1019
|
+
|
|
1020
|
+
def windows_of(devices):
|
|
1021
|
+
"""{window: its devices/<window>.json} for each view whose recorded `base` is one of `devices` (mac-2 and mac-w2
|
|
1022
|
+
are mac's windows: base_device), the devices themselves excluded."""
|
|
1023
|
+
found = {}
|
|
1024
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
1025
|
+
meta = L.read_json(meta_file)
|
|
1026
|
+
if (isinstance(meta, dict) and meta.get('base') in devices and meta_file.stem not in devices
|
|
1027
|
+
and L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32):
|
|
1028
|
+
found[meta_file.stem] = meta
|
|
1029
|
+
return found
|
|
1030
|
+
|
|
1031
|
+
|
|
1032
|
+
def fold_profile(login, devices):
|
|
1033
|
+
"""Fold the views of `devices` and their windows that `login` owns now into `login`'s profile (users.migrate over
|
|
1034
|
+
exactly those: look keys the profile lacks from the most recently attached, seen marks union-max, then their look
|
|
1035
|
+
keys leave the device files) -> 0, or 1 when the profile write was dropped (said on stderr, with the claim that
|
|
1036
|
+
finishes it; no device file was touched). Devices with no view and no seen marks have nothing to fold: 0, silent.
|
|
1037
|
+
What `lanes device claim`, `lanes keys adopt --login` and `lanes keys owner` end with."""
|
|
1038
|
+
users = libexec_module('users')
|
|
1039
|
+
wanted = set(devices) | set(windows_of(set(devices)))
|
|
1040
|
+
owned = [found['device'] for found in users.owned_devices(login) if found['device'] in wanted]
|
|
1041
|
+
if not owned:
|
|
1042
|
+
return 0
|
|
1043
|
+
if users.migrate(login, devices=owned) is None:
|
|
1044
|
+
print(f"⚠ {login}'s profile was not updated: its lock was busy, or its file under users/ is not one this "
|
|
1045
|
+
f'release writes (`lanes user show {login}` says why); {", ".join(owned)} keep their own look until '
|
|
1046
|
+
f'`lanes device claim {owned[0]} --login {login}` runs again', file=sys.stderr)
|
|
1047
|
+
return 1
|
|
1048
|
+
print(f'profile {login}: folded in {", ".join(owned)} — `lanes user show {login}`')
|
|
1049
|
+
return 0
|
|
1050
|
+
|
|
1051
|
+
|
|
603
1052
|
def cmd_layout(args):
|
|
604
1053
|
print(layout_kdl(device_name(args[0]), '--mobile' in args, viewports_arg(args)), end='')
|
|
605
1054
|
return 0
|
|
@@ -607,7 +1056,7 @@ def cmd_layout(args):
|
|
|
607
1056
|
|
|
608
1057
|
def cmd_attach(args):
|
|
609
1058
|
device = device_name(args[0])
|
|
610
|
-
look_args(args) # a bad --theme/--color/--glyphs refuses here, before any session is created
|
|
1059
|
+
look_args(args) # a bad --theme/--color/--glyphs/--editor refuses here, before any session is created
|
|
611
1060
|
# Old muscle memory: `coder-launch worlds`. A workspace id is not a device: open the caller's normal
|
|
612
1061
|
# device session and bring that workspace's tab to the front.
|
|
613
1062
|
tab = None
|
|
@@ -632,15 +1081,22 @@ def cmd_attach(args):
|
|
|
632
1081
|
raise L.LanesError(f'{ZELLIJ} is not installed: run install/host-install.sh')
|
|
633
1082
|
if not (L.STATE / 'registry.json').exists():
|
|
634
1083
|
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)
|
|
1084
|
+
with contextlib.ExitStack() as stack:
|
|
1085
|
+
# Only a glance at the view-session lock (DEF-1792): a person is waiting. Not taken in time is unknown, not
|
|
1086
|
+
# missing — the holder may be rebuilding this very view — so this creates nothing and just attaches.
|
|
1087
|
+
refused = hold_sessions(stack, SESSIONS_GLANCE, f'lanes attach {device}')
|
|
641
1088
|
missing = False
|
|
642
|
-
|
|
643
|
-
|
|
1089
|
+
if refused:
|
|
1090
|
+
print(f'lanes: {refused}; attaching without creating {device}', file=sys.stderr)
|
|
1091
|
+
else:
|
|
1092
|
+
try:
|
|
1093
|
+
missing = device not in live_sessions()
|
|
1094
|
+
except L.LanesError as error:
|
|
1095
|
+
# Unknown is not missing: create nothing and just try to attach. If the session really is gone, zellij
|
|
1096
|
+
# says so itself (nothing is resurrected: session_serialization is off) and lanes-launch retries.
|
|
1097
|
+
print(f'lanes: {error}', file=sys.stderr)
|
|
1098
|
+
if missing:
|
|
1099
|
+
create_session(device, '--mobile' in args, viewports_arg(args))
|
|
644
1100
|
# Record the base and bring the owner (W8 A2) in line, also for sessions that existed before either was known.
|
|
645
1101
|
record_base(device, args)
|
|
646
1102
|
record_look(device, args)
|
|
@@ -704,14 +1160,22 @@ ENV_HOOK = '[ -r "$HOME/.lz/env.sh" ] && . "$HOME/.lz/env.sh" # lanes-env: temp
|
|
|
704
1160
|
|
|
705
1161
|
def hook_env(home):
|
|
706
1162
|
"""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.
|
|
1163
|
+
the login files bash/zsh really read. Append only, idempotent, never rewrites a dotfile; remove the line to undo.
|
|
1164
|
+
A dotfile (or ~/.tmp) that is a symbolic link is left alone and named: this runs on the host, where a link the
|
|
1165
|
+
container's home holds resolves outside the container (an absolute `/home/coder/dotfiles/.bashrc` is the HOST
|
|
1166
|
+
account's file; DEF-1798)."""
|
|
708
1167
|
tmp = home / '.tmp'
|
|
709
|
-
if
|
|
1168
|
+
if tmp.is_symlink():
|
|
1169
|
+
print(f'! {tmp} is a symbolic link — left alone')
|
|
1170
|
+
elif not tmp.exists():
|
|
710
1171
|
tmp.mkdir(mode=0o700)
|
|
711
1172
|
tmp.chmod(0o700)
|
|
712
|
-
login = home / '.bash_profile' if (home / '.bash_profile')
|
|
713
|
-
targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if (home / '.zshrc')
|
|
1173
|
+
login = home / '.bash_profile' if os.path.lexists(home / '.bash_profile') else home / '.profile'
|
|
1174
|
+
targets = [login, home / '.bashrc'] + ([home / '.zshrc'] if os.path.lexists(home / '.zshrc') else [])
|
|
714
1175
|
for path in targets:
|
|
1176
|
+
if path.is_symlink():
|
|
1177
|
+
print(f'! {path} is a symbolic link — not changed; add this line to the file it points at by hand: {ENV_HOOK}')
|
|
1178
|
+
continue
|
|
715
1179
|
text = path.read_text(errors='replace') if path.exists() else ''
|
|
716
1180
|
if 'lanes-env' in text:
|
|
717
1181
|
continue
|
|
@@ -720,44 +1184,130 @@ def hook_env(home):
|
|
|
720
1184
|
|
|
721
1185
|
|
|
722
1186
|
INSTALL_CONTAINER_USAGE = 'usage: lanes install-container <name> [--no-mcp]'
|
|
1187
|
+
# lane_core.py first: `lane` imports it, so no moment has a new `lane` beside an old (or no) lane_core.py.
|
|
1188
|
+
CONTRACT_FILES = ('lane_core.py', 'lane-mcp', 'lane', 'lane-run', 'lane-signal', 'lane-edit')
|
|
1189
|
+
|
|
1190
|
+
|
|
1191
|
+
def lane_core():
|
|
1192
|
+
"""container/lane_core.py, the in-container contract's shared helpers, as a module: the agents' MCP entries are
|
|
1193
|
+
written from the host through the same code `lane mcp install` runs in the container."""
|
|
1194
|
+
module = sys.modules.get('lane_core')
|
|
1195
|
+
if module is None:
|
|
1196
|
+
import importlib.util
|
|
1197
|
+
spec = importlib.util.spec_from_file_location('lane_core', str(L.REPO / 'container' / 'lane_core.py'))
|
|
1198
|
+
module = importlib.util.module_from_spec(spec)
|
|
1199
|
+
spec.loader.exec_module(module)
|
|
1200
|
+
sys.modules['lane_core'] = module
|
|
1201
|
+
return module
|
|
1202
|
+
|
|
1203
|
+
|
|
1204
|
+
def container_dir(home, *parts):
|
|
1205
|
+
"""<home>/<parts…> as a real directory, created when missing; LanesError when it, or a component of it below the
|
|
1206
|
+
container home, is a symbolic link. The container owns its home and this runs on the HOST: a link planted there
|
|
1207
|
+
resolves outside the container (DEF-1798, the DEF-1796 class)."""
|
|
1208
|
+
path = Path(home).joinpath(*parts)
|
|
1209
|
+
core = lane_core()
|
|
1210
|
+
try:
|
|
1211
|
+
core.no_links(home, path)
|
|
1212
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
1213
|
+
core.no_links(home, path)
|
|
1214
|
+
except core.LaneError as error:
|
|
1215
|
+
raise L.LanesError(f'{error}: nothing installed through it — remove the link in the container, then run again')
|
|
1216
|
+
return path
|
|
1217
|
+
|
|
1218
|
+
|
|
1219
|
+
def install_file(source, target):
|
|
1220
|
+
"""Copy `source` (its bytes and mode) to `target` without following anything at the target: an O_EXCL temp file
|
|
1221
|
+
beside it, renamed over whatever the name holds — a planted symbolic link is replaced, never written through
|
|
1222
|
+
(DEF-1798), and a `lane open` starting mid-install runs the old file or the new one, never a half-written one.
|
|
1223
|
+
The directory must be a real one (container_dir)."""
|
|
1224
|
+
fd, tmp = tempfile.mkstemp(prefix=f'.{target.name}.', dir=str(target.parent))
|
|
1225
|
+
try:
|
|
1226
|
+
with os.fdopen(fd, 'wb') as handle, open(source, 'rb') as data:
|
|
1227
|
+
shutil.copyfileobj(data, handle)
|
|
1228
|
+
shutil.copystat(source, tmp)
|
|
1229
|
+
os.replace(tmp, target)
|
|
1230
|
+
except BaseException:
|
|
1231
|
+
try:
|
|
1232
|
+
os.unlink(tmp)
|
|
1233
|
+
except OSError:
|
|
1234
|
+
pass
|
|
1235
|
+
raise
|
|
1236
|
+
|
|
1237
|
+
|
|
1238
|
+
def install_mcp_entries(home):
|
|
1239
|
+
"""`mcpServers.lanes` in <home>/.claude.json and `[mcp_servers.lanes]` in <home>/.codex/config.toml, both starting
|
|
1240
|
+
/home/coder/.lz/bin/lane-mcp, the Codex table also passing it LANE_ID, LANE_WS, TMUX and TMUX_PANE in `env_vars`
|
|
1241
|
+
(Codex clears an MCP server's environment) (lane_core.mcp_apply: written only where missing, `.bak-<UTC>` first,
|
|
1242
|
+
atomic, the file's own mode) -> exit code. Refused on a uid split (the agent could not read a file this account
|
|
1243
|
+
creates: DEF-1727's rule); a file left alone (not valid JSON/TOML, a `lanes` entry that starts something else)
|
|
1244
|
+
is said and makes the exit 1, while the other agent still gets its entry."""
|
|
1245
|
+
split = uid_split(home)
|
|
1246
|
+
if split:
|
|
1247
|
+
print(f'! MCP entries not written: {split} — the agent could not read what this account would write; run '
|
|
1248
|
+
f'`lanes install-container` from a host account with uid {L.CONTAINER_UID}, or `~/.lz/bin/lane mcp '
|
|
1249
|
+
f'install --write` inside the container')
|
|
1250
|
+
return 1
|
|
1251
|
+
code = 0
|
|
1252
|
+
for result in lane_core().mcp_apply(home):
|
|
1253
|
+
if result['status'] == 'refused':
|
|
1254
|
+
print(f"! {result['path']}: {result['why']}")
|
|
1255
|
+
code = 1
|
|
1256
|
+
continue
|
|
1257
|
+
if result['backup']:
|
|
1258
|
+
print(f"backup {result['backup']}")
|
|
1259
|
+
print(f"MCP ({result['agent']}): " + (f"wrote {result['path']}" if result['status'] == 'added'
|
|
1260
|
+
else f"{result['path']} already starts lane-mcp"))
|
|
1261
|
+
return code
|
|
723
1262
|
|
|
724
1263
|
|
|
725
1264
|
def cmd_install_container(args):
|
|
726
1265
|
names = [word for word in args if word != '--no-mcp']
|
|
727
1266
|
if len(names) != 1 or names[0].startswith('-'):
|
|
728
1267
|
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.
|
|
1268
|
+
no_mcp = '--no-mcp' in args # leaves out lane-mcp and the agents' MCP entries; removes nothing already there
|
|
731
1269
|
container = names[0]
|
|
732
1270
|
info = L.container_info(container)
|
|
733
1271
|
if not info.get('home'):
|
|
734
1272
|
raise L.LanesError(f'{container}: home volume not found')
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
for
|
|
739
|
-
|
|
1273
|
+
home = Path(info['home'])
|
|
1274
|
+
root = home / '.lz'
|
|
1275
|
+
# Every directory is a real one and every file lands by rename (DEF-1798): nothing is written through a link.
|
|
1276
|
+
for sub in ('bin', 's', 'meta', 'state', 'hooks'):
|
|
1277
|
+
container_dir(home, '.lz', sub)
|
|
1278
|
+
for name in CONTRACT_FILES:
|
|
1279
|
+
if not (no_mcp and name == 'lane-mcp'):
|
|
1280
|
+
install_file(L.REPO / 'container' / name, root / 'bin' / name)
|
|
1281
|
+
install_file(L.REPO / 'VERSION', root / 'VERSION') # what lane-mcp reports as its version
|
|
1282
|
+
# lane-edit (a CONTRACT_FILES member) is what `lanes edit` (Ctrl-Alt-e) execs in the container; vim and nvim start
|
|
1283
|
+
# there with -u ~/.lz/vimrc (W9 E3). Both are ours and replaced on every install, by rename like the rest, whatever
|
|
1284
|
+
# --no-mcp says. ~/.lz/tmp/, where that vim keeps its swap files, is created by lane-edit as the container's own
|
|
1285
|
+
# user (0700), never here.
|
|
1286
|
+
install_file(L.REPO / 'container/vimrc', root / 'vimrc')
|
|
740
1287
|
# The hook DEFINITIONS travel with the contract for reference; no hook is installed here — that is the opt-in,
|
|
741
1288
|
# per-container `lanes hooks install --agent claude|codex --container <name> --write` (RUNBOOK).
|
|
742
|
-
(root / 'hooks').mkdir(exist_ok=True)
|
|
743
1289
|
for source in sorted((L.REPO / 'container/hooks').glob('*.json')):
|
|
744
|
-
|
|
1290
|
+
install_file(source, root / 'hooks' / source.name)
|
|
745
1291
|
env_file = L.REPO / 'container/env.sh' # TMPDIR off the RAM-backed /tmp; only NEW shells and agents read it
|
|
746
1292
|
if env_file.exists():
|
|
747
|
-
|
|
748
|
-
hook_env(
|
|
1293
|
+
install_file(env_file, root / 'env.sh')
|
|
1294
|
+
hook_env(home)
|
|
749
1295
|
for binary in sorted((L.SHARE / 'bin').glob('zmx-*')):
|
|
750
1296
|
target = root / 'bin' / binary.name
|
|
751
|
-
if not
|
|
752
|
-
|
|
1297
|
+
if not os.path.lexists(target): # a running lane's daemon keeps executing its own version: never overwrite
|
|
1298
|
+
install_file(binary, target)
|
|
753
1299
|
link = Path.home() / '.lz' / container
|
|
754
1300
|
link.parent.mkdir(parents=True, exist_ok=True)
|
|
755
1301
|
if link.is_symlink() or link.exists():
|
|
756
1302
|
link.unlink()
|
|
757
1303
|
link.symlink_to(root)
|
|
758
|
-
print(f
|
|
759
|
-
+ (f' and {root}/env.sh' if env_file.exists() else '')
|
|
760
|
-
|
|
1304
|
+
print(f"installed {root}/bin/{{lane,lane_core.py,lane-run,lane-signal,lane-edit{'' if no_mcp else ',lane-mcp'},zmx-*}}, "
|
|
1305
|
+
f'{root}/VERSION, {root}/vimrc, {root}/hooks/' + (f' and {root}/env.sh' if env_file.exists() else '')
|
|
1306
|
+
+ f'; host link {link} -> {root}')
|
|
1307
|
+
if no_mcp:
|
|
1308
|
+
print('--no-mcp: lane-mcp and the agents\' MCP entries left out (anything an earlier install wrote is kept)')
|
|
1309
|
+
return 0
|
|
1310
|
+
return install_mcp_entries(home)
|
|
761
1311
|
|
|
762
1312
|
|
|
763
1313
|
HOOK_TARGETS = {'claude': ('claude-settings.json', '.claude/settings.json'),
|
|
@@ -914,20 +1464,31 @@ def cmd_hooks(args):
|
|
|
914
1464
|
|
|
915
1465
|
def cmd_rebuild(args):
|
|
916
1466
|
device = device_name(args[0])
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
#
|
|
920
|
-
|
|
921
|
-
|
|
922
|
-
|
|
923
|
-
|
|
924
|
-
|
|
925
|
-
|
|
1467
|
+
with contextlib.ExitStack() as stack:
|
|
1468
|
+
# From the list to the create, under the view-session lock (DEF-1792): between this kill and this create no
|
|
1469
|
+
# `lanes ensure` may find the device missing and create it as well. Re-entered when `ensure` rebuilds.
|
|
1470
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes rebuild {device}')
|
|
1471
|
+
if refused:
|
|
1472
|
+
raise L.LanesError(f'{refused}: {device} was NOT rebuilt (no lane was touched); run `lanes rebuild {device}` '
|
|
1473
|
+
'again')
|
|
1474
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json') or {}
|
|
1475
|
+
if device in live_sessions():
|
|
1476
|
+
# Attached clients see an ordinary exit (0) when their session is killed: leave a stamp so that
|
|
1477
|
+
# lanes-launch (`lanes rebuilt <device>`) reconnects instead of closing the window.
|
|
1478
|
+
L.atomic_write(L.STATE / 'clients' / f'{device}.rebuilt', f'{time.time()}\n')
|
|
1479
|
+
if zellij(['kill-session', device], 30)[0] is None:
|
|
1480
|
+
raise L.LanesError(f'zellij did not answer in 30 s: {device} was NOT rebuilt (no lane was touched)')
|
|
1481
|
+
zellij(['delete-session', '--force', device], 30)
|
|
1482
|
+
create_session(device, bool(meta.get('mobile')) or '--mobile' in args,
|
|
1483
|
+
viewports_arg(args, int(meta.get('viewports') or 1)))
|
|
926
1484
|
print(f'rebuilt view session {device}; no lane was touched')
|
|
927
1485
|
return 0
|
|
928
1486
|
|
|
929
1487
|
|
|
930
1488
|
def cmd_upgrade_session(args):
|
|
1489
|
+
"""Bring each running view session (the named devices, else every device) in line with the catalog and with its
|
|
1490
|
+
owner's profile (device_sync.reconcile): add missing tabs, rename titles, order them, and close the tab of a
|
|
1491
|
+
workspace the profile hides (cockpit v3 R4, LNS-77) — only a tab whose panes identify that workspace."""
|
|
931
1492
|
import device_sync
|
|
932
1493
|
registry = L.load_registry()
|
|
933
1494
|
live = live_sessions()
|
|
@@ -939,7 +1500,10 @@ def cmd_upgrade_session(args):
|
|
|
939
1500
|
meta = L.read_json(L.STATE / 'devices' / (device + '.json')) or {}
|
|
940
1501
|
mobile, viewports = bool(meta.get('mobile')), int(meta.get('viewports') or 1)
|
|
941
1502
|
try:
|
|
942
|
-
|
|
1503
|
+
specs = tab_specs(device, registry, mobile, viewports, device_profile(device))
|
|
1504
|
+
shown = {spec['key'].split('/')[1] for spec in specs if spec['key'].startswith('workspace/')}
|
|
1505
|
+
hidden = [workspace['id'] for workspace in registry['workspaces'] if workspace['id'] not in shown]
|
|
1506
|
+
for change in device_sync.reconcile(device, registry, specs, mobile, zellij, hidden=hidden):
|
|
943
1507
|
print(f'{device}: {change}')
|
|
944
1508
|
except (L.LanesError, ValueError, KeyError) as error:
|
|
945
1509
|
failures.append(str(error))
|
|
@@ -962,26 +1526,198 @@ def cmd_reattach(_args):
|
|
|
962
1526
|
return 0
|
|
963
1527
|
|
|
964
1528
|
|
|
965
|
-
def
|
|
1529
|
+
def catches_signal(pid, signum):
|
|
1530
|
+
"""Whether process `pid` belongs to this account and has a handler installed for `signum` (the SigCgt mask of
|
|
1531
|
+
/proc/<pid>/status). A signal a process neither catches nor ignores takes its default action, which for SIGUSR2 is
|
|
1532
|
+
to end it: reload-views signals only a view.sh whose `trap … USR2` is in place."""
|
|
1533
|
+
try:
|
|
1534
|
+
fields = dict(line.split(':', 1) for line in (PROC / str(pid) / 'status').read_text().splitlines()
|
|
1535
|
+
if ':' in line)
|
|
1536
|
+
uid, mask = int(fields['Uid'].split()[0]), int(fields['SigCgt'].strip(), 16)
|
|
1537
|
+
except (OSError, KeyError, ValueError, IndexError):
|
|
1538
|
+
return False
|
|
1539
|
+
return uid == os.getuid() and bool(mask >> (signum - 1) & 1)
|
|
1540
|
+
|
|
1541
|
+
|
|
1542
|
+
# The first release whose view.sh traps SIGUSR2 (cockpit v3 U5, shipped in 1.2.0 — plan D3). A view.sh of an older
|
|
1543
|
+
# release has no such trap: the signal's default action ENDS it (RUNBOOK, U5), and with it the viewport.
|
|
1544
|
+
VIEW_RELOAD_SINCE = (1, 2, 0)
|
|
1545
|
+
|
|
1546
|
+
|
|
1547
|
+
def view_tree(pid, words):
|
|
1548
|
+
"""The lanes tree the viewport process `pid` runs (`words`: its command line). First the tree of the view.sh file
|
|
1549
|
+
its shell holds open (/proc/<pid>/fd/*: dash and bash keep the script open while it runs, and the kernel names the
|
|
1550
|
+
file it RESOLVED at start, so a `…/current/libexec/view.sh` start names the tree `current` pointed at then, not the
|
|
1551
|
+
one an update has flipped it to since); else, with no readable fd, the resolved tree of the view.sh path in its
|
|
1552
|
+
command line. None when neither is a lanes tree file."""
|
|
1553
|
+
suffix = '/libexec/view.sh'
|
|
1554
|
+
try:
|
|
1555
|
+
fds = sorted((PROC / str(pid) / 'fd').iterdir())
|
|
1556
|
+
except OSError:
|
|
1557
|
+
fds = []
|
|
1558
|
+
for fd in fds:
|
|
1559
|
+
try:
|
|
1560
|
+
target = os.readlink(fd)
|
|
1561
|
+
except OSError:
|
|
1562
|
+
continue
|
|
1563
|
+
if target.startswith('/') and target.endswith(suffix): # a pruned tree reads `… (deleted)`: no match
|
|
1564
|
+
return Path(target[:-len(suffix)])
|
|
1565
|
+
at = L.tree_file_at(words, 'libexec/view.sh')
|
|
1566
|
+
return Path(os.path.realpath(words[at])).parent.parent if at is not None else None
|
|
1567
|
+
|
|
1568
|
+
|
|
1569
|
+
def view_reloads(tree):
|
|
1570
|
+
"""Whether a view.sh of `tree` handles SIGUSR2: its release (`<tree>/VERSION`, lanes_lib.tree_version, compared as
|
|
1571
|
+
major.minor.patch by version_key) is VIEW_RELOAD_SINCE or later. No tree, no VERSION or one that is no release
|
|
1572
|
+
('dev') is never assumed to: the signal would end a view that does not."""
|
|
1573
|
+
key = version_key(L.tree_version(tree) or '') if tree is not None else None
|
|
1574
|
+
return key is not None and key >= VIEW_RELOAD_SINCE
|
|
1575
|
+
|
|
1576
|
+
|
|
1577
|
+
def cmd_reload_views(args):
|
|
1578
|
+
"""SIGUSR2 to every running viewport (`views/<device>/<ws>.<slot>.pid`) whose process is a view.sh of a lanes tree
|
|
1579
|
+
(`L.tree_file_at`) that handles it: such a view.sh starts again from the tree `current` names once its lane client
|
|
1580
|
+
next exits (W9 U5), never dropping an attached client. One that does not handle it — a view.sh of a release before
|
|
1581
|
+
that — would be ENDED by the signal (SIGUSR2's default action), so it is left alone and counted: it keeps its
|
|
1582
|
+
release until `lanes rebuild <device>`. Two checks, both required: the release of the tree the process runs
|
|
1583
|
+
(view_tree: the script file it holds open, else its command line's) is VIEW_RELOAD_SINCE or later (view_reloads),
|
|
1584
|
+
and the process has a SIGUSR2 handler installed now (catches_signal: the trap has run). Sidebars swap by themselves
|
|
1585
|
+
(W9 U5). A stale pid file, a recycled pid or another account's process is skipped. Exit 0; 2 for any argument."""
|
|
1586
|
+
if args:
|
|
1587
|
+
print('usage: lanes reload-views')
|
|
1588
|
+
return 2
|
|
1589
|
+
asked, older = 0, {}
|
|
1590
|
+
for pid_file in sorted((L.STATE / 'views').glob('*/*.pid')):
|
|
1591
|
+
try:
|
|
1592
|
+
pid = int(pid_file.read_text().strip())
|
|
1593
|
+
words = [word.decode(errors='replace') for word in (PROC / str(pid) / 'cmdline').read_bytes().split(b'\0')]
|
|
1594
|
+
except (OSError, ValueError):
|
|
1595
|
+
continue
|
|
1596
|
+
if pid <= 1 or L.tree_file_at(words, 'libexec/view.sh') is None:
|
|
1597
|
+
continue
|
|
1598
|
+
if not view_reloads(view_tree(pid, words)) or not catches_signal(pid, signal.SIGUSR2):
|
|
1599
|
+
older.setdefault(pid_file.parent.name, []).append(pid)
|
|
1600
|
+
continue
|
|
1601
|
+
try:
|
|
1602
|
+
os.kill(pid, signal.SIGUSR2)
|
|
1603
|
+
except OSError:
|
|
1604
|
+
continue
|
|
1605
|
+
asked += 1
|
|
1606
|
+
print(f"reload-views: asked {asked} view{'' if asked == 1 else 's'} to restart on the installed release "
|
|
1607
|
+
'(each at its next lane switch or reconnect)')
|
|
1608
|
+
if older:
|
|
1609
|
+
count = sum(len(pids) for pids in older.values())
|
|
1610
|
+
print(f"reload-views: {count} view{'' if count == 1 else 's'} of {', '.join(sorted(older))} "
|
|
1611
|
+
f"{'runs' if count == 1 else 'run'} a view.sh that cannot reload in place (an older release): "
|
|
1612
|
+
"`lanes rebuild <device>` moves "
|
|
1613
|
+
f"{'it' if count == 1 else 'them'}")
|
|
1614
|
+
return 0
|
|
1615
|
+
|
|
1616
|
+
|
|
1617
|
+
ENSURE_USAGE = 'usage: lanes ensure [--if-layout-changed]'
|
|
1618
|
+
|
|
1619
|
+
|
|
1620
|
+
def view_clients(device):
|
|
1621
|
+
"""How many clients are attached to the view session `device`: the rows `zellij action list-clients` prints under
|
|
1622
|
+
its header (`CLIENT_ID ZELLIJ_PANE_ID RUNNING_COMMAND` on 0.45.1; a row starts with the client id — the sidebar's
|
|
1623
|
+
poll_mobile_view reads it the same way). None when zellij did not answer (no answer in 10 s, an error, a session
|
|
1624
|
+
gone since the list): an unknown list is never an empty one."""
|
|
1625
|
+
rc, out, _err = zellij(['-s', device, 'action', 'list-clients'], 10)
|
|
1626
|
+
if rc != 0:
|
|
1627
|
+
return None
|
|
1628
|
+
return sum(1 for line in out.splitlines() if re.match(r'^\s*\d+\s', line))
|
|
1629
|
+
|
|
1630
|
+
|
|
1631
|
+
def refresh_view(device, meta):
|
|
1632
|
+
"""`lanes ensure --if-layout-changed` for the running view `device` (W9 U6, LNS-94): nothing while its layout file
|
|
1633
|
+
is what this release generates (a stamp left from before — a rollback — is dropped); otherwise rebuilt when zellij
|
|
1634
|
+
answers that no client is attached (cmd_rebuild, which writes the reconnect stamp and the new layout, and drops the
|
|
1635
|
+
stamp through create_session), else stamped once (update_ready_path; never rewritten, so its time is when the
|
|
1636
|
+
change was first found attached). Raises LanesError when the rebuild failed."""
|
|
1637
|
+
stamp = update_ready_path(device)
|
|
1638
|
+
try:
|
|
1639
|
+
stored = (L.STATE / 'layouts' / f'{device}.kdl').read_text()
|
|
1640
|
+
except (OSError, UnicodeDecodeError):
|
|
1641
|
+
stored = None
|
|
1642
|
+
if not layout_changed(stored, layout_kdl(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))):
|
|
1643
|
+
if stamp.exists():
|
|
1644
|
+
stamp.unlink(missing_ok=True)
|
|
1645
|
+
print(f'{device}: its layout is current again; dropped {stamp.name}')
|
|
1646
|
+
return
|
|
1647
|
+
clients = view_clients(device)
|
|
1648
|
+
if clients == 0:
|
|
1649
|
+
print(f'{device}: its layout changed and no client is attached: rebuilding it')
|
|
1650
|
+
cmd_rebuild([device])
|
|
1651
|
+
return
|
|
1652
|
+
if not stamp.exists():
|
|
1653
|
+
L.atomic_write(stamp, f'{time.time()}\n')
|
|
1654
|
+
attached = 'zellij did not say which clients are attached' if clients is None else (
|
|
1655
|
+
f"{clients} client{'' if clients == 1 else 's'} attached")
|
|
1656
|
+
print(f'{device}: its layout changed; {attached}: left running, its sidebars offer `update ready · reload (r)` '
|
|
1657
|
+
f'({stamp.name})')
|
|
1658
|
+
|
|
1659
|
+
|
|
1660
|
+
def cmd_ensure(args):
|
|
1661
|
+
if any(arg != '--if-layout-changed' for arg in args):
|
|
1662
|
+
print(ENSURE_USAGE)
|
|
1663
|
+
return 2
|
|
1664
|
+
every = '--if-layout-changed' in args
|
|
1665
|
+
with contextlib.ExitStack() as stack:
|
|
1666
|
+
# One pass under the view-session lock (DEF-1792), from the list to the last tab sync: no other ensure, rebuild
|
|
1667
|
+
# or create acts on a session between what this run listed and what it does. The update's run waits for one
|
|
1668
|
+
# that holds it; the minute timer's only glances, and skips its tick — the holder is acting on the views now.
|
|
1669
|
+
refused = hold_sessions(stack, SESSIONS_WAIT if every else SESSIONS_GLANCE,
|
|
1670
|
+
'lanes ensure --if-layout-changed' if every else 'lanes ensure')
|
|
1671
|
+
if isinstance(refused, L.SessionsBusy) and not every:
|
|
1672
|
+
print(f'ensure: skipped: {refused}; nothing was recreated (lanes-ensure.timer runs again in a minute)')
|
|
1673
|
+
return 0
|
|
1674
|
+
if refused:
|
|
1675
|
+
raise L.LanesError(f'{refused}: no view was recreated, compared or rebuilt')
|
|
1676
|
+
return ensure_views(every)
|
|
1677
|
+
|
|
1678
|
+
|
|
1679
|
+
def ensure_views(every):
|
|
1680
|
+
"""cmd_ensure's pass, under the view-session lock: recreate each device with no live session, compare the running
|
|
1681
|
+
ones (every one with `every`, else only a device the update stamped), then sync the tabs of every live view."""
|
|
966
1682
|
# live_sessions() raises when zellij did not answer: then this run ends here and recreates nothing (the timer
|
|
967
1683
|
# tries again in a minute). "Missing" is only ever concluded from a list zellij actually gave.
|
|
968
1684
|
live = live_sessions()
|
|
1685
|
+
failures = []
|
|
969
1686
|
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
970
1687
|
device = meta_file.stem
|
|
1688
|
+
meta = L.read_json(meta_file) or {}
|
|
1689
|
+
meta = meta if isinstance(meta, dict) else {}
|
|
971
1690
|
if device not in live:
|
|
972
|
-
meta = L.read_json(meta_file) or {}
|
|
973
1691
|
create_session(device, bool(meta.get('mobile')), int(meta.get('viewports') or 1))
|
|
974
1692
|
print(f'recreated view session {device}')
|
|
975
|
-
|
|
1693
|
+
elif every or update_ready_path(device).exists():
|
|
1694
|
+
# The update asks for every view; the minute timer only finishes what an update left (W9 U6). Before the
|
|
1695
|
+
# tab sync, so a rebuilt view is reconciled like any other; one device's failure never stops the next.
|
|
1696
|
+
try:
|
|
1697
|
+
refresh_view(device, meta)
|
|
1698
|
+
except L.LanesError as error:
|
|
1699
|
+
failures.append(f'{device}: {error}')
|
|
1700
|
+
if not failures:
|
|
1701
|
+
return cmd_upgrade_session([])
|
|
1702
|
+
try:
|
|
1703
|
+
cmd_upgrade_session([])
|
|
1704
|
+
except L.LanesError as error:
|
|
1705
|
+
failures.append(str(error))
|
|
1706
|
+
raise L.LanesError('; '.join(failures))
|
|
976
1707
|
|
|
977
1708
|
|
|
978
|
-
def prepare_view(device, mobile, viewports=1, explicit=False):
|
|
1709
|
+
def prepare_view(device, mobile, viewports=1, explicit=False, doing='lanes prepare'):
|
|
979
1710
|
"""Create the device's view session unless it runs -> 'created' | 'reused'. A running view is never rebuilt:
|
|
980
1711
|
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
|
-
|
|
1712
|
+
mobile one. Without a flag, `prepare` keeps reusing whatever layout runs. Under the view-session lock (DEF-1792),
|
|
1713
|
+
waited for like `lanes rebuild` waits; `doing` names the command to a process that waits for it."""
|
|
1714
|
+
with contextlib.ExitStack() as stack:
|
|
1715
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'{doing} {device}')
|
|
1716
|
+
if refused:
|
|
1717
|
+
raise L.LanesError(f'{refused}: {device} was not prepared; try again shortly')
|
|
1718
|
+
if device not in live_sessions():
|
|
1719
|
+
create_session(device, mobile, viewports)
|
|
1720
|
+
return 'created'
|
|
985
1721
|
running_mobile = bool((L.read_json(L.STATE / 'devices' / f'{device}.json') or {}).get('mobile'))
|
|
986
1722
|
if mobile and not running_mobile:
|
|
987
1723
|
raise L.LanesError(f'{device} already has a desktop layout; choose another device name')
|
|
@@ -1232,7 +1968,7 @@ def cmd_web(args):
|
|
|
1232
1968
|
prepare = ['lanes', 'prepare', device, flag] + (['--viewports', str(viewports)] if viewports != 1 else [])
|
|
1233
1969
|
state = 'planned'
|
|
1234
1970
|
if not print_only:
|
|
1235
|
-
state = prepare_view(device, mobile, viewports, explicit=explicit)
|
|
1971
|
+
state = prepare_view(device, mobile, viewports, explicit=explicit, doing='lanes web')
|
|
1236
1972
|
record_base(device, args)
|
|
1237
1973
|
rc, out, err = L.run_bounded(['sh', str(L.REPO / 'install/enable-web.sh'), device, flag], 180)
|
|
1238
1974
|
if rc != 0:
|
|
@@ -1414,8 +2150,12 @@ class UploadReader:
|
|
|
1414
2150
|
|
|
1415
2151
|
|
|
1416
2152
|
def cmd_upload(args):
|
|
2153
|
+
"""`lanes upload <workspace> | --active <device> [--device D] [--paste] [--request R]`: a tar stream on stdin into
|
|
2154
|
+
the workspace's `uploads/` AND the docs store (W10 W5, libexec/uploads.py receive_tar): each file lands as it always
|
|
2155
|
+
did — same folder, `-1` collision suffix, `.git/info/exclude` line — and gets a sha256 receipt, `source` ssh, for
|
|
2156
|
+
the login that owns the asking device (users.login_for; none: a docs.log `no-owner` line), under the lane its view
|
|
2157
|
+
shows (uploads.upload_lane). stdout and `<request>.done` stay the landed in-container paths, one per line."""
|
|
1417
2158
|
import shlex
|
|
1418
|
-
import tarfile
|
|
1419
2159
|
registry = L.load_registry()
|
|
1420
2160
|
device = args[args.index('--active') + 1] if '--active' in args else None
|
|
1421
2161
|
paste_device = args[args.index('--device') + 1] if '--device' in args else device
|
|
@@ -1436,34 +2176,20 @@ def cmd_upload(args):
|
|
|
1436
2176
|
raise L.LanesError(f'unknown board: {names[0]} (see `lanes workspaces`)')
|
|
1437
2177
|
else:
|
|
1438
2178
|
raise L.LanesError('usage: lanes upload <workspace> | --active <device> [--paste] (tar stream on stdin)')
|
|
1439
|
-
|
|
1440
|
-
|
|
1441
|
-
|
|
1442
|
-
|
|
1443
|
-
|
|
1444
|
-
|
|
1445
|
-
|
|
1446
|
-
# check-ignore: 0 = ignored, 1 = not ignored; an error or no answer (None) is not a reason to append.
|
|
1447
|
-
if (root / '.git').is_dir() and L.run_bounded(['git', '-C', str(root), 'check-ignore', '-q', 'uploads/x'], 10)[0] == 1:
|
|
1448
|
-
exclude.parent.mkdir(exist_ok=True)
|
|
1449
|
-
with open(exclude, 'a') as handle:
|
|
1450
|
-
handle.write('\n# lanes upload: local intake folder, never committed\nuploads/\n')
|
|
1451
|
-
staging = uploads / f'.incoming-{os.getpid()}'
|
|
1452
|
-
staging.mkdir()
|
|
1453
|
-
landed = []
|
|
2179
|
+
uploads_lib, users = libexec_module('uploads'), libexec_module('users')
|
|
2180
|
+
folder, home, parts = uploads_lib.landing(registry, workspace)
|
|
2181
|
+
# The view that asked (a suffixed view's sidecar, else the device named): whose upload it is, and whose lane.
|
|
2182
|
+
view_device = paste_target(spool, request, paste_device) if spool else paste_device
|
|
2183
|
+
login = users.login_for(view_device) if view_device else None
|
|
2184
|
+
lane = uploads_lib.upload_lane(workspace, view_device)
|
|
2185
|
+
uploads = uploads_lib.open_uploads(home, parts)
|
|
1454
2186
|
try:
|
|
1455
2187
|
stream = UploadReader(sys.stdin.buffer, spool / f'{request}.progress' if spool else None)
|
|
1456
|
-
|
|
1457
|
-
|
|
1458
|
-
|
|
1459
|
-
final, n = uploads / entry.name, 1
|
|
1460
|
-
while final.exists(): # never overwrite an earlier upload
|
|
1461
|
-
final = uploads / f'{entry.stem}-{n}{entry.suffix}'
|
|
1462
|
-
n += 1
|
|
1463
|
-
entry.rename(final)
|
|
1464
|
-
landed.append(f"{folder}/uploads/{final.name}")
|
|
2188
|
+
landed = [f'{folder}/uploads/{name}' for name in uploads_lib.receive_tar(
|
|
2189
|
+
stream, uploads, login=login, ws=workspace['id'], lane=lane,
|
|
2190
|
+
say=lambda line: print(line, file=sys.stderr, flush=True))]
|
|
1465
2191
|
finally:
|
|
1466
|
-
|
|
2192
|
+
os.close(uploads)
|
|
1467
2193
|
if not landed:
|
|
1468
2194
|
# The helper reports the client-side cause (eg. macOS denied Downloads). Publishing a generic .err here
|
|
1469
2195
|
# races that report: the sidebar consumes it and loses the useful error before the helper can send it.
|
|
@@ -1472,7 +2198,6 @@ def cmd_upload(args):
|
|
|
1472
2198
|
print(path)
|
|
1473
2199
|
if '--paste' in args and paste_device:
|
|
1474
2200
|
# The result files (.done / .err) stay in the base spool, where the asking sidebar looks for them.
|
|
1475
|
-
view_device = paste_target(spool, request, paste_device) if spool else paste_device
|
|
1476
2201
|
shown = type_into_lane(registry, device_name(view_device), workspace, ' '.join(shlex.quote(p) for p in landed) + ' ')
|
|
1477
2202
|
print(f'# typed into lane: {shown}' if shown else '# could not type into the lane (not a tmux lane?)', file=sys.stderr)
|
|
1478
2203
|
if spool:
|
|
@@ -1480,6 +2205,32 @@ def cmd_upload(args):
|
|
|
1480
2205
|
return 0
|
|
1481
2206
|
|
|
1482
2207
|
|
|
2208
|
+
DOCS_USAGE = 'usage: lanes docs push <id>'
|
|
2209
|
+
# What a publication target needs and does not have yet: the data service's token-authenticated publication surface
|
|
2210
|
+
# (plan W10 "Operator gates" 3 — decided once that surface is stable). Until then `lanes docs push` is the hook only.
|
|
2211
|
+
DOCS_PUSH_UNCONFIGURED = ('lanes docs push: not configured — no publication target exists yet (the data service\'s '
|
|
2212
|
+
'publication surface is not built; plan W10, operator gate 3): nothing was sent')
|
|
2213
|
+
|
|
2214
|
+
|
|
2215
|
+
def cmd_docs(args):
|
|
2216
|
+
"""`lanes docs push <id>` (docs cockpit W10 W9, LNS-110): the hook for the data service's publication surface — a
|
|
2217
|
+
STUB, not built. It looks the document up in every login's docs store (docs_store.operator_receipt: the operator is
|
|
2218
|
+
the account the store belongs to, so no login's visibility applies), prints its receipt.json on stdout
|
|
2219
|
+
(DM-LANES-DOCS-RECEIPT, as stored: indent 2), says `not configured` on stderr and returns 2. Nothing is sent,
|
|
2220
|
+
written or logged. No such document, an id that is not one (docs_store.ID_RE) or any other argument: LanesError,
|
|
2221
|
+
exit 1."""
|
|
2222
|
+
if len(args) != 2 or args[0] != 'push':
|
|
2223
|
+
raise L.LanesError(DOCS_USAGE)
|
|
2224
|
+
D = libexec_module('docs_store')
|
|
2225
|
+
found = D.operator_receipt(args[1])
|
|
2226
|
+
if found is None:
|
|
2227
|
+
raise L.LanesError(f'docs push: no document {args[1]} in the docs store ({D.root()})')
|
|
2228
|
+
_meta, receipt = found
|
|
2229
|
+
print(json.dumps(receipt, indent=2, ensure_ascii=False))
|
|
2230
|
+
print(DOCS_PUSH_UNCONFIGURED, file=sys.stderr)
|
|
2231
|
+
return 2
|
|
2232
|
+
|
|
2233
|
+
|
|
1483
2234
|
def resolve_device(name, args):
|
|
1484
2235
|
"""`coder-launch worlds` style: a board id given where a device is expected means "my default device"."""
|
|
1485
2236
|
try:
|
|
@@ -1544,18 +2295,22 @@ def cmd_upload_result(args):
|
|
|
1544
2295
|
|
|
1545
2296
|
|
|
1546
2297
|
WORKSPACES_USAGE = ('usage: lanes workspaces | lanes workspaces add <id> --container <name> --repo <path> '
|
|
1547
|
-
'[--title T] [--session S] [--write]'
|
|
2298
|
+
'[--title T] [--session S] [--write] | lanes workspaces hide|show|order <id…> '
|
|
2299
|
+
'[--device D | --login L]')
|
|
1548
2300
|
|
|
1549
2301
|
|
|
1550
2302
|
def cmd_workspaces(args):
|
|
1551
2303
|
"""`lanes workspaces` prints the board ids (the client installer reads them); `lanes workspaces add` appends a
|
|
1552
2304
|
board to the site catalog and writes its manifest (libexec/site_edit.py). Dry run (the printed diff) unless
|
|
1553
2305
|
--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).
|
|
2306
|
+
another board already declares is refused (LanesError, before any write). `hide|show|order` arrange the boards in
|
|
2307
|
+
a person's profile (cmd_workspaces_profile, cockpit v3 R6)."""
|
|
1555
2308
|
if not args:
|
|
1556
2309
|
for workspace in L.load_registry()['workspaces']:
|
|
1557
2310
|
print(workspace['id'])
|
|
1558
2311
|
return 0
|
|
2312
|
+
if args[0] in ARRANGE_WORKSPACES:
|
|
2313
|
+
return cmd_workspaces_profile(args)
|
|
1559
2314
|
if args[0] != 'add' or len(args) < 2 or args[1].startswith('-'):
|
|
1560
2315
|
raise L.LanesError(WORKSPACES_USAGE)
|
|
1561
2316
|
opts, rest = {}, list(args[2:])
|
|
@@ -1819,18 +2574,44 @@ def cmd_onboard(args):
|
|
|
1819
2574
|
print(f'backup {backup}')
|
|
1820
2575
|
return ('changed' if write else 'planned'), f"{board} in {result['catalog']}"
|
|
1821
2576
|
|
|
2577
|
+
mcp_before = {}
|
|
2578
|
+
|
|
2579
|
+
def mcp_files():
|
|
2580
|
+
return [home() / lane_core().MCP_FILES[agent] for agent in lane_core().MCP_AGENTS]
|
|
2581
|
+
|
|
1822
2582
|
def contract_step():
|
|
1823
2583
|
root = home() / '.lz'
|
|
1824
2584
|
if not write:
|
|
1825
2585
|
print(f'would install the lane contract into {root} and link {Path.home() / ".lz" / container}')
|
|
1826
2586
|
return 'planned', str(root)
|
|
2587
|
+
for path in mcp_files(): # what the lane-mcp step compares against: install-container writes the entries
|
|
2588
|
+
mcp_before[path] = lane_core().read_regular(path, lane_core().EDIT_MAX) # never a link or a FIFO
|
|
1827
2589
|
cmd_install_container([container] + (['--no-mcp'] if no_mcp else []))
|
|
1828
2590
|
return 'ok', str(root)
|
|
1829
2591
|
|
|
1830
2592
|
def mcp_step():
|
|
2593
|
+
"""install-container (the contract step) wrote the agents' MCP entries; this step plans them again and says
|
|
2594
|
+
what that changed. A dry run prints the diffs install-container would apply."""
|
|
1831
2595
|
if no_mcp:
|
|
1832
2596
|
return 'skipped', '--no-mcp: left out'
|
|
1833
|
-
|
|
2597
|
+
plan = lane_core().mcp_plan(home())
|
|
2598
|
+
files = ', '.join(str(item['path']) for item in plan)
|
|
2599
|
+
refused = '; '.join(f"{item['path']}: {item['why']}" for item in plan if item['why'])
|
|
2600
|
+
pending = [str(item['path']) for item in plan if not item['why'] and item['after'] != item['before']]
|
|
2601
|
+
if not write:
|
|
2602
|
+
for item in plan:
|
|
2603
|
+
if item['why']:
|
|
2604
|
+
print(f"! {item['path']}: {item['why']}")
|
|
2605
|
+
elif item['after'] == item['before']:
|
|
2606
|
+
print(f"{item['path']}: the lanes MCP server is already there")
|
|
2607
|
+
else:
|
|
2608
|
+
sys.stdout.write(lane_core().mcp_diff(item))
|
|
2609
|
+
return ('warn', refused) if refused else (('planned', files) if pending else ('unchanged', files))
|
|
2610
|
+
if refused or pending:
|
|
2611
|
+
return 'warn', refused or f"not written: {', '.join(pending)}"
|
|
2612
|
+
changed = [str(path) for path in mcp_files()
|
|
2613
|
+
if lane_core().read_regular(path, lane_core().EDIT_MAX) != mcp_before.get(path)]
|
|
2614
|
+
return ('changed', ', '.join(changed)) if changed else ('unchanged', files)
|
|
1834
2615
|
|
|
1835
2616
|
def hooks_step(agent):
|
|
1836
2617
|
def step():
|
|
@@ -1851,7 +2632,7 @@ def cmd_onboard(args):
|
|
|
1851
2632
|
return 'planned', 'lanes sync'
|
|
1852
2633
|
if cmd_sync([]) == 0:
|
|
1853
2634
|
return 'ok', 'registry published'
|
|
1854
|
-
return 'warn', 'a board has registry errors (listed above)'
|
|
2635
|
+
return 'warn', 'a board has registry errors or a container refused its projection (listed above)'
|
|
1855
2636
|
|
|
1856
2637
|
cursor = {}
|
|
1857
2638
|
|
|
@@ -1933,10 +2714,10 @@ def cmd_rebuilt(args):
|
|
|
1933
2714
|
return 0 if time.time() - stamp < 60 else 1
|
|
1934
2715
|
|
|
1935
2716
|
|
|
1936
|
-
def server_version():
|
|
1937
|
-
"""
|
|
1938
|
-
|
|
1939
|
-
return
|
|
2717
|
+
def server_version(repo=None):
|
|
2718
|
+
"""A tree's release (default this server's, L.REPO/VERSION), as `lanes hello` and `lanes version` report it; 'dev'
|
|
2719
|
+
when the file is absent, empty or unreadable."""
|
|
2720
|
+
return L.tree_version(repo if repo is not None else L.REPO) or 'dev'
|
|
1940
2721
|
|
|
1941
2722
|
|
|
1942
2723
|
VERSION_RE = re.compile(r'(\d+)\.(\d+)\.(\d+)(?:[-+][A-Za-z0-9.]+)?')
|
|
@@ -1949,6 +2730,23 @@ def version_key(text):
|
|
|
1949
2730
|
return tuple(int(part) for part in match.groups()) if match else None
|
|
1950
2731
|
|
|
1951
2732
|
|
|
2733
|
+
def client_older(client, server):
|
|
2734
|
+
"""Whether the release `client` names is older than `server`: both parse (version_key) and the client's
|
|
2735
|
+
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
|
|
2736
|
+
1.1.0). `dev`, a missing value or client text that is no version is never older. The one comparison behind both
|
|
2737
|
+
server answers to an older client: `lanes attach`'s notice (client_notice) and `lanes hello`'s update= (W9 K1)."""
|
|
2738
|
+
client_key, server_key = version_key(client), version_key(server)
|
|
2739
|
+
return client_key is not None and server_key is not None and client_key < server_key
|
|
2740
|
+
|
|
2741
|
+
|
|
2742
|
+
def client_min(server):
|
|
2743
|
+
"""The release a lanes-launch must be at for this server not to tell it to update: the server's major.minor.patch
|
|
2744
|
+
(client_notice compares exactly that — a client below it gets the notice, one at it none); None for a server
|
|
2745
|
+
whose version is no release ('dev'), which tells no client to update."""
|
|
2746
|
+
key = version_key(server)
|
|
2747
|
+
return '.'.join(str(part) for part in key) if key else None
|
|
2748
|
+
|
|
2749
|
+
|
|
1952
2750
|
# The newest published release whose lanes-launch passes `--client` but no `--client-version` (@vibelord/lanes 1.0.4).
|
|
1953
2751
|
# 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
2752
|
# about what was published: bumping lanes/VERSION never moves it.
|
|
@@ -1966,21 +2764,205 @@ def client_notice(args):
|
|
|
1966
2764
|
return None
|
|
1967
2765
|
if '--client-version' in args:
|
|
1968
2766
|
client = args[args.index('--client-version') + 1] if '--client-version' in args[:-1] else ''
|
|
1969
|
-
|
|
1970
|
-
|
|
2767
|
+
return (f'lanes client {client} is older than the server ({server}) — run lanes-launch update'
|
|
2768
|
+
if client_older(client, server) else None)
|
|
1971
2769
|
if '--client' in args and version_key(server) > LAST_FLAGLESS_CLIENT:
|
|
1972
2770
|
return f'lanes client is older than the server ({server}) — update it: npx @vibelord/lanes@latest'
|
|
1973
2771
|
return None
|
|
1974
2772
|
|
|
1975
2773
|
|
|
1976
2774
|
def cmd_hello(args):
|
|
1977
|
-
|
|
2775
|
+
"""`lanes hello [device] [--client-version V]`: one line, exit 0 — `ok device=<device> version=<server>
|
|
2776
|
+
boards=<n>`, what the client installer reads to prove the SSH path, the key and the server's release. With
|
|
2777
|
+
`--client-version V` older than this server (client_older: major.minor.patch as numbers) the line ends
|
|
2778
|
+
` update=<server>` (W9 K1, LNS-86): the exact release, as this tree's VERSION names it, that a client updates to
|
|
2779
|
+
(lanes-launch's self-update, W9 K2, installs exactly that and never @latest). An equal or newer client, `dev`
|
|
2780
|
+
on either side, text that is no version, or no flag at all: the line every earlier release printed. The hint is
|
|
2781
|
+
appended, so a reader of the first four fields (install.sh check_server, lanes-launch web) reads them as before.
|
|
2782
|
+
The flag and its value may stand anywhere; the device is the first other argument."""
|
|
2783
|
+
rest, client = list(args), None
|
|
2784
|
+
if '--client-version' in rest:
|
|
2785
|
+
at = rest.index('--client-version')
|
|
2786
|
+
client = rest[at + 1] if at + 1 < len(rest) else None
|
|
2787
|
+
del rest[at:at + 2]
|
|
2788
|
+
device = resolve_device(rest[0], args) if rest else '-'
|
|
1978
2789
|
version = server_version()
|
|
1979
2790
|
boards = len(L.load_registry()['workspaces']) if (L.STATE / 'registry.json').exists() else 0
|
|
1980
|
-
|
|
2791
|
+
update = f' update={version}' if client_older(client, version) else ''
|
|
2792
|
+
print(f'ok device={device} version={version} boards={boards}{update}')
|
|
2793
|
+
return 0
|
|
2794
|
+
|
|
2795
|
+
|
|
2796
|
+
# ------------------------------------------------------------------ version (W9 V1, LNS-83)
|
|
2797
|
+
# `lanes version` answers "which release runs here, and what still runs an older one": this CLI's tree, the tree the
|
|
2798
|
+
# `current` link names (install/services.py writes it; the units run from it after W9 U1), and the tree each
|
|
2799
|
+
# long-running process recorded when it last started (L.record_tree, W9 U0). `current` is read from the link ITSELF:
|
|
2800
|
+
# L.CURRENT collapses to this tree's own path whenever the link names another tree, so realpath(L.CURRENT) is this tree
|
|
2801
|
+
# by construction and could never show a stale CLI. A checkout updated in place (`git pull`, USERGUIDE Update) keeps its
|
|
2802
|
+
# path, so a record names the current tree before and after the update: the time of the tree's code says which starts
|
|
2803
|
+
# came before it (tree_code_mtime).
|
|
2804
|
+
|
|
2805
|
+
# The record_tree names, in the order `lanes version` lists them: statusd first (every view's status comes from it),
|
|
2806
|
+
# then the services, then the newest sidebar. A name not listed (a newer tree's process) follows, sorted.
|
|
2807
|
+
RUN_ORDER = ('statusd', 'enrold', 'docsd', 'notify', 'economy', 'studio_host', 'sidebar')
|
|
2808
|
+
VERSION_SCHEMA = 'lanes.version/1'
|
|
2809
|
+
COMMIT_RE = re.compile(r'([0-9a-f]{7})[0-9a-f]{0,33}(-dirty)?') # scripts/build-payload.mjs: rev-parse HEAD [-dirty]
|
|
2810
|
+
# A tree's code, as far as a recorded process runs it: VERSION and the regular files directly in these folders (every
|
|
2811
|
+
# record_tree process starts from libexec/ or bin/lanes and imports libexec/*.py).
|
|
2812
|
+
TREE_CODE = ('bin', 'libexec')
|
|
2813
|
+
|
|
2814
|
+
|
|
2815
|
+
def utc(seconds):
|
|
2816
|
+
return time.strftime('%Y-%m-%dT%H:%M:%SZ', time.gmtime(seconds))
|
|
2817
|
+
|
|
2818
|
+
|
|
2819
|
+
def tree_code_mtime(tree):
|
|
2820
|
+
"""The newest modification time (epoch seconds) of a tree's code: its VERSION and every regular file directly in
|
|
2821
|
+
bin/ and libexec/ — dotfiles (editor swap files), __pycache__ and every other folder (docs/, web/) are not code. None
|
|
2822
|
+
when there is none (a pruned tree). `git pull` writes each file it changes with the time of the pull, and the npm
|
|
2823
|
+
wrapper re-stages a version (other bytes) as a fresh copy, so a time later than a run record says that process
|
|
2824
|
+
started before its tree's code changed — a checkout updated in place keeps its path. A pull that only deletes files
|
|
2825
|
+
changes no file's time and is not seen."""
|
|
2826
|
+
tree = Path(tree)
|
|
2827
|
+
paths = [tree / 'VERSION']
|
|
2828
|
+
for folder in TREE_CODE:
|
|
2829
|
+
try:
|
|
2830
|
+
with os.scandir(tree / folder) as entries:
|
|
2831
|
+
paths.extend(Path(entry.path) for entry in entries if not entry.name.startswith('.'))
|
|
2832
|
+
except OSError:
|
|
2833
|
+
continue
|
|
2834
|
+
newest = None
|
|
2835
|
+
for path in paths:
|
|
2836
|
+
try:
|
|
2837
|
+
if path.is_file():
|
|
2838
|
+
mtime = path.stat().st_mtime
|
|
2839
|
+
newest = mtime if newest is None else max(newest, mtime)
|
|
2840
|
+
except OSError:
|
|
2841
|
+
continue
|
|
2842
|
+
return newest
|
|
2843
|
+
|
|
2844
|
+
|
|
2845
|
+
def run_records(state, reference):
|
|
2846
|
+
"""{name: {tree, version, started, current, changed}} for each `<state>/run/<name>.tree`, RUN_ORDER first. `tree`
|
|
2847
|
+
is the record's one absolute path (None for a record that names none), `version` that tree's VERSION now (None once
|
|
2848
|
+
the tree is pruned), `started` the record's mtime in UTC (the process's last start), `changed` the time of the tree's
|
|
2849
|
+
newest code (tree_code_mtime) when it is LATER than that start — the process runs code from before it — else None,
|
|
2850
|
+
and `current` whether the process runs the current code: its tree resolves to `reference` and `changed` is None. A
|
|
2851
|
+
record outlives its process: it says what the last start ran, never that it still runs."""
|
|
2852
|
+
found, code_times = {}, {}
|
|
2853
|
+
try:
|
|
2854
|
+
paths = sorted((Path(state) / L.RUN_DIR).glob('*.tree'))
|
|
2855
|
+
except OSError:
|
|
2856
|
+
paths = []
|
|
2857
|
+
for path in paths:
|
|
2858
|
+
name = path.name[:-len('.tree')]
|
|
2859
|
+
if not L.IDENT.match(name): # never a name record_tree writes
|
|
2860
|
+
continue
|
|
2861
|
+
try:
|
|
2862
|
+
if not path.is_file():
|
|
2863
|
+
continue
|
|
2864
|
+
lines = path.read_text().splitlines()
|
|
2865
|
+
started = path.stat().st_mtime
|
|
2866
|
+
except (OSError, ValueError): # ValueError: undecodable bytes
|
|
2867
|
+
continue
|
|
2868
|
+
tree = lines[0] if len(lines) == 1 and lines[0].startswith('/') else None
|
|
2869
|
+
if tree is not None and tree not in code_times:
|
|
2870
|
+
code_times[tree] = tree_code_mtime(tree)
|
|
2871
|
+
code = code_times.get(tree)
|
|
2872
|
+
changed = code if code is not None and code > started else None # a start AT the code's time is after it
|
|
2873
|
+
found[name] = {'tree': tree, 'version': L.tree_version(tree) if tree else None, 'started': utc(started),
|
|
2874
|
+
'current': tree is not None and os.path.realpath(tree) == reference and changed is None,
|
|
2875
|
+
'changed': utc(changed) if changed is not None else None}
|
|
2876
|
+
order = [name for name in RUN_ORDER if name in found] + sorted(set(found) - set(RUN_ORDER))
|
|
2877
|
+
return {name: found[name] for name in order}
|
|
2878
|
+
|
|
2879
|
+
|
|
2880
|
+
def version_report(repo=None, state=None, home=None):
|
|
2881
|
+
"""The `lanes version --json` document (schema lanes.version/1): {schema, version, commit, tree, current, running,
|
|
2882
|
+
client_min}. `version` is the tree's release ('dev' without one, as `lanes hello`), `commit` the npm wrapper's
|
|
2883
|
+
stamp (None for a checkout), `tree` the resolved tree this CLI runs, `current` the resolved target of
|
|
2884
|
+
~/.local/share/lanes/current (None without the link), `running` run_records() compared with `current` — or with
|
|
2885
|
+
`tree` when there is no link — and with the time of each tree's code, and `client_min` the release attach stops
|
|
2886
|
+
telling lanes-launch to update at."""
|
|
2887
|
+
repo = Path(repo) if repo is not None else L.REPO
|
|
2888
|
+
try:
|
|
2889
|
+
link = L.current_link(home)
|
|
2890
|
+
current = os.path.realpath(link) if os.path.lexists(link) else None
|
|
2891
|
+
except (KeyError, RuntimeError): # no HOME and no passwd entry: no link to read
|
|
2892
|
+
current = None
|
|
2893
|
+
version = server_version(repo)
|
|
2894
|
+
return {'schema': VERSION_SCHEMA, 'version': version, 'commit': (L.tree_stamp(repo) or {}).get('commit'),
|
|
2895
|
+
'tree': str(repo), 'current': current,
|
|
2896
|
+
'running': run_records(state if state is not None else L.STATE,
|
|
2897
|
+
current if current is not None else os.path.realpath(repo)),
|
|
2898
|
+
'client_min': client_min(version)}
|
|
2899
|
+
|
|
2900
|
+
|
|
2901
|
+
def plain(text):
|
|
2902
|
+
"""Text for a terminal: every character that is not printable (escape sequences included) becomes `?`."""
|
|
2903
|
+
return ''.join(char if char.isprintable() else '?' for char in str(text))
|
|
2904
|
+
|
|
2905
|
+
|
|
2906
|
+
def tree_shown(path, home=None):
|
|
2907
|
+
"""A tree as `lanes version` prints it: `pkg/<version>` for an npm-staged tree, `~/…` under the home, else the
|
|
2908
|
+
path."""
|
|
2909
|
+
path = Path(path)
|
|
2910
|
+
if path.parent.name == 'pkg':
|
|
2911
|
+
return plain(f'pkg/{path.name}')
|
|
2912
|
+
try:
|
|
2913
|
+
return plain(f'~/{path.relative_to(Path(home) if home is not None else Path.home())}')
|
|
2914
|
+
except (ValueError, KeyError, RuntimeError):
|
|
2915
|
+
return plain(path)
|
|
2916
|
+
|
|
2917
|
+
|
|
2918
|
+
def record_said(record, reference, home=None, past=False):
|
|
2919
|
+
"""What a record that does not run the current code says, as `lanes version` and doctor print it after the name:
|
|
2920
|
+
`started before its tree changed` when its tree is `reference` (updated in place after that start), else `runs`
|
|
2921
|
+
(`ran` with `past`) its tree's release — or the tree once that is pruned, or `?` for a record that names none."""
|
|
2922
|
+
if record['tree'] is not None and os.path.realpath(record['tree']) == reference:
|
|
2923
|
+
return 'started before its tree changed'
|
|
2924
|
+
ran = (plain(record['version']) if record['version'] else
|
|
2925
|
+
tree_shown(record['tree'], home) if record['tree'] else '?')
|
|
2926
|
+
return f"{'ran' if past else 'runs'} {ran}"
|
|
2927
|
+
|
|
2928
|
+
|
|
2929
|
+
def version_text(report, home=None):
|
|
2930
|
+
"""The one line `lanes version` prints, e.g. `lanes 1.2.0 (abc1234) · tree pkg/1.2.0 · current ✓ · statusd runs
|
|
2931
|
+
1.1.0 ✗ · enrold notify ✓`: each record that does not run the current code on its own — `runs <version>` (or its
|
|
2932
|
+
tree once that is pruned, or `?` for a record that names no tree) when its tree is another one, `started before its
|
|
2933
|
+
tree changed` when it is the current tree, updated in place after that start — then the current ones together."""
|
|
2934
|
+
commit = COMMIT_RE.fullmatch(report['commit'] or '')
|
|
2935
|
+
parts = [f"lanes {plain(report['version'])}" + (f' ({commit.group(1)}{commit.group(2) or ""})' if commit else ''),
|
|
2936
|
+
f"tree {tree_shown(report['tree'], home)}"]
|
|
2937
|
+
current = report['current']
|
|
2938
|
+
parts.append('no current link' if current is None else 'current ✓' if current == os.path.realpath(report['tree'])
|
|
2939
|
+
else f'current {tree_shown(current, home)} ✗')
|
|
2940
|
+
reference = current if current is not None else os.path.realpath(report['tree'])
|
|
2941
|
+
running = report['running']
|
|
2942
|
+
for name, record in running.items():
|
|
2943
|
+
if not record['current']:
|
|
2944
|
+
parts.append(f'{plain(name)} {record_said(record, reference, home)} ✗')
|
|
2945
|
+
fresh = [plain(name) for name, record in running.items() if record['current']]
|
|
2946
|
+
if fresh:
|
|
2947
|
+
parts.append(' '.join(fresh) + ' ✓')
|
|
2948
|
+
return ' · '.join(parts)
|
|
2949
|
+
|
|
2950
|
+
|
|
2951
|
+
def cmd_version(args):
|
|
2952
|
+
if any(arg != '--json' for arg in args):
|
|
2953
|
+
print('usage: lanes version [--json]')
|
|
2954
|
+
return 2
|
|
2955
|
+
report = version_report()
|
|
2956
|
+
print(json.dumps(report, indent=1, ensure_ascii=False) if '--json' in args else version_text(report))
|
|
1981
2957
|
return 0
|
|
1982
2958
|
|
|
1983
2959
|
|
|
2960
|
+
def cmd_update(args):
|
|
2961
|
+
"""`lanes update [--now | --timer | --status | --rollback]` (W9 U3, LNS-91): libexec/update.py owns the flow, its
|
|
2962
|
+
refusals and its exit statuses; releases are compared as `lanes hello` compares them (version_key)."""
|
|
2963
|
+
return libexec_module('update').update_main(args, version_key)
|
|
2964
|
+
|
|
2965
|
+
|
|
1984
2966
|
def cmd_client_config(_args):
|
|
1985
2967
|
"""Site facts a client may want after its first connection (e.g. the public fallback address), so that they
|
|
1986
2968
|
need not be written into the distributed package. KEY=VALUE lines, LANES_* keys only."""
|
|
@@ -2030,9 +3012,227 @@ def cmd_gc(_args):
|
|
|
2030
3012
|
if stale.name not in known and time.time() - stale.stat().st_mtime > 3600:
|
|
2031
3013
|
stale.unlink()
|
|
2032
3014
|
print(f'removed stale {stale.name}')
|
|
3015
|
+
gc_trees()
|
|
2033
3016
|
return 0
|
|
2034
3017
|
|
|
2035
3018
|
|
|
3019
|
+
# ------------------------------------------------------------------ staged trees (plan W9 U4, LNS-92)
|
|
3020
|
+
# The npm wrapper stages each release at <pkg root>/<version>. Since W9 U4 it prunes only where the `current` link is
|
|
3021
|
+
# absent — a client, where nothing runs from an old tree (npm/lanes/lib/plan.mjs prunable; DEF-1768) — and a server's
|
|
3022
|
+
# trees are pruned here, by `lanes gc` (the daily lanes-gc.timer), where what still uses one can be seen.
|
|
3023
|
+
TREE_KEEP = 3 # the newest staged trees each pkg root keeps, as the wrapper keeps them on a client
|
|
3024
|
+
PRUNING = '.pruning-' # a tree being removed is first renamed to .pruning-<version>-<pid>-<ns> in its root
|
|
3025
|
+
DELETED = ' (deleted)' # what readlink(2) of a /proc link appends once the file it names is gone
|
|
3026
|
+
|
|
3027
|
+
|
|
3028
|
+
def home_shown(path, home):
|
|
3029
|
+
"""A path as `lanes gc` prints it: `~/…` under the home, else whole (two roots may both be called pkg)."""
|
|
3030
|
+
for base in (Path(home), Path(home).resolve()):
|
|
3031
|
+
try:
|
|
3032
|
+
return plain(f'~/{Path(path).relative_to(base)}')
|
|
3033
|
+
except ValueError:
|
|
3034
|
+
continue
|
|
3035
|
+
return plain(path)
|
|
3036
|
+
|
|
3037
|
+
|
|
3038
|
+
def pkg_roots(home):
|
|
3039
|
+
"""The folders a server's staged trees are in, resolved, each once and existing only, in this order: the folder of
|
|
3040
|
+
the tree `current` names, of the tree `current.previous` names and of the tree this lanes runs from, each when it is
|
|
3041
|
+
a staged tree (L.staged_tree); then the folder the npm wrapper stages in from this environment (lanes-install.mjs
|
|
3042
|
+
pkgRoot: an absolute $LANES_PKG_ROOT, else $XDG_DATA_HOME/lanes/pkg) and its default, ~/.local/share/lanes/pkg.
|
|
3043
|
+
A timer's environment holds neither variable: the first three find a tree staged elsewhere."""
|
|
3044
|
+
link = L.current_link(home)
|
|
3045
|
+
folders = []
|
|
3046
|
+
for tree in (link, link.with_name('current.previous'), L.REPO):
|
|
3047
|
+
try:
|
|
3048
|
+
resolved = Path(tree).resolve(strict=True)
|
|
3049
|
+
except (OSError, RuntimeError):
|
|
3050
|
+
continue
|
|
3051
|
+
if L.staged_tree(resolved):
|
|
3052
|
+
folders.append(resolved.parent)
|
|
3053
|
+
own, data = os.environ.get('LANES_PKG_ROOT', ''), os.environ.get('XDG_DATA_HOME', '')
|
|
3054
|
+
if own:
|
|
3055
|
+
folders += [Path(own)] if own.startswith('/') else []
|
|
3056
|
+
elif data.startswith('/'):
|
|
3057
|
+
folders.append(Path(data) / 'lanes/pkg')
|
|
3058
|
+
folders.append(Path(home) / '.local/share/lanes/pkg')
|
|
3059
|
+
roots = []
|
|
3060
|
+
for folder in folders:
|
|
3061
|
+
try:
|
|
3062
|
+
resolved = folder.resolve(strict=True)
|
|
3063
|
+
except (OSError, RuntimeError):
|
|
3064
|
+
continue
|
|
3065
|
+
if resolved.is_dir() and resolved not in roots:
|
|
3066
|
+
roots.append(resolved)
|
|
3067
|
+
return roots
|
|
3068
|
+
|
|
3069
|
+
|
|
3070
|
+
def staged_entries(root):
|
|
3071
|
+
"""[(tree, mtime)] of the trees the npm wrapper staged directly in `root`, newest first (ties: the higher name
|
|
3072
|
+
first): a directory, never a link, whose name does not start with `.` (the wrapper's own `.staging-*`, a tree being
|
|
3073
|
+
pruned) and which holds the wrapper's stamp — a regular file that reads as a JSON object (L.tree_stamp), as the
|
|
3074
|
+
wrapper's own prune asks of it. A checkout, or anything else that is there, never is one."""
|
|
3075
|
+
found = []
|
|
3076
|
+
try:
|
|
3077
|
+
with os.scandir(root) as entries:
|
|
3078
|
+
for entry in entries:
|
|
3079
|
+
stamp = os.path.join(entry.path, L.PACKAGE_STAMP)
|
|
3080
|
+
try:
|
|
3081
|
+
if entry.name.startswith('.') or not entry.is_dir(follow_symlinks=False) \
|
|
3082
|
+
or os.path.islink(stamp) or L.tree_stamp(entry.path) is None:
|
|
3083
|
+
continue
|
|
3084
|
+
found.append((Path(root) / entry.name, entry.stat(follow_symlinks=False).st_mtime))
|
|
3085
|
+
except OSError:
|
|
3086
|
+
continue
|
|
3087
|
+
except OSError:
|
|
3088
|
+
return []
|
|
3089
|
+
return sorted(found, key=lambda each: (each[1], each[0].name), reverse=True)
|
|
3090
|
+
|
|
3091
|
+
|
|
3092
|
+
def proc_link(path):
|
|
3093
|
+
try:
|
|
3094
|
+
return os.readlink(path)
|
|
3095
|
+
except OSError:
|
|
3096
|
+
return None
|
|
3097
|
+
|
|
3098
|
+
|
|
3099
|
+
def process_paths(proc=None):
|
|
3100
|
+
"""What this account's processes use now, read from /proc (`proc`, default PROC) — best effort: a process that
|
|
3101
|
+
ends or denies a read mid-scan is skipped -> ({path: pid}, {RUN_SCRIPTS name}), or None when /proc cannot be listed.
|
|
3102
|
+
The paths are every absolute path a process of this uid uses: its exe, its cwd and each file it holds open
|
|
3103
|
+
(/proc/<pid>/exe, cwd, fd/*), as the kernel resolved them when the process opened them — so a script started
|
|
3104
|
+
through `current` before a flip still names its tree: bash and dash read their script through an open descriptor —
|
|
3105
|
+
and each absolute word of its argv (or the value of an `--option=/path` word), as written and resolved now; the
|
|
3106
|
+
lowest pid that uses a path is the one named. The names are the daemons among the processes (run_name)."""
|
|
3107
|
+
proc = Path(proc) if proc is not None else PROC
|
|
3108
|
+
try:
|
|
3109
|
+
pids = sorted((name for name in os.listdir(proc) if name.isdigit()), key=int)
|
|
3110
|
+
except OSError:
|
|
3111
|
+
return None
|
|
3112
|
+
uid, paths, names = os.geteuid(), {}, set()
|
|
3113
|
+
for pid in pids:
|
|
3114
|
+
base = proc / pid
|
|
3115
|
+
try:
|
|
3116
|
+
if base.stat().st_uid != uid:
|
|
3117
|
+
continue
|
|
3118
|
+
fds = os.listdir(base / 'fd')
|
|
3119
|
+
except OSError:
|
|
3120
|
+
continue
|
|
3121
|
+
used = [proc_link(base / 'exe'), proc_link(base / 'cwd')] + [proc_link(base / 'fd' / fd) for fd in fds]
|
|
3122
|
+
try:
|
|
3123
|
+
raw = (base / 'cmdline').read_bytes()
|
|
3124
|
+
except OSError:
|
|
3125
|
+
raw = b''
|
|
3126
|
+
words = [os.fsdecode(word) for word in (raw[:-1] if raw.endswith(b'\0') else raw).split(b'\0')] if raw else []
|
|
3127
|
+
name = run_name(words)
|
|
3128
|
+
if name is not None:
|
|
3129
|
+
names.add(name)
|
|
3130
|
+
for word in words:
|
|
3131
|
+
for part in (word, word.partition('=')[2]):
|
|
3132
|
+
if part.startswith('/'):
|
|
3133
|
+
used += [part, os.path.realpath(part)]
|
|
3134
|
+
for path in used:
|
|
3135
|
+
path = path[:-len(DELETED)] if path and path.endswith(DELETED) else path
|
|
3136
|
+
if path and path.startswith('/'):
|
|
3137
|
+
paths.setdefault(os.path.normpath(path), int(pid))
|
|
3138
|
+
return paths, names
|
|
3139
|
+
|
|
3140
|
+
|
|
3141
|
+
def staged_tree_plan(home=None, proc=None):
|
|
3142
|
+
"""[(tree, reason)] for every staged tree of pkg_roots, root by root and newest first: why `lanes gc` keeps it, or
|
|
3143
|
+
None when it is pruned. Kept: the TREE_KEEP newest of each root (by mtime, as the wrapper counts them on a client),
|
|
3144
|
+
and any older one that `current` or `current.previous` (the rollback target) names, that this lanes runs from,
|
|
3145
|
+
that a view layout (`layouts/<device>.kdl`) names — a release before W9 U1 wrote `pkg/<version>` into one, and the
|
|
3146
|
+
view keeps running it until it is rebuilt — that a running daemon's run record names (run/<name>.tree: one started
|
|
3147
|
+
through `current` before the last flip, whose argv now resolves to the new tree), or that a process of this account
|
|
3148
|
+
uses (process_paths, read only when an older tree has no other reason). When /proc cannot be listed no older tree
|
|
3149
|
+
is pruned. The units are not read: since W9 U1 each names `current`, and every install that moves the link renders
|
|
3150
|
+
them all."""
|
|
3151
|
+
home = Path(home) if home is not None else Path.home()
|
|
3152
|
+
link = L.current_link(home)
|
|
3153
|
+
named = {}
|
|
3154
|
+
for each, reason in ((link, 'current names it'),
|
|
3155
|
+
(link.with_name('current.previous'), 'current.previous names it (the rollback target)'),
|
|
3156
|
+
(L.REPO, 'this lanes runs from it')):
|
|
3157
|
+
if os.path.lexists(each):
|
|
3158
|
+
named.setdefault(os.path.realpath(each), reason)
|
|
3159
|
+
layouts = []
|
|
3160
|
+
for kdl in sorted((L.STATE / 'layouts').glob('*.kdl')):
|
|
3161
|
+
try:
|
|
3162
|
+
text = kdl.read_text()
|
|
3163
|
+
except (OSError, ValueError):
|
|
3164
|
+
continue
|
|
3165
|
+
said = f'the view layout of {plain(kdl.stem)} names it'
|
|
3166
|
+
layouts.append((said, text))
|
|
3167
|
+
for tree in layout_trees(text):
|
|
3168
|
+
named.setdefault(os.path.realpath(tree), said)
|
|
3169
|
+
plan = []
|
|
3170
|
+
for root in pkg_roots(home):
|
|
3171
|
+
for index, (tree, _mtime) in enumerate(staged_entries(root)):
|
|
3172
|
+
forms = {str(tree), os.path.realpath(tree)}
|
|
3173
|
+
plan.append([tree, forms, f'one of the {TREE_KEEP} newest' if index < TREE_KEEP else
|
|
3174
|
+
next((named[form] for form in forms if form in named), None)
|
|
3175
|
+
or next((said for said, text in layouts if any(form + '/' in text for form in forms)), None)])
|
|
3176
|
+
if not all(reason for _tree, _forms, reason in plan):
|
|
3177
|
+
scan = process_paths(proc)
|
|
3178
|
+
paths, live = scan if scan is not None else ({}, set())
|
|
3179
|
+
records = {}
|
|
3180
|
+
for name, record in run_records(L.STATE, '').items():
|
|
3181
|
+
if name in live and record['tree']:
|
|
3182
|
+
records.setdefault(os.path.realpath(record['tree']), f'{name} runs from it (run/{name}.tree)')
|
|
3183
|
+
for entry in plan:
|
|
3184
|
+
tree, forms, reason = entry
|
|
3185
|
+
if reason is None:
|
|
3186
|
+
entry[2] = ('the process table could not be read' if scan is None else
|
|
3187
|
+
next((records[form] for form in forms if form in records), None)
|
|
3188
|
+
or next((f'process {pid} uses it' for path, pid in paths.items()
|
|
3189
|
+
if any(path == form or path.startswith(form + '/') for form in forms)), None))
|
|
3190
|
+
return [(tree, reason) for tree, _forms, reason in plan]
|
|
3191
|
+
|
|
3192
|
+
|
|
3193
|
+
def gc_trees(home=None, proc=None):
|
|
3194
|
+
"""`lanes gc`'s staged-tree sweep (plan W9 U4): what an earlier sweep left half-removed (`.pruning-*`) goes first;
|
|
3195
|
+
then each tree staged_tree_plan gives no reason to keep is renamed to `.pruning-<version>-…` — one rename(2), so no
|
|
3196
|
+
half-removed tree is ever left under its version name, which the wrapper would reuse by its stamp — and removed.
|
|
3197
|
+
One line for each tree past the newest: `pruned <tree>`, or `kept <tree>: <reason>`; `skip <tree>: <error>`."""
|
|
3198
|
+
try:
|
|
3199
|
+
home = Path(home) if home is not None else Path.home()
|
|
3200
|
+
except (KeyError, RuntimeError): # no HOME and no passwd entry: no link and no default root to read
|
|
3201
|
+
return
|
|
3202
|
+
for root in pkg_roots(home):
|
|
3203
|
+
try:
|
|
3204
|
+
with os.scandir(root) as entries:
|
|
3205
|
+
remains = sorted(entry.path for entry in entries
|
|
3206
|
+
if entry.name.startswith(PRUNING) and entry.is_dir(follow_symlinks=False))
|
|
3207
|
+
except OSError:
|
|
3208
|
+
remains = []
|
|
3209
|
+
for path in remains:
|
|
3210
|
+
try:
|
|
3211
|
+
shutil.rmtree(path)
|
|
3212
|
+
print(f'pruned {home_shown(path, home)} (left by an earlier lanes gc)')
|
|
3213
|
+
except OSError as error:
|
|
3214
|
+
print(f'skip {home_shown(path, home)}: {error.strerror or error}')
|
|
3215
|
+
for tree, reason in staged_tree_plan(home, proc):
|
|
3216
|
+
shown = home_shown(tree, home)
|
|
3217
|
+
if reason == f'one of the {TREE_KEEP} newest':
|
|
3218
|
+
continue
|
|
3219
|
+
if reason is not None:
|
|
3220
|
+
print(f'kept {shown}: {reason}')
|
|
3221
|
+
continue
|
|
3222
|
+
aside = tree.with_name(f'{PRUNING}{tree.name}-{os.getpid()}-{time.time_ns()}')
|
|
3223
|
+
try:
|
|
3224
|
+
os.rename(tree, aside)
|
|
3225
|
+
except OSError as error:
|
|
3226
|
+
print(f'skip {shown}: {error.strerror or error}')
|
|
3227
|
+
continue
|
|
3228
|
+
try:
|
|
3229
|
+
shutil.rmtree(aside)
|
|
3230
|
+
except OSError as error:
|
|
3231
|
+
print(f'skip {shown}: {error.strerror or error} (left as {aside.name}; the next lanes gc removes it)')
|
|
3232
|
+
continue
|
|
3233
|
+
print(f'pruned {shown}')
|
|
3234
|
+
|
|
3235
|
+
|
|
2036
3236
|
def cmd_tmp(args):
|
|
2037
3237
|
return libexec_module('tmpscan').run(args) # verbs, options and the usage text are tmpscan's
|
|
2038
3238
|
|
|
@@ -2046,7 +3246,364 @@ def cmd_studio_host(args):
|
|
|
2046
3246
|
|
|
2047
3247
|
|
|
2048
3248
|
def cmd_keys(args):
|
|
2049
|
-
|
|
3249
|
+
# verbs, refusals and exit codes are keys.py's; a verb that names an owner (adopt --login, owner) ends by folding
|
|
3250
|
+
# the views of the devices it named into that person's profile (cockpit v3 R2), and a dropped fold is its exit 1
|
|
3251
|
+
return libexec_module('keys').main(args, on_owned=fold_profile)
|
|
3252
|
+
|
|
3253
|
+
|
|
3254
|
+
USER_USAGE = 'usage: lanes user list | lanes user show <login> [--json]'
|
|
3255
|
+
|
|
3256
|
+
|
|
3257
|
+
def shown(text):
|
|
3258
|
+
"""A file name for a terminal line: as it is when printable, else Python's escaped form (no control character from
|
|
3259
|
+
a directory listing reaches the terminal)."""
|
|
3260
|
+
return text if text.isprintable() else ascii(text)
|
|
3261
|
+
|
|
3262
|
+
|
|
3263
|
+
def shown_utc(epoch):
|
|
3264
|
+
"""A time as `lanes user show` and `lanes here` print it: `2026-10-07 09:46:40Z` (utc() is the ISO form the JSON
|
|
3265
|
+
of `lanes version` carries)."""
|
|
3266
|
+
return time.strftime('%Y-%m-%d %H:%M:%SZ', time.gmtime(epoch))
|
|
3267
|
+
|
|
3268
|
+
|
|
3269
|
+
def look_words(look):
|
|
3270
|
+
"""`theme dark · glyphs ascii`: a validated look (users.LOOK keys and values) in LOOK order."""
|
|
3271
|
+
return ' · '.join(f'{key} {look[key]}' for key in ('theme', 'color', 'glyphs') if key in look)
|
|
3272
|
+
|
|
3273
|
+
|
|
3274
|
+
def device_looks(users, devices):
|
|
3275
|
+
"""{device: {'device_only': {…}, 'unfolded': {…}}} for each of `devices` whose devices/<device>.json keeps a
|
|
3276
|
+
look of its own (users.device_only / users.unfolded: validated values only), for `lanes user show` (DEF-1782):
|
|
3277
|
+
the keys that paint on that device whatever — or wherever — the profile says. Read-only."""
|
|
3278
|
+
looks = {}
|
|
3279
|
+
for device in devices:
|
|
3280
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
3281
|
+
own, early = users.device_only(meta), users.unfolded(meta)
|
|
3282
|
+
if own or early:
|
|
3283
|
+
looks[device] = {'device_only': own, 'unfolded': early}
|
|
3284
|
+
return looks
|
|
3285
|
+
|
|
3286
|
+
|
|
3287
|
+
def profile_lines(profile):
|
|
3288
|
+
"""[(label, value)] of one profile as `lanes user show` prints it (every value validated by users.normalize)."""
|
|
3289
|
+
look, seen = profile['look'], profile['seen']
|
|
3290
|
+
last, quiet = profile['last_opened'], profile['notify'].get('quiet_s')
|
|
3291
|
+
by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
|
|
3292
|
+
return [('look', look_words(look) or 'nothing set (the device file, the environment and the defaults decide)'),
|
|
3293
|
+
('seen', f"{len(seen)} lane{'s' if len(seen) != 1 else ''}" if seen else 'none'),
|
|
3294
|
+
('workspaces', f"order {' '.join(profile['workspaces']['order']) or '-'} · "
|
|
3295
|
+
f"hidden {' '.join(profile['workspaces']['hidden']) or '-'}"),
|
|
3296
|
+
('lanes', f"pinned {' '.join(profile['lanes']['pins']) or '-'} · "
|
|
3297
|
+
f"hidden {' '.join(profile['lanes']['hidden']) or '-'}"),
|
|
3298
|
+
('last opened', f"{last['lane']} on {last['device']} at {shown_utc(last['at'])}" if last else 'none'),
|
|
3299
|
+
('notify', f'quiet {quiet} s' if quiet is not None else 'default'),
|
|
3300
|
+
('updated', f"{shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'never')]
|
|
3301
|
+
|
|
3302
|
+
|
|
3303
|
+
def cmd_user(args):
|
|
3304
|
+
"""`lanes user list | show <login> [--json]` (cockpit v3 R2, LNS-75): the people lanes knows and one person's
|
|
3305
|
+
profile (users/<login>.json, libexec/users.py). A person is a login with a profile, or the owner of a device
|
|
3306
|
+
(users.device_logins: a view, a member record or seen marks, owned as device_owner derives it). show also names
|
|
3307
|
+
every look a device of the person keeps in its own devices/<device>.json (device_looks: --device-only keys, and
|
|
3308
|
+
keys from before the profile, with the claim that folds them in; DEF-1782) — why one device paints differently.
|
|
3309
|
+
Read-only: neither verb writes a file. Every value printed was validated (users.normalize, members.validate_owner,
|
|
3310
|
+
users.device_only/unfolded); a file name that is no profile is printed escaped."""
|
|
3311
|
+
users = libexec_module('users')
|
|
3312
|
+
if args == ['list']:
|
|
3313
|
+
profiles, problems = users.load_all()
|
|
3314
|
+
people = {profile['login']: {'profile': profile, 'devices': [], 'name': profile['name']}
|
|
3315
|
+
for profile in profiles}
|
|
3316
|
+
for device, login in users.device_logins().items():
|
|
3317
|
+
if login:
|
|
3318
|
+
person = people.setdefault(login, {'profile': None, 'devices': [], 'name': None})
|
|
3319
|
+
person['devices'].append(device)
|
|
3320
|
+
if not person['name']:
|
|
3321
|
+
person['name'] = (users.owner_of(device) or {}).get('name')
|
|
3322
|
+
for login, person in sorted(people.items()):
|
|
3323
|
+
profile = person['profile']
|
|
3324
|
+
if profile is None:
|
|
3325
|
+
state = 'no profile yet'
|
|
3326
|
+
else:
|
|
3327
|
+
by = f" by {profile['updated_by_device']}" if profile['updated_by_device'] else ''
|
|
3328
|
+
state = f"profile updated {shown_utc(profile['updated_at'])}{by}" if profile['updated_at'] else 'profile'
|
|
3329
|
+
print(f"{login:<32} {person['name'] or '-':<24} {', '.join(person['devices']) or '-':<32} {state}")
|
|
3330
|
+
if not people:
|
|
3331
|
+
print(f'no users: no profile in {users.users_dir()} and no device with an owner — '
|
|
3332
|
+
'`lanes device claim <device> --login <login>` gives a device one')
|
|
3333
|
+
for name, reason in problems:
|
|
3334
|
+
print(f'⚠ users/{shown(name)}: {reason} — not read and never overwritten (fix or remove the file)',
|
|
3335
|
+
file=sys.stderr)
|
|
3336
|
+
return 0
|
|
3337
|
+
rest = [word for word in args[1:] if word != '--json']
|
|
3338
|
+
if args[:1] != ['show'] or len(rest) != 1 or len(args) - len(rest) > 2:
|
|
3339
|
+
raise L.LanesError(USER_USAGE)
|
|
3340
|
+
login = rest[0]
|
|
3341
|
+
profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
|
|
3342
|
+
devices = [device for device, owner in users.device_logins().items() if owner == login]
|
|
3343
|
+
if profile is None and problem is None and not devices:
|
|
3344
|
+
raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
|
|
3345
|
+
looks = device_looks(users, devices)
|
|
3346
|
+
if '--json' in args:
|
|
3347
|
+
print(json.dumps({'login': login, 'devices': devices, 'device_looks': looks, 'profile': profile,
|
|
3348
|
+
'problem': problem}, indent=2, ensure_ascii=False))
|
|
3349
|
+
return 1 if problem else 0
|
|
3350
|
+
name = profile['name'] if profile else next(
|
|
3351
|
+
(owner['name'] for owner in map(users.owner_of, devices) if owner and owner.get('name')), None)
|
|
3352
|
+
lines = [('login', login)] + ([('name', name)] if name else []) + [('devices', ', '.join(devices) or '-')]
|
|
3353
|
+
for device, look in looks.items():
|
|
3354
|
+
if look['unfolded']:
|
|
3355
|
+
lines.append(('device look', f"{device}: {look_words(look['unfolded'])} — from before the profile: "
|
|
3356
|
+
'painted only where the profile sets none; '
|
|
3357
|
+
f'`lanes device claim {device} --login {login}` folds it in'))
|
|
3358
|
+
if look['device_only']:
|
|
3359
|
+
lines.append(('device look', f"{device}: {look_words(look['device_only'])} — --device-only: wins over "
|
|
3360
|
+
f'the profile on {device}; the same flag without --device-only hands it back'))
|
|
3361
|
+
if problem:
|
|
3362
|
+
lines.append(('profile', f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or '
|
|
3363
|
+
'remove the file)'))
|
|
3364
|
+
elif profile is None:
|
|
3365
|
+
lines.append(('profile', f'none yet — `lanes device claim <device> --login {login}` or a look flag on '
|
|
3366
|
+
'`lanes attach` starts it'))
|
|
3367
|
+
else:
|
|
3368
|
+
lines += profile_lines(profile) + [('file', str(users.path(login)))]
|
|
3369
|
+
for label, value in lines:
|
|
3370
|
+
print(f'{label:<12} {value}')
|
|
3371
|
+
return 1 if problem else 0
|
|
3372
|
+
|
|
3373
|
+
|
|
3374
|
+
# ------------------------------------------------------------------ arrangement (cockpit v3 R6)
|
|
3375
|
+
# A person's boards and lanes are arranged once for every device they own: `lanes workspaces hide|show|order` and
|
|
3376
|
+
# `lanes lane pin|unpin|hide|show` write the `workspaces` and `lanes` lists of their profile users/<login>.json
|
|
3377
|
+
# (DM-LANES-USER-PROFILE) through users.update — its 1 s lock and merge, never a file this release does not read — and
|
|
3378
|
+
# `lanes here <device>` shows on that device the lane they opened last anywhere (`last_opened`). The person is the owner
|
|
3379
|
+
# of --device D (profile_for, as `lanes attach` derives it; else $LANES_DEVICE's) or --login L. Readers: the tabs of
|
|
3380
|
+
# every device of the person (workspace order and hidden boards, cockpit v3 R4) and their sidebars (pinned and hidden
|
|
3381
|
+
# lanes, R5). Nothing follows the person from device to device by itself: `lanes here` is asked for (v1).
|
|
3382
|
+
|
|
3383
|
+
ARRANGE_WORKSPACES = ('hide', 'show', 'order')
|
|
3384
|
+
LANE_VERBS = ('pin', 'unpin', 'hide', 'show')
|
|
3385
|
+
LANE_USAGE = 'usage: lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]'
|
|
3386
|
+
HERE_USAGE = 'usage: lanes here <device>'
|
|
3387
|
+
|
|
3388
|
+
|
|
3389
|
+
def target_args(args, usage):
|
|
3390
|
+
"""The words of `args` and the person they arrange -> (words, device, login): `--device D` or `--login L`, at most
|
|
3391
|
+
one of them and each once, anywhere among the words; any other option is the usage."""
|
|
3392
|
+
words, options, rest = [], {}, list(args)
|
|
3393
|
+
while rest:
|
|
3394
|
+
word = rest.pop(0)
|
|
3395
|
+
if word in ('--device', '--login'):
|
|
3396
|
+
if not rest or options:
|
|
3397
|
+
raise L.LanesError(usage)
|
|
3398
|
+
options[word] = rest.pop(0)
|
|
3399
|
+
elif word.startswith('-'):
|
|
3400
|
+
raise L.LanesError(usage)
|
|
3401
|
+
else:
|
|
3402
|
+
words.append(word)
|
|
3403
|
+
return words, options.get('--device'), options.get('--login')
|
|
3404
|
+
|
|
3405
|
+
|
|
3406
|
+
def profile_target(users, device, login, usage):
|
|
3407
|
+
"""The login whose profile an arranging verb writes. --login L: a person lanes knows (a profile, or a device they
|
|
3408
|
+
own — `lanes user list`). Else the device --device names, or $LANES_DEVICE: its owner (profile_for; a window such
|
|
3409
|
+
as mac-2 is its machine's owner's). The device only names the person: the write is the CLI's, stamped
|
|
3410
|
+
`cli:<command>` (arrange), never that device's — a verb run over SSH is nobody at the desk, and the push notifier
|
|
3411
|
+
must not mute that person's pushes for it (W9 review F3). Refused, never echoing an invalid value: no person
|
|
3412
|
+
named, a device nobody owns, an unknown login, or a profile file this release does not read (it is never
|
|
3413
|
+
overwritten)."""
|
|
3414
|
+
if login is None:
|
|
3415
|
+
device = device if device is not None else os.environ.get('LANES_DEVICE') or None
|
|
3416
|
+
if device is None:
|
|
3417
|
+
raise L.LanesError(f'{usage} — name the device (--device D, or LANES_DEVICE) or the person (--login L)')
|
|
3418
|
+
device = device_name(device)
|
|
3419
|
+
login = profile_for(device)
|
|
3420
|
+
if not login:
|
|
3421
|
+
raise L.LanesError(f'{device} has no owner, so it follows no profile: `lanes device claim {device} --login '
|
|
3422
|
+
'<login>` names one, or name the person with --login <login>')
|
|
3423
|
+
profile, problem = users.inspect(login) # refuses a login that can have no profile, never echoing it
|
|
3424
|
+
if problem:
|
|
3425
|
+
raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
|
|
3426
|
+
'the file); nothing changed')
|
|
3427
|
+
if device is None and profile is None and login not in users.device_logins().values():
|
|
3428
|
+
raise L.LanesError(f'no user {login}: no profile and no device it owns (lanes user list)')
|
|
3429
|
+
return login
|
|
3430
|
+
|
|
3431
|
+
|
|
3432
|
+
def arrange(users, login, command, change):
|
|
3433
|
+
"""users.update(login, `cli:<command>`, change) -> 0, then the profile row it changed is printed by the caller; 1
|
|
3434
|
+
when the write was dropped (the lock stayed busy for users.LOCK_S, or the store cannot be written), said on
|
|
3435
|
+
stderr. A refusal `change` raises under the lock propagates, and nothing is written. The stamp is the CLI writer
|
|
3436
|
+
(users.cli_writer), never a device: see profile_target."""
|
|
3437
|
+
if users.update(login, users.cli_writer(command), change):
|
|
3438
|
+
return 0
|
|
3439
|
+
print(f"lanes: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why); nothing "
|
|
3440
|
+
'changed — run it again', file=sys.stderr)
|
|
3441
|
+
return 1
|
|
3442
|
+
|
|
3443
|
+
|
|
3444
|
+
def arranged(users, login, label, groups):
|
|
3445
|
+
"""Print what an arranging verb did — `<words> for <login>: <ids>` per (words, ids) group with ids — and the
|
|
3446
|
+
profile's row `label` as `lanes user show` prints it."""
|
|
3447
|
+
for words, ids in groups:
|
|
3448
|
+
if ids:
|
|
3449
|
+
print(f"{words} for {login}: {' '.join(ids)}")
|
|
3450
|
+
profile = users.read(login) or users.empty(login)
|
|
3451
|
+
print(next(f'{name:<12} {value}' for name, value in profile_lines(profile) if name == label))
|
|
3452
|
+
|
|
3453
|
+
|
|
3454
|
+
def cmd_workspaces_profile(args):
|
|
3455
|
+
"""`lanes workspaces hide|show|order <id…> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's board
|
|
3456
|
+
arrangement, on every device they own. hide adds boards to `workspaces.hidden` — refused when no board would stay
|
|
3457
|
+
visible (decided under the profile's lock); show takes ids off it, also one no board has any more (no registry is
|
|
3458
|
+
read); order REPLACES `workspaces.order` with the ids given (each once; the boards it leaves out follow in catalog
|
|
3459
|
+
order, as the reader places them). hide and order take the board ids `lanes workspaces` prints: an SSH workspace or
|
|
3460
|
+
a tool tab is the site's, not arranged per person. -> 0, 1 when the write was dropped (arrange)."""
|
|
3461
|
+
words, device, login = target_args(args, WORKSPACES_USAGE)
|
|
3462
|
+
verb, ids = words[0], words[1:]
|
|
3463
|
+
if not ids:
|
|
3464
|
+
raise L.LanesError(WORKSPACES_USAGE)
|
|
3465
|
+
users = libexec_module('users')
|
|
3466
|
+
for board in ids:
|
|
3467
|
+
users.check_workspace(board) # never echoes an invalid value
|
|
3468
|
+
if verb == 'order':
|
|
3469
|
+
twice = next((board for index, board in enumerate(ids) if board in ids[:index]), None)
|
|
3470
|
+
if twice:
|
|
3471
|
+
raise L.LanesError(f'{twice} is named twice; nothing changed')
|
|
3472
|
+
ids = list(dict.fromkeys(ids))
|
|
3473
|
+
boards = [] if verb == 'show' else [workspace['id'] for workspace in L.load_registry()['workspaces']]
|
|
3474
|
+
unknown = next((board for board in ids if verb != 'show' and board not in boards), None)
|
|
3475
|
+
if unknown:
|
|
3476
|
+
raise L.LanesError(f'no board {unknown} (lanes workspaces lists them); nothing changed')
|
|
3477
|
+
login = profile_target(users, device, login, WORKSPACES_USAGE)
|
|
3478
|
+
groups = []
|
|
3479
|
+
|
|
3480
|
+
def change(profile):
|
|
3481
|
+
hidden, order = profile['workspaces']['hidden'], profile['workspaces']['order']
|
|
3482
|
+
if verb == 'order':
|
|
3483
|
+
follow = '(the other boards follow in catalog order)'
|
|
3484
|
+
groups[:] = [('order', ids + [follow]) if order != ids else ('order was already', ids)]
|
|
3485
|
+
profile['workspaces']['order'] = list(ids)
|
|
3486
|
+
return
|
|
3487
|
+
if verb == 'hide':
|
|
3488
|
+
new = [board for board in ids if board not in hidden]
|
|
3489
|
+
if len(hidden) + len(new) > users.MAX_WORKSPACES:
|
|
3490
|
+
raise L.LanesError(f'a profile keeps at most {users.MAX_WORKSPACES} hidden boards; nothing changed')
|
|
3491
|
+
if not [board for board in boards if board not in hidden + new]:
|
|
3492
|
+
raise L.LanesError('that would hide every board: at least one stays visible (`lanes workspaces show '
|
|
3493
|
+
'<id>` shows one again); nothing changed')
|
|
3494
|
+
hidden.extend(new)
|
|
3495
|
+
groups[:] = [('hidden', new), ('already hidden', [board for board in ids if board not in new])]
|
|
3496
|
+
return
|
|
3497
|
+
shown = [board for board in ids if board in hidden]
|
|
3498
|
+
profile['workspaces']['hidden'] = [board for board in hidden if board not in shown]
|
|
3499
|
+
groups[:] = [('shown again', shown), ('not hidden', [board for board in ids if board not in shown])]
|
|
3500
|
+
|
|
3501
|
+
code = arrange(users, login, 'workspaces', change)
|
|
3502
|
+
if code == 0:
|
|
3503
|
+
arranged(users, login, 'workspaces', groups)
|
|
3504
|
+
return code
|
|
3505
|
+
|
|
3506
|
+
|
|
3507
|
+
def cmd_lane(args):
|
|
3508
|
+
"""`lanes lane pin|unpin|hide|show <lane-id> [--device D | --login L]` (cockpit v3 R6, LNS-79): the person's
|
|
3509
|
+
`lanes.pins` / `lanes.hidden`, on every device they own. pin takes a lane the boards list (registered, or a live
|
|
3510
|
+
unregistered session); hide only a registered one — the sidebar always lists an unregistered session that runs, so
|
|
3511
|
+
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
|
|
3512
|
+
most users.MAX_LANES per list (refused beyond, never truncated). -> 0, 1 when the write was dropped (arrange)."""
|
|
3513
|
+
words, device, login = target_args(args, LANE_USAGE)
|
|
3514
|
+
if len(words) != 2 or words[0] not in LANE_VERBS:
|
|
3515
|
+
raise L.LanesError(LANE_USAGE)
|
|
3516
|
+
verb, lane_id = words
|
|
3517
|
+
users = libexec_module('users')
|
|
3518
|
+
users.check_lane(lane_id) # never echoes an invalid value
|
|
3519
|
+
if verb in ('pin', 'hide'):
|
|
3520
|
+
registry = L.load_registry()
|
|
3521
|
+
L.find_lane(registry, lane_id) # Unknown lane: <id>
|
|
3522
|
+
registered = any(lane['id'] == lane_id for workspace in registry['workspaces'] for lane in workspace['lanes'])
|
|
3523
|
+
if verb == 'hide' and not registered:
|
|
3524
|
+
raise L.LanesError(f'{lane_id} is not registered in a workspace: an unregistered session that runs is always '
|
|
3525
|
+
f'listed — `lanes register {lane_id}` adds it to its workspace first; nothing changed')
|
|
3526
|
+
login = profile_target(users, device, login, LANE_USAGE)
|
|
3527
|
+
key = 'pins' if verb in ('pin', 'unpin') else 'hidden'
|
|
3528
|
+
adds = verb in ('pin', 'hide')
|
|
3529
|
+
said = {'pin': ('pinned', 'already pinned'), 'unpin': ('unpinned', 'not pinned'),
|
|
3530
|
+
'hide': ('hidden', 'already hidden'), 'show': ('shown again', 'not hidden')}[verb]
|
|
3531
|
+
groups = []
|
|
3532
|
+
|
|
3533
|
+
def change(profile):
|
|
3534
|
+
listed = profile['lanes'][key]
|
|
3535
|
+
changed = (lane_id not in listed) if adds else (lane_id in listed)
|
|
3536
|
+
if changed and adds:
|
|
3537
|
+
if len(listed) >= users.MAX_LANES:
|
|
3538
|
+
raise L.LanesError(f"a profile keeps at most {users.MAX_LANES} {'pinned' if key == 'pins' else key} "
|
|
3539
|
+
f'lanes and {login}\'s has that many: {"unpin" if key == "pins" else "show"} one '
|
|
3540
|
+
'first; nothing changed')
|
|
3541
|
+
listed.append(lane_id)
|
|
3542
|
+
elif changed:
|
|
3543
|
+
listed.remove(lane_id)
|
|
3544
|
+
groups[:] = [(said[0] if changed else said[1], [lane_id])]
|
|
3545
|
+
|
|
3546
|
+
code = arrange(users, login, 'lane', change)
|
|
3547
|
+
if code == 0:
|
|
3548
|
+
arranged(users, login, 'lanes', groups)
|
|
3549
|
+
return code
|
|
3550
|
+
|
|
3551
|
+
|
|
3552
|
+
def cmd_here(args):
|
|
3553
|
+
"""`lanes here <device>` (cockpit v3 R6, LNS-79; `lanes-launch here` from a client): show on `device` the lane its
|
|
3554
|
+
owner opened last on any of their devices — the profile's `last_opened`, which a sidebar's Enter and `lanes
|
|
3555
|
+
next|prev` write when they open a lane with an agent — exactly as those do: its board's first viewport is pointed
|
|
3556
|
+
at it (lanes_lib.show_lane: the target file, SIGUSR1 to a running view.sh), and a lane with an agent is marked seen
|
|
3557
|
+
on the device and in the profile (mark_profile_seen: the mark and `last_opened` now name this device). When a view
|
|
3558
|
+
was woken, the board's tab is brought to the front (one bounded `zellij action go-to-tab-name`, as `lanes attach
|
|
3559
|
+
<workspace>` does). Says why it opens nothing (LanesError, exit 1): an unknown device, one nobody owns, no lane
|
|
3560
|
+
opened yet, a lane no board lists any more, or a board the person hides (cockpit v3 R4). Never follows by itself.
|
|
3561
|
+
-> 0."""
|
|
3562
|
+
if len(args) != 1 or args[0].startswith('-'):
|
|
3563
|
+
raise L.LanesError(HERE_USAGE)
|
|
3564
|
+
device = device_name(args[0])
|
|
3565
|
+
if not isinstance(L.read_json(L.STATE / 'devices' / f'{device}.json'), dict):
|
|
3566
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list)')
|
|
3567
|
+
login = profile_for(device)
|
|
3568
|
+
if not login:
|
|
3569
|
+
raise L.LanesError(f'{device} has no owner, so there is no profile to follow: `lanes device claim {device} '
|
|
3570
|
+
'--login <login>` names one')
|
|
3571
|
+
users = libexec_module('users')
|
|
3572
|
+
profile, problem = users.inspect(login)
|
|
3573
|
+
if problem:
|
|
3574
|
+
raise L.LanesError(f'users/{users.stem(login)}.json: {problem} — not read and never overwritten (fix or remove '
|
|
3575
|
+
'the file)')
|
|
3576
|
+
last = (profile or {}).get('last_opened')
|
|
3577
|
+
if not last:
|
|
3578
|
+
raise L.LanesError(f'nothing to open: {login} has opened no lane with an agent yet (its profile records the last '
|
|
3579
|
+
'one opened)')
|
|
3580
|
+
when = f"on {last['device']} at {shown_utc(last['at'])}"
|
|
3581
|
+
try:
|
|
3582
|
+
workspace, lane = L.find_lane(L.load_registry(), last['lane'])
|
|
3583
|
+
except L.LanesError:
|
|
3584
|
+
raise L.LanesError(f"{last['lane']}, the lane {login} opened last ({when}), is not listed any more; nothing to "
|
|
3585
|
+
'open') from None
|
|
3586
|
+
if workspace['id'] in profile['workspaces']['hidden']:
|
|
3587
|
+
raise L.LanesError(f"{lane['name']} is on {workspace['title']}, which {login} hides on every device: `lanes "
|
|
3588
|
+
f"workspaces show {workspace['id']} --device {device}` shows it again")
|
|
3589
|
+
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
|
|
3590
|
+
agent = (status.get(lane['id']) or {}).get('agent') if isinstance(status, dict) else None
|
|
3591
|
+
if isinstance(agent, dict) and agent:
|
|
3592
|
+
seen = L.record_seen(L.STATE / 'clients' / f'{device}.seen.json', lane['id'], agent.get('since'), set(status))
|
|
3593
|
+
mark_profile_seen(device, lane['id'], seen[lane['id']], 'here')
|
|
3594
|
+
woken = L.show_lane(str(L.STATE / 'views' / device), workspace['id'], 'main', lane['id'])
|
|
3595
|
+
print(f"{device}: {lane['name']} ({workspace['title']}) — the lane {login} opened last, {when}")
|
|
3596
|
+
if lane['id'] in profile['lanes']['hidden']:
|
|
3597
|
+
print(f"{lane['name']} is hidden from your lists: `lanes lane show {lane['id']} --device {device}` lists it "
|
|
3598
|
+
'again')
|
|
3599
|
+
if not woken:
|
|
3600
|
+
print(f"{device}'s view of {workspace['title']} is not running: it shows {lane['name']} when it starts")
|
|
3601
|
+
return 0
|
|
3602
|
+
rc, out, err = zellij(['-s', device, 'action', 'go-to-tab-name', workspace['title']], 10)
|
|
3603
|
+
if rc != 0:
|
|
3604
|
+
reason = 'no answer in 10 s' if rc is None else (err.strip() or out.strip() or f'exit {rc}')
|
|
3605
|
+
print(f"the {workspace['title']} tab was not brought to the front ({reason}); Ctrl-Alt-←/→ reaches it")
|
|
3606
|
+
return 0
|
|
2050
3607
|
|
|
2051
3608
|
|
|
2052
3609
|
def cmd_invite(args):
|
|
@@ -2083,18 +3640,25 @@ def revoke_web_token(device, windows=()):
|
|
|
2083
3640
|
|
|
2084
3641
|
def end_view(device):
|
|
2085
3642
|
"""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
|
-
|
|
3643
|
+
delete the serialized session) without its reconnect stamp and without recreating it, under the view-session lock
|
|
3644
|
+
(DEF-1792). No lane is touched."""
|
|
3645
|
+
with contextlib.ExitStack() as stack:
|
|
3646
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes offboard {device}')
|
|
3647
|
+
if refused:
|
|
3648
|
+
return 'failed', f'the view session {device} was not ended ({refused}); `lanes offboard {device} --yes` again'
|
|
3649
|
+
try:
|
|
3650
|
+
live = live_sessions()
|
|
3651
|
+
except L.LanesError as error:
|
|
3652
|
+
return 'failed', (f'whether the view session {device} runs is unknown ({error}); '
|
|
3653
|
+
f'`lanes offboard {device} --yes` again')
|
|
3654
|
+
if device not in live:
|
|
3655
|
+
return 'none', f'no view session {device} is running'
|
|
3656
|
+
rc = zellij(['kill-session', device], 30)[0]
|
|
3657
|
+
if rc != 0:
|
|
3658
|
+
return 'failed', (f'the view session {device} did not end ('
|
|
3659
|
+
+ ('no answer in 30 s' if rc is None else f'exit {rc}')
|
|
3660
|
+
+ f'); `lanes offboard {device} --yes` again')
|
|
3661
|
+
zellij(['delete-session', '--force', device], 30)
|
|
2098
3662
|
return 'done', f'ended view session {device} (no lane was touched)'
|
|
2099
3663
|
|
|
2100
3664
|
|
|
@@ -2232,14 +3796,118 @@ def socket_report(containers, live_by_container):
|
|
|
2232
3796
|
return warnings
|
|
2233
3797
|
|
|
2234
3798
|
|
|
3799
|
+
DEVICE_USAGE = 'usage: lanes device list | lanes device forget <name> | lanes device claim <device> --login L [--name N]'
|
|
3800
|
+
|
|
3801
|
+
|
|
3802
|
+
def claim_args(rest):
|
|
3803
|
+
"""[device, --login L (, --name N)] in any order of the two options, each once, --login required ->
|
|
3804
|
+
(device, login, name). Anything else is the usage."""
|
|
3805
|
+
if len(rest) not in (3, 5):
|
|
3806
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3807
|
+
options = {}
|
|
3808
|
+
for flag, value in zip(rest[1::2], rest[2::2]):
|
|
3809
|
+
if flag not in ('--login', '--name') or flag in options:
|
|
3810
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3811
|
+
options[flag] = value
|
|
3812
|
+
if '--login' not in options:
|
|
3813
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
3814
|
+
return rest[0], options['--login'], options.get('--name')
|
|
3815
|
+
|
|
3816
|
+
|
|
3817
|
+
def deciding_member(members, device, meta):
|
|
3818
|
+
"""(name, owner or None) of the member record that decides who owns `device` — members/<device>.json, else
|
|
3819
|
+
members/<base>.json (members.owner_for's order) — or None while neither exists. A record that exists but does not
|
|
3820
|
+
load is refused here (owner_for would keep the recorded owner beside it, but a claim must not be written while it
|
|
3821
|
+
is there)."""
|
|
3822
|
+
base = meta.get('base')
|
|
3823
|
+
for name in [device] + ([base] if isinstance(base, str) and base != device else []):
|
|
3824
|
+
if not L.IDENT.match(name) or len(name) > 32: # a corrupt base names no member file
|
|
3825
|
+
continue
|
|
3826
|
+
try:
|
|
3827
|
+
document = members.read(name)
|
|
3828
|
+
except L.LanesError as error:
|
|
3829
|
+
raise L.LanesError(f'{error} — while it exists it, not a claim, decides who owns {device}: fix or remove it '
|
|
3830
|
+
'first; nothing changed') from None
|
|
3831
|
+
if document is not None:
|
|
3832
|
+
return name, members.valid_owner(document.get('owner'))
|
|
3833
|
+
return None
|
|
3834
|
+
|
|
3835
|
+
|
|
3836
|
+
def claimed_owner(before, owner, renamed):
|
|
3837
|
+
"""The owner a claim records on a view whose valid recorded owner is `before` (or None): `owner` ({login, name,
|
|
3838
|
+
email: null}) for a view another login or nobody holds; for the login it records already, that record itself — its
|
|
3839
|
+
name and email kept — with only the name replaced when the claim gave --name (`renamed`). So a claim run again
|
|
3840
|
+
without --name, as fold_profile's repair hint words it, changes nothing that is done."""
|
|
3841
|
+
if before and before['login'] == owner['login']:
|
|
3842
|
+
return dict(before, name=owner['name']) if renamed else before
|
|
3843
|
+
return owner
|
|
3844
|
+
|
|
3845
|
+
|
|
3846
|
+
def owner_words(owner):
|
|
3847
|
+
"""`login (name)` — or the login alone — of a validated owner, or `none`."""
|
|
3848
|
+
if not owner:
|
|
3849
|
+
return 'none'
|
|
3850
|
+
return owner['login'] + (f" ({owner['name']})" if owner['name'] else '')
|
|
3851
|
+
|
|
3852
|
+
|
|
3853
|
+
def cmd_device_claim(rest):
|
|
3854
|
+
"""`lanes device claim <device> --login L [--name N]` (cockpit v3 R2, LNS-75): name the owner of a view no member
|
|
3855
|
+
record speaks for — a device from before enrolment (W8) — so it follows L's profile. Writes `owner` into
|
|
3856
|
+
devices/<device>.json, where device_owner keeps it across every rewrite (create_session, record_base) while no
|
|
3857
|
+
member record exists, and into each of its windows' files (views whose recorded base is the device) that have no
|
|
3858
|
+
member record of their own; then folds those views into L's profile (fold_profile). A member record decides
|
|
3859
|
+
ownership whenever one exists (DM-LANES-DEVICE-OWNER): a claim that disagrees with it is refused with the verb that
|
|
3860
|
+
changes it, one that agrees writes no owner and only folds. A view L already owns keeps its recorded name and email
|
|
3861
|
+
unless --name replaces the name (claimed_owner), so re-running a claim — with or without --name — changes nothing
|
|
3862
|
+
that is done and finishes a fold that was dropped -> 0, or 1 when the profile could not be written."""
|
|
3863
|
+
device, login, name = claim_args(rest)
|
|
3864
|
+
device = device_name(device)
|
|
3865
|
+
meta_file = L.STATE / 'devices' / f'{device}.json'
|
|
3866
|
+
meta = L.read_json(meta_file) if meta_file.exists() else None
|
|
3867
|
+
if not isinstance(meta, dict):
|
|
3868
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list): a claim names the owner of a view '
|
|
3869
|
+
'lanes keeps')
|
|
3870
|
+
members = libexec_module('members')
|
|
3871
|
+
owner = members.validate_owner({'login': login, 'name': name})
|
|
3872
|
+
login = owner['login']
|
|
3873
|
+
decided = deciding_member(members, device, meta)
|
|
3874
|
+
if decided:
|
|
3875
|
+
record, current = decided
|
|
3876
|
+
if not current or current['login'] != login:
|
|
3877
|
+
who = current['login'] if current else 'nobody: a key added by hand'
|
|
3878
|
+
raise L.LanesError(f'members/{record}.json decides who owns {device} ({who}); a claim never overrides a '
|
|
3879
|
+
f'member record — `lanes keys owner {record} {login}` names its owner; nothing changed')
|
|
3880
|
+
print(f"{device} is already {login}'s: members/{record}.json names {login}")
|
|
3881
|
+
else:
|
|
3882
|
+
before = members.valid_owner(meta.get('owner'))
|
|
3883
|
+
after = claimed_owner(before, owner, name is not None)
|
|
3884
|
+
if before == after:
|
|
3885
|
+
print(f"{device} is already owned by {login} ("
|
|
3886
|
+
+ (f"{before['name']}, " if before['name'] else '') + f'devices/{device}.json)')
|
|
3887
|
+
else:
|
|
3888
|
+
L.atomic_write(meta_file, json.dumps(dict(meta, owner=after)) + '\n')
|
|
3889
|
+
print(f'owner of {device}: {owner_words(before)} -> {owner_words(after)}')
|
|
3890
|
+
for window, window_meta in windows_of({device}).items():
|
|
3891
|
+
before = members.valid_owner(window_meta.get('owner'))
|
|
3892
|
+
after = claimed_owner(before, owner, name is not None)
|
|
3893
|
+
if (members.members_dir() / f'{window}.json').exists():
|
|
3894
|
+
print(f'kept {window} (a window of {device}): members/{window}.json decides who owns it')
|
|
3895
|
+
elif not decided and before != after:
|
|
3896
|
+
L.atomic_write(L.STATE / 'devices' / f'{window}.json', json.dumps(dict(window_meta, owner=after)) + '\n')
|
|
3897
|
+
print(f'owner of {window} (a window of {device}): {owner_words(before)} -> {owner_words(after)}')
|
|
3898
|
+
return fold_profile(login, [device])
|
|
3899
|
+
|
|
3900
|
+
|
|
2235
3901
|
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
|
-
|
|
3902
|
+
"""lanes device list | forget <name> | claim <device> --login L [--name N]: the devices/<name>.json files `lanes
|
|
3903
|
+
ensure` (lanes-ensure.timer) recreates a view session from, and `sync`/`upgrade-session` update. Forgetting removes
|
|
3904
|
+
that file only: a running view session is never ended here. list prints the owner's login as the next rewrite
|
|
3905
|
+
records it (device_owner: the member record's whenever one exists, so it agrees with `lanes keys list`; else the
|
|
3906
|
+
validated recorded one), blank for none — a devices/*.json or members/*.json value never reaches the terminal
|
|
3907
|
+
unchecked. claim: cmd_device_claim."""
|
|
2242
3908
|
devices = L.STATE / 'devices'
|
|
3909
|
+
if args[:1] == ['claim']:
|
|
3910
|
+
return cmd_device_claim(args[1:])
|
|
2243
3911
|
if args == ['list']:
|
|
2244
3912
|
try:
|
|
2245
3913
|
live = live_sessions()
|
|
@@ -2257,17 +3925,23 @@ def cmd_device(args):
|
|
|
2257
3925
|
+ (f" base {base}" if base else ''))
|
|
2258
3926
|
return 0
|
|
2259
3927
|
if len(args) != 2 or args[0] != 'forget':
|
|
2260
|
-
raise L.LanesError(
|
|
3928
|
+
raise L.LanesError(DEVICE_USAGE)
|
|
2261
3929
|
device = device_name(args[1])
|
|
2262
3930
|
meta_file = devices / f'{device}.json'
|
|
2263
|
-
|
|
2264
|
-
|
|
2265
|
-
|
|
2266
|
-
|
|
2267
|
-
|
|
2268
|
-
|
|
2269
|
-
|
|
2270
|
-
|
|
3931
|
+
with contextlib.ExitStack() as stack:
|
|
3932
|
+
# Under the view-session lock (DEF-1792): a `lanes ensure` pass that listed this device before the unlink and
|
|
3933
|
+
# recreated its view after it would write devices/<device>.json again (create_session), undoing the forget.
|
|
3934
|
+
refused = hold_sessions(stack, SESSIONS_WAIT, f'lanes device forget {device}')
|
|
3935
|
+
if refused:
|
|
3936
|
+
raise L.LanesError(f'{refused}: {device} was not forgotten')
|
|
3937
|
+
if not meta_file.exists():
|
|
3938
|
+
raise L.LanesError(f'no device {device!r} is known (lanes device list)')
|
|
3939
|
+
meta = L.read_json(meta_file) or {}
|
|
3940
|
+
try:
|
|
3941
|
+
live = device in live_sessions()
|
|
3942
|
+
except L.LanesError:
|
|
3943
|
+
live = None
|
|
3944
|
+
meta_file.unlink()
|
|
2271
3945
|
kind = 'phone' if meta.get('mobile') else f"desktop, {int(meta.get('viewports') or 1)} viewports"
|
|
2272
3946
|
print(f'forgot {device} ({kind}): `lanes ensure` (lanes-ensure.timer) no longer recreates its view session, and '
|
|
2273
3947
|
'`lanes sync` / `upgrade-session` no longer update it')
|
|
@@ -2281,6 +3955,25 @@ def cmd_device(args):
|
|
|
2281
3955
|
return 0
|
|
2282
3956
|
|
|
2283
3957
|
|
|
3958
|
+
def unit_properties(units):
|
|
3959
|
+
"""One bounded `systemctl --user show` of `units` (Id, LoadState, ActiveState, UnitFileState, MainPID) ->
|
|
3960
|
+
({unit: {property: value}}, None), or (None, why) when systemctl did not answer or failed. Read-only."""
|
|
3961
|
+
argv = ['systemctl', '--user', 'show', '-p', 'Id', '-p', 'LoadState', '-p', 'ActiveState', '-p', 'UnitFileState',
|
|
3962
|
+
'-p', 'MainPID', *units]
|
|
3963
|
+
rc, out, err = L.run_bounded(argv, 10, env=clean_env())
|
|
3964
|
+
if rc != 0:
|
|
3965
|
+
return None, 'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'
|
|
3966
|
+
found = {}
|
|
3967
|
+
for block in out.strip().split('\n\n'):
|
|
3968
|
+
props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
|
|
3969
|
+
found[props.get('Id', '')] = props
|
|
3970
|
+
return found, None
|
|
3971
|
+
|
|
3972
|
+
|
|
3973
|
+
def unit_enabled(props):
|
|
3974
|
+
return props.get('UnitFileState', '').startswith(('enabled', 'linked', 'alias'))
|
|
3975
|
+
|
|
3976
|
+
|
|
2284
3977
|
def web_doctor():
|
|
2285
3978
|
"""`lanes doctor`'s browser/phone lines -> [(mark, text)], mark ✓ | ⚠ | ✗ (LNS-50). One bounded
|
|
2286
3979
|
`systemctl --user show` for both web units, a read of the running lanes-web's environment, `tailscale status --json`
|
|
@@ -2294,20 +3987,14 @@ def web_doctor():
|
|
|
2294
3987
|
if not shutil.which('systemctl'):
|
|
2295
3988
|
lines.append(('⚠', 'web units not checked — no systemctl on this host (`lanes web` needs systemd user services)'))
|
|
2296
3989
|
else:
|
|
2297
|
-
|
|
2298
|
-
|
|
2299
|
-
|
|
2300
|
-
if rc != 0:
|
|
2301
|
-
lines.append(('✗', f"web units NOT checked — systemctl --user {'did not answer in 10 s' if rc is None else err.strip() or f'exit {rc}'}"))
|
|
3990
|
+
units, why = unit_properties(WEB_UNITS)
|
|
3991
|
+
if units is None:
|
|
3992
|
+
lines.append(('✗', f'web units NOT checked — systemctl --user {why}'))
|
|
2302
3993
|
else:
|
|
2303
|
-
units = {}
|
|
2304
|
-
for block in out.strip().split('\n\n'):
|
|
2305
|
-
props = dict(line.partition('=')[::2] for line in block.splitlines() if '=' in line)
|
|
2306
|
-
units[props.get('Id', '')] = props
|
|
2307
3994
|
for unit in WEB_UNITS:
|
|
2308
3995
|
props = units.get(unit, {})
|
|
2309
3996
|
active = props.get('ActiveState') or 'unknown'
|
|
2310
|
-
enabled = props
|
|
3997
|
+
enabled = unit_enabled(props)
|
|
2311
3998
|
enabled_any = enabled_any or enabled
|
|
2312
3999
|
if enabled and active == 'active':
|
|
2313
4000
|
lines.append(('✓', f'{unit} active'))
|
|
@@ -2352,6 +4039,469 @@ def web_doctor():
|
|
|
2352
4039
|
return lines
|
|
2353
4040
|
|
|
2354
4041
|
|
|
4042
|
+
# ------------------------------------------------------------------ doctor: the docs service (W10 W8, LNS-109)
|
|
4043
|
+
DOCS_UNIT = 'lanes-docsd.service'
|
|
4044
|
+
DOCS_PROBE_S = 2 # seconds the loopback connect to lanes-docsd may take
|
|
4045
|
+
ENABLE_DOCS = 'install/enable-docs.sh'
|
|
4046
|
+
|
|
4047
|
+
|
|
4048
|
+
def home_shown(path, home=None):
|
|
4049
|
+
"""`path` as `~/…` under the home, else as it is (plain)."""
|
|
4050
|
+
try:
|
|
4051
|
+
return plain(f'~/{Path(path).relative_to(Path(home) if home is not None else Path.home())}')
|
|
4052
|
+
except (ValueError, KeyError, RuntimeError):
|
|
4053
|
+
return plain(path)
|
|
4054
|
+
|
|
4055
|
+
|
|
4056
|
+
def byte_size(count):
|
|
4057
|
+
"""A size for a person: `812 B`, `3.4 KiB`, `17.0 MiB`, `1.2 GiB`."""
|
|
4058
|
+
if count < 1024:
|
|
4059
|
+
return f'{count} B'
|
|
4060
|
+
for unit in ('KiB', 'MiB', 'GiB'):
|
|
4061
|
+
count /= 1024
|
|
4062
|
+
if count < 1024 or unit == 'GiB':
|
|
4063
|
+
return f'{count:.1f} {unit}'
|
|
4064
|
+
|
|
4065
|
+
|
|
4066
|
+
def tree_bytes(root):
|
|
4067
|
+
"""(regular files, their bytes) under the directory `root`, never through a link (a link at `root` itself counts
|
|
4068
|
+
nothing); (0, 0) when it does not exist. Unreadable entries are skipped."""
|
|
4069
|
+
if os.path.islink(root):
|
|
4070
|
+
return 0, 0
|
|
4071
|
+
files = total = 0
|
|
4072
|
+
stack = [os.fspath(root)]
|
|
4073
|
+
while stack:
|
|
4074
|
+
try:
|
|
4075
|
+
with os.scandir(stack.pop()) as entries:
|
|
4076
|
+
for entry in entries:
|
|
4077
|
+
try:
|
|
4078
|
+
if entry.is_dir(follow_symlinks=False):
|
|
4079
|
+
stack.append(entry.path)
|
|
4080
|
+
elif entry.is_file(follow_symlinks=False):
|
|
4081
|
+
files, total = files + 1, total + entry.stat(follow_symlinks=False).st_size
|
|
4082
|
+
except OSError:
|
|
4083
|
+
continue
|
|
4084
|
+
except OSError:
|
|
4085
|
+
continue
|
|
4086
|
+
return files, total
|
|
4087
|
+
|
|
4088
|
+
|
|
4089
|
+
def docs_port():
|
|
4090
|
+
"""(port, None): the port lanes-docsd listens on — LANES_DOCS_PORT from its drop-in (docs_store.unit_environment,
|
|
4091
|
+
the files `lanes upload` reads too), else docsd.DEFAULT_PORT — or (None, why) for a setting that is not a port."""
|
|
4092
|
+
value = libexec_module('docs_store').unit_environment().get('LANES_DOCS_PORT') or ''
|
|
4093
|
+
if not value:
|
|
4094
|
+
return libexec_module('docsd').DEFAULT_PORT, None
|
|
4095
|
+
if value.isdigit() and 0 < int(value) <= 65535:
|
|
4096
|
+
return int(value), None
|
|
4097
|
+
return None, f'LANES_DOCS_PORT={plain(value)[:20]} in its drop-in is not a port number'
|
|
4098
|
+
|
|
4099
|
+
|
|
4100
|
+
def port_answers(port, timeout=DOCS_PROBE_S):
|
|
4101
|
+
"""Whether 127.0.0.1:<port> accepts a TCP connection within `timeout` s. It connects and closes without sending a
|
|
4102
|
+
byte, so lanes-docsd answers nothing and writes no docs.log line (its handler reads an empty request line)."""
|
|
4103
|
+
import socket as sockets
|
|
4104
|
+
try:
|
|
4105
|
+
with sockets.create_connection(('127.0.0.1', port), timeout=timeout):
|
|
4106
|
+
return True
|
|
4107
|
+
except OSError:
|
|
4108
|
+
return False
|
|
4109
|
+
|
|
4110
|
+
|
|
4111
|
+
def docs_unit_line(probe):
|
|
4112
|
+
restart = f'journalctl --user -u {DOCS_UNIT}; systemctl --user restart {DOCS_UNIT}'
|
|
4113
|
+
if not shutil.which('systemctl'):
|
|
4114
|
+
return ('⚠', f'{DOCS_UNIT} not checked — no systemctl on this host')
|
|
4115
|
+
units, why = unit_properties([DOCS_UNIT])
|
|
4116
|
+
if units is None:
|
|
4117
|
+
return ('✗', f'{DOCS_UNIT} NOT checked — systemctl --user {why}')
|
|
4118
|
+
props = units.get(DOCS_UNIT, {})
|
|
4119
|
+
active, enabled = props.get('ActiveState') or 'unknown', unit_enabled(props)
|
|
4120
|
+
if active != 'active':
|
|
4121
|
+
if enabled:
|
|
4122
|
+
return ('✗', f'{DOCS_UNIT} enabled but {plain(active)} — {restart}')
|
|
4123
|
+
return ('⚠', f'{DOCS_UNIT} not enabled, so there is no docs page — the full server install enables it (an '
|
|
4124
|
+
f'update with --update-only enables nothing): systemctl --user enable --now {DOCS_UNIT}')
|
|
4125
|
+
port, why = docs_port()
|
|
4126
|
+
if port is None:
|
|
4127
|
+
return ('✗', f'{DOCS_UNIT} active, but {why} — fix it with systemctl --user edit {DOCS_UNIT}')
|
|
4128
|
+
where = f'127.0.0.1:{port}'
|
|
4129
|
+
if not probe(port):
|
|
4130
|
+
return ('✗', f'{DOCS_UNIT} active, but {where} does not answer — {restart}')
|
|
4131
|
+
if not enabled:
|
|
4132
|
+
return ('⚠', f'{DOCS_UNIT} active on {where} but not enabled: a reboot loses it — systemctl --user enable '
|
|
4133
|
+
f'{DOCS_UNIT}')
|
|
4134
|
+
return ('✓', f'{DOCS_UNIT} active, answering on {where}')
|
|
4135
|
+
|
|
4136
|
+
|
|
4137
|
+
def docs_pin_line():
|
|
4138
|
+
"""The pin gate as docsd reads it on every request (enrold.read_mode): an operator gate, so never a ✗."""
|
|
4139
|
+
mode, problem = libexec_module('enrold').read_mode()
|
|
4140
|
+
if mode == 'request':
|
|
4141
|
+
return ('✓', 'docs: pinned — lanes-docsd answers members through Tailscale Serve (lanes enrol mode request)')
|
|
4142
|
+
said = problem or ('pinned invite only' if mode == 'invite' else 'unpinned: auto')
|
|
4143
|
+
return ('⚠', f'docs: refuses until the Serve header check is pinned ({plain(said)}): every request is 403 '
|
|
4144
|
+
'not-pinned — run the check (RUNBOOK "Enrolment service", Operator verification), then lanes enrol '
|
|
4145
|
+
'mode request (RUNBOOK "Docs service")')
|
|
4146
|
+
|
|
4147
|
+
|
|
4148
|
+
def docs_store_line(home=None):
|
|
4149
|
+
D = libexec_module('docs_store')
|
|
4150
|
+
counts = D.read_counts()
|
|
4151
|
+
documents = sum(entry['n'] for entry in counts.values())
|
|
4152
|
+
_files, size = tree_bytes(D.root())
|
|
4153
|
+
try:
|
|
4154
|
+
log = f'docs.log {byte_size(D.log_path().lstat().st_size)}'
|
|
4155
|
+
except FileNotFoundError:
|
|
4156
|
+
log = 'no docs.log yet'
|
|
4157
|
+
except OSError as error:
|
|
4158
|
+
log = f'docs.log not readable ({error.strerror or error})'
|
|
4159
|
+
return ('', f"docs store: {documents} document{'' if documents == 1 else 's'} in {len(counts)} "
|
|
4160
|
+
f"lane{'' if len(counts) == 1 else 's'} (counts.json), {byte_size(size)} under "
|
|
4161
|
+
f'{home_shown(D.root(), home)}; {log}')
|
|
4162
|
+
|
|
4163
|
+
|
|
4164
|
+
def docs_mermaid_line(home=None):
|
|
4165
|
+
"""holders.lock's opt-in `mermaid` pin against the file install/enable-docs.sh installs: the member digest, read
|
|
4166
|
+
without following a link or blocking on a FIFO."""
|
|
4167
|
+
import hashlib
|
|
4168
|
+
SH = libexec_module('studio_host')
|
|
4169
|
+
script = f"sh {home_shown(L.CURRENT, home)}/{ENABLE_DOCS}"
|
|
4170
|
+
try:
|
|
4171
|
+
pin = SH.parse_lock((L.REPO / 'holders.lock').read_text()).get('mermaid')
|
|
4172
|
+
why = 'no mermaid line' if pin is None else '' if pin.opt_in else 'the mermaid line is not an opt-in pin'
|
|
4173
|
+
except (OSError, ValueError, SH.Refusal) as error:
|
|
4174
|
+
pin, why = None, str(error)
|
|
4175
|
+
if why:
|
|
4176
|
+
return ('✗', f'docs: Mermaid not checked — {L.REPO / "holders.lock"}: {plain(why)}')
|
|
4177
|
+
target = L.SHARE / 'vendor' / f'mermaid-{pin.version}.min.js'
|
|
4178
|
+
shown, version = home_shown(target, home), plain(pin.version)
|
|
4179
|
+
try:
|
|
4180
|
+
fd = os.open(target, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | getattr(os, 'O_CLOEXEC', 0))
|
|
4181
|
+
except FileNotFoundError:
|
|
4182
|
+
return ('⚠', f'docs: Mermaid {version} not installed ({shown}), so diagrams show as source — run {script} '
|
|
4183
|
+
'(every server install and update runs it)')
|
|
4184
|
+
except OSError as error:
|
|
4185
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file '
|
|
4186
|
+
f'({error.strerror or error}) — {script} replaces it from a verified download')
|
|
4187
|
+
try:
|
|
4188
|
+
if not stat.S_ISREG(os.fstat(fd).st_mode):
|
|
4189
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not a regular file — {script} replaces it '
|
|
4190
|
+
'from a verified download')
|
|
4191
|
+
digest = hashlib.sha256()
|
|
4192
|
+
for chunk in iter(lambda: os.read(fd, 1 << 20), b''):
|
|
4193
|
+
digest.update(chunk)
|
|
4194
|
+
finally:
|
|
4195
|
+
os.close(fd)
|
|
4196
|
+
actual = digest.hexdigest()
|
|
4197
|
+
if actual != pin.binary_sha256:
|
|
4198
|
+
return ('✗', f'docs: Mermaid {version} refused — {shown} is not the pinned build (sha256 {actual[:12]}…, '
|
|
4199
|
+
f'holders.lock {pin.binary_sha256[:12]}…): browsers refuse it by its integrity, so diagrams '
|
|
4200
|
+
f'show as source — {script} replaces it from a verified download')
|
|
4201
|
+
return ('✓', f'docs: Mermaid {version} pinned ({shown}, sha256 {actual[:12]}…)')
|
|
4202
|
+
|
|
4203
|
+
|
|
4204
|
+
def docs_age_line():
|
|
4205
|
+
"""The store's encryption switch (docs_store.age_settings: this environment, else lanes-docsd's drop-in, as
|
|
4206
|
+
`lanes upload` reads it) and the `age` on this PATH. The recipient is never printed."""
|
|
4207
|
+
D = libexec_module('docs_store')
|
|
4208
|
+
binary = D.age_binary()
|
|
4209
|
+
has = f'age at {plain(binary)}' if binary else 'no age binary on PATH'
|
|
4210
|
+
try:
|
|
4211
|
+
recipient, identity = D.age_settings()
|
|
4212
|
+
except D.DocsError as error:
|
|
4213
|
+
return ('✗', f'docs: age encryption NOT usable — {error}: every upload and publish fails until it is fixed '
|
|
4214
|
+
f'({home_shown(D.dropin_dir())})')
|
|
4215
|
+
if recipient is None:
|
|
4216
|
+
return ('', f'docs: age encryption off (LANES_DOCS_AGE_RECIPIENT unset; {has})')
|
|
4217
|
+
if binary is None:
|
|
4218
|
+
return ('✗', 'docs: age encryption on (LANES_DOCS_AGE_RECIPIENT set) but there is no age binary on PATH: every '
|
|
4219
|
+
'upload and publish fails (encrypt-failed) — install age or remove the setting (the server '
|
|
4220
|
+
'install refuses it)')
|
|
4221
|
+
opens = ('lanes-docsd opens them with LANES_DOCS_AGE_IDENTITY' if identity else
|
|
4222
|
+
'no LANES_DOCS_AGE_IDENTITY, so their downloads answer 409 encrypted')
|
|
4223
|
+
return ('✓', f'docs: age encryption on — new store copies are age output ({has}); {opens}')
|
|
4224
|
+
|
|
4225
|
+
|
|
4226
|
+
def docs_doctor(home=None, probe=None):
|
|
4227
|
+
"""`lanes doctor`'s docs lines (W10 W8, LNS-109) -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information), in this
|
|
4228
|
+
order; read-only (one bounded `systemctl --user show`, one loopback connect, file reads):
|
|
4229
|
+
lanes-docsd.service — enabled, active and its loopback port (docs_port) accepting a connection ✓ · enabled but not
|
|
4230
|
+
active, or active while its port refuses ✗ · active but not enabled ⚠ (a reboot loses it) · not enabled ⚠ (the
|
|
4231
|
+
full server install enables it; an update with --update-only enables nothing) · systemctl silent ✗ · no
|
|
4232
|
+
systemctl ⚠. `probe` (port -> bool) stands in for port_answers.
|
|
4233
|
+
the pin gate — `request` ✓ `docs: pinned` · else ⚠ `docs: refuses until the Serve header check is pinned`
|
|
4234
|
+
the store — information: documents and lanes from counts.json, the bytes under docs/, docs.log's size
|
|
4235
|
+
the Mermaid pin — the pinned member digest ✓ · absent ⚠ (run install/enable-docs.sh) · another digest or not a
|
|
4236
|
+
regular file ✗ (refused: browsers refuse it by its integrity) · no valid pin ✗
|
|
4237
|
+
the `age` switch — unset: information · set with an `age` on PATH ✓ · set without one, or no recipient ✗"""
|
|
4238
|
+
home = Path(home) if home is not None else Path.home()
|
|
4239
|
+
return [docs_unit_line(port_answers if probe is None else probe), docs_pin_line(), docs_store_line(home),
|
|
4240
|
+
docs_mermaid_line(home), docs_age_line()]
|
|
4241
|
+
|
|
4242
|
+
|
|
4243
|
+
# ------------------------------------------------------------------ doctor: the release that runs (W9 V3, LNS-85)
|
|
4244
|
+
# `lanes version` says which tree each process last started from; doctor judges it, with the reference `lanes version`
|
|
4245
|
+
# uses (the tree the `current` link names; this tree when there is none). A run record outlives its process, so only a
|
|
4246
|
+
# record whose process runs now is a fault (a stopped economy poller keeps its last one); a lanes process that runs
|
|
4247
|
+
# with NO record started from a release before the records (W9 U0) — what a server install that only `enable --now`s
|
|
4248
|
+
# statusd leaves running. Layouts and units are judged by the tree they resolve to; a layout that names the current tree
|
|
4249
|
+
# by another path than `lanes rebuild` writes now (a pinned pkg/<version> written before W9 U1, which writes through
|
|
4250
|
+
# `current`) is a warning: it runs the current tree, but the next update leaves its views behind.
|
|
4251
|
+
|
|
4252
|
+
# record_tree names by the script their process runs, in the argv Lanes starts it with (every release so far): the
|
|
4253
|
+
# units' `/usr/bin/python3 -S -E <tree>/libexec/<script> …` and the sidebar wrapper's `python3 -S -E
|
|
4254
|
+
# <tree>/libexec/sidebar.py …`. Anything else that names the file — an editor, a pager, a grep, the script run by
|
|
4255
|
+
# hand another way — and a one-shot that never records (RUN_ONESHOTS) is not that daemon (DEF-1785). studio_host
|
|
4256
|
+
# records a oneshot step (install/enable-studio-host.sh), never a running process.
|
|
4257
|
+
RUN_SCRIPTS = {'statusd': 'statusd.py', 'enrold': 'enrold.py', 'docsd': 'docsd.py', 'notify': 'notify.py',
|
|
4258
|
+
'economy': 'economy_poll.py', 'sidebar': 'sidebar.py'}
|
|
4259
|
+
RUN_INTERPRETER = re.compile(r'python3(?:\.\d+)?') # argv[0]'s name, by path or not: python3, a versioned python3.N
|
|
4260
|
+
RUN_FLAGS = ['-S', '-E'] # the interpreter flags of every launch, in this order
|
|
4261
|
+
# The arguments that make a run a one-shot that never records, as each record_tree comment names them. statusd reads
|
|
4262
|
+
# its first argument itself (`sys.argv[1] == '--once'`); notify and economy_poll use argparse, which takes the option
|
|
4263
|
+
# anywhere and by any prefix it can tell apart (`--dry`; none of these prefixes another option of its script).
|
|
4264
|
+
RUN_ONESHOTS = {'statusd': ('--once',), 'notify': ('--test',), 'economy': ('--once', '--dry-run')}
|
|
4265
|
+
RUN_FIRST_ARGUMENT = {'statusd'}
|
|
4266
|
+
RUN_UNITS = {'statusd': 'lanes-statusd.service', 'enrold': 'lanes-enrold.service', 'docsd': 'lanes-docsd.service',
|
|
4267
|
+
'notify': 'lanes-notify.service', 'economy': 'lanes-economy.service'} # what restarts each daemon; a sidebar restarts with its view
|
|
4268
|
+
LAYOUT_COMMAND = re.compile(r'command=("(?:[^"\\]|\\.)*")') # q(): a JSON string
|
|
4269
|
+
LAYOUT_FILES = L.LAYOUT_FILES # the tree files a generated layout runs
|
|
4270
|
+
UNIT_ENTRY = re.compile(r'"@REPO@/([^"]+)"') # a unit template's tree file (install/services.py)
|
|
4271
|
+
CURRENT_SHOWN = '~/' + L.CURRENT_LINK
|
|
4272
|
+
|
|
4273
|
+
|
|
4274
|
+
def run_name(argv):
|
|
4275
|
+
"""The RUN_SCRIPTS name of the daemon a process with this `argv` is, or None. It is one only in the shape Lanes
|
|
4276
|
+
starts it: argv[0] a python3 interpreter, then exactly RUN_FLAGS, then an absolute `<tree>/libexec/<script>`, and
|
|
4277
|
+
no one-shot among the script's arguments, read as that script reads them."""
|
|
4278
|
+
if len(argv) < 4 or not RUN_INTERPRETER.fullmatch(os.path.basename(argv[0])) or argv[1:3] != RUN_FLAGS \
|
|
4279
|
+
or not os.path.isabs(argv[3]):
|
|
4280
|
+
return None
|
|
4281
|
+
name = next((name for name, script in RUN_SCRIPTS.items() if argv[3].endswith('/libexec/' + script)), None)
|
|
4282
|
+
args, oneshots = argv[4:], RUN_ONESHOTS.get(name, ())
|
|
4283
|
+
if name in RUN_FIRST_ARGUMENT:
|
|
4284
|
+
oneshot = bool(args) and args[0] in oneshots
|
|
4285
|
+
else:
|
|
4286
|
+
oneshot = any(len(word) > 2 and flag.startswith(word) for word in args for flag in oneshots)
|
|
4287
|
+
return None if oneshot else name
|
|
4288
|
+
|
|
4289
|
+
|
|
4290
|
+
def lanes_processes(table):
|
|
4291
|
+
"""The RUN_SCRIPTS names of this account's daemons that run now, from a `ps -eo pid=,comm=,args=` table. A row that
|
|
4292
|
+
names a /libexec/<script> is only a candidate: its argv is read from /proc/<pid>/cmdline — the words as exec'd, a
|
|
4293
|
+
tree path with a space intact, where the table joins them with spaces — and judged by run_name, for a /proc/<pid>
|
|
4294
|
+
that is this account's (gone since the sweep: none)."""
|
|
4295
|
+
found = set()
|
|
4296
|
+
for line in table.splitlines():
|
|
4297
|
+
pid, _, rest = line.strip().partition(' ')
|
|
4298
|
+
if not pid.isdigit() or not any(f'/libexec/{script}' in rest for script in RUN_SCRIPTS.values()):
|
|
4299
|
+
continue
|
|
4300
|
+
try:
|
|
4301
|
+
if (PROC / pid).stat().st_uid != os.geteuid():
|
|
4302
|
+
continue
|
|
4303
|
+
cmdline = (PROC / pid / 'cmdline').read_bytes()
|
|
4304
|
+
except OSError:
|
|
4305
|
+
continue
|
|
4306
|
+
words = cmdline[:-1] if cmdline.endswith(b'\0') else cmdline # each word NUL-terminated
|
|
4307
|
+
name = run_name([os.fsdecode(word) for word in words.split(b'\0')])
|
|
4308
|
+
if name is not None:
|
|
4309
|
+
found.add(name)
|
|
4310
|
+
return found
|
|
4311
|
+
|
|
4312
|
+
|
|
4313
|
+
def current_doctor(report, home=None):
|
|
4314
|
+
"""(mark, text): `current` names a tree that exists, whose VERSION agrees with its staged `<version>` directory name
|
|
4315
|
+
(an npm-staged tree, L.staged_tree: `pkg/<version>`, or `$LANES_PKG_ROOT/<version>` with the stamp) and its npm
|
|
4316
|
+
stamp, and that this lanes runs."""
|
|
4317
|
+
tree, current = os.path.realpath(report['tree']), report['current']
|
|
4318
|
+
if current is None:
|
|
4319
|
+
return ('⚠', f'no {CURRENT_SHOWN} link (the server installer writes it): layouts, units and run records are '
|
|
4320
|
+
f'compared with this tree, {tree_shown(tree, home)}')
|
|
4321
|
+
shown = tree_shown(current, home)
|
|
4322
|
+
if not os.path.isdir(current):
|
|
4323
|
+
return ('✗', f'current → {shown} does not exist — re-run the server installer')
|
|
4324
|
+
version = L.tree_version(current)
|
|
4325
|
+
if version is None:
|
|
4326
|
+
return ('✗', f'current → {shown} has no VERSION — re-run the server installer')
|
|
4327
|
+
named = Path(current).name if L.staged_tree(current) else version
|
|
4328
|
+
stamped = (L.tree_stamp(current) or {}).get('version') or version
|
|
4329
|
+
if (named, stamped) != (version, version):
|
|
4330
|
+
return ('✗', f'current → {shown}, but its VERSION says {plain(version)}'
|
|
4331
|
+
+ (f' and its npm stamp {plain(stamped)}' if stamped != version else '')
|
|
4332
|
+
+ ' — re-run the server installer')
|
|
4333
|
+
if current != tree:
|
|
4334
|
+
return ('✗', f'current → {shown} ({plain(version)}), but this lanes runs {tree_shown(tree, home)} '
|
|
4335
|
+
f'({plain(report["version"])}) — run {CURRENT_SHOWN}/bin/lanes; when ~/.local/bin/lanes is this '
|
|
4336
|
+
'one, re-run the server installer')
|
|
4337
|
+
return ('✓', f'current → {shown} ({plain(version)}), the release this lanes runs')
|
|
4338
|
+
|
|
4339
|
+
|
|
4340
|
+
def records_doctor(report, table, reference, home=None):
|
|
4341
|
+
"""[(mark, text)]: every lanes process that runs has a record of the current code. A record whose process is not
|
|
4342
|
+
running is information, never a fault; without a process table (`table` None) every daemon's counts."""
|
|
4343
|
+
running = report['running']
|
|
4344
|
+
live = lanes_processes(table) if table is not None else set(RUN_SCRIPTS)
|
|
4345
|
+
faults, stopped, restart = [], [], []
|
|
4346
|
+
for name, record in running.items():
|
|
4347
|
+
if record['current']:
|
|
4348
|
+
continue
|
|
4349
|
+
if name in live:
|
|
4350
|
+
faults.append(f'{plain(name)} {record_said(record, reference, home)}')
|
|
4351
|
+
restart.append(name)
|
|
4352
|
+
else:
|
|
4353
|
+
stopped.append(f'{plain(name)} {record_said(record, reference, home, past=True)}')
|
|
4354
|
+
if table is not None:
|
|
4355
|
+
for name in [name for name in RUN_ORDER if name in live and name not in running]:
|
|
4356
|
+
faults.append(f'{name} runs without a run record (started by an older release)')
|
|
4357
|
+
restart.append(name)
|
|
4358
|
+
lines = []
|
|
4359
|
+
if faults:
|
|
4360
|
+
units = [RUN_UNITS[name] for name in RUN_ORDER if name in restart and name in RUN_UNITS]
|
|
4361
|
+
hints = ([f"systemctl --user restart {' '.join(units)}"] if units else []) + \
|
|
4362
|
+
(['lanes rebuild <device>'] if 'sidebar' in restart else [])
|
|
4363
|
+
lines.append(('✗', 'processes run the current release — ' + '; '.join(faults)
|
|
4364
|
+
+ ('' if table is not None else ' (the process table was not read: every older record counts)')
|
|
4365
|
+
+ (' — ' + '; '.join(hints) if hints else '')))
|
|
4366
|
+
else:
|
|
4367
|
+
fresh = [plain(name) for name, record in running.items() if record['current']]
|
|
4368
|
+
lines.append(('✓', 'processes run the current release' + (': ' + ' '.join(fresh) if fresh else '')))
|
|
4369
|
+
if stopped:
|
|
4370
|
+
lines.append(('', 'not running now, so not judged (a record stays until its process starts again): '
|
|
4371
|
+
+ '; '.join(stopped)))
|
|
4372
|
+
return lines
|
|
4373
|
+
|
|
4374
|
+
|
|
4375
|
+
def layout_commands(text):
|
|
4376
|
+
"""The pane commands of a generated layout's text, in order, as q() wrote them (a JSON string after `command=`);
|
|
4377
|
+
one that does not decode is skipped."""
|
|
4378
|
+
commands = []
|
|
4379
|
+
for match in LAYOUT_COMMAND.finditer(text):
|
|
4380
|
+
try:
|
|
4381
|
+
commands.append(json.loads(match.group(1)))
|
|
4382
|
+
except ValueError:
|
|
4383
|
+
continue
|
|
4384
|
+
return commands
|
|
4385
|
+
|
|
4386
|
+
|
|
4387
|
+
def layout_trees(text):
|
|
4388
|
+
"""The trees a generated layout runs: each pane command naming one of LAYOUT_FILES, without that file."""
|
|
4389
|
+
trees = []
|
|
4390
|
+
for command in layout_commands(text):
|
|
4391
|
+
for name in LAYOUT_FILES:
|
|
4392
|
+
if command.endswith('/' + name) and command[:-len(name) - 1] not in trees:
|
|
4393
|
+
trees.append(command[:-len(name) - 1])
|
|
4394
|
+
return trees
|
|
4395
|
+
|
|
4396
|
+
|
|
4397
|
+
def layout_tree_now():
|
|
4398
|
+
"""The tree path `lanes rebuild` writes into a layout now, read from the generator itself: L.CURRENT (W9 U1) —
|
|
4399
|
+
the `current` link for a staged tree it names, else this tree's own path."""
|
|
4400
|
+
return layout_trees('\n'.join(workspace_tab_kdl('probe', {'id': 'probe', 'title': 'probe'})))[0]
|
|
4401
|
+
|
|
4402
|
+
|
|
4403
|
+
def layouts_doctor(reference, home=None):
|
|
4404
|
+
"""(mark, text): each device's layout (devices/<device>.json with a layouts/<device>.kdl) runs the current tree
|
|
4405
|
+
(✗ with `lanes rebuild <device>`), through the path `lanes rebuild` writes now (⚠ when it names the current tree by
|
|
4406
|
+
another path). A layout that runs no tree file (or none at all) says nothing."""
|
|
4407
|
+
now, faults, pinned, fresh = layout_tree_now(), [], {}, []
|
|
4408
|
+
for meta in sorted((L.STATE / 'devices').glob('*.json')):
|
|
4409
|
+
device = plain(meta.stem)
|
|
4410
|
+
try:
|
|
4411
|
+
trees = layout_trees((L.STATE / 'layouts' / f'{meta.stem}.kdl').read_text())
|
|
4412
|
+
except (OSError, ValueError):
|
|
4413
|
+
continue
|
|
4414
|
+
if not trees:
|
|
4415
|
+
continue
|
|
4416
|
+
other = [tree for tree in trees if os.path.realpath(tree) != reference]
|
|
4417
|
+
if other:
|
|
4418
|
+
ran = [plain(L.tree_version(tree) or tree_shown(tree, home)) for tree in other]
|
|
4419
|
+
faults.append((device, f"{device} runs {', '.join(ran)}"))
|
|
4420
|
+
continue
|
|
4421
|
+
fresh.append(device)
|
|
4422
|
+
for tree in trees:
|
|
4423
|
+
if tree != now:
|
|
4424
|
+
pinned.setdefault(tree_shown(tree, home), []).append(device)
|
|
4425
|
+
if faults:
|
|
4426
|
+
return ('✗', 'view layouts run the current release — ' + '; '.join(said for _device, said in faults)
|
|
4427
|
+
+ ' — ' + '; '.join(f'lanes rebuild {device}' for device, _said in faults))
|
|
4428
|
+
if pinned:
|
|
4429
|
+
devices = list(dict.fromkeys(device for named in pinned.values() for device in named))
|
|
4430
|
+
return ('⚠', '; '.join(f"view layouts of {' '.join(named)} name {shown} by its own path"
|
|
4431
|
+
for shown, named in pinned.items())
|
|
4432
|
+
+ f', not as lanes rebuild writes them now ({tree_shown(now, home)}): the next update leaves those '
|
|
4433
|
+
'views on it — ' + '; '.join(f'lanes rebuild {device}' for device in devices))
|
|
4434
|
+
return ('✓', 'view layouts run the current release' + (': ' + ' '.join(fresh) if fresh else ''))
|
|
4435
|
+
|
|
4436
|
+
|
|
4437
|
+
def unit_dirs(home=None):
|
|
4438
|
+
"""Where install/services.py writes the units: the user manager's $XDG_CONFIG_HOME/systemd/user (this shell's
|
|
4439
|
+
XDG_CONFIG_HOME stands in for it: doctor never asks systemctl here) and ~/.config/systemd/user."""
|
|
4440
|
+
home = Path(home) if home is not None else Path.home()
|
|
4441
|
+
xdg = os.environ.get('XDG_CONFIG_HOME', '')
|
|
4442
|
+
folders = ([Path(xdg) / 'systemd/user'] if xdg.startswith('/') else []) + [home / '.config/systemd/user']
|
|
4443
|
+
return list(dict.fromkeys(folders))
|
|
4444
|
+
|
|
4445
|
+
|
|
4446
|
+
def unit_trees(home=None):
|
|
4447
|
+
"""{unit: tree or None} for each lanes unit of this release whose template's ExecStart runs a tree file
|
|
4448
|
+
(`"@REPO@/<file>"`) and that is installed (the first of unit_dirs that has it): the tree its ExecStart names,
|
|
4449
|
+
unescaped as services.unit_value escaped it, or None when it names no `<tree>/<file>` (written by hand)."""
|
|
4450
|
+
found = {}
|
|
4451
|
+
for template in sorted((L.REPO / 'systemd').glob('lanes*.service')):
|
|
4452
|
+
entry = next((UNIT_ENTRY.search(line) for line in template.read_text().splitlines()
|
|
4453
|
+
if line.startswith('ExecStart=') and UNIT_ENTRY.search(line)), None)
|
|
4454
|
+
if entry is None:
|
|
4455
|
+
continue
|
|
4456
|
+
pattern = re.compile(r'"((?:[^"\\]|\\.)*)/' + re.escape(entry.group(1)) + '"')
|
|
4457
|
+
for folder in unit_dirs(home):
|
|
4458
|
+
try:
|
|
4459
|
+
text = (folder / template.name).read_text()
|
|
4460
|
+
except (OSError, ValueError):
|
|
4461
|
+
continue
|
|
4462
|
+
match = next((pattern.search(line) for line in text.splitlines()
|
|
4463
|
+
if line.startswith('ExecStart=') and pattern.search(line)), None)
|
|
4464
|
+
found[template.name] = (re.sub(r'\\(.)', r'\1', match.group(1).replace('$$', '$').replace('%%', '%'))
|
|
4465
|
+
if match else None)
|
|
4466
|
+
break
|
|
4467
|
+
return found
|
|
4468
|
+
|
|
4469
|
+
|
|
4470
|
+
def units_doctor(reference, home=None):
|
|
4471
|
+
"""(mark, text): every installed lanes unit's ExecStart runs the current tree (✗: re-run the server installer)."""
|
|
4472
|
+
trees = unit_trees(home)
|
|
4473
|
+
if not trees:
|
|
4474
|
+
shown = ' or '.join(tree_shown(folder, home) for folder in unit_dirs(home))
|
|
4475
|
+
return ('⚠', f'lanes units not checked: none in {shown} (the server installer writes them)')
|
|
4476
|
+
other = {}
|
|
4477
|
+
for unit, tree in trees.items():
|
|
4478
|
+
if tree is None:
|
|
4479
|
+
other.setdefault(None, []).append(unit)
|
|
4480
|
+
elif os.path.realpath(tree) != reference:
|
|
4481
|
+
other.setdefault(plain(L.tree_version(tree) or tree_shown(tree, home)), []).append(unit)
|
|
4482
|
+
if not other:
|
|
4483
|
+
return ('✓', f'lanes units run the current release ({len(trees)} units)')
|
|
4484
|
+
said = []
|
|
4485
|
+
for ran, units in other.items():
|
|
4486
|
+
names, one = ' '.join(units[:3]) + (' …' if len(units) > 3 else ''), len(units) == 1
|
|
4487
|
+
said.append(f"{names} name{'s' if one else ''} no lanes tree" if ran is None else
|
|
4488
|
+
f"{len(units)} unit{'' if one else 's'} run{'s' if one else ''} {ran} ({names})")
|
|
4489
|
+
return ('✗', 'lanes units run the current release — ' + '; '.join(said) + ' — re-run the server installer')
|
|
4490
|
+
|
|
4491
|
+
|
|
4492
|
+
def release_doctor(table, home=None):
|
|
4493
|
+
"""`lanes doctor`'s release lines -> [(mark, text)], mark ✓ | ✗ | ⚠ | '' (information) (W9 V3, LNS-85): `current`
|
|
4494
|
+
and its VERSION, the run records against the processes in `table` (`ps -eo pid=,comm=,args=`; None when ps did
|
|
4495
|
+
not answer), the device layouts, the units, then the updates (W9 U3: libexec/update.py doctor_line, with the unit
|
|
4496
|
+
folders it looks for lanes-update.timer's enable link in; no line without an installed release). Read-only: files
|
|
4497
|
+
and the one process table doctor already swept."""
|
|
4498
|
+
report = version_report(home=home)
|
|
4499
|
+
reference = report['current'] or os.path.realpath(report['tree'])
|
|
4500
|
+
updates = libexec_module('update').doctor_line(L.STATE, home, units=unit_dirs(home))
|
|
4501
|
+
return [current_doctor(report, home), *records_doctor(report, table, reference, home),
|
|
4502
|
+
layouts_doctor(reference, home), units_doctor(reference, home), *([updates] if updates else [])]
|
|
4503
|
+
|
|
4504
|
+
|
|
2355
4505
|
def cmd_doctor(_args):
|
|
2356
4506
|
problems = 0
|
|
2357
4507
|
|
|
@@ -2449,6 +4599,18 @@ def cmd_doctor(_args):
|
|
|
2449
4599
|
print(' view sessions:', ', '.join(sorted(live_sessions())) or 'none')
|
|
2450
4600
|
except (OSError, L.LanesError) as error:
|
|
2451
4601
|
check(False, 'zellij reachable', str(error))
|
|
4602
|
+
# Cockpit v3 R2 (LNS-75): a device nobody owns follows no profile — its look and seen marks stay its own. Owner as
|
|
4603
|
+
# device_owner derives it (a member record, its machine's for a window, or a recorded claim); a warning, never a ✗.
|
|
4604
|
+
ownerless = []
|
|
4605
|
+
for meta_file in sorted((L.STATE / 'devices').glob('*.json')):
|
|
4606
|
+
meta = L.read_json(meta_file)
|
|
4607
|
+
if (L.IDENT.match(meta_file.stem) and len(meta_file.stem) <= 32
|
|
4608
|
+
and not device_owner(meta_file.stem, meta if isinstance(meta, dict) else {})):
|
|
4609
|
+
ownerless.append(meta_file.stem)
|
|
4610
|
+
if ownerless:
|
|
4611
|
+
print(f'⚠ devices without an owner: {", ".join(ownerless[:8])}{" …" if len(ownerless) > 8 else ""} — each keeps '
|
|
4612
|
+
'its look and seen marks to itself; `lanes device claim <device> --login <login>` names one (`lanes keys '
|
|
4613
|
+
'owner <device> <login>` when members/ holds its key)')
|
|
2452
4614
|
stale_pickers = stale_picker_layouts()
|
|
2453
4615
|
check(not stale_pickers, 'phone picker panes report their own title (DEF-1531)',
|
|
2454
4616
|
'built from a layout that names the picker pane, so the phone shows no counts; upgrade-session does not '
|
|
@@ -2458,6 +4620,19 @@ def cmd_doctor(_args):
|
|
|
2458
4620
|
print(f'⚠ {text}')
|
|
2459
4621
|
else:
|
|
2460
4622
|
check(mark == '✓', text)
|
|
4623
|
+
# The docs service (W10 W8, LNS-109): lanes-docsd and its port, the pin gate, the store, the Mermaid pin, `age`.
|
|
4624
|
+
for mark, text in docs_doctor():
|
|
4625
|
+
if mark in ('⚠', ''):
|
|
4626
|
+
print(f'{mark or " "} {text}')
|
|
4627
|
+
else:
|
|
4628
|
+
check(mark == '✓', text)
|
|
4629
|
+
# The release that runs (W9 V3): `current`, the run records against the one process-table sweep above, the
|
|
4630
|
+
# device layouts, the units.
|
|
4631
|
+
for mark, text in release_doctor(table if rc == 0 else None):
|
|
4632
|
+
if mark in ('⚠', ''):
|
|
4633
|
+
print(f'{mark or " "} {text}')
|
|
4634
|
+
else:
|
|
4635
|
+
check(mark == '✓', text)
|
|
2461
4636
|
print('PRESSURE')
|
|
2462
4637
|
health = L.compute_health()
|
|
2463
4638
|
print('\n'.join(pressure_lines(health, L.read_json(L.STATE / 'containers.json') or {})))
|
|
@@ -2472,7 +4647,11 @@ def cmd_rename(args):
|
|
|
2472
4647
|
Rename a lane's display name.
|
|
2473
4648
|
- Unregistered sessions: writes user-sessions metadata + updates resolve.dynamic.
|
|
2474
4649
|
- 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.
|
|
4650
|
+
- Repository lanes: writes a displayNames override to the workspace manifest and syncs the registry. Two layers
|
|
4651
|
+
name such a lane (LNS-33): this host override wins over the container's own `lanes.terminals[<name>]
|
|
4652
|
+
.displayName` in terminals.json, which rename never writes; renaming back to the lane's default (that
|
|
4653
|
+
container name when declared, else the terminal's title) removes the override. The id never changes. A
|
|
4654
|
+
container name another lane of the board answers to is not that lane's default: the reader drops it.
|
|
2476
4655
|
"""
|
|
2477
4656
|
if len(args) < 2:
|
|
2478
4657
|
raise L.LanesError('usage: lanes rename <lane-id> <new-name>')
|
|
@@ -2512,8 +4691,11 @@ def cmd_rename(args):
|
|
|
2512
4691
|
if not lane.get('repo'):
|
|
2513
4692
|
raise L.LanesError('No repository for this lane; set displayNames in the workspace manifest manually')
|
|
2514
4693
|
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
|
-
|
|
4694
|
+
SA.rename_registered(L.manifest_path(workspace_obj['id']), lane['repo'], registry_name, new_name,
|
|
4695
|
+
declared_display=lane.get('declared_display'))
|
|
4696
|
+
_failed, refused = sync_registry() # rebuild registry so all consumers see the new name
|
|
4697
|
+
if refused: # saved and published, except where a container refused its own `~/.lz/registry.json` (DEF-1796)
|
|
4698
|
+
raise L.ProjectionRefused('Rename', refused)
|
|
2517
4699
|
print(f"renamed registered lane {lane_id!r} ({registry_name!r}) → {new_name!r}")
|
|
2518
4700
|
return 0
|
|
2519
4701
|
|
|
@@ -2555,11 +4737,9 @@ def preview_renderer():
|
|
|
2555
4737
|
return 'less -R (no glow on the view server PATH; ./install.sh --server installs the pinned one)'
|
|
2556
4738
|
|
|
2557
4739
|
|
|
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
|
|
4740
|
+
def view_listings(device):
|
|
4741
|
+
"""(tabs, panes): `zellij action list-tabs -j` and `list-panes -j -a` of `device`'s view session. Raises when either
|
|
4742
|
+
did not answer or is not JSON: an unknown listing is never an empty one."""
|
|
2563
4743
|
listings = []
|
|
2564
4744
|
for action in ('list-tabs', 'list-panes'):
|
|
2565
4745
|
rc, out, err = zellij(['-s', device, 'action', action, '-j'] + (['-a'] if action == 'list-panes' else []), 10)
|
|
@@ -2569,10 +4749,26 @@ def focused_view(device):
|
|
|
2569
4749
|
listings.append(json.loads(out))
|
|
2570
4750
|
except ValueError:
|
|
2571
4751
|
raise L.LanesError(f'zellij answered {action} for {device} with something that is not JSON')
|
|
2572
|
-
|
|
2573
|
-
|
|
2574
|
-
|
|
2575
|
-
|
|
4752
|
+
return listings[0], listings[1]
|
|
4753
|
+
|
|
4754
|
+
|
|
4755
|
+
def active_tab(tabs):
|
|
4756
|
+
"""The tab_id list-tabs marks active, else None (a session no client is attached to marks none, measured)."""
|
|
4757
|
+
return next((t.get('tab_id') for t in tabs if isinstance(t, dict) and t.get('active')), None)
|
|
4758
|
+
|
|
4759
|
+
|
|
4760
|
+
def active_tab_panes(device, listings=None):
|
|
4761
|
+
"""[(kind, (view device, workspace, slot), focused)] for the board panes of `device`'s active tab, TILED only (a
|
|
4762
|
+
floating pane — the Run pane asking — is never one): kind `view` (a viewport's view.sh; its slot) or `sidebar`
|
|
4763
|
+
(slot 'main'). Two bounded zellij listings (view_listings), or `listings` when the caller already holds them. While
|
|
4764
|
+
a floating pane holds the focus, zellij 0.45.1 still marks the tiled pane under it focused (measured 2026-10-06,
|
|
4765
|
+
cockpit v3 S4), which is what `focused` reports."""
|
|
4766
|
+
import shlex
|
|
4767
|
+
tabs, panes = listings or view_listings(device)
|
|
4768
|
+
active = active_tab(tabs)
|
|
4769
|
+
# By tree file, never by this CLI's tree path (W9 U1): the panes may run the pkg/<version> tree of a layout built
|
|
4770
|
+
# before, the `current` link of one built since, or a checkout (lanes_lib.tree_file_at).
|
|
4771
|
+
found = []
|
|
2576
4772
|
for pane in panes:
|
|
2577
4773
|
if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('is_floating') or pane.get('tab_id') != active:
|
|
2578
4774
|
continue
|
|
@@ -2580,17 +4776,29 @@ def focused_view(device):
|
|
|
2580
4776
|
args = shlex.split(pane.get('terminal_command') or pane.get('pane_command') or '')
|
|
2581
4777
|
except ValueError:
|
|
2582
4778
|
continue
|
|
2583
|
-
for script in (
|
|
2584
|
-
|
|
4779
|
+
for script in ('libexec/view.sh', 'libexec/sidebar'):
|
|
4780
|
+
at = L.tree_file_at(args, script)
|
|
4781
|
+
tail = args[at + 1:] if at is not None else []
|
|
2585
4782
|
if len(tail) < 2 or not all(L.IDENT.match(part) for part in tail[:2]):
|
|
2586
4783
|
continue
|
|
2587
|
-
if script ==
|
|
4784
|
+
if script == 'libexec/view.sh':
|
|
2588
4785
|
slot = tail[2] if len(tail) > 2 and tail[2] in SLOTS else 'main'
|
|
2589
|
-
|
|
4786
|
+
found.append(('view', (tail[0], tail[1], slot), bool(pane.get('is_focused'))))
|
|
2590
4787
|
else:
|
|
2591
|
-
|
|
4788
|
+
found.append(('sidebar', (tail[0], tail[1], 'main'), bool(pane.get('is_focused'))))
|
|
4789
|
+
return found
|
|
4790
|
+
|
|
4791
|
+
|
|
4792
|
+
def focused_view(device, listings=None):
|
|
4793
|
+
"""(view device, workspace, slot) behind the focused pane of `device`'s active tab. The floating preview pane holds
|
|
4794
|
+
the focus while this runs, so the TILED view pane zellij marks focused decides; a focused sidebar (or none
|
|
4795
|
+
marked) means the tab's first viewport. A tab without a board (Host, tools) is said, never guessed. `listings`:
|
|
4796
|
+
view_listings(device) when the caller already holds them (`lanes edit` reads them once, for its guard first)."""
|
|
4797
|
+
panes = active_tab_panes(device, listings)
|
|
4798
|
+
views = [(not focused, SLOTS.index(where[2]), where) for kind, where, focused in panes if kind == 'view']
|
|
2592
4799
|
if views:
|
|
2593
4800
|
return min(views)[2]
|
|
4801
|
+
boards = [where for kind, where, _focused in panes if kind == 'sidebar']
|
|
2594
4802
|
if boards:
|
|
2595
4803
|
return boards[0]
|
|
2596
4804
|
raise L.LanesError('the focused tab shows no board (Host or a tool tab): nothing to preview')
|
|
@@ -2707,7 +4915,8 @@ def cmd_preview(args):
|
|
|
2707
4915
|
# closes when it exits. They used to walk the focus left three times and type J/K into whatever pane that reached
|
|
2708
4916
|
# (the sidebar, or — on a tab without one — the lane itself). Now nothing is typed and no focus moves: the viewport
|
|
2709
4917
|
# 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
|
|
4918
|
+
# (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
|
|
4919
|
+
# with an owner, in that person's profile too (cockpit v3 R3, mark_profile_seen).
|
|
2711
4920
|
|
|
2712
4921
|
|
|
2713
4922
|
def step_lane(args, step):
|
|
@@ -2738,12 +4947,34 @@ def step_lane(args, step):
|
|
|
2738
4947
|
status = (L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}
|
|
2739
4948
|
agent = (status.get(lane_id) or {}).get('agent') if isinstance(status, dict) else None
|
|
2740
4949
|
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))
|
|
4950
|
+
seen = L.record_seen(L.STATE / 'clients' / f'{view_device}.seen.json', lane_id, agent.get('since'), set(status))
|
|
4951
|
+
mark_profile_seen(view_device, lane_id, seen[lane_id], verb)
|
|
2742
4952
|
if not L.show_lane(str(L.STATE / 'views' / view_device), workspace, slot, lane_id):
|
|
2743
4953
|
print('view not running yet')
|
|
2744
4954
|
return 0
|
|
2745
4955
|
|
|
2746
4956
|
|
|
4957
|
+
def mark_profile_seen(device, lane_id, at, verb):
|
|
4958
|
+
"""`lanes next|prev` opened a lane with an agent on `device` (cockpit v3 R3, LNS-76): on a device with an owner the
|
|
4959
|
+
mark and `last_opened` {lane, device, at} also go to that person's profile (users.update as the device, its 1 s
|
|
4960
|
+
lock), as the sidebar's mark_seen writes them, so their other devices leave Done on the next tick. The device's own
|
|
4961
|
+
seen file was already written. A dropped write (a busy or unreadable profile) is said on stderr and changes nothing
|
|
4962
|
+
else: the lane is still shown, and seen on this device. -> False only for that drop."""
|
|
4963
|
+
login = profile_for(device)
|
|
4964
|
+
if not login:
|
|
4965
|
+
return True
|
|
4966
|
+
users = libexec_module('users')
|
|
4967
|
+
|
|
4968
|
+
def mark(profile):
|
|
4969
|
+
profile['seen'][lane_id] = max(at, profile['seen'].get(lane_id, at))
|
|
4970
|
+
profile['last_opened'] = {'lane': lane_id, 'device': device, 'at': at}
|
|
4971
|
+
if users.update(login, device, mark):
|
|
4972
|
+
return True
|
|
4973
|
+
print(f"lanes {verb}: {login}'s profile was busy or cannot be written (`lanes user show {login}` says why) — "
|
|
4974
|
+
f'{lane_id} is seen on {device} only', file=sys.stderr)
|
|
4975
|
+
return False
|
|
4976
|
+
|
|
4977
|
+
|
|
2747
4978
|
def cmd_next(args):
|
|
2748
4979
|
return step_lane(args, +1)
|
|
2749
4980
|
|
|
@@ -2752,13 +4983,333 @@ def cmd_prev(args):
|
|
|
2752
4983
|
return step_lane(args, -1)
|
|
2753
4984
|
|
|
2754
4985
|
|
|
2755
|
-
|
|
4986
|
+
# ------------------------------------------------------------------ sidebar-key
|
|
4987
|
+
# Ctrl-Alt-/ and Ctrl-Alt-u (zellij/config.kdl, cockpit v3 S4) walk the focus left to the sidebar, then run
|
|
4988
|
+
# `lanes sidebar-key filter|upload --focused` in a floating pane that closes when it exits. They used to end in
|
|
4989
|
+
# WriteChars "/" / "U", which typed into whatever pane held the focus by then — on a tab without a sidebar (Host,
|
|
4990
|
+
# Throttle, the phone's view tab) the pane itself, a shell or an agent's input. Now nothing is typed: the key is a
|
|
4991
|
+
# file, views/<device>/<ws>.sidebar.cmd (DM-LANES-SIDEBAR-CMD, lanes_lib.send_sidebar_key), written only for a sidebar
|
|
4992
|
+
# on the active tab that holds the focus; that sidebar acts on it within a tick and before the next key it reads.
|
|
4993
|
+
|
|
4994
|
+
|
|
4995
|
+
def cmd_sidebar_key(args):
|
|
4996
|
+
key = args[0] if args else ''
|
|
4997
|
+
if key not in L.SIDEBAR_KEYS or '--focused' not in args[1:]:
|
|
4998
|
+
print('usage: lanes sidebar-key filter|upload --focused [--device D] (Ctrl-Alt-/ and Ctrl-Alt-u run this)')
|
|
4999
|
+
return 2
|
|
5000
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
5001
|
+
try:
|
|
5002
|
+
device = device_name(device)
|
|
5003
|
+
sidebars = [(where, focused) for kind, where, focused in active_tab_panes(device) if kind == 'sidebar']
|
|
5004
|
+
if not sidebars:
|
|
5005
|
+
raise L.LanesError('this tab has no sidebar (Host or a tool tab): nothing to do')
|
|
5006
|
+
(view_device, workspace, _slot), focused = sidebars[0]
|
|
5007
|
+
if not focused:
|
|
5008
|
+
raise L.LanesError('the sidebar does not have the focus (it was in a floating pane): nothing to do')
|
|
5009
|
+
except L.LanesError as error:
|
|
5010
|
+
print(f'lanes sidebar-key: {error}')
|
|
5011
|
+
return 1
|
|
5012
|
+
L.send_sidebar_key(L.STATE / 'views' / view_device, workspace, key)
|
|
5013
|
+
return 0
|
|
5014
|
+
|
|
5015
|
+
|
|
5016
|
+
# ------------------------------------------------------------------ edit
|
|
5017
|
+
# Ctrl-Alt-e (zellij/config.kdl, W9 E1, LNS-95) runs `lanes edit --focused` in a floating pane named EDIT_PANE. Zellij
|
|
5018
|
+
# has no single-instance Run (LaunchOrFocus is for plugins only) and catches Ctrl-Alt-e in every unlocked mode,
|
|
5019
|
+
# whichever pane has the focus (the open editor's included), so each press opens one more pane. Two editors on one
|
|
5020
|
+
# file lose work: the one quit last writes its stale buffer over the other's save, and only vim and nvim warn (swap
|
|
5021
|
+
# files). So the verb is its own guard, before it resolves anything: one Edit pane per view session (DEF-1793).
|
|
5022
|
+
|
|
5023
|
+
EDIT_PANE = 'Edit' # the binding's `name`; test_server_cli EditWiring pins the two to one value
|
|
5024
|
+
|
|
5025
|
+
|
|
5026
|
+
def edit_pane(pane):
|
|
5027
|
+
"""Whether a list-panes entry is an Edit pane: a live terminal pane zellij started as `lanes edit` (it keeps the
|
|
5028
|
+
command it started, renamed or embedded since), or a floating one named EDIT_PANE (zellij keeps a given name over
|
|
5029
|
+
the title its program sets: measured 0.45.1, an OSC 2 title left `title` "Edit"). A TILED pane titled Edit is
|
|
5030
|
+
not one: a pane without a given name shows whatever title its program set."""
|
|
5031
|
+
if not isinstance(pane, dict) or pane.get('is_plugin') or pane.get('exited'):
|
|
5032
|
+
return False
|
|
5033
|
+
words = str(pane.get('terminal_command') or '').split() # zellij joins the Run argv with spaces (measured)
|
|
5034
|
+
if len(words) >= 2 and Path(words[0]).name == 'lanes' and words[1] == 'edit':
|
|
5035
|
+
return True
|
|
5036
|
+
return bool(pane.get('is_floating')) and pane.get('title') == EDIT_PANE
|
|
5037
|
+
|
|
5038
|
+
|
|
5039
|
+
def edit_pane_open(device, tabs, panes):
|
|
5040
|
+
"""The Edit pane already open in `device`'s view session that this process does not run in, or None; one in the
|
|
5041
|
+
active tab first. This process's own pane is ZELLIJ_PANE_ID (zellij gives each pane it starts its bare terminal
|
|
5042
|
+
id, measured 0.45.1) when it runs in that session; run by hand, or with --device from another session, every
|
|
5043
|
+
Edit pane counts. Every other one counts whichever is older: two presses at once both stand down (press again)
|
|
5044
|
+
rather than both open an editor."""
|
|
5045
|
+
own = os.environ.get('ZELLIJ_PANE_ID', '') if os.environ.get('ZELLIJ_SESSION_NAME') == device else ''
|
|
5046
|
+
own = own.removeprefix('terminal_')
|
|
5047
|
+
others = [pane for pane in (panes if isinstance(panes, list) else [])
|
|
5048
|
+
if edit_pane(pane) and (not own or str(pane.get('id')) != own)]
|
|
5049
|
+
active = active_tab(tabs)
|
|
5050
|
+
others.sort(key=lambda pane: active is None or pane.get('tab_id') != active)
|
|
5051
|
+
return others[0] if others else None
|
|
5052
|
+
|
|
5053
|
+
|
|
5054
|
+
def edit_defer(device, tabs, pane):
|
|
5055
|
+
"""Hand this press to the Edit pane already open. In the active tab it gets the focus and this pane closes (exit
|
|
5056
|
+
0, nothing said); in another tab, or when zellij will not focus it, it is named and the pane waits for Enter
|
|
5057
|
+
(exit 1). Either way nothing opens."""
|
|
5058
|
+
active = active_tab(tabs)
|
|
5059
|
+
if active is not None and pane.get('tab_id') == active:
|
|
5060
|
+
rc, _out, err = zellij(['-s', device, 'action', 'focus-pane-id', f"terminal_{pane.get('id')}"], 5)
|
|
5061
|
+
if rc == 0:
|
|
5062
|
+
return 0
|
|
5063
|
+
where = f'in this tab, and zellij did not focus it ({err.strip() or "no answer"})'
|
|
5064
|
+
else:
|
|
5065
|
+
name = next((t.get('name') for t in tabs if isinstance(t, dict) and t.get('tab_id') == pane.get('tab_id')), '')
|
|
5066
|
+
where = f'in tab "{name}"' if name else 'in another tab'
|
|
5067
|
+
print(f'An Edit pane is already open {where}. One per view: quit that editor, then press Ctrl-Alt-e again.')
|
|
5068
|
+
preview_hold()
|
|
5069
|
+
return 1
|
|
5070
|
+
|
|
5071
|
+
|
|
5072
|
+
# Past the guard (W9 E2, LNS-96): the focused lane as `lanes preview` finds it (tmux lanes; the container by the
|
|
5073
|
+
# registry's name and the init pid statusd recorded in containers.json: no docker inspect), the git checkout its
|
|
5074
|
+
# working directory is in (lanes_lib.repo_roots, through /proc/<init>/root), the files below offered in a one-key
|
|
5075
|
+
# picker painted with the sidebar's tokens, and the editor started INSIDE the container as uid 1000 (`coder`) by
|
|
5076
|
+
# execvp, argv only: `docker exec -it -u coder -w <checkout> ... <container> ~/.lz/bin/lane-edit <path>` (E3, LNS-97).
|
|
5077
|
+
# No file is created. `.env.local` is named by its label, never by its path, in anything printed.
|
|
5078
|
+
|
|
5079
|
+
EDIT_FILES = ('AGENTS.md', 'CLAUDE.md') # the checkout's own pair, then the main checkout's when it is another one
|
|
5080
|
+
EDIT_ENV_FILE = '.env.local' # the main checkout's only
|
|
5081
|
+
EDIT_MAIN = ' (main checkout)' # the label suffix of a main-checkout file when the lane is in a worktree
|
|
5082
|
+
LANE_EDIT = L.CONTAINER_HOME + '/.lz/bin/lane-edit' # where cmd_install_container copies container/lane-edit
|
|
5083
|
+
EDIT_QUIT = (b'', b'q', b'Q', b'\x1b', b'\x03', b'\x04') # end of input, q, Esc, Ctrl-C, Ctrl-D (raw mode)
|
|
5084
|
+
|
|
5085
|
+
|
|
5086
|
+
def edit_candidate(root, checkout, name, label):
|
|
5087
|
+
"""{label, root, container_path, present} for the file `name` of the container checkout `checkout`. Present only
|
|
5088
|
+
as a regular file that is no symbolic link, inside the checkout once both are resolved in the container
|
|
5089
|
+
(lanes_lib.container_realpath), on a path without control characters. Any error reads as not present and is never
|
|
5090
|
+
printed (it would carry the path)."""
|
|
5091
|
+
path, base = posixpath.join(checkout, name), checkout
|
|
5092
|
+
present = False
|
|
5093
|
+
if not L.CONTROL_CHARS.search(path):
|
|
5094
|
+
try:
|
|
5095
|
+
base = L.container_realpath(root, checkout)
|
|
5096
|
+
path = posixpath.join(L.container_realpath(root, posixpath.dirname(path)), name)
|
|
5097
|
+
except L.LanesError:
|
|
5098
|
+
base = None
|
|
5099
|
+
found = L.container_lstat(root, path) if base else None
|
|
5100
|
+
present = bool(found and stat.S_ISREG(found.st_mode) and path.startswith(base.rstrip('/') + '/'))
|
|
5101
|
+
return {'label': label, 'root': base or checkout, 'container_path': path, 'present': present}
|
|
5102
|
+
|
|
5103
|
+
|
|
5104
|
+
def edit_candidates(roots, root):
|
|
5105
|
+
"""The files quick edit offers for (worktree root, main checkout), in picker order: AGENTS.md and CLAUDE.md of the
|
|
5106
|
+
worktree, the main checkout's pair when it is another folder, then the main checkout's .env.local."""
|
|
5107
|
+
worktree, main = roots
|
|
5108
|
+
other = main != worktree
|
|
5109
|
+
rows = [(worktree, name, name) for name in EDIT_FILES]
|
|
5110
|
+
rows += [(main, name, name + EDIT_MAIN) for name in EDIT_FILES] if other else []
|
|
5111
|
+
rows.append((main, EDIT_ENV_FILE, EDIT_ENV_FILE + (EDIT_MAIN if other else '')))
|
|
5112
|
+
return [edit_candidate(root, checkout, name, label) for checkout, name, label in rows]
|
|
5113
|
+
|
|
5114
|
+
|
|
5115
|
+
def edit_roots(lane, board, root):
|
|
5116
|
+
"""repo_roots of the lane's working directory (statusd's pane cwd), else of its repository folder, else of its
|
|
5117
|
+
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
|
|
5118
|
+
a checkout."""
|
|
5119
|
+
status = ((L.read_json(L.STATE / 'status.json') or {}).get('lanes') or {}).get(lane['id']) or {}
|
|
5120
|
+
tried, first = [], None
|
|
5121
|
+
for cwd in (status.get('cwd'), lane.get('folder'), board.get('cwd')):
|
|
5122
|
+
if not cwd or cwd in tried:
|
|
5123
|
+
continue
|
|
5124
|
+
tried.append(cwd)
|
|
5125
|
+
try:
|
|
5126
|
+
return L.repo_roots(cwd, root)
|
|
5127
|
+
except L.LanesError as error:
|
|
5128
|
+
first = first or error
|
|
5129
|
+
raise first or L.LanesError('the lane has no usable working directory')
|
|
5130
|
+
|
|
5131
|
+
|
|
5132
|
+
def edit_look(device):
|
|
5133
|
+
"""(SGR table, glyph set) the picker paints with: the device's sidebar look — sidebar.resolve_modes over
|
|
5134
|
+
devices/<device>.json and this pane's environment, then tokens.sgr_table / glyph_set — in the variant its theme
|
|
5135
|
+
names, else the one its sidebar published (clients/<device>.theme), else dark."""
|
|
5136
|
+
tokens = libexec_module('tokens')
|
|
5137
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
5138
|
+
color, theme, glyphs = libexec_module('sidebar').resolve_modes(os.environ, meta if isinstance(meta, dict) else {})
|
|
5139
|
+
if theme not in ('dark', 'light'):
|
|
5140
|
+
try:
|
|
5141
|
+
theme = (L.STATE / 'clients' / f'{device}.theme').read_text().strip()
|
|
5142
|
+
except OSError:
|
|
5143
|
+
theme = ''
|
|
5144
|
+
return tokens.sgr_table(color, theme if theme in ('dark', 'light') else 'dark'), tokens.glyph_set(glyphs)
|
|
5145
|
+
|
|
5146
|
+
|
|
5147
|
+
def edit_picker(name, roots, candidates, tone, glyphs):
|
|
5148
|
+
"""The picker: the lane and its checkout(s), one numbered row per candidate (a missing one dim, `not present`, its
|
|
5149
|
+
number never taken), and the keys. Nothing present: the rows and `Nothing to edit in …` instead of the keys."""
|
|
5150
|
+
reset, dim, sep = tone['reset'], tone['ink-dim'], f" {glyphs['marks']['sep']} "
|
|
5151
|
+
worktree, main = roots
|
|
5152
|
+
lines = [f"{tone['ink']}{tone['bold']}Edit{sep}{plain(name)}{reset}", f'{dim} {plain(worktree)}{reset}']
|
|
5153
|
+
if main != worktree:
|
|
5154
|
+
lines.append(f'{dim} main checkout {plain(main)}{reset}')
|
|
5155
|
+
lines.append('')
|
|
5156
|
+
for number, row in enumerate(candidates, 1):
|
|
5157
|
+
if row['present']:
|
|
5158
|
+
lines.append(f" {tone['accent']}{number}{reset} {tone['ink']}{plain(row['label'])}{reset}")
|
|
5159
|
+
else:
|
|
5160
|
+
lines.append(f"{dim} {number} {plain(row['label'])} not present{reset}")
|
|
5161
|
+
numbers = [str(number) for number, row in enumerate(candidates, 1) if row['present']]
|
|
5162
|
+
lines.append('')
|
|
5163
|
+
if not numbers:
|
|
5164
|
+
lines.append(f'{dim}Nothing to edit in {plain(worktree)}: lanes edit creates no file{reset}')
|
|
5165
|
+
else:
|
|
5166
|
+
keys = f"{numbers[0]} or {glyphs['marks']['enter']}" if len(numbers) == 1 else ' '.join(numbers)
|
|
5167
|
+
lines.append(f'{dim}{keys} edit{sep}q or Esc close{reset}')
|
|
5168
|
+
return '\n'.join(lines) + '\n'
|
|
5169
|
+
|
|
5170
|
+
|
|
5171
|
+
@contextlib.contextmanager
|
|
5172
|
+
def key_reader(stream=None):
|
|
5173
|
+
"""A read() of one key byte (b'' at the end of input). A terminal takes raw input for the duration (one byte, no
|
|
5174
|
+
echo, no Enter needed, Ctrl-C a byte) and is restored after; anything else (a pipe, a test's StringIO) is read as it
|
|
5175
|
+
is. The switch is TCSANOW, never tty.setraw's TCSAFLUSH: a key already typed (while the pane started, or while the
|
|
5176
|
+
picker painted) is the first key read, not discarded (DEF-1836). Output processing stays on (setraw clears OPOST),
|
|
5177
|
+
so the caller paints the picker inside the block: once it shows, the terminal is raw."""
|
|
5178
|
+
stream = stream or sys.stdin
|
|
5179
|
+
try:
|
|
5180
|
+
fd = stream.fileno()
|
|
5181
|
+
terminal = os.isatty(fd)
|
|
5182
|
+
except (AttributeError, OSError, ValueError):
|
|
5183
|
+
terminal = False
|
|
5184
|
+
if not terminal:
|
|
5185
|
+
def read():
|
|
5186
|
+
key = stream.read(1)
|
|
5187
|
+
return key.encode() if isinstance(key, str) else (key or b'')
|
|
5188
|
+
yield read
|
|
5189
|
+
return
|
|
5190
|
+
import termios
|
|
5191
|
+
saved = termios.tcgetattr(fd)
|
|
5192
|
+
raw = termios.tcgetattr(fd) # tty.setraw's input, control and local flags; its oflag and `when` are not taken
|
|
5193
|
+
raw[0] &= ~(termios.BRKINT | termios.ICRNL | termios.INPCK | termios.ISTRIP | termios.IXON)
|
|
5194
|
+
raw[2] = raw[2] & ~(termios.CSIZE | termios.PARENB) | termios.CS8
|
|
5195
|
+
raw[3] &= ~(termios.ECHO | termios.ICANON | termios.IEXTEN | termios.ISIG)
|
|
5196
|
+
raw[6][termios.VMIN], raw[6][termios.VTIME] = 1, 0
|
|
5197
|
+
try:
|
|
5198
|
+
termios.tcsetattr(fd, termios.TCSANOW, raw)
|
|
5199
|
+
yield lambda: os.read(fd, 1)
|
|
5200
|
+
finally:
|
|
5201
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, saved)
|
|
5202
|
+
|
|
5203
|
+
|
|
5204
|
+
def pick_key(candidates, read):
|
|
5205
|
+
"""The index of the candidate one key picks, or None to close. `1`…`5` take that row when it is present; Enter takes
|
|
5206
|
+
the only present one (and nothing when several are); q, Esc, Ctrl-C, Ctrl-D and the end of input close; every
|
|
5207
|
+
other key (a missing row's number included) is ignored."""
|
|
5208
|
+
present = [index for index, row in enumerate(candidates) if row.get('present')]
|
|
5209
|
+
while True:
|
|
5210
|
+
key = read()
|
|
5211
|
+
if key in EDIT_QUIT:
|
|
5212
|
+
return None
|
|
5213
|
+
if key in (b'\r', b'\n') and len(present) == 1:
|
|
5214
|
+
return present[0]
|
|
5215
|
+
if len(key) == 1 and key.isdigit() and int(key) - 1 in present:
|
|
5216
|
+
return int(key) - 1
|
|
5217
|
+
|
|
5218
|
+
|
|
5219
|
+
def edit_editor(device):
|
|
5220
|
+
"""The LANES_EDITOR lane-edit gets: the device's `lanes attach --editor` choice, else this process's LANES_EDITOR
|
|
5221
|
+
(without control characters), else empty (lane-edit then takes the first of micro hx nvim vim vi). The Ctrl-Alt-e
|
|
5222
|
+
pane runs with the view server's environment, which clean_env builds without LANES_EDITOR: there the device's
|
|
5223
|
+
choice is the one that counts; the variable serves a `lanes edit` run by hand."""
|
|
5224
|
+
meta = L.read_json(L.STATE / 'devices' / f'{device}.json')
|
|
5225
|
+
chosen = meta.get('editor') if isinstance(meta, dict) else None
|
|
5226
|
+
if chosen in LOOK_KEYS['--editor'][1] and chosen != 'auto':
|
|
5227
|
+
return chosen
|
|
5228
|
+
value = os.environ.get('LANES_EDITOR', '')
|
|
5229
|
+
return '' if L.CONTROL_CHARS.search(value) else value
|
|
5230
|
+
|
|
5231
|
+
|
|
5232
|
+
def edit_argv(container, root, path, editor, label):
|
|
5233
|
+
"""`docker exec` of lane-edit on `path` in `container`, as uid 1000 in `root`, argv only (no shell)."""
|
|
5234
|
+
return ['docker', 'exec', '-it', '-u', 'coder', '-w', root, '-e', 'TERM=xterm-256color', '-e', 'COLORTERM=truecolor',
|
|
5235
|
+
'-e', 'LANG=C.UTF-8', '-e', f'LANES_EDITOR={editor}', '-e', f'LANES_EDIT_LABEL={label}', container, LANE_EDIT,
|
|
5236
|
+
path]
|
|
5237
|
+
|
|
5238
|
+
|
|
5239
|
+
def edit_container(lane):
|
|
5240
|
+
"""(container, /proc/<init>/root) of a running container, from the registry's name and containers.json's pid;
|
|
5241
|
+
refused when it is not running or lane-edit is not installed there."""
|
|
5242
|
+
container = lane.get('container') or ''
|
|
5243
|
+
info = (L.read_json(L.STATE / 'containers.json') or {}).get(container) or {}
|
|
5244
|
+
root = PROC / str(info.get('pid') or 0) / 'root'
|
|
5245
|
+
try:
|
|
5246
|
+
running = bool(info.get('pid')) and stat.S_ISDIR(os.stat(root).st_mode)
|
|
5247
|
+
except FileNotFoundError:
|
|
5248
|
+
running = False
|
|
5249
|
+
except OSError as error:
|
|
5250
|
+
raise L.LanesError(f'container {container}: its files cannot be read through /proc ({error.strerror})')
|
|
5251
|
+
if not running:
|
|
5252
|
+
raise L.LanesError(f'container {container} is not running')
|
|
5253
|
+
found = L.container_lstat(root, L.container_realpath(root, LANE_EDIT))
|
|
5254
|
+
if not (found and stat.S_ISREG(found.st_mode)):
|
|
5255
|
+
raise L.LanesError(f'lane-edit is not installed in {container}: run lanes install-container {container}')
|
|
5256
|
+
return container, root
|
|
5257
|
+
|
|
5258
|
+
|
|
5259
|
+
def cmd_edit(args):
|
|
5260
|
+
if '--focused' not in args:
|
|
5261
|
+
print('usage: lanes edit --focused [--device D] (the Ctrl-Alt-e floating pane runs this)')
|
|
5262
|
+
return 2
|
|
5263
|
+
device = args[args.index('--device') + 1] if '--device' in args else os.environ.get('ZELLIJ_SESSION_NAME', '')
|
|
5264
|
+
try:
|
|
5265
|
+
device = device_name(device)
|
|
5266
|
+
tabs, panes = view_listings(device)
|
|
5267
|
+
other = edit_pane_open(device, tabs, panes)
|
|
5268
|
+
if other is not None:
|
|
5269
|
+
return edit_defer(device, tabs, other)
|
|
5270
|
+
view_device, workspace, slot = focused_view(device, (tabs, panes))
|
|
5271
|
+
board, lane = L.find_lane(L.load_registry(), lane_shown(view_device, workspace, slot))
|
|
5272
|
+
if lane.get('holder') != 'tmux':
|
|
5273
|
+
raise L.LanesError(f"{lane['name']} is a {lane.get('holder')} lane: quick edit opens tmux lanes only")
|
|
5274
|
+
container, root = edit_container(lane)
|
|
5275
|
+
roots = edit_roots(lane, board, root)
|
|
5276
|
+
candidates = edit_candidates(roots, root)
|
|
5277
|
+
tone, glyphs = edit_look(device)
|
|
5278
|
+
except L.LanesError as error:
|
|
5279
|
+
print(f'lanes edit: {error}')
|
|
5280
|
+
preview_hold()
|
|
5281
|
+
return 1
|
|
5282
|
+
picker = edit_picker(lane['name'], roots, candidates, tone, glyphs)
|
|
5283
|
+
if not any(row['present'] for row in candidates):
|
|
5284
|
+
print(picker, end='', flush=True)
|
|
5285
|
+
preview_hold()
|
|
5286
|
+
return 0
|
|
5287
|
+
with key_reader() as read: # raw before the picker shows: a key pressed once it is on screen is never lost
|
|
5288
|
+
print(picker, end='', flush=True)
|
|
5289
|
+
choice = pick_key(candidates, read)
|
|
5290
|
+
if choice is None:
|
|
5291
|
+
return 0
|
|
5292
|
+
row = candidates[choice]
|
|
5293
|
+
argv = edit_argv(container, row['root'], row['container_path'], edit_editor(device), row['label'])
|
|
5294
|
+
try:
|
|
5295
|
+
os.execvp(argv[0], argv)
|
|
5296
|
+
except OSError as error: # its text names docker, never the file; the label says which file it was
|
|
5297
|
+
print(f"lanes edit: could not start docker for {row['label']}: {error.strerror or 'failed'}")
|
|
5298
|
+
preview_hold()
|
|
5299
|
+
return 1
|
|
5300
|
+
return 0 # only reached when execvp is a test double
|
|
5301
|
+
|
|
5302
|
+
|
|
5303
|
+
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
5304
|
'prepare': cmd_prepare, 'web': cmd_web, 'attach': cmd_attach, 'rebuild': cmd_rebuild, 'ensure': cmd_ensure, 'invariant': cmd_invariant,
|
|
2757
5305
|
'doctor': cmd_doctor, 'pressure': cmd_pressure, 'sockets': cmd_sockets, 'tmp': cmd_tmp,
|
|
2758
|
-
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, '
|
|
2759
|
-
'
|
|
5306
|
+
'guard': cmd_guard, 'rename': cmd_rename, 'register': cmd_register, 'unregister': cmd_unregister,
|
|
5307
|
+
'hooks': cmd_hooks, 'economy': cmd_economy,
|
|
5308
|
+
'preview': cmd_preview, 'edit': cmd_edit, 'device': cmd_device, 'next': cmd_next, 'prev': cmd_prev,
|
|
5309
|
+
'sidebar-key': cmd_sidebar_key, 'studio-host': cmd_studio_host,
|
|
2760
5310
|
'keys': cmd_keys, 'invite': cmd_invite, 'enrol': cmd_enrol, 'offboard': cmd_offboard,
|
|
2761
|
-
'token': cmd_token, 'onboard': cmd_onboard
|
|
5311
|
+
'token': cmd_token, 'onboard': cmd_onboard, 'user': cmd_user, 'lane': cmd_lane, 'here': cmd_here,
|
|
5312
|
+
'version': cmd_version, 'update': cmd_update, 'docs': cmd_docs}
|
|
2762
5313
|
|
|
2763
5314
|
if __name__ == '__main__':
|
|
2764
5315
|
if len(sys.argv) < 2 or sys.argv[1] not in COMMANDS:
|