claude-code-station 0.2.1

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,528 @@
1
+ /**
2
+ * ccs-config.ts — Config parser for Claude Code Station (ccs) v0.2.0
3
+ *
4
+ * Reads, validates, and resolves ~/.config/ccs/repos.yml into typed RepoEntry
5
+ * records. Handles XDG paths, first-run scaffolding, path-expansion security,
6
+ * defaults resolution, and SHA256 content hashing for cache invalidation.
7
+ */
8
+
9
+ import {
10
+ existsSync,
11
+ mkdirSync,
12
+ readFileSync,
13
+ realpathSync,
14
+ writeFileSync,
15
+ } from "node:fs";
16
+ import { homedir } from "node:os";
17
+ import { join, resolve } from "node:path";
18
+ import { createHash } from "node:crypto";
19
+ import { parse as parseYaml } from "yaml";
20
+
21
+ import { SHELL_METACHARS } from "./ccs-sanitize.ts";
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Types
25
+ // ---------------------------------------------------------------------------
26
+
27
+ export interface RepoEntry {
28
+ name: string;
29
+ path: string;
30
+ description: string;
31
+ command: string;
32
+ cwd: string;
33
+ tags: string[];
34
+ disabled: boolean;
35
+ scan: boolean;
36
+ icon: string;
37
+ custom: Record<string, unknown>;
38
+ configHash: string;
39
+ }
40
+
41
+ export interface CcsConfig {
42
+ version: 1;
43
+ defaults: { command: string };
44
+ repos: RepoEntry[];
45
+ }
46
+
47
+ export interface ConfigPaths {
48
+ configDir: string;
49
+ cacheDir: string;
50
+ reposYml: string;
51
+ stateDb: string;
52
+ readme: string;
53
+ }
54
+
55
+ export class ConfigError extends Error {
56
+ constructor(message: string) {
57
+ super(message);
58
+ this.name = "ConfigError";
59
+ }
60
+ }
61
+
62
+ // ---------------------------------------------------------------------------
63
+ // Constants
64
+ // ---------------------------------------------------------------------------
65
+
66
+ const KNOWN_TOP_KEYS = new Set(["version", "defaults", "repos"]);
67
+ const KNOWN_REPO_KEYS = new Set([
68
+ "name",
69
+ "path",
70
+ "description",
71
+ "command",
72
+ "cwd",
73
+ "tags",
74
+ "disabled",
75
+ "scan",
76
+ "icon",
77
+ "custom",
78
+ ]);
79
+
80
+ const TEMPLATE_REPOS_YML = `# Claude Code Station — Repository Definitions
81
+ # https://github.com/indigo-gr/claude-code-station
82
+
83
+ version: 1
84
+
85
+ defaults:
86
+ command: "claude"
87
+
88
+ repos:
89
+ - name: Example Project
90
+ path: ~/path/to/your/project
91
+ description: Edit this entry to add your real repos
92
+ tags: [example]
93
+ `;
94
+
95
+ const TEMPLATE_README = `# Claude Code Station (ccs)
96
+
97
+ This directory holds your Claude Code Station configuration.
98
+
99
+ - \`repos.yml\`: Repository definitions — edit to register your projects
100
+ - (auto-generated cache lives in $XDG_CACHE_HOME/ccs/state.db)
101
+
102
+ Docs: https://github.com/indigo-gr/claude-code-station
103
+ `;
104
+
105
+ // ---------------------------------------------------------------------------
106
+ // Path helpers
107
+ // ---------------------------------------------------------------------------
108
+
109
+ export function getPaths(): ConfigPaths {
110
+ const home = homedir();
111
+ const xdgConfig = process.env.XDG_CONFIG_HOME || join(home, ".config");
112
+ const xdgCache = process.env.XDG_CACHE_HOME || join(home, ".cache");
113
+ const configDir = join(xdgConfig, "ccs");
114
+ const cacheDir = join(xdgCache, "ccs");
115
+ return {
116
+ configDir,
117
+ cacheDir,
118
+ reposYml: join(configDir, "repos.yml"),
119
+ stateDb: join(cacheDir, "state.db"),
120
+ readme: join(configDir, "README.md"),
121
+ };
122
+ }
123
+
124
+ function expandPath(p: string): string {
125
+ const home = homedir();
126
+ if (p === "~") return home;
127
+ if (p.startsWith("~/")) return join(home, p.slice(2));
128
+ return p;
129
+ }
130
+
131
+ // Symlink-aware $HOME containment check (audit M-4).
132
+ //
133
+ // Two layers:
134
+ // 1. Lexical: the resolve()d path must sit under $HOME as written. This is
135
+ // the only possible check for paths that do not exist yet (disabled
136
+ // repos may point at absent directories — loadConfig only warns there).
137
+ // 2. Physical: when the path EXISTS, its realpath must also sit under the
138
+ // realpath of $HOME — otherwise `~/escape -> /` style symlinks smuggle
139
+ // the repo outside $HOME while passing the lexical check. Both sides are
140
+ // realpath'd so macOS /var -> /private/var aliasing compares cleanly.
141
+ function isUnderHome(absPath: string): boolean {
142
+ const home = homedir();
143
+ const lexical = resolve(absPath);
144
+ if (lexical !== home && !lexical.startsWith(home + "/")) return false;
145
+
146
+ let physical: string;
147
+ try {
148
+ physical = realpathSync(lexical);
149
+ } catch {
150
+ return true; // path absent — lexical check above is all we can assert
151
+ }
152
+ let physicalHome: string;
153
+ try {
154
+ physicalHome = realpathSync(home);
155
+ } catch {
156
+ return true; // home unresolvable (exotic FS) — fall back to lexical result
157
+ }
158
+ return physical === physicalHome || physical.startsWith(physicalHome + "/");
159
+ }
160
+
161
+ // SHELL_METACHARS is shared with the session-intake sanitizer; see
162
+ // ccs-sanitize.ts for the policy rationale.
163
+
164
+ function rejectShellMetachars(
165
+ field: string,
166
+ value: string,
167
+ idx: number,
168
+ reposYmlPath: string,
169
+ ): void {
170
+ if (SHELL_METACHARS.test(value)) {
171
+ throw new ConfigError(
172
+ `${reposYmlPath}: repos[${idx}].${field} contains shell metacharacter(s): ${JSON.stringify(value)}`,
173
+ );
174
+ }
175
+ }
176
+
177
+ // ---------------------------------------------------------------------------
178
+ // First-run setup
179
+ // ---------------------------------------------------------------------------
180
+
181
+ export function ensureConfigDir(): void {
182
+ const paths = getPaths();
183
+ mkdirSync(paths.configDir, { recursive: true, mode: 0o700 });
184
+ mkdirSync(paths.cacheDir, { recursive: true, mode: 0o700 });
185
+ if (!existsSync(paths.reposYml)) {
186
+ writeFileSync(paths.reposYml, TEMPLATE_REPOS_YML, { mode: 0o600 });
187
+ }
188
+ if (!existsSync(paths.readme)) {
189
+ writeFileSync(paths.readme, TEMPLATE_README, { mode: 0o600 });
190
+ }
191
+ }
192
+
193
+ // ---------------------------------------------------------------------------
194
+ // Hashing
195
+ // ---------------------------------------------------------------------------
196
+
197
+ function canonicalJson(value: unknown): string {
198
+ if (value === null || typeof value !== "object") {
199
+ return JSON.stringify(value);
200
+ }
201
+ if (Array.isArray(value)) {
202
+ return "[" + value.map(canonicalJson).join(",") + "]";
203
+ }
204
+ const obj = value as Record<string, unknown>;
205
+ const keys = Object.keys(obj).sort();
206
+ return (
207
+ "{" +
208
+ keys
209
+ .map((k) => JSON.stringify(k) + ":" + canonicalJson(obj[k]))
210
+ .join(",") +
211
+ "}"
212
+ );
213
+ }
214
+
215
+ function hashEntry(raw: unknown): string {
216
+ return createHash("sha256").update(canonicalJson(raw)).digest("hex");
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Validation helpers
221
+ // ---------------------------------------------------------------------------
222
+
223
+ function isPlainObject(v: unknown): v is Record<string, unknown> {
224
+ return (
225
+ typeof v === "object" &&
226
+ v !== null &&
227
+ !Array.isArray(v) &&
228
+ Object.getPrototypeOf(v) === Object.prototype
229
+ );
230
+ }
231
+
232
+ function warnUnknownKeys(
233
+ obj: Record<string, unknown>,
234
+ known: Set<string>,
235
+ context: string,
236
+ ): void {
237
+ for (const key of Object.keys(obj)) {
238
+ if (!known.has(key)) {
239
+ console.warn(`[ccs] unknown key "${key}" in ${context} (ignored)`);
240
+ }
241
+ }
242
+ }
243
+
244
+ // ---------------------------------------------------------------------------
245
+ // Command resolution (with CCR_CMD migration warning)
246
+ // ---------------------------------------------------------------------------
247
+
248
+ let migrationWarned = false;
249
+
250
+ /**
251
+ * Reset the CCR_CMD deprecation warning flag. Test-only hook —
252
+ * production code re-warns once per process by design.
253
+ */
254
+ export function resetMigrationWarning(): void {
255
+ migrationWarned = false;
256
+ }
257
+
258
+ function envCommand(): string | undefined {
259
+ if (process.env.CCS_CMD) return process.env.CCS_CMD;
260
+ if (process.env.CCR_CMD) {
261
+ if (!migrationWarned) {
262
+ console.warn(
263
+ "[ccs] CCR_CMD is deprecated, please rename to CCS_CMD (using CCR_CMD value for now)",
264
+ );
265
+ migrationWarned = true;
266
+ }
267
+ return process.env.CCR_CMD;
268
+ }
269
+ return undefined;
270
+ }
271
+
272
+ // ---------------------------------------------------------------------------
273
+ // Repo entry resolution
274
+ // ---------------------------------------------------------------------------
275
+
276
+ function resolveRepoEntry(
277
+ raw: unknown,
278
+ index: number,
279
+ defaultsCommand: string | undefined,
280
+ reposYmlPath: string,
281
+ ): RepoEntry {
282
+ if (!isPlainObject(raw)) {
283
+ throw new ConfigError(
284
+ `${reposYmlPath}: repos[${index}] must be an object`,
285
+ );
286
+ }
287
+ warnUnknownKeys(raw, KNOWN_REPO_KEYS, `repos[${index}]`);
288
+
289
+ const name = raw.name;
290
+ if (typeof name !== "string" || name.length === 0) {
291
+ throw new ConfigError(
292
+ `${reposYmlPath}: repos[${index}].name is required and must be a non-empty string`,
293
+ );
294
+ }
295
+ // Same policy as path/cwd/command (audit NEW-3): the name lands in fzf's
296
+ // ANSI-rendered label column and in the `new:<name>` row key, so shell
297
+ // metacharacters and control chars (incl. ESC) are rejected outright.
298
+ if (SHELL_METACHARS.test(name)) {
299
+ throw new ConfigError(
300
+ `${reposYmlPath}: repos[${index}].name contains shell metacharacter(s) or control char(s): ${JSON.stringify(name)}`,
301
+ );
302
+ }
303
+
304
+ const disabled =
305
+ raw.disabled === undefined ? false : Boolean(raw.disabled);
306
+
307
+ // path
308
+ let resolvedPath = "";
309
+ if (raw.path !== undefined) {
310
+ if (typeof raw.path !== "string") {
311
+ throw new ConfigError(
312
+ `${reposYmlPath}: repos[${index}].path must be a string`,
313
+ );
314
+ }
315
+ resolvedPath = resolve(expandPath(raw.path));
316
+ if (!isUnderHome(resolvedPath)) {
317
+ throw new ConfigError(
318
+ `${reposYmlPath}: repos[${index}].path "${raw.path}" is outside $HOME (security)`,
319
+ );
320
+ }
321
+ rejectShellMetachars("path", resolvedPath, index, reposYmlPath);
322
+ if (!disabled && !existsSync(resolvedPath)) {
323
+ console.warn(
324
+ `[ccs] path not found: ${raw.path} (repos[${index}] "${name}") — set disabled: true to silence`,
325
+ );
326
+ }
327
+ }
328
+
329
+ // cwd
330
+ let resolvedCwd = resolvedPath;
331
+ if (raw.cwd !== undefined) {
332
+ if (typeof raw.cwd !== "string") {
333
+ throw new ConfigError(
334
+ `${reposYmlPath}: repos[${index}].cwd must be a string`,
335
+ );
336
+ }
337
+ resolvedCwd = resolve(expandPath(raw.cwd));
338
+ if (!isUnderHome(resolvedCwd)) {
339
+ throw new ConfigError(
340
+ `${reposYmlPath}: repos[${index}].cwd "${raw.cwd}" is outside $HOME (security)`,
341
+ );
342
+ }
343
+ rejectShellMetachars("cwd", resolvedCwd, index, reposYmlPath);
344
+ }
345
+
346
+ // command
347
+ let command: string;
348
+ if (raw.command !== undefined) {
349
+ if (typeof raw.command !== "string") {
350
+ throw new ConfigError(
351
+ `${reposYmlPath}: repos[${index}].command must be a string`,
352
+ );
353
+ }
354
+ command = raw.command;
355
+ } else if (defaultsCommand !== undefined) {
356
+ command = defaultsCommand;
357
+ } else {
358
+ command = envCommand() ?? "claude";
359
+ }
360
+ // Reject shell metachars in resolved command (S1 hardening). The command
361
+ // string is re-executed unquoted by bin/ccs via `${ROW_CMD} ...`, so any
362
+ // metachar here would break the last line of defense.
363
+ rejectShellMetachars("command", command, index, reposYmlPath);
364
+
365
+ // description
366
+ const description =
367
+ raw.description === undefined ? "" : String(raw.description);
368
+
369
+ // tags
370
+ let tags: string[] = [];
371
+ if (raw.tags !== undefined) {
372
+ if (!Array.isArray(raw.tags) || !raw.tags.every((t) => typeof t === "string")) {
373
+ throw new ConfigError(
374
+ `${reposYmlPath}: repos[${index}].tags must be an array of strings`,
375
+ );
376
+ }
377
+ tags = [...raw.tags];
378
+ }
379
+
380
+ // scan
381
+ const scan = raw.scan === undefined ? true : Boolean(raw.scan);
382
+
383
+ // icon
384
+ const icon = raw.icon === undefined ? "📁" : String(raw.icon);
385
+
386
+ // custom
387
+ let custom: Record<string, unknown> = {};
388
+ if (raw.custom !== undefined) {
389
+ if (!isPlainObject(raw.custom)) {
390
+ throw new ConfigError(
391
+ `${reposYmlPath}: repos[${index}].custom must be an object`,
392
+ );
393
+ }
394
+ // Reject bloated `custom` blobs before they reach downstream code.
395
+ // Size check only — downstream code re-serializes as needed.
396
+ const customJson = JSON.stringify(raw.custom);
397
+ if (customJson.length > 64_000) {
398
+ throw new ConfigError(
399
+ `${reposYmlPath}: repos[${index}].custom exceeds 64KB JSON size limit (got ${customJson.length} bytes)`,
400
+ );
401
+ }
402
+ custom = { ...raw.custom };
403
+ }
404
+
405
+ return {
406
+ name,
407
+ path: resolvedPath,
408
+ description,
409
+ command,
410
+ cwd: resolvedCwd,
411
+ tags,
412
+ disabled,
413
+ scan,
414
+ icon,
415
+ custom,
416
+ configHash: hashEntry(raw),
417
+ };
418
+ }
419
+
420
+ // ---------------------------------------------------------------------------
421
+ // Top-level load
422
+ // ---------------------------------------------------------------------------
423
+
424
+ export function loadConfig(): CcsConfig {
425
+ ensureConfigDir();
426
+ const paths = getPaths();
427
+
428
+ let source: string;
429
+ try {
430
+ source = readFileSync(paths.reposYml, "utf-8");
431
+ } catch (err) {
432
+ throw new ConfigError(
433
+ `${paths.reposYml}: cannot read config file (${(err as Error).message})`,
434
+ );
435
+ }
436
+
437
+ let parsed: unknown;
438
+ try {
439
+ parsed = parseYaml(source);
440
+ } catch (err) {
441
+ throw new ConfigError(
442
+ `${paths.reposYml}: YAML parse error: ${(err as Error).message}`,
443
+ );
444
+ }
445
+
446
+ if (!isPlainObject(parsed)) {
447
+ throw new ConfigError(
448
+ `${paths.reposYml}: top-level must be a mapping/object`,
449
+ );
450
+ }
451
+
452
+ warnUnknownKeys(parsed, KNOWN_TOP_KEYS, "top-level");
453
+
454
+ // version
455
+ if (parsed.version !== 1) {
456
+ throw new ConfigError(
457
+ `${paths.reposYml}: unsupported version: ${String(parsed.version)}, only 1 is supported`,
458
+ );
459
+ }
460
+
461
+ // defaults
462
+ let defaultsCommand: string | undefined;
463
+ if (parsed.defaults !== undefined) {
464
+ if (!isPlainObject(parsed.defaults)) {
465
+ throw new ConfigError(
466
+ `${paths.reposYml}: defaults must be an object`,
467
+ );
468
+ }
469
+ const defaults = parsed.defaults;
470
+ if (defaults.command !== undefined) {
471
+ if (typeof defaults.command !== "string") {
472
+ throw new ConfigError(
473
+ `${paths.reposYml}: defaults.command must be a string`,
474
+ );
475
+ }
476
+ defaultsCommand = defaults.command;
477
+ }
478
+ }
479
+
480
+ // defaults.command resolution: explicit defaults > env > "claude"
481
+ const resolvedDefaultsCommand =
482
+ defaultsCommand ?? envCommand() ?? "claude";
483
+ // Gate the resolved default at origin (review A-6): it is persisted to the
484
+ // meta table for unmapped-session fallback and exposed on CcsConfig, so it
485
+ // must satisfy the same metachar policy as per-repo commands even when no
486
+ // repo currently inherits it. resolveRepoEntry re-checks as defense in depth.
487
+ if (SHELL_METACHARS.test(resolvedDefaultsCommand)) {
488
+ throw new ConfigError(
489
+ `${paths.reposYml}: defaults.command (resolved from defaults.command / CCS_CMD / CCR_CMD) contains shell metacharacter(s): ${JSON.stringify(resolvedDefaultsCommand)}`,
490
+ );
491
+ }
492
+
493
+ // repos
494
+ if (!Array.isArray(parsed.repos) || parsed.repos.length === 0) {
495
+ throw new ConfigError(
496
+ `${paths.reposYml}: repos must be a non-empty array`,
497
+ );
498
+ }
499
+
500
+ const repos: RepoEntry[] = parsed.repos.map((raw, i) =>
501
+ resolveRepoEntry(raw, i, defaultsCommand, paths.reposYml),
502
+ );
503
+
504
+ // uniqueness check
505
+ const byName = new Map<string, number[]>();
506
+ repos.forEach((r, i) => {
507
+ const list = byName.get(r.name) ?? [];
508
+ list.push(i);
509
+ byName.set(r.name, list);
510
+ });
511
+ const dupes: string[] = [];
512
+ for (const [n, idxs] of byName) {
513
+ if (idxs.length > 1) {
514
+ dupes.push(`"${n}" at indexes [${idxs.join(", ")}]`);
515
+ }
516
+ }
517
+ if (dupes.length > 0) {
518
+ throw new ConfigError(
519
+ `${paths.reposYml}: duplicate name ${dupes.join("; ")}`,
520
+ );
521
+ }
522
+
523
+ return {
524
+ version: 1,
525
+ defaults: { command: resolvedDefaultsCommand },
526
+ repos,
527
+ };
528
+ }