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,734 @@
1
+ #!/usr/bin/env tsx
2
+ /**
3
+ * ccs-scan.ts - Parallel repository scan engine for ccs v0.2.0
4
+ *
5
+ * Responsibilities:
6
+ * A. Sync repos.yml -> DB (repos table + meta defaults)
7
+ * B. Parallel repo_stats scan with TTL-based invalidation, git/workspace data
8
+ * C. CLI entrypoint with --force / --no-sessions / --quiet flags
9
+ *
10
+ * Session indexing (~/.claude/projects/) lives in ccs-scan-sessions.ts
11
+ * (review A-4); scan() orchestrates it BEFORE the repo pass (audit C-1).
12
+ *
13
+ * Source of truth: docs/design/sqlite-schema.md
14
+ */
15
+
16
+ import { execFile } from "node:child_process";
17
+ import { promisify } from "node:util";
18
+ import { open, readdir, stat } from "node:fs/promises";
19
+ import {
20
+ closeSync,
21
+ existsSync,
22
+ openSync,
23
+ statSync,
24
+ unlinkSync,
25
+ writeSync,
26
+ } from "node:fs";
27
+ import { join } from "node:path";
28
+ import type Database from "better-sqlite3";
29
+
30
+ import { loadConfig, getPaths, type RepoEntry } from "./ccs-config.ts";
31
+ import {
32
+ openDb,
33
+ upsertRepo,
34
+ getAllRepos,
35
+ deleteReposNotIn,
36
+ setMeta,
37
+ type RepoRow,
38
+ } from "./ccs-db.ts";
39
+ import { maskSecrets } from "./ccs-secrets.ts";
40
+ import { stripControlChars } from "./ccs-sanitize.ts";
41
+ import { nowIso } from "./ccs-utils.ts";
42
+ import { scanSessions } from "./ccs-scan-sessions.ts";
43
+
44
+ const execFileP = promisify(execFile);
45
+
46
+ // ---------------------------------------------------------------------------
47
+ // Types
48
+ // ---------------------------------------------------------------------------
49
+
50
+ export interface ScanOptions {
51
+ force?: boolean;
52
+ ttlSeconds?: number;
53
+ parallelism?: number;
54
+ scanSessions?: boolean;
55
+ }
56
+
57
+ export interface ScanResult {
58
+ reposScanned: number;
59
+ reposSkipped: number;
60
+ reposErrored: number;
61
+ sessionsIndexed: number;
62
+ sessionsSkipped: number;
63
+ durationMs: number;
64
+ /** True when another scan held the advisory lock and this run did nothing. */
65
+ lockSkipped?: boolean;
66
+ }
67
+
68
+ // ---------------------------------------------------------------------------
69
+ // Constants
70
+ // ---------------------------------------------------------------------------
71
+
72
+ const DEFAULT_TTL_SECONDS = 10;
73
+ const DEFAULT_PARALLELISM = 8;
74
+ const GIT_TIMEOUT_MS = 5000;
75
+ const MAX_FIRST_LINE_LEN = 100;
76
+ const PREVIEW_FILE_LIMIT = 10;
77
+
78
+ // ---------------------------------------------------------------------------
79
+ // Utilities
80
+ // ---------------------------------------------------------------------------
81
+
82
+ async function runGit(
83
+ cwd: string,
84
+ args: string[],
85
+ ): Promise<{ stdout: string; stderr: string } | null> {
86
+ try {
87
+ const res = await execFileP("git", args, {
88
+ cwd,
89
+ timeout: GIT_TIMEOUT_MS,
90
+ maxBuffer: 4 * 1024 * 1024,
91
+ windowsHide: true,
92
+ });
93
+ return { stdout: res.stdout, stderr: res.stderr };
94
+ } catch {
95
+ return null;
96
+ }
97
+ }
98
+
99
+ /** Simple semaphore for bounded parallelism. */
100
+ function makeLimiter(limit: number) {
101
+ let active = 0;
102
+ const queue: Array<() => void> = [];
103
+ const next = () => {
104
+ if (active >= limit) return;
105
+ const run = queue.shift();
106
+ if (run) {
107
+ active++;
108
+ run();
109
+ }
110
+ };
111
+ return async function <T>(fn: () => Promise<T>): Promise<T> {
112
+ return new Promise<T>((resolve, reject) => {
113
+ const run = () => {
114
+ fn()
115
+ .then(resolve, reject)
116
+ .finally(() => {
117
+ active--;
118
+ next();
119
+ });
120
+ };
121
+ queue.push(run);
122
+ next();
123
+ });
124
+ };
125
+ }
126
+
127
+ // ---------------------------------------------------------------------------
128
+ // A. repos.yml -> DB sync
129
+ // ---------------------------------------------------------------------------
130
+
131
+ function syncReposToDb(
132
+ db: Database.Database,
133
+ entries: RepoEntry[],
134
+ ): void {
135
+ const tx = db.transaction(() => {
136
+ for (const r of entries) {
137
+ upsertRepo(db, {
138
+ name: r.name,
139
+ path: r.path,
140
+ description: r.description,
141
+ command: r.command,
142
+ cwd: r.cwd && r.cwd !== r.path ? r.cwd : null,
143
+ tags_json: JSON.stringify(r.tags),
144
+ icon: r.icon,
145
+ disabled: r.disabled ? 1 : 0,
146
+ scan_enabled: r.scan ? 1 : 0,
147
+ custom_json: JSON.stringify(r.custom),
148
+ config_hash: r.configHash,
149
+ });
150
+ }
151
+ deleteReposNotIn(
152
+ db,
153
+ entries.map((e) => e.name),
154
+ );
155
+ });
156
+ tx();
157
+ }
158
+
159
+ // ---------------------------------------------------------------------------
160
+ // B. Per-repo scan
161
+ // ---------------------------------------------------------------------------
162
+
163
+ interface PreviewFile {
164
+ filename: string;
165
+ size: number;
166
+ mtime: string;
167
+ first_line: string | null;
168
+ }
169
+
170
+ interface RepoStatsRow {
171
+ name: string;
172
+ is_git: 0 | 1;
173
+ branch: string | null;
174
+ last_commit_hash: string | null;
175
+ last_commit_subject: string | null;
176
+ last_commit_at: string | null;
177
+ uncommitted_files: number;
178
+ uncommitted_insertions: number;
179
+ uncommitted_deletions: number;
180
+ handoff_count: number;
181
+ pending_count: number;
182
+ claude_room_latest: string | null;
183
+ claude_room_latest_at: string | null;
184
+ session_count_total: number;
185
+ session_last_at: string | null;
186
+ scanned_at: string;
187
+ scan_duration_ms: number;
188
+ scan_error: string | null;
189
+ }
190
+
191
+ // Only the first line is ever displayed, so reading whole files (which can be
192
+ // large walkthrough docs) is wasted I/O — read just the head of the file.
193
+ const FIRST_LINE_READ_BYTES = 4096;
194
+
195
+ async function readFirstLine(filePath: string): Promise<string | null> {
196
+ let fh;
197
+ try {
198
+ fh = await open(filePath, "r");
199
+ } catch {
200
+ return null;
201
+ }
202
+ try {
203
+ const buf = Buffer.alloc(FIRST_LINE_READ_BYTES);
204
+ const { bytesRead } = await fh.read(buf, 0, FIRST_LINE_READ_BYTES, 0);
205
+ const text = buf.subarray(0, bytesRead).toString("utf-8");
206
+ return text.split("\n", 1)[0] ?? "";
207
+ } catch {
208
+ return null;
209
+ } finally {
210
+ await fh.close();
211
+ }
212
+ }
213
+
214
+ async function listDirPreview(
215
+ dirPath: string,
216
+ ): Promise<{ count: number; previews: PreviewFile[] }> {
217
+ if (!existsSync(dirPath)) return { count: 0, previews: [] };
218
+ let entries: string[];
219
+ try {
220
+ entries = await readdir(dirPath);
221
+ } catch {
222
+ return { count: 0, previews: [] };
223
+ }
224
+ const visible = entries.filter((n) => !n.startsWith("."));
225
+
226
+ const results = await Promise.all(
227
+ visible.map(async (name): Promise<PreviewFile | null> => {
228
+ const full = join(dirPath, name);
229
+ try {
230
+ const st = await stat(full);
231
+ if (!st.isFile()) return null;
232
+ const line = await readFirstLine(full);
233
+ const firstLine =
234
+ line === null
235
+ ? null
236
+ : stripControlChars(maskSecrets(line)).slice(0, MAX_FIRST_LINE_LEN);
237
+ return {
238
+ filename: name,
239
+ size: st.size,
240
+ mtime: new Date(st.mtimeMs).toISOString(),
241
+ first_line: firstLine,
242
+ };
243
+ } catch {
244
+ return null;
245
+ }
246
+ }),
247
+ );
248
+ const statted = results.filter((p): p is PreviewFile => p !== null);
249
+
250
+ statted.sort((a, b) => (a.mtime < b.mtime ? 1 : -1));
251
+ return {
252
+ count: statted.length,
253
+ previews: statted.slice(0, PREVIEW_FILE_LIMIT),
254
+ };
255
+ }
256
+
257
+ async function getClaudeRoomLatest(
258
+ roomDir: string,
259
+ ): Promise<{ path: string | null; mtime: string | null }> {
260
+ if (!existsSync(roomDir)) return { path: null, mtime: null };
261
+ let entries: string[];
262
+ try {
263
+ entries = await readdir(roomDir);
264
+ } catch {
265
+ return { path: null, mtime: null };
266
+ }
267
+ let latest: { name: string; mtimeMs: number } | null = null;
268
+ for (const name of entries) {
269
+ if (name.startsWith(".")) continue;
270
+ try {
271
+ const st = await stat(join(roomDir, name));
272
+ if (!st.isFile()) continue;
273
+ if (!latest || st.mtimeMs > latest.mtimeMs) {
274
+ latest = { name, mtimeMs: st.mtimeMs };
275
+ }
276
+ } catch {
277
+ // skip
278
+ }
279
+ }
280
+ if (!latest) return { path: null, mtime: null };
281
+ return {
282
+ path: join(roomDir, latest.name),
283
+ mtime: new Date(latest.mtimeMs).toISOString(),
284
+ };
285
+ }
286
+
287
+ async function scanOneRepo(
288
+ db: Database.Database,
289
+ repo: RepoRow,
290
+ opts: { preserveSessionAgg: boolean },
291
+ ): Promise<void> {
292
+ const start = Date.now();
293
+ const stats: RepoStatsRow = {
294
+ name: repo.name,
295
+ is_git: 0,
296
+ branch: null,
297
+ last_commit_hash: null,
298
+ last_commit_subject: null,
299
+ last_commit_at: null,
300
+ uncommitted_files: 0,
301
+ uncommitted_insertions: 0,
302
+ uncommitted_deletions: 0,
303
+ handoff_count: 0,
304
+ pending_count: 0,
305
+ claude_room_latest: null,
306
+ claude_room_latest_at: null,
307
+ session_count_total: 0,
308
+ session_last_at: null,
309
+ scanned_at: nowIso(),
310
+ scan_duration_ms: 0,
311
+ scan_error: null,
312
+ };
313
+
314
+ let handoffPreviews: PreviewFile[] = [];
315
+ let pendingPreviews: PreviewFile[] = [];
316
+
317
+ try {
318
+ if (!existsSync(repo.path)) {
319
+ throw new Error(`path not found: ${repo.path}`);
320
+ }
321
+
322
+ // --- Git data (all best-effort) ---
323
+ const isGit = await runGit(repo.path, [
324
+ "rev-parse",
325
+ "--is-inside-work-tree",
326
+ ]);
327
+ if (isGit && isGit.stdout.trim() === "true") {
328
+ stats.is_git = 1;
329
+
330
+ const branch = await runGit(repo.path, ["branch", "--show-current"]);
331
+ // Branch names land in fzf badges (--ansi) — strip control chars so a
332
+ // hostile branch name cannot smuggle terminal escapes (audit NEW-1).
333
+ if (branch) stats.branch = stripControlChars(branch.stdout.trim()) || null;
334
+
335
+ // NUL separators (%x00), not tabs: a commit subject can legally contain
336
+ // a literal TAB, which would shift the split and persist subject
337
+ // fragments into last_commit_at (review C-1). Git forbids NUL in commit
338
+ // messages, so the field boundaries are unambiguous.
339
+ const log = await runGit(repo.path, [
340
+ "log",
341
+ "-1",
342
+ "--format=%H%x00%s%x00%cI",
343
+ ]);
344
+ if (log && log.stdout.trim()) {
345
+ const [hash, subject, at] = log.stdout.trim().split("\0");
346
+ stats.last_commit_hash = hash ?? null;
347
+ // Mask before persisting: a developer could accidentally commit a
348
+ // secret in a commit message subject, and we must not replicate it
349
+ // into state.db (even though the file is mode 0600). Control chars
350
+ // are stripped because the subject is rendered in the preview pane.
351
+ stats.last_commit_subject = subject
352
+ ? stripControlChars(maskSecrets(subject))
353
+ : null;
354
+ stats.last_commit_at = at ?? null;
355
+ }
356
+
357
+ const status = await runGit(repo.path, ["status", "--porcelain"]);
358
+ if (status) {
359
+ const lines = status.stdout
360
+ .split("\n")
361
+ .filter((l) => l.length > 0);
362
+ stats.uncommitted_files = lines.length;
363
+ }
364
+
365
+ // shortstat needs HEAD; may fail on "no commits yet" -> leave zeros
366
+ const shortstat = await runGit(repo.path, [
367
+ "diff",
368
+ "--shortstat",
369
+ "HEAD",
370
+ ]);
371
+ if (shortstat && shortstat.stdout.trim()) {
372
+ const text = shortstat.stdout;
373
+ const ins = /(\d+) insertion/.exec(text);
374
+ const del = /(\d+) deletion/.exec(text);
375
+ // \d+ guarantees a numeric capture today; Number.isFinite guards the
376
+ // DB write in case the regex is ever loosened (audit logic L-1).
377
+ if (ins) {
378
+ const n = parseInt(ins[1], 10);
379
+ if (Number.isFinite(n)) stats.uncommitted_insertions = n;
380
+ }
381
+ if (del) {
382
+ const n = parseInt(del[1], 10);
383
+ if (Number.isFinite(n)) stats.uncommitted_deletions = n;
384
+ }
385
+ }
386
+ }
387
+
388
+ // --- Workspace dirs ---
389
+ const handoff = await listDirPreview(join(repo.path, "handoff"));
390
+ stats.handoff_count = handoff.count;
391
+ handoffPreviews = handoff.previews;
392
+
393
+ const pending = await listDirPreview(join(repo.path, "pendings"));
394
+ stats.pending_count = pending.count;
395
+ pendingPreviews = pending.previews;
396
+
397
+ const room = await getClaudeRoomLatest(join(repo.path, "claude-room"));
398
+ stats.claude_room_latest = room.path;
399
+ stats.claude_room_latest_at = room.mtime;
400
+ } catch (err) {
401
+ // Mask before persisting AND before logging: git subprocess errors can
402
+ // contain remote URLs with embedded credentials
403
+ // (e.g. https://user:token@github.com/...).
404
+ const errMsg = err instanceof Error ? err.message : String(err);
405
+ const maskedErr = maskSecrets(errMsg);
406
+ stats.scan_error = maskedErr;
407
+ process.stderr.write(`[ccs-scan] ${repo.name}: ${maskedErr}\n`);
408
+ }
409
+
410
+ stats.scan_duration_ms = Date.now() - start;
411
+
412
+ // --- Single transaction: aggregate sessions + upsert stats + previews ---
413
+ const tx = db.transaction(() => {
414
+ // Sessions aggregation INSIDE the write transaction (backlog: COUNT(*)
415
+ // outside tx): a concurrent ccs process committing session changes
416
+ // between this read and the upsert below would persist a stale count.
417
+ // better-sqlite3 statements are synchronous, so the read is atomic with
418
+ // the write here.
419
+ //
420
+ // When this scan skipped the sessions pass (--no-sessions), the sessions
421
+ // table may be stale or empty; recomputing from it would rewind the
422
+ // aggregates (audit logic H-1). Carry the previous repo_stats values
423
+ // forward instead. scan() runs scanSessions BEFORE this point in the
424
+ // normal path (audit C-1), so the recompute sees fresh data.
425
+ if (opts.preserveSessionAgg) {
426
+ const prev = db
427
+ .prepare(
428
+ `SELECT session_count_total, session_last_at
429
+ FROM repo_stats WHERE name = ?`,
430
+ )
431
+ .get(repo.name) as
432
+ | { session_count_total: number; session_last_at: string | null }
433
+ | undefined;
434
+ stats.session_count_total = prev?.session_count_total ?? 0;
435
+ stats.session_last_at = prev?.session_last_at ?? null;
436
+ } else {
437
+ const sessRow = db
438
+ .prepare(
439
+ `SELECT COUNT(*) AS cnt, MAX(last_activity_at) AS last_at
440
+ FROM sessions WHERE repo_name = ?`,
441
+ )
442
+ .get(repo.name) as { cnt: number; last_at: string | null };
443
+ stats.session_count_total = sessRow?.cnt ?? 0;
444
+ stats.session_last_at = sessRow?.last_at ?? null;
445
+ }
446
+
447
+ db.prepare(
448
+ `INSERT INTO repo_stats (
449
+ name, is_git, branch, last_commit_hash, last_commit_subject,
450
+ last_commit_at, uncommitted_files, uncommitted_insertions,
451
+ uncommitted_deletions, handoff_count, pending_count,
452
+ claude_room_latest, claude_room_latest_at,
453
+ session_count_total, session_last_at,
454
+ scanned_at, scan_duration_ms, scan_error
455
+ ) VALUES (
456
+ @name, @is_git, @branch, @last_commit_hash, @last_commit_subject,
457
+ @last_commit_at, @uncommitted_files, @uncommitted_insertions,
458
+ @uncommitted_deletions, @handoff_count, @pending_count,
459
+ @claude_room_latest, @claude_room_latest_at,
460
+ @session_count_total, @session_last_at,
461
+ @scanned_at, @scan_duration_ms, @scan_error
462
+ )
463
+ ON CONFLICT(name) DO UPDATE SET
464
+ is_git = excluded.is_git,
465
+ branch = excluded.branch,
466
+ last_commit_hash = excluded.last_commit_hash,
467
+ last_commit_subject = excluded.last_commit_subject,
468
+ last_commit_at = excluded.last_commit_at,
469
+ uncommitted_files = excluded.uncommitted_files,
470
+ uncommitted_insertions = excluded.uncommitted_insertions,
471
+ uncommitted_deletions = excluded.uncommitted_deletions,
472
+ handoff_count = excluded.handoff_count,
473
+ pending_count = excluded.pending_count,
474
+ claude_room_latest = excluded.claude_room_latest,
475
+ claude_room_latest_at = excluded.claude_room_latest_at,
476
+ session_count_total = excluded.session_count_total,
477
+ session_last_at = excluded.session_last_at,
478
+ scanned_at = excluded.scanned_at,
479
+ scan_duration_ms = excluded.scan_duration_ms,
480
+ scan_error = excluded.scan_error`,
481
+ ).run(stats);
482
+
483
+ db.prepare(`DELETE FROM handoff_files WHERE repo_name = ?`).run(
484
+ repo.name,
485
+ );
486
+ db.prepare(`DELETE FROM pending_items WHERE repo_name = ?`).run(
487
+ repo.name,
488
+ );
489
+
490
+ const hInsert = db.prepare(
491
+ `INSERT INTO handoff_files (repo_name, filename, size, mtime, first_line)
492
+ VALUES (?, ?, ?, ?, ?)`,
493
+ );
494
+ for (const p of handoffPreviews) {
495
+ hInsert.run(repo.name, p.filename, p.size, p.mtime, p.first_line);
496
+ }
497
+ const pInsert = db.prepare(
498
+ `INSERT INTO pending_items (repo_name, filename, size, mtime, first_line)
499
+ VALUES (?, ?, ?, ?, ?)`,
500
+ );
501
+ for (const p of pendingPreviews) {
502
+ pInsert.run(repo.name, p.filename, p.size, p.mtime, p.first_line);
503
+ }
504
+ });
505
+ tx();
506
+ }
507
+
508
+ interface ReposScanSummary {
509
+ scanned: number;
510
+ skipped: number;
511
+ errored: number;
512
+ }
513
+
514
+ async function scanAllRepos(
515
+ db: Database.Database,
516
+ opts: {
517
+ force: boolean;
518
+ ttlSeconds: number;
519
+ parallelism: number;
520
+ preserveSessionAgg: boolean;
521
+ },
522
+ ): Promise<ReposScanSummary> {
523
+ const allRepos = getAllRepos(db).filter(
524
+ (r) => r.disabled === 0 && r.scan_enabled === 1,
525
+ );
526
+
527
+ // TTL check: read existing scanned_at per repo
528
+ const ttlMs = opts.ttlSeconds * 1000;
529
+ const nowMs = Date.now();
530
+ const skipSet = new Set<string>();
531
+ if (!opts.force) {
532
+ const rows = db
533
+ .prepare(`SELECT name, scanned_at FROM repo_stats`)
534
+ .all() as Array<{ name: string; scanned_at: string }>;
535
+ for (const r of rows) {
536
+ const t = Date.parse(r.scanned_at);
537
+ if (!isNaN(t) && nowMs - t < ttlMs) skipSet.add(r.name);
538
+ }
539
+ }
540
+
541
+ const toScan = allRepos.filter((r) => !skipSet.has(r.name));
542
+ const limit = makeLimiter(opts.parallelism);
543
+
544
+ const results = await Promise.allSettled(
545
+ toScan.map((r) =>
546
+ limit(() =>
547
+ scanOneRepo(db, r, { preserveSessionAgg: opts.preserveSessionAgg }),
548
+ ),
549
+ ),
550
+ );
551
+
552
+ let errored = 0;
553
+ for (const res of results) {
554
+ if (res.status === "rejected") {
555
+ errored++;
556
+ process.stderr.write(
557
+ `[ccs-scan] unexpected rejection: ${String(res.reason)}\n`,
558
+ );
559
+ }
560
+ }
561
+
562
+ return {
563
+ scanned: toScan.length,
564
+ skipped: skipSet.size,
565
+ errored,
566
+ };
567
+ }
568
+
569
+ // ---------------------------------------------------------------------------
570
+ // Advisory scan lock
571
+ // ---------------------------------------------------------------------------
572
+
573
+ // Two concurrent scans (Ctrl-R right after `ccs --refresh`, or two terminals)
574
+ // can race on the sessions DELETE-not-in cleanup and the repo_stats writes
575
+ // (backlog: concurrent scan race / original H3). WAL keeps the DB consistent,
576
+ // but the UX race ("session disappears for one reload") is real — so a second
577
+ // scan simply skips while the first holds the lock.
578
+ const LOCK_STALE_MS = 5 * 60 * 1000; // scans run in seconds; 5min = crashed
579
+
580
+ function acquireScanLock(cacheDir: string): (() => void) | null {
581
+ const lockPath = join(cacheDir, "scan.lock");
582
+ // Two attempts: the second runs only after removing a stale lock. If yet
583
+ // another process wins the recreate race in between, its lock is fresh and
584
+ // we correctly yield.
585
+ for (let attempt = 0; attempt < 2; attempt++) {
586
+ try {
587
+ const fd = openSync(lockPath, "wx"); // O_EXCL — fails when held
588
+ writeSync(fd, `${process.pid} ${new Date().toISOString()}\n`);
589
+ closeSync(fd);
590
+ return () => {
591
+ try {
592
+ unlinkSync(lockPath);
593
+ } catch {
594
+ // already gone (stale takeover by a later process) — nothing to do
595
+ }
596
+ };
597
+ } catch {
598
+ try {
599
+ const st = statSync(lockPath);
600
+ if (Date.now() - st.mtimeMs <= LOCK_STALE_MS) return null; // held
601
+ unlinkSync(lockPath); // stale (crashed scan) — take over
602
+ } catch {
603
+ // lock vanished between open and stat — loop and retry the create
604
+ }
605
+ }
606
+ }
607
+ return null;
608
+ }
609
+
610
+ // ---------------------------------------------------------------------------
611
+ // Public entrypoint
612
+ // ---------------------------------------------------------------------------
613
+
614
+ export async function scan(opts: ScanOptions = {}): Promise<ScanResult> {
615
+ const started = Date.now();
616
+ const force = opts.force ?? false;
617
+ const ttlSeconds = opts.ttlSeconds ?? DEFAULT_TTL_SECONDS;
618
+ const parallelism = opts.parallelism ?? DEFAULT_PARALLELISM;
619
+ const scanSessionsFlag = opts.scanSessions ?? true;
620
+
621
+ // loadConfig() runs ensureConfigDir(), so cacheDir exists before the lock.
622
+ const config = loadConfig();
623
+ const paths = getPaths();
624
+
625
+ const releaseLock = acquireScanLock(paths.cacheDir);
626
+ if (!releaseLock) {
627
+ process.stderr.write(
628
+ "[ccs-scan] another scan is in progress — skipping (stale DB is fine, it self-heals on the next refresh)\n",
629
+ );
630
+ return {
631
+ reposScanned: 0,
632
+ reposSkipped: 0,
633
+ reposErrored: 0,
634
+ sessionsIndexed: 0,
635
+ sessionsSkipped: 0,
636
+ durationMs: Date.now() - started,
637
+ lockSkipped: true,
638
+ };
639
+ }
640
+
641
+ const handle = openDb(paths.stateDb);
642
+
643
+ try {
644
+ syncReposToDb(handle.db, config.repos);
645
+ // Persist the resolved launch-command fallback (defaults.command >
646
+ // CCS_CMD > "claude") so ccs-list can apply the same chain to sessions
647
+ // that map to no repo (review A-8). Validated at load time (review A-6).
648
+ setMeta(handle.db, "defaults_command", config.defaults.command);
649
+
650
+ // Order matters (audit C-1): scanOneRepo aggregates session counts FROM
651
+ // the sessions table, so sessions must be refreshed first — otherwise
652
+ // repo_stats lags one full scan behind (all repos showed "💤 未使用" on a
653
+ // fresh DB until the second --refresh).
654
+ let sessionsIndexed = 0;
655
+ let sessionsSkipped = 0;
656
+ if (scanSessionsFlag) {
657
+ const s = await scanSessions(handle.db);
658
+ sessionsIndexed = s.indexed;
659
+ sessionsSkipped = s.skipped;
660
+ }
661
+
662
+ const repoSummary = await scanAllRepos(handle.db, {
663
+ force,
664
+ ttlSeconds,
665
+ parallelism,
666
+ preserveSessionAgg: !scanSessionsFlag,
667
+ });
668
+
669
+ return {
670
+ reposScanned: repoSummary.scanned,
671
+ reposSkipped: repoSummary.skipped,
672
+ reposErrored: repoSummary.errored,
673
+ sessionsIndexed,
674
+ sessionsSkipped,
675
+ durationMs: Date.now() - started,
676
+ };
677
+ } finally {
678
+ handle.close();
679
+ releaseLock();
680
+ }
681
+ }
682
+
683
+ // ---------------------------------------------------------------------------
684
+ // CLI
685
+ // ---------------------------------------------------------------------------
686
+
687
+ function parseArgs(argv: string[]): {
688
+ force: boolean;
689
+ noSessions: boolean;
690
+ quiet: boolean;
691
+ } {
692
+ return {
693
+ force: argv.includes("--force"),
694
+ noSessions: argv.includes("--no-sessions"),
695
+ quiet: argv.includes("--quiet"),
696
+ };
697
+ }
698
+
699
+ async function main(): Promise<number> {
700
+ const args = parseArgs(process.argv.slice(2));
701
+ try {
702
+ const res = await scan({
703
+ force: args.force,
704
+ scanSessions: !args.noSessions,
705
+ });
706
+ if (!args.quiet) {
707
+ const secs = (res.durationMs / 1000).toFixed(1);
708
+ process.stderr.write(
709
+ `[ccs-scan] ${res.reposScanned} repos scanned ` +
710
+ `(${res.reposSkipped} skipped, ${res.reposErrored} errors), ` +
711
+ `${res.sessionsIndexed} sessions indexed ` +
712
+ `(${res.sessionsSkipped} cached) in ${secs}s\n`,
713
+ );
714
+ }
715
+ return 0;
716
+ } catch (err) {
717
+ process.stderr.write(
718
+ `[ccs-scan] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
719
+ );
720
+ return 1;
721
+ }
722
+ }
723
+
724
+ if (import.meta.url === `file://${process.argv[1]}`) {
725
+ main().then(
726
+ (code) => process.exit(code),
727
+ (err) => {
728
+ process.stderr.write(
729
+ `[ccs-scan] fatal: ${err instanceof Error ? err.message : String(err)}\n`,
730
+ );
731
+ process.exit(1);
732
+ },
733
+ );
734
+ }