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
package/bin/ccs-scan.ts
ADDED
|
@@ -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
|
+
}
|