@vibelord/lanes 1.0.3 → 1.1.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 +55 -15
- package/bin/lanes-install.mjs +8 -2
- package/lib/plan.mjs +34 -1
- package/package.json +2 -2
- package/payload/lanes/README.md +91 -33
- package/payload/lanes/VERSION +1 -1
- package/payload/lanes/bin/lanes +994 -49
- package/payload/lanes/client/lanes-launch +160 -14
- package/payload/lanes/client/ssh.stanza.in +11 -2
- package/payload/lanes/docs/ARCHITECTURE.md +88 -3
- package/payload/lanes/docs/INSTALL.md +297 -43
- package/payload/lanes/docs/RUNBOOK.md +458 -31
- package/payload/lanes/docs/USERGUIDE.md +1612 -0
- package/payload/lanes/install/enable-phone.sh +8 -20
- package/payload/lanes/install/enable-web.sh +2 -2
- package/payload/lanes/install/host-install.sh +6 -0
- package/payload/lanes/install.sh +888 -52
- package/payload/lanes/libexec/enrol.py +695 -0
- package/payload/lanes/libexec/enrold.py +1264 -0
- package/payload/lanes/libexec/keys.py +644 -0
- package/payload/lanes/libexec/lanes_lib.py +14 -6
- package/payload/lanes/libexec/members.py +297 -0
- package/payload/lanes/libexec/notify.py +146 -21
- package/payload/lanes/libexec/qr.py +217 -0
- package/payload/lanes/libexec/site_edit.py +237 -0
- package/payload/lanes/libexec/web_tokens.py +315 -0
- package/payload/lanes/site/README.md +13 -1
- package/payload/lanes/site/beyondwork/site.conf +3 -0
- package/payload/lanes/site/site.example.conf +5 -0
- package/payload/lanes/sudo/one-time-root.sh +1 -1
- package/payload/lanes/systemd/lanes-enrold.service +22 -0
- package/payload/lanes/systemd/lanes-notify.service +4 -4
- package/payload/lanes/systemd/lanes-web.service +4 -0
- package/payload/lanes/web/Caddyfile +10 -0
- package/payload/lanes/web/README.md +8 -2
- package/payload/lanes/workspaces.example/README.md +6 -1
- package/payload/manifest.json +7 -4
|
@@ -0,0 +1,217 @@
|
|
|
1
|
+
"""qr.py — a QR code of one short text, standard library only (W8 B5, LNS-49).
|
|
2
|
+
|
|
3
|
+
`lanes web --token` draws the view's private URL with it when `qrencode` is not installed, so a phone camera opens the
|
|
4
|
+
page; the login token is never encoded (it does not exist until zellij prints it, after the exec). Scope, chosen for
|
|
5
|
+
that one use: byte mode, error correction level M, versions 1-6 (up to 106 bytes, 41x41 modules), all eight masks
|
|
6
|
+
scored with the four penalty rules of ISO/IEC 18004 §7.8.3 (the rule-3 form zxing uses), lowest penalty wins.
|
|
7
|
+
|
|
8
|
+
encode(text, mask=None) -> Symbol(version, mask, modules) modules[row][col] is True for a dark module;
|
|
9
|
+
ValueError past 106 UTF-8 bytes
|
|
10
|
+
render(modules) -> str half blocks (▀▄█), the light modules lit on a black background with a 4-module quiet
|
|
11
|
+
zone: line for line the framing of `qrencode -t ANSIUTF8`
|
|
12
|
+
penalty(modules) -> int the mask-selection score
|
|
13
|
+
|
|
14
|
+
python3 libexec/qr.py TEXT prints the code (a manual check; nothing calls it).
|
|
15
|
+
"""
|
|
16
|
+
import sys
|
|
17
|
+
from collections import namedtuple
|
|
18
|
+
|
|
19
|
+
Symbol = namedtuple('Symbol', 'version mask modules')
|
|
20
|
+
|
|
21
|
+
# ISO/IEC 18004 table 9, level M: version -> (error correction codewords per block, blocks, data codewords per block).
|
|
22
|
+
# Every block of a version has the same size at level M for versions 1-6.
|
|
23
|
+
LEVEL_M = {1: (10, 1, 16), 2: (16, 1, 28), 3: (26, 1, 44), 4: (18, 2, 32), 5: (24, 2, 43), 6: (16, 4, 27)}
|
|
24
|
+
FORMAT_LEVEL_M = 0b00 # the two error-correction bits of the format information (L 01, M 00, Q 11, H 10)
|
|
25
|
+
MASKS = (lambda r, c: (r + c) % 2 == 0, lambda r, c: r % 2 == 0, lambda r, c: c % 3 == 0,
|
|
26
|
+
lambda r, c: (r + c) % 3 == 0, lambda r, c: (r // 2 + c // 3) % 2 == 0,
|
|
27
|
+
lambda r, c: (r * c) % 2 + (r * c) % 3 == 0, lambda r, c: ((r * c) % 2 + (r * c) % 3) % 2 == 0,
|
|
28
|
+
lambda r, c: ((r + c) % 2 + (r * c) % 3) % 2 == 0)
|
|
29
|
+
QUIET = 4 # modules of light border on every side (ISO 18004 §6.3.8)
|
|
30
|
+
ON, OFF = '\x1b[40;37;1m', '\x1b[0m' # black background, bright white blocks: scans on a dark or a light theme
|
|
31
|
+
HALF = ' ▄▀█' # index = 2 * (upper module light) + (lower module light)
|
|
32
|
+
|
|
33
|
+
# GF(256) with the QR polynomial x^8 + x^4 + x^3 + x^2 + 1
|
|
34
|
+
_EXP, _LOG = [0] * 510, [0] * 256
|
|
35
|
+
_x = 1
|
|
36
|
+
for _i in range(255):
|
|
37
|
+
_EXP[_i] = _EXP[_i + 255] = _x
|
|
38
|
+
_LOG[_x] = _i
|
|
39
|
+
_x <<= 1
|
|
40
|
+
if _x & 0x100:
|
|
41
|
+
_x ^= 0x11D
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _mul(a, b):
|
|
45
|
+
return 0 if a == 0 or b == 0 else _EXP[_LOG[a] + _LOG[b]]
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def _ecc(data, degree):
|
|
49
|
+
"""The Reed-Solomon remainder: data * x^degree mod the generator (x - α^0)…(x - α^(degree-1))."""
|
|
50
|
+
generator = [1]
|
|
51
|
+
for i in range(degree):
|
|
52
|
+
generator = [a ^ _mul(b, _EXP[i]) for a, b in zip(generator + [0], [0] + generator)]
|
|
53
|
+
remainder = [0] * degree
|
|
54
|
+
for word in data:
|
|
55
|
+
factor = word ^ remainder[0]
|
|
56
|
+
remainder = remainder[1:] + [0]
|
|
57
|
+
for i in range(degree):
|
|
58
|
+
remainder[i] ^= _mul(generator[i + 1], factor)
|
|
59
|
+
return remainder
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def _version(length):
|
|
63
|
+
for version, (_ecc_words, blocks, per_block) in LEVEL_M.items():
|
|
64
|
+
if length <= blocks * per_block - 2: # 4 mode bits + 8 count bits leave room for (codewords - 1.5) bytes
|
|
65
|
+
return version
|
|
66
|
+
raise ValueError(f'{length} bytes do not fit a version 1-6 QR code at level M (106 at most)')
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def _codewords(data, version):
|
|
70
|
+
"""Mode, count, bytes, terminator and pad codewords, split into blocks, each with its error correction, interleaved."""
|
|
71
|
+
ecc_words, blocks, per_block = LEVEL_M[version]
|
|
72
|
+
capacity = blocks * per_block * 8
|
|
73
|
+
bits = [int(b) for b in f'0100{len(data):08b}'] + [int(b) for byte in data for b in f'{byte:08b}']
|
|
74
|
+
bits += [0] * min(4, capacity - len(bits))
|
|
75
|
+
bits += [0] * (-len(bits) % 8)
|
|
76
|
+
words = [int(''.join(map(str, bits[i:i + 8])), 2) for i in range(0, len(bits), 8)]
|
|
77
|
+
words += [(0xEC, 0x11)[i % 2] for i in range(blocks * per_block - len(words))]
|
|
78
|
+
data_blocks = [words[b * per_block:(b + 1) * per_block] for b in range(blocks)]
|
|
79
|
+
ecc_blocks = [_ecc(block, ecc_words) for block in data_blocks]
|
|
80
|
+
return ([block[i] for i in range(per_block) for block in data_blocks]
|
|
81
|
+
+ [block[i] for i in range(ecc_words) for block in ecc_blocks])
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
def _function_patterns(version):
|
|
85
|
+
"""(modules, reserved): finders with separators, timing, the alignment pattern, the dark module, and the format
|
|
86
|
+
areas reserved (drawn per mask)."""
|
|
87
|
+
size = 17 + 4 * version
|
|
88
|
+
modules = [[False] * size for _ in range(size)]
|
|
89
|
+
reserved = [[False] * size for _ in range(size)]
|
|
90
|
+
|
|
91
|
+
def put(row, col, dark):
|
|
92
|
+
modules[row][col], reserved[row][col] = dark, True
|
|
93
|
+
for top, left in ((0, 0), (0, size - 7), (size - 7, 0)):
|
|
94
|
+
for dr in range(-1, 8):
|
|
95
|
+
for dc in range(-1, 8):
|
|
96
|
+
if 0 <= top + dr < size and 0 <= left + dc < size:
|
|
97
|
+
ring = max(abs(dr - 3), abs(dc - 3)) # 0-1 centre, 2 light ring, 3 outer ring, 4 separator
|
|
98
|
+
put(top + dr, left + dc, ring in (0, 1, 3))
|
|
99
|
+
for i in range(8, size - 8):
|
|
100
|
+
put(6, i, i % 2 == 0)
|
|
101
|
+
put(i, 6, i % 2 == 0)
|
|
102
|
+
if version > 1: # versions 2-6 have one alignment pattern, centred at (size - 7, size - 7)
|
|
103
|
+
centre = size - 7
|
|
104
|
+
for dr in range(-2, 3):
|
|
105
|
+
for dc in range(-2, 3):
|
|
106
|
+
put(centre + dr, centre + dc, max(abs(dr), abs(dc)) != 1)
|
|
107
|
+
for row, col in _format_cells(size)[0] + _format_cells(size)[1]:
|
|
108
|
+
put(row, col, False)
|
|
109
|
+
put(size - 8, 8, True) # the dark module
|
|
110
|
+
return modules, reserved
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _format_cells(size):
|
|
114
|
+
"""Where bit i of the 15 format bits goes, bit 0 first, in each of the two copies (ISO 18004 figure 25)."""
|
|
115
|
+
first = [(i, 8) for i in range(6)] + [(7, 8), (8, 8), (8, 7)] + [(8, 5 - i) for i in range(6)]
|
|
116
|
+
second = [(8, size - 1 - i) for i in range(8)] + [(size - 7 + i, 8) for i in range(7)]
|
|
117
|
+
return first, second
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def _format_bits(mask):
|
|
121
|
+
data = FORMAT_LEVEL_M << 3 | mask
|
|
122
|
+
remainder = data << 10
|
|
123
|
+
for shift in range(4, -1, -1): # BCH(15,5), generator x^10 + x^8 + x^5 + x^4 + x^2 + x + 1
|
|
124
|
+
if remainder & (1 << (shift + 10)):
|
|
125
|
+
remainder ^= 0x537 << shift
|
|
126
|
+
return (data << 10 | remainder) ^ 0x5412
|
|
127
|
+
|
|
128
|
+
|
|
129
|
+
def _place(modules, reserved, codewords):
|
|
130
|
+
"""Codeword bits, most significant first, in the two-column zigzag from the bottom-right corner; column 6 (timing)
|
|
131
|
+
is skipped. Remainder bits stay light."""
|
|
132
|
+
size = len(modules)
|
|
133
|
+
bits = [(word >> (7 - i)) & 1 for word in codewords for i in range(8)]
|
|
134
|
+
index = 0
|
|
135
|
+
for right in list(range(size - 1, 7, -2)) + [5, 3, 1]: # right-hand column of each pair; the pairs alternate
|
|
136
|
+
upward = ((right + 1) & 2) == 0 # up, down, up… (the size is odd: size - 1 is even)
|
|
137
|
+
for step in range(size):
|
|
138
|
+
row = size - 1 - step if upward else step
|
|
139
|
+
for col in (right, right - 1):
|
|
140
|
+
if not reserved[row][col] and index < len(bits):
|
|
141
|
+
modules[row][col] = bool(bits[index])
|
|
142
|
+
index += 1
|
|
143
|
+
return modules
|
|
144
|
+
|
|
145
|
+
|
|
146
|
+
def _with_mask(modules, reserved, mask):
|
|
147
|
+
size = len(modules)
|
|
148
|
+
masked = [[modules[r][c] ^ (not reserved[r][c] and MASKS[mask](r, c)) for c in range(size)] for r in range(size)]
|
|
149
|
+
bits = _format_bits(mask)
|
|
150
|
+
for cells in _format_cells(size):
|
|
151
|
+
for i, (row, col) in enumerate(cells):
|
|
152
|
+
masked[row][col] = bool((bits >> i) & 1)
|
|
153
|
+
return masked
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def penalty(modules):
|
|
157
|
+
"""ISO 18004 §7.8.3: N1 runs of five or more (3 + run - 5), N2 2x2 blocks of one colour (3 each), N3 dark-light-
|
|
158
|
+
dark x3-light-dark with four light modules before or after it, outside the symbol counting as light (40 each),
|
|
159
|
+
N4 10 per full 5 % that the dark share is away from 50 %."""
|
|
160
|
+
height, width = len(modules), len(modules[0])
|
|
161
|
+
columns = [[modules[r][c] for r in range(height)] for c in range(width)]
|
|
162
|
+
score = 0
|
|
163
|
+
for line in list(modules) + columns:
|
|
164
|
+
run, previous = 0, None
|
|
165
|
+
for cell in line + [None]:
|
|
166
|
+
if cell == previous:
|
|
167
|
+
run += 1
|
|
168
|
+
continue
|
|
169
|
+
if run >= 5:
|
|
170
|
+
score += 3 + run - 5
|
|
171
|
+
run, previous = 1, cell
|
|
172
|
+
for i in range(len(line) - 6):
|
|
173
|
+
if line[i:i + 7] == [True, False, True, True, True, False, True]:
|
|
174
|
+
before, after = line[max(0, i - 4):i], line[i + 7:i + 11]
|
|
175
|
+
if not any(before) or not any(after):
|
|
176
|
+
score += 40
|
|
177
|
+
for r in range(height - 1):
|
|
178
|
+
for c in range(width - 1):
|
|
179
|
+
if modules[r][c] == modules[r][c + 1] == modules[r + 1][c] == modules[r + 1][c + 1]:
|
|
180
|
+
score += 3
|
|
181
|
+
dark = sum(map(sum, modules))
|
|
182
|
+
return score + 10 * (abs(2 * dark - height * width) * 10 // (height * width))
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def encode(text, mask=None):
|
|
186
|
+
"""The QR code of `text` (its UTF-8 bytes, byte mode, level M) -> Symbol. mask=None picks the mask with the lowest
|
|
187
|
+
penalty (the lowest index on a tie); 0-7 forces one."""
|
|
188
|
+
data = text.encode('utf-8')
|
|
189
|
+
version = _version(len(data))
|
|
190
|
+
modules, reserved = _function_patterns(version)
|
|
191
|
+
_place(modules, reserved, _codewords(data, version))
|
|
192
|
+
if mask is not None:
|
|
193
|
+
return Symbol(version, mask, _with_mask(modules, reserved, mask))
|
|
194
|
+
scored = [(penalty(candidate), m, candidate) for m in range(8)
|
|
195
|
+
for candidate in [_with_mask(modules, reserved, m)]]
|
|
196
|
+
best = min(scored, key=lambda item: (item[0], item[1]))
|
|
197
|
+
return Symbol(version, best[1], best[2])
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def render(modules):
|
|
201
|
+
"""Two module rows per text line; light modules lit, dark ones the black background; a light quiet zone."""
|
|
202
|
+
size = len(modules)
|
|
203
|
+
span = size + 2 * QUIET
|
|
204
|
+
|
|
205
|
+
def light(row, col):
|
|
206
|
+
row, col = row - QUIET, col - QUIET
|
|
207
|
+
return not (0 <= row < size and 0 <= col < size and modules[row][col])
|
|
208
|
+
lines = []
|
|
209
|
+
for row in range(0, span, 2):
|
|
210
|
+
lines.append(ON + ''.join(HALF[2 * light(row, col) + light(row + 1, col)] for col in range(span)) + OFF)
|
|
211
|
+
return '\n'.join(lines) + '\n'
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
if __name__ == '__main__':
|
|
215
|
+
if len(sys.argv) != 2:
|
|
216
|
+
sys.exit('usage: python3 qr.py TEXT')
|
|
217
|
+
sys.stdout.write(render(encode(sys.argv[1]).modules))
|
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Catalog and workspace-manifest writer (LNS-51). Python 3 stdlib only.
|
|
2
|
+
|
|
3
|
+
`add_workspace` appends one board to the site's `catalog.json` and writes its `workspaces.<id>.json` — the two files an
|
|
4
|
+
operator used to edit by hand (lanes/workspaces.example/README.md). `lanes workspaces add` is the CLI over it, and
|
|
5
|
+
`lanes onboard` (LNS-52) calls it first.
|
|
6
|
+
|
|
7
|
+
The readers are `lanes_lib.build_registry` / `manifest_path` / `_workspace_lanes` and `cursor_sync.plan`; every value
|
|
8
|
+
written here passes the rules those readers apply (identifiers, the in-container home-volume path), so a board this
|
|
9
|
+
adds never makes `lanes sync` reject the catalog. Rules:
|
|
10
|
+
|
|
11
|
+
* an id already in the catalog is VERIFIED, never rewritten: agreement is a no-op (`exists`), any difference is
|
|
12
|
+
reported (`mismatch`) and nothing is written. The session is compared only when the caller names one;
|
|
13
|
+
* an existing manifest file is never overwritten; an orphan (no catalog entry) that declares the same repository
|
|
14
|
+
is adopted — that is what a run interrupted between its two writes leaves;
|
|
15
|
+
* a repository belongs to one board: a registry or Cursor projection (same container, same normalised path) that
|
|
16
|
+
another catalog board already names is refused before any write, because `lanes cursor-sync` rewrites it once
|
|
17
|
+
per board that names it, each time with that board's lane ids. An adopted orphan is checked with every
|
|
18
|
+
repository it declares; a board whose manifest cannot be read cannot be checked and refuses the write too;
|
|
19
|
+
* the manifest is written BEFORE the catalog entry that names it, both under `registry_lock()`, so a concurrent
|
|
20
|
+
`lanes sync` (which holds the same lock) reads the old catalog or the new one with its manifest present;
|
|
21
|
+
* the first write keeps `catalog.json.bak-<UTC>Z` beside it (session_actions.rename_registered's idiom);
|
|
22
|
+
* the site directory is the one `lanes_lib.workspaces_dir()` resolves after the write (`site_dir`).
|
|
23
|
+
"""
|
|
24
|
+
import contextlib
|
|
25
|
+
import difflib
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import posixpath
|
|
29
|
+
import time
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import lanes_lib as L
|
|
33
|
+
|
|
34
|
+
RESERVED_TITLES = ('Host', 'Throttle') # the layout's own tabs; lanes_lib.ssh_workspaces reserves the same titles
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def site_dir():
|
|
38
|
+
"""Where a write goes: the directory `lanes_lib.workspaces_dir()` reads ($LANES_WORKSPACES, else
|
|
39
|
+
~/.config/lanes/workspaces holding a catalog, else a single-site checkout's workspaces/ holding one). On a host
|
|
40
|
+
with no catalog anywhere it is ~/.config/lanes/workspaces — where host-install.sh puts the empty catalog and where
|
|
41
|
+
workspaces_dir() looks once the catalog exists. A fresh catalog is never created inside the checkout."""
|
|
42
|
+
if os.environ.get('LANES_WORKSPACES'):
|
|
43
|
+
return Path(os.environ['LANES_WORKSPACES'])
|
|
44
|
+
current = L.workspaces_dir()
|
|
45
|
+
if (current / 'catalog.json').exists():
|
|
46
|
+
return current
|
|
47
|
+
return Path.home() / '.config/lanes/workspaces'
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def _ident(value, label):
|
|
51
|
+
# fullmatch: IDENT's `$` would also accept a trailing newline under re.match.
|
|
52
|
+
if not isinstance(value, str) or not L.IDENT.fullmatch(value):
|
|
53
|
+
raise L.LanesError(f'Invalid {label}: {value!r}')
|
|
54
|
+
return value
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def _repo_path(value):
|
|
58
|
+
"""The repository folder as the container sees it, normalised; under the home volume, because that is the only
|
|
59
|
+
place `lanes_lib.host_path` reaches its `.vscode/terminals.json` from the host."""
|
|
60
|
+
if not isinstance(value, str) or not value.startswith('/') or '..' in value.split('/'):
|
|
61
|
+
raise L.LanesError(f'Invalid repository path: {value!r} (an absolute path inside the container)')
|
|
62
|
+
path = '/' + posixpath.normpath(value).lstrip('/') # normpath keeps a leading '//'
|
|
63
|
+
if not L.SAFE_PATH.fullmatch(path) or not (path + '/').startswith(L.CONTAINER_HOME + '/'):
|
|
64
|
+
raise L.LanesError(f'Invalid repository path: {value!r} (must be under {L.CONTAINER_HOME}, the container home '
|
|
65
|
+
'volume, in letters, digits and ._-/)')
|
|
66
|
+
return path
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def registry_path(repo_path):
|
|
70
|
+
"""`<repo>/.vscode/terminals.json` as the container sees it — the file a board's manifest names (and the one
|
|
71
|
+
`lanes onboard` looks for before it runs cursor-sync). Validates `repo_path` like add_workspace does."""
|
|
72
|
+
return _repo_path(repo_path) + '/.vscode/terminals.json'
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def render(document):
|
|
76
|
+
return json.dumps(document, indent=2, ensure_ascii=False) + '\n'
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _manifest_differences(name, manifest, repo, session):
|
|
80
|
+
"""What an existing manifest says that differs from the board asked for. Extra repositories and extra keys
|
|
81
|
+
(remoteShell, displayNames, monitor, …) are the operator's and never a difference."""
|
|
82
|
+
if not isinstance(manifest, dict):
|
|
83
|
+
return [f'{name}: not a JSON object']
|
|
84
|
+
differences = []
|
|
85
|
+
if session is not None and manifest.get('session') != session:
|
|
86
|
+
differences.append(f"session: {manifest.get('session')!r} (asked {session!r})")
|
|
87
|
+
repos = [r for r in manifest.get('repositories') or [] if isinstance(r, dict)]
|
|
88
|
+
match = next((r for r in repos if r.get('host') == repo['host'] and r.get('registry') == repo['registry']), None)
|
|
89
|
+
if match is None:
|
|
90
|
+
declared = '; '.join(f"{r.get('host')} {r.get('registry')}" for r in repos) or 'no repository'
|
|
91
|
+
differences.append(f"{name}: declares {declared}; asked {repo['host']} {repo['registry']}")
|
|
92
|
+
elif (match.get('prefix') or '') != repo['prefix']:
|
|
93
|
+
differences.append(f"prefix: {match.get('prefix')!r} (asked {repo['prefix']!r})")
|
|
94
|
+
return differences
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _read_manifest(site, name):
|
|
98
|
+
try:
|
|
99
|
+
return L.read_site_json(site / name), None
|
|
100
|
+
except (OSError, ValueError, TypeError, L.LanesError) as error: # read_site_json refuses a JSON array (DEF-1718)
|
|
101
|
+
return None, f'{name}: unreadable ({error})'
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _container_path(value):
|
|
105
|
+
"""A hand-written in-container path the way every reader resolves it (host_path joins it onto a pathlib.Path,
|
|
106
|
+
which drops `//` and `.` segments), so `/home/coder//x/./f` and `/home/coder/x/f` are one file."""
|
|
107
|
+
if not isinstance(value, str) or not value.startswith('/'):
|
|
108
|
+
return value
|
|
109
|
+
return '/' + posixpath.normpath(value).lstrip('/')
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _claimed_files(manifest):
|
|
113
|
+
"""{(container, in-container path): what it is} for every file `lanes cursor-sync` writes for a board: each
|
|
114
|
+
repository's registry and its Cursor projection (`cursor`). cursor_sync.plan walks the catalog and rewrites these
|
|
115
|
+
for each board that names them, with that board's `lane open <board>--<lane>` commands and `lanes` ids — so a file
|
|
116
|
+
two boards name is rewritten twice per run, the last board winning."""
|
|
117
|
+
claims = {}
|
|
118
|
+
repos = manifest.get('repositories') if isinstance(manifest, dict) else None
|
|
119
|
+
for repo in repos if isinstance(repos, list) else []:
|
|
120
|
+
if not isinstance(repo, dict):
|
|
121
|
+
continue
|
|
122
|
+
for key, what in (('registry', 'registry'), ('cursor', 'Cursor projection')):
|
|
123
|
+
if repo.get(key):
|
|
124
|
+
claims.setdefault((repo.get('host'), _container_path(repo[key])), what)
|
|
125
|
+
return claims
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
def _refuse_shared_files(site, catalog_path, boards, manifest_name, manifest):
|
|
129
|
+
"""One repository, one board: refuse when a file the new board makes cursor-sync write is already one it writes
|
|
130
|
+
for another catalog board. A board whose manifest cannot be read is refused too — its files cannot be checked,
|
|
131
|
+
and `lanes sync` already fails on that catalog (build_registry reads every manifest outside its per-board
|
|
132
|
+
error handling)."""
|
|
133
|
+
claims = _claimed_files(manifest)
|
|
134
|
+
for board in boards:
|
|
135
|
+
other, error = _read_manifest(site, board.get('manifest'))
|
|
136
|
+
if error:
|
|
137
|
+
raise L.LanesError(f"Cannot check the repositories of board {board.get('id')!r} in {catalog_path} against "
|
|
138
|
+
f'{manifest_name}: {error}; fix or remove that board first (`lanes sync` fails on it '
|
|
139
|
+
'too)')
|
|
140
|
+
for (container, path), what in _claimed_files(other).items():
|
|
141
|
+
if (container, path) in claims:
|
|
142
|
+
raise L.LanesError(
|
|
143
|
+
f"{container} {path} is already the {what} of board {board.get('id')!r} ({board.get('manifest')}): "
|
|
144
|
+
'a repository belongs to one board, because `lanes cursor-sync` rewrites its terminals.json for '
|
|
145
|
+
f"every board that names it — its lanes are on board {board.get('id')!r}; choose another --repo")
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def add_workspace(workspace_id, title, container, repo_path, session=None, *, write=True):
|
|
149
|
+
"""Add board `workspace_id` (tab `title`) whose lanes come from `<repo_path>/.vscode/terminals.json` inside
|
|
150
|
+
`container`. Returns {status, site, catalog, manifest, differences, changes, backups}: status is `added`,
|
|
151
|
+
`would-add` (write=False: nothing touched, `changes` holds the exact texts a real run writes), `exists` or
|
|
152
|
+
`mismatch` (nothing written; `differences` says what the site already states). Invalid values and collisions
|
|
153
|
+
with another tab (title, SSH workspace id, a manifest file another board names, a repository another board
|
|
154
|
+
declares, a board whose manifest cannot be read) raise LanesError before any write — in a dry run too."""
|
|
155
|
+
_ident(workspace_id, 'workspace id')
|
|
156
|
+
L._title(title)
|
|
157
|
+
_ident(container, 'container')
|
|
158
|
+
path = _repo_path(repo_path)
|
|
159
|
+
if session is not None:
|
|
160
|
+
_ident(session, 'session')
|
|
161
|
+
folder = path.rsplit('/', 1)[1]
|
|
162
|
+
repo = {'id': folder if L.IDENT.fullmatch(folder) else workspace_id, 'host': container,
|
|
163
|
+
'registry': registry_path(path), 'prefix': ''}
|
|
164
|
+
with L.registry_lock() if write else contextlib.nullcontext():
|
|
165
|
+
return _add(site_dir(), workspace_id, title, repo, session, write)
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _add(site, workspace_id, title, repo, session, write):
|
|
169
|
+
catalog_path = site / 'catalog.json'
|
|
170
|
+
manifest_name = f'workspaces.{workspace_id}.json'
|
|
171
|
+
manifest_path = site / manifest_name
|
|
172
|
+
result = {'status': None, 'site': str(site), 'catalog': str(catalog_path), 'manifest': str(manifest_path),
|
|
173
|
+
'differences': [], 'changes': [], 'backups': []}
|
|
174
|
+
if catalog_path.exists():
|
|
175
|
+
catalog_text = catalog_path.read_text()
|
|
176
|
+
catalog = L.read_site_json(catalog_path) # refuses a newer site schema
|
|
177
|
+
else:
|
|
178
|
+
catalog_text, catalog = None, {'version': L.SITE_SCHEMA, 'workspaces': []}
|
|
179
|
+
if not isinstance(catalog, dict):
|
|
180
|
+
raise L.LanesError(f'{catalog_path}: not a JSON object — refusing to rewrite it')
|
|
181
|
+
boards = catalog.setdefault('workspaces', [])
|
|
182
|
+
if not isinstance(boards, list):
|
|
183
|
+
raise L.LanesError(f'{catalog_path}: "workspaces" is not a list — refusing to rewrite it')
|
|
184
|
+
boards_seen = [w for w in boards if isinstance(w, dict)]
|
|
185
|
+
existing = next((w for w in boards_seen if w.get('id') == workspace_id), None)
|
|
186
|
+
if existing is not None:
|
|
187
|
+
differences = [] if existing.get('title') == title else [f"title: {existing.get('title')!r} (asked {title!r})"]
|
|
188
|
+
manifest, error = _read_manifest(site, existing.get('manifest'))
|
|
189
|
+
differences += [error] if error else _manifest_differences(existing.get('manifest'), manifest, repo, session)
|
|
190
|
+
result.update(status='mismatch' if differences else 'exists', differences=differences)
|
|
191
|
+
return result
|
|
192
|
+
ssh = [s for s in catalog.get('sshWorkspaces') or [] if isinstance(s, dict)]
|
|
193
|
+
if any(s.get('id') == workspace_id for s in ssh):
|
|
194
|
+
raise L.LanesError(f'{workspace_id!r} is already an SSH workspace id in {catalog_path}')
|
|
195
|
+
if title in {w.get('title') for w in boards_seen + ssh} | set(RESERVED_TITLES):
|
|
196
|
+
raise L.LanesError(f'Title {title!r} is already a tab (tabs are matched by title); choose another --title')
|
|
197
|
+
if any(w.get('manifest') == manifest_name for w in boards_seen):
|
|
198
|
+
raise L.LanesError(f'{manifest_name} is already the manifest of another board in {catalog_path}')
|
|
199
|
+
changes = []
|
|
200
|
+
if manifest_path.exists():
|
|
201
|
+
manifest, error = _read_manifest(site, manifest_name)
|
|
202
|
+
differences = [error] if error else _manifest_differences(manifest_name, manifest, repo, session)
|
|
203
|
+
if differences:
|
|
204
|
+
result.update(status='mismatch', differences=[
|
|
205
|
+
f'{manifest_name} exists without a catalog entry and was not overwritten'] + differences)
|
|
206
|
+
return result
|
|
207
|
+
else:
|
|
208
|
+
manifest = {'session': session or workspace_id, 'repositories': [repo]}
|
|
209
|
+
changes.append({'path': str(manifest_path), 'before': None, 'after': render(manifest)})
|
|
210
|
+
# After adoption is decided: an adopted orphan's catalog entry makes cursor-sync write EVERY repository it declares.
|
|
211
|
+
_refuse_shared_files(site, catalog_path, boards_seen, manifest_name, manifest)
|
|
212
|
+
boards.append({'id': workspace_id, 'title': title, 'manifest': manifest_name})
|
|
213
|
+
changes.append({'path': str(catalog_path), 'before': catalog_text, 'after': render(catalog)})
|
|
214
|
+
result['changes'] = changes
|
|
215
|
+
if not write:
|
|
216
|
+
result['status'] = 'would-add'
|
|
217
|
+
return result
|
|
218
|
+
for change in changes: # manifest first: the catalog never names a manifest that is not there yet
|
|
219
|
+
target = Path(change['path'])
|
|
220
|
+
if change['before'] is not None and not any(site.glob(target.name + '.bak-*')):
|
|
221
|
+
backup = site / (target.name + '.bak-' + time.strftime('%Y%m%dT%H%M%SZ', time.gmtime()))
|
|
222
|
+
L.atomic_write(backup, change['before'], mode=0o644)
|
|
223
|
+
result['backups'].append(str(backup))
|
|
224
|
+
L.atomic_write(target, change['after'], mode=0o644)
|
|
225
|
+
result['status'] = 'added'
|
|
226
|
+
return result
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def diff_text(result):
|
|
230
|
+
"""The planned or applied changes as unified diffs (what `lanes workspaces add` and `lanes onboard` print)."""
|
|
231
|
+
lines = []
|
|
232
|
+
for change in result['changes']:
|
|
233
|
+
before = change['before'] or ''
|
|
234
|
+
lines += difflib.unified_diff(before.splitlines(True), change['after'].splitlines(True),
|
|
235
|
+
f"{change['path']} ({'now' if change['before'] is not None else 'new file'})",
|
|
236
|
+
f"{change['path']} (with the board)")
|
|
237
|
+
return ''.join(lines)
|