@indigo-gr/claude-code-station 0.3.3 → 0.5.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.
@@ -0,0 +1,432 @@
1
+ /**
2
+ * ccs-doctor.ts — stage 2 of `ccs doctor [--json]`.
3
+ *
4
+ * bin/ccs runs stage 1 in bash (node, tsx, fzf, claude: what must hold before
5
+ * any .ts module can load) and passes the results here; this stage adds what
6
+ * needs Node (the modules themselves, better-sqlite3, repos.yml, the cache)
7
+ * and prints the whole answer. When this stage cannot start or answer, bin/ccs
8
+ * builds the answer from stage 1 alone. Contract:
9
+ * docs/design/machine-interface.md 2.1.
10
+ *
11
+ * tsx ccs-doctor.ts [--json] --node <check> --tsx <check> --fzf <check> --claude <check>
12
+ * <check> = <status>:<code>[:<version>] e.g. ok:ok:24.1.0, fail:fzf_missing
13
+ *
14
+ * Writes nothing, anywhere: no config template, no cache directory, no
15
+ * state.db (it is opened read-only and must already exist), no status file.
16
+ * No network, no update check. Prints no repo name or path, only words,
17
+ * counts, versions and times. Exit code: 0 works, 1 does not, 3 cannot tell.
18
+ *
19
+ * Imports nothing that loads better-sqlite3 up front: that module is one of
20
+ * the things checked, so it is loaded inside the check.
21
+ */
22
+
23
+ import { existsSync } from "node:fs";
24
+ import { createRequire } from "node:module";
25
+ import { isMap, isScalar, isSeq, parseDocument } from "yaml";
26
+
27
+ import { getPaths, readConfig, ConfigError, type CcsConfig, type TerminalApp } from "./ccs-config.ts";
28
+ import { readLogicalGroups } from "./ccs-groups.ts";
29
+ import { envelope, EXIT_CODE, type Status } from "./ccs-machine.ts";
30
+ import { isUiMode, type UiMode } from "./ccs-utils.ts";
31
+ import { pathToFileURL } from "node:url";
32
+
33
+ const COMMAND = "doctor";
34
+
35
+ // ---------------------------------------------------------------------------
36
+ // Checks
37
+ // ---------------------------------------------------------------------------
38
+
39
+ export type CheckStatus = "ok" | "warn" | "fail" | "unknown" | "skipped";
40
+
41
+ export interface Check {
42
+ name: string;
43
+ status: CheckStatus;
44
+ code: string;
45
+ [extra: string]: unknown;
46
+ }
47
+
48
+ /** checks[] order, and which of them decide the answer. */
49
+ export const CHECK_ORDER = [
50
+ "node",
51
+ "tsx",
52
+ "modules",
53
+ "sqlite",
54
+ "fzf",
55
+ "claude",
56
+ "config",
57
+ "config_layout",
58
+ "cache",
59
+ "terminal",
60
+ "groups_dir",
61
+ ] as const;
62
+
63
+ export const REQUIRED = new Set<string>([
64
+ "node",
65
+ "tsx",
66
+ "modules",
67
+ "sqlite",
68
+ "fzf",
69
+ "claude",
70
+ "config",
71
+ ]);
72
+
73
+ const STATUSES = new Set<CheckStatus>(["ok", "warn", "fail", "unknown", "skipped"]);
74
+ const WORD_RE = /^[a-z][a-z0-9_]*$/;
75
+ const VERSION_RE = /^[0-9A-Za-z.+-]{1,32}$/;
76
+
77
+ /** fzf below this cannot run the simple look (--ghost and the labels). */
78
+ const FZF_SIMPLE_MIN: [number, number] = [0, 61];
79
+
80
+ /**
81
+ * Read one stage-1 result from bin/ccs. Anything not in the expected shape
82
+ * counts as a crashed check rather than being trusted.
83
+ */
84
+ export function parseStage1(name: string, raw: string | undefined): Check {
85
+ const parts = (raw ?? "").split(":");
86
+ const [status, code, version] = parts;
87
+ if (
88
+ parts.length < 2 ||
89
+ parts.length > 3 ||
90
+ !STATUSES.has(status as CheckStatus) ||
91
+ !WORD_RE.test(code ?? "") ||
92
+ (version !== undefined && !VERSION_RE.test(version))
93
+ ) {
94
+ return { name, status: "unknown", code: "check_crashed" };
95
+ }
96
+ const check: Check = { name, status: status as CheckStatus, code };
97
+ if (version !== undefined) check.version = version;
98
+ return check;
99
+ }
100
+
101
+ function versionAtLeast(version: string, [maj, min]: [number, number]): boolean {
102
+ const m = /^(\d+)\.(\d+)/.exec(version);
103
+ if (!m) return false;
104
+ const a = Number(m[1]);
105
+ const b = Number(m[2]);
106
+ return a > maj || (a === maj && b >= min);
107
+ }
108
+
109
+ /** `sqlite`: better-sqlite3 loads and opens an in-memory database. */
110
+ export function checkSqlite(): Check {
111
+ try {
112
+ const require = createRequire(import.meta.url);
113
+ const Database = require("better-sqlite3");
114
+ const db = new Database(":memory:");
115
+ db.close();
116
+ return { name: "sqlite", status: "ok", code: "ok" };
117
+ } catch (err) {
118
+ const msg = String((err as Error)?.message ?? err);
119
+ const missing =
120
+ /Cannot find module|Could not locate the bindings file|MODULE_NOT_FOUND|ENOENT/.test(msg) ||
121
+ (err as { code?: string })?.code === "MODULE_NOT_FOUND";
122
+ return {
123
+ name: "sqlite",
124
+ status: "fail",
125
+ code: missing ? "sqlite_native_module_missing" : "sqlite_load_failed",
126
+ };
127
+ }
128
+ }
129
+
130
+ export interface ConfigFindings {
131
+ config: Check;
132
+ layout: Check;
133
+ /** defaults.ui when repos.yml could be read, else undefined. */
134
+ ui: UiMode | undefined;
135
+ /** defaults.terminal when set (repos.yml read), else null. */
136
+ terminal?: TerminalApp | null;
137
+ /** defaults.update_check when repos.yml could be read, else undefined. */
138
+ updateCheck?: boolean;
139
+ /** `groups_dir`: the logical groups' folder, when set. */
140
+ groupsDir?: Check;
141
+ }
142
+
143
+ /**
144
+ * `config` and `config_layout`: repos.yml through ccs's checks, read-only.
145
+ * Only counts come out — which entries are meant is for a person to see in
146
+ * the list's own message.
147
+ */
148
+ export function checkConfig(): ConfigFindings {
149
+ let result: ReturnType<typeof readConfig>;
150
+ try {
151
+ result = readConfig();
152
+ } catch (err) {
153
+ const code = err instanceof ConfigError ? err.code : "config_not_readable";
154
+ return {
155
+ config: { name: "config", status: "fail", code },
156
+ layout: { name: "config_layout", status: "skipped", code: "blocked" },
157
+ ui: undefined,
158
+ };
159
+ }
160
+ if (!result.found) {
161
+ return {
162
+ config: { name: "config", status: "warn", code: "config_missing" },
163
+ layout: { name: "config_layout", status: "skipped", code: "not_set" },
164
+ ui: undefined,
165
+ };
166
+ }
167
+ const { config, warnings, source } = result;
168
+ const unknownKeys = warnings.filter((w) => w.code === "unknown_key").length;
169
+ const pathsMissing = warnings.filter((w) => w.code === "path_missing").length;
170
+ const counts = {
171
+ repos: config.repos.length,
172
+ disabled: config.repos.filter((r) => r.disabled).length,
173
+ paths_missing: pathsMissing,
174
+ unknown_keys: unknownKeys,
175
+ };
176
+ let code = "ok";
177
+ if (unknownKeys > 0) code = "unknown_keys";
178
+ else if (pathsMissing > 0) code = "paths_missing";
179
+ return {
180
+ config: { name: "config", status: code === "ok" ? "ok" : "warn", code, ...counts },
181
+ layout: checkLayout(source),
182
+ ui: config.defaults.ui,
183
+ terminal: config.settings.terminal,
184
+ updateCheck: config.settings.updateCheck,
185
+ groupsDir: checkGroupsDir(config),
186
+ };
187
+ }
188
+
189
+ /**
190
+ * `config_layout`: can a reader that does not use a YAML parser read it?
191
+ * That needs a block list under `repos:`, and every item starting `- name:`
192
+ * with `path:` right after (repos-yml-schema.md, the recommended form).
193
+ */
194
+ export function checkLayout(source: string): Check {
195
+ const doc = parseDocument(source, { logLevel: "error" });
196
+ const repos = isMap(doc.contents) ? doc.contents.get("repos", true) : undefined;
197
+ if (isSeq(repos) && repos.flow) {
198
+ return { name: "config_layout", status: "warn", code: "flow_style_repos" };
199
+ }
200
+ if (isSeq(repos)) {
201
+ for (const item of repos.items) {
202
+ if (!isMap(item) || item.flow) {
203
+ return { name: "config_layout", status: "warn", code: "name_path_not_direct" };
204
+ }
205
+ const keys = item.items.map((p) => (isScalar(p.key) ? p.key.value : undefined));
206
+ const pathAt = keys.indexOf("path");
207
+ if (keys[0] !== "name" || (pathAt !== -1 && pathAt !== 1)) {
208
+ return { name: "config_layout", status: "warn", code: "name_path_not_direct" };
209
+ }
210
+ }
211
+ }
212
+ return { name: "config_layout", status: "ok", code: "ok" };
213
+ }
214
+
215
+ /**
216
+ * `cache`: state.db opens read-only and its schema version is known. Never
217
+ * creates it (a missing file is a warning, and stays missing).
218
+ */
219
+ export async function checkCache(): Promise<Check> {
220
+ const { stateDb } = getPaths();
221
+ if (!existsSync(stateDb)) return { name: "cache", status: "warn", code: "cache_missing" };
222
+ try {
223
+ const { openDb, getCurrentSchemaVersion, CURRENT_SCHEMA_VERSION } = await import(
224
+ "./ccs-db.ts"
225
+ );
226
+ const handle = openDb(stateDb, { readonly: true, skipMigrate: true });
227
+ try {
228
+ const schema = getCurrentSchemaVersion(handle.db);
229
+ if (schema > CURRENT_SCHEMA_VERSION) {
230
+ return { name: "cache", status: "warn", code: "cache_schema_newer", schema };
231
+ }
232
+ return { name: "cache", status: "ok", code: "ok", schema };
233
+ } finally {
234
+ handle.db.close();
235
+ }
236
+ } catch {
237
+ return { name: "cache", status: "warn", code: "cache_open_failed" };
238
+ }
239
+ }
240
+
241
+ // ---------------------------------------------------------------------------
242
+ // The answer
243
+ // ---------------------------------------------------------------------------
244
+
245
+ /**
246
+ * Overall status from the checks (2.1): a required `fail` → failed, with
247
+ * every failing code; else a required `unknown` → unverifiable; else ok
248
+ * (warnings stay ok).
249
+ */
250
+ export function decide(checks: Check[]): { status: Status; codes: string[] } {
251
+ const required = checks.filter((c) => REQUIRED.has(c.name));
252
+ const failed = required.filter((c) => c.status === "fail");
253
+ if (failed.length > 0) return { status: "failed", codes: failed.map((c) => c.code) };
254
+ const unknown = required.filter((c) => c.status === "unknown");
255
+ if (unknown.length > 0) return { status: "unverifiable", codes: unknown.map((c) => c.code) };
256
+ return { status: "ok", codes: ["ok"] };
257
+ }
258
+
259
+ /**
260
+ * `groups_dir`: when defaults.groups_dir is set, can its folder be read, and
261
+ * was every group file usable? Counts only (no file or group name).
262
+ */
263
+ export function checkGroupsDir(config: CcsConfig): Check {
264
+ const dir = config.settings.groupsDir;
265
+ if (!dir) return { name: "groups_dir", status: "skipped", code: "not_set" };
266
+ const read = readLogicalGroups(dir, config.repos);
267
+ if (read.missing) return { name: "groups_dir", status: "warn", code: "groups_dir_missing" };
268
+ const counts = { groups: read.groups.length, files_skipped: read.skipped };
269
+ if (read.skipped > 0) {
270
+ return { name: "groups_dir", status: "warn", code: "group_files_skipped", ...counts };
271
+ }
272
+ return { name: "groups_dir", status: "ok", code: "ok", ...counts };
273
+ }
274
+
275
+ /**
276
+ * `terminal`: when defaults.terminal is set, can `ccs open --new-window` use
277
+ * it here (macOS, and the app's bundle in its fixed place)?
278
+ */
279
+ export async function checkTerminal(app: TerminalApp | null): Promise<Check> {
280
+ if (!app) return { name: "terminal", status: "skipped", code: "not_set" };
281
+ if (process.platform !== "darwin") {
282
+ return { name: "terminal", status: "warn", code: "terminal_unsupported_os" };
283
+ }
284
+ const { terminalAppInstalled } = await import("./ccs-open.ts");
285
+ if (!terminalAppInstalled(app)) {
286
+ return { name: "terminal", status: "warn", code: "terminal_app_missing" };
287
+ }
288
+ return { name: "terminal", status: "ok", code: "ok" };
289
+ }
290
+
291
+ function effectiveUi(configUi: UiMode | undefined): UiMode {
292
+ const env = process.env.CCS_UI;
293
+ if (isUiMode(env)) return env;
294
+ return configUi ?? "classic";
295
+ }
296
+
297
+ export interface DoctorAnswer {
298
+ exitCode: number;
299
+ json: Record<string, unknown>;
300
+ checks: Check[];
301
+ }
302
+
303
+ export async function runDoctor(stage1: Record<string, string | undefined>): Promise<DoctorAnswer> {
304
+ const node = parseStage1("node", stage1.node);
305
+ const tsx = parseStage1("tsx", stage1.tsx);
306
+ const fzf = parseStage1("fzf", stage1.fzf);
307
+ const claude = parseStage1("claude", stage1.claude);
308
+ const modules: Check = { name: "modules", status: "ok", code: "ok" };
309
+ const sqlite = checkSqlite();
310
+ const found = checkConfig();
311
+ const cache: Check =
312
+ sqlite.status === "ok"
313
+ ? await checkCache()
314
+ : { name: "cache", status: "skipped", code: "blocked" };
315
+ const ui = effectiveUi(found.ui);
316
+
317
+ // The simple look needs a newer fzf than the list does; bin/ccs then opens
318
+ // the classic look. Only stage 2 knows the look, so the warning is added here.
319
+ if (
320
+ fzf.status === "ok" &&
321
+ ui === "simple" &&
322
+ typeof fzf.version === "string" &&
323
+ !versionAtLeast(fzf.version, FZF_SIMPLE_MIN)
324
+ ) {
325
+ fzf.status = "warn";
326
+ fzf.code = "fzf_too_old_for_simple";
327
+ }
328
+
329
+ const terminal = await checkTerminal(found.terminal ?? null);
330
+ const groupsDir: Check = found.groupsDir ?? {
331
+ name: "groups_dir",
332
+ status: "skipped",
333
+ code: found.config.status === "fail" ? "blocked" : "not_set",
334
+ };
335
+
336
+ const checks = [
337
+ node,
338
+ tsx,
339
+ modules,
340
+ sqlite,
341
+ fzf,
342
+ claude,
343
+ found.config,
344
+ found.layout,
345
+ cache,
346
+ terminal,
347
+ groupsDir,
348
+ ];
349
+ const { status, codes } = decide(checks);
350
+ const updateOffByEnv = (process.env.CCS_NO_UPDATE_CHECK ?? "") !== "";
351
+ const updateOffByConfig = !updateOffByEnv && found.updateCheck === false;
352
+ const json = envelope(
353
+ COMMAND,
354
+ status,
355
+ codes.map((code) => ({ vocab: "ccs.health" as const, code })),
356
+ {
357
+ installed: "installed",
358
+ running: "not_applicable",
359
+ checks,
360
+ platform: { os: process.platform, arch: process.arch },
361
+ settings: {
362
+ ui,
363
+ update_check: updateOffByEnv || updateOffByConfig ? "off" : "on",
364
+ update_check_off_by: updateOffByEnv ? "env" : updateOffByConfig ? "config" : null,
365
+ },
366
+ },
367
+ );
368
+ // envelope() puts the outer keys first; the Health frame's installed/running
369
+ // follow status, before reasons.
370
+ const ordered: Record<string, unknown> = {};
371
+ for (const key of [
372
+ "schema_version",
373
+ "product_id",
374
+ "product_version",
375
+ "command",
376
+ "written_at",
377
+ "status",
378
+ "installed",
379
+ "running",
380
+ "reasons",
381
+ "checks",
382
+ "platform",
383
+ "settings",
384
+ ]) {
385
+ ordered[key] = json[key];
386
+ }
387
+ return { exitCode: EXIT_CODE[status], json: ordered, checks };
388
+ }
389
+
390
+ /** The people's table (outside the contract: wording may change). */
391
+ export function humanTable(answer: DoctorAnswer): string {
392
+ const lines = answer.checks.map((c) => {
393
+ const extra = Object.entries(c)
394
+ .filter(([k]) => !["name", "status", "code"].includes(k))
395
+ .map(([k, v]) => `${k}=${String(v)}`)
396
+ .join(" ");
397
+ const code = c.code === "ok" ? "" : c.code;
398
+ return ` ${c.name.padEnd(14)}${c.status.padEnd(9)}${code}${extra ? (code ? " " : "") + extra : ""}`.trimEnd();
399
+ });
400
+ const status = String(answer.json.status);
401
+ const verdict =
402
+ status === "ok" ? "ccs works" : status === "failed" ? "ccs does not work" : "could not tell";
403
+ return `ccs doctor: ${verdict} (${status})\n${lines.join("\n")}\n`;
404
+ }
405
+
406
+ // ---------------------------------------------------------------------------
407
+ // Entry
408
+ // ---------------------------------------------------------------------------
409
+
410
+ const STAGE1_FLAGS = ["--node", "--tsx", "--fzf", "--claude"];
411
+
412
+ // pathToFileURL, not `file://${argv[1]}`: a folder with a space (or any
413
+ // character a URL escapes) in its name would never match.
414
+ if (import.meta.url === pathToFileURL(process.argv[1] ?? "").href) {
415
+ const argv = process.argv.slice(2);
416
+ let json = false;
417
+ const stage1: Record<string, string | undefined> = {};
418
+ for (let i = 0; i < argv.length; i++) {
419
+ const a = argv[i];
420
+ if (a === "--json") json = true;
421
+ else if (STAGE1_FLAGS.includes(a) && i + 1 < argv.length) stage1[a.slice(2)] = argv[++i];
422
+ // Anything else came from bin/ccs, which validated the user's arguments.
423
+ }
424
+ process.once("SIGINT", () => {
425
+ // bin/ccs answers an interrupted doctor itself; leave without output.
426
+ process.exit(130);
427
+ });
428
+ runDoctor(stage1).then((answer) => {
429
+ process.stdout.write(json ? JSON.stringify(answer.json) + "\n" : humanTable(answer));
430
+ process.exitCode = answer.exitCode;
431
+ });
432
+ }
@@ -0,0 +1,167 @@
1
+ /**
2
+ * ccs-groups.ts — logical groups from definition files other tools keep.
3
+ *
4
+ * `defaults.groups_dir` (off unless set) names a folder of `*.json` files,
5
+ * each describing one group of projects that may live in unrelated folders.
6
+ * ccs reads five keys and nothing else — `version`, `id`, `label`, `kind`,
7
+ * `members[].path` — and only `kind: logical` groups: a `physical` group's
8
+ * projects already nest by path in the list. ccs never writes these files.
9
+ * Contract: docs/design/machine-interface.md chapter 5.
10
+ *
11
+ * The scan reads them and stores the result in the cache's meta table
12
+ * (`logical_groups`); the list reads that. `ccs list --json` and `ccs doctor`
13
+ * read the folder themselves.
14
+ */
15
+
16
+ import { readdirSync, readFileSync, realpathSync, statSync } from "node:fs";
17
+ import { join, resolve } from "node:path";
18
+
19
+ import { expandPath } from "./ccs-config.ts";
20
+ import { SHELL_METACHARS, stripControlChars } from "./ccs-sanitize.ts";
21
+
22
+ /** At most this many files are read (the first ones in name order). */
23
+ export const GROUP_FILES_MAX = 200;
24
+ /** A file larger than this is skipped. */
25
+ export const GROUP_FILE_BYTES_MAX = 256 * 1024;
26
+ const ID_MAX = 64;
27
+ const LABEL_MAX = 80;
28
+
29
+ export interface LogicalGroup {
30
+ id: string;
31
+ label: string;
32
+ /** repos.yml names, in the order of the file's members[]. */
33
+ members: string[];
34
+ /** members[] entries that matched no repos.yml entry. */
35
+ unregistered: number;
36
+ }
37
+
38
+ export interface GroupsRead {
39
+ /** The folder could not be read (missing, not a folder, no permission). */
40
+ missing: boolean;
41
+ groups: LogicalGroup[];
42
+ /** Files skipped: broken JSON, too large, an unknown version or shape, a repeated id. */
43
+ skipped: number;
44
+ }
45
+
46
+ /** What a repos.yml entry offers for matching: its name and resolved path. */
47
+ export interface RepoPath {
48
+ name: string;
49
+ path: string;
50
+ }
51
+
52
+ function realOr(p: string): string {
53
+ try {
54
+ return realpathSync(p);
55
+ } catch {
56
+ return p;
57
+ }
58
+ }
59
+
60
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
61
+ return typeof v === "object" && v !== null && !Array.isArray(v);
62
+ }
63
+
64
+ /** The group's id, or null when it is not usable. */
65
+ function readId(v: unknown): string | null {
66
+ if (typeof v !== "string" || v.length === 0 || [...v].length > ID_MAX) return null;
67
+ if (SHELL_METACHARS.test(v)) return null;
68
+ return v;
69
+ }
70
+
71
+ /**
72
+ * Read the group files in `dir` and match their members to `repos` by path
73
+ * (`~` expanded; both sides resolved through symlinks when they exist).
74
+ * Never throws: whatever cannot be used is counted in `skipped`.
75
+ */
76
+ export function readLogicalGroups(dir: string, repos: readonly RepoPath[]): GroupsRead {
77
+ let names: string[];
78
+ try {
79
+ if (!statSync(dir).isDirectory()) return { missing: true, groups: [], skipped: 0 };
80
+ names = readdirSync(dir, { withFileTypes: true })
81
+ .filter((e) => e.isFile() && e.name.endsWith(".json"))
82
+ .map((e) => e.name)
83
+ .sort();
84
+ } catch {
85
+ return { missing: true, groups: [], skipped: 0 };
86
+ }
87
+
88
+ // Path → name, by the written path and by the real one.
89
+ const byPath = new Map<string, string>();
90
+ for (const r of repos) {
91
+ if (!r.path) continue;
92
+ if (!byPath.has(r.path)) byPath.set(r.path, r.name);
93
+ const real = realOr(r.path);
94
+ if (!byPath.has(real)) byPath.set(real, r.name);
95
+ }
96
+
97
+ const groups: LogicalGroup[] = [];
98
+ const seen = new Set<string>();
99
+ let skipped = Math.max(0, names.length - GROUP_FILES_MAX);
100
+ for (const file of names.slice(0, GROUP_FILES_MAX)) {
101
+ let raw: unknown;
102
+ try {
103
+ const full = join(dir, file);
104
+ if (statSync(full).size > GROUP_FILE_BYTES_MAX) {
105
+ skipped++;
106
+ continue;
107
+ }
108
+ raw = JSON.parse(readFileSync(full, "utf-8"));
109
+ } catch {
110
+ skipped++;
111
+ continue;
112
+ }
113
+ if (!isPlainObject(raw) || raw.version !== 1 || typeof raw.kind !== "string") {
114
+ skipped++;
115
+ continue;
116
+ }
117
+ if (raw.kind !== "logical") continue; // physical and others: nested by path already
118
+ const id = readId(raw.id);
119
+ if (id === null || !Array.isArray(raw.members) || seen.has(id)) {
120
+ skipped++;
121
+ continue;
122
+ }
123
+ seen.add(id);
124
+ const labelRaw = typeof raw.label === "string" ? stripControlChars(raw.label) : "";
125
+ const label = [...(labelRaw || id)].slice(0, LABEL_MAX).join("");
126
+ const members: string[] = [];
127
+ let unregistered = 0;
128
+ for (const m of raw.members) {
129
+ const p = isPlainObject(m) && typeof m.path === "string" && m.path !== "" ? m.path : null;
130
+ const abs = p === null ? null : resolve(expandPath(p));
131
+ const name = abs === null ? undefined : (byPath.get(abs) ?? byPath.get(realOr(abs)));
132
+ if (name === undefined) {
133
+ unregistered++;
134
+ } else if (!members.includes(name)) {
135
+ members.push(name);
136
+ }
137
+ }
138
+ groups.push({ id, label, members, unregistered });
139
+ }
140
+ return { missing: false, groups, skipped };
141
+ }
142
+
143
+ /** The meta table's form (`logical_groups`): what the list needs, nothing more. */
144
+ export function groupsToMeta(groups: readonly LogicalGroup[]): string {
145
+ return JSON.stringify(groups.map((g) => ({ id: g.id, label: g.label, members: g.members })));
146
+ }
147
+
148
+ /** Read `logical_groups` back; anything malformed is dropped. */
149
+ export function groupsFromMeta(json: string | null): { id: string; label: string; members: string[] }[] {
150
+ if (!json) return [];
151
+ let raw: unknown;
152
+ try {
153
+ raw = JSON.parse(json);
154
+ } catch {
155
+ return [];
156
+ }
157
+ if (!Array.isArray(raw)) return [];
158
+ const out: { id: string; label: string; members: string[] }[] = [];
159
+ for (const g of raw) {
160
+ if (!isPlainObject(g)) continue;
161
+ const id = readId(g.id);
162
+ if (id === null || typeof g.label !== "string" || !Array.isArray(g.members)) continue;
163
+ const members = g.members.filter((m): m is string => typeof m === "string" && !SHELL_METACHARS.test(m));
164
+ out.push({ id, label: stripControlChars(g.label) || id, members });
165
+ }
166
+ return out;
167
+ }