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.
- package/CHANGELOG.md +176 -0
- package/LICENSE +21 -0
- package/README.md +311 -0
- package/bin/ccs +376 -0
- package/bin/ccs-config.ts +528 -0
- package/bin/ccs-db.ts +404 -0
- package/bin/ccs-delete-session.ts +48 -0
- package/bin/ccs-delete.sh +100 -0
- package/bin/ccs-init.ts +287 -0
- package/bin/ccs-list.ts +363 -0
- package/bin/ccs-preview-session.ts +147 -0
- package/bin/ccs-preview.ts +368 -0
- package/bin/ccs-sanitize.ts +57 -0
- package/bin/ccs-scan-sessions.ts +402 -0
- package/bin/ccs-scan.ts +734 -0
- package/bin/ccs-secrets.ts +104 -0
- package/bin/ccs-time.ts +27 -0
- package/bin/ccs-utils.ts +161 -0
- package/docs/design/repos-yml-schema.md +217 -0
- package/docs/design/sqlite-schema.md +253 -0
- package/docs/v0.2.0-regression-checklist.md +40 -0
- package/docs/v0.2.0-review-notes.md +151 -0
- package/docs/v0.2.1-backlog.md +225 -0
- package/package.json +44 -0
|
@@ -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
|
+
}
|