@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.
Files changed (37) hide show
  1. package/README.md +55 -15
  2. package/bin/lanes-install.mjs +8 -2
  3. package/lib/plan.mjs +34 -1
  4. package/package.json +2 -2
  5. package/payload/lanes/README.md +91 -33
  6. package/payload/lanes/VERSION +1 -1
  7. package/payload/lanes/bin/lanes +994 -49
  8. package/payload/lanes/client/lanes-launch +160 -14
  9. package/payload/lanes/client/ssh.stanza.in +11 -2
  10. package/payload/lanes/docs/ARCHITECTURE.md +88 -3
  11. package/payload/lanes/docs/INSTALL.md +297 -43
  12. package/payload/lanes/docs/RUNBOOK.md +458 -31
  13. package/payload/lanes/docs/USERGUIDE.md +1612 -0
  14. package/payload/lanes/install/enable-phone.sh +8 -20
  15. package/payload/lanes/install/enable-web.sh +2 -2
  16. package/payload/lanes/install/host-install.sh +6 -0
  17. package/payload/lanes/install.sh +888 -52
  18. package/payload/lanes/libexec/enrol.py +695 -0
  19. package/payload/lanes/libexec/enrold.py +1264 -0
  20. package/payload/lanes/libexec/keys.py +644 -0
  21. package/payload/lanes/libexec/lanes_lib.py +14 -6
  22. package/payload/lanes/libexec/members.py +297 -0
  23. package/payload/lanes/libexec/notify.py +146 -21
  24. package/payload/lanes/libexec/qr.py +217 -0
  25. package/payload/lanes/libexec/site_edit.py +237 -0
  26. package/payload/lanes/libexec/web_tokens.py +315 -0
  27. package/payload/lanes/site/README.md +13 -1
  28. package/payload/lanes/site/beyondwork/site.conf +3 -0
  29. package/payload/lanes/site/site.example.conf +5 -0
  30. package/payload/lanes/sudo/one-time-root.sh +1 -1
  31. package/payload/lanes/systemd/lanes-enrold.service +22 -0
  32. package/payload/lanes/systemd/lanes-notify.service +4 -4
  33. package/payload/lanes/systemd/lanes-web.service +4 -0
  34. package/payload/lanes/web/Caddyfile +10 -0
  35. package/payload/lanes/web/README.md +8 -2
  36. package/payload/lanes/workspaces.example/README.md +6 -1
  37. 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)