@zzusp/ccsm 1.0.0 → 1.0.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/README.md +7 -3
- package/bin/cli.mjs +52 -52
- package/dist/assets/DiskUsage-CKhggLs5.js +2 -0
- package/dist/assets/DiskUsage-CKhggLs5.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.js → ImportPage-wge4VhZ-.js} +2 -2
- package/dist/assets/{ImportPage-b8NORa8b.js.map → ImportPage-wge4VhZ-.js.map} +1 -1
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js → ProjectMemory-Q4XX40j_.js} +2 -2
- package/dist/assets/{ProjectMemory-aSV8UzQ9.js.map → ProjectMemory-Q4XX40j_.js.map} +1 -1
- package/dist/assets/{charts-A5eNHLjX.js → charts-jxJqXXUr.js} +2 -2
- package/dist/assets/{charts-A5eNHLjX.js.map → charts-jxJqXXUr.js.map} +1 -1
- package/dist/assets/index-7aMrnHJG.js +7 -0
- package/dist/assets/index-7aMrnHJG.js.map +1 -0
- package/dist/assets/index-BOeI_J4B.css +1 -0
- package/dist/assets/{query-C1K1uQRu.js → query-CS7JQ86v.js} +2 -2
- package/dist/assets/{query-C1K1uQRu.js.map → query-CS7JQ86v.js.map} +1 -1
- package/dist/assets/{react-W0jzChlo.js → react-CPkiFScu.js} +10 -10
- package/dist/assets/{react-W0jzChlo.js.map → react-CPkiFScu.js.map} +1 -1
- package/dist/assets/{router-DfbutHY3.js → router-DwaHAh1G.js} +2 -2
- package/dist/assets/{router-DfbutHY3.js.map → router-DwaHAh1G.js.map} +1 -1
- package/dist/assets/vendor-Cs8vYp-N.js +27 -0
- package/dist/assets/vendor-Cs8vYp-N.js.map +1 -0
- package/dist/favicon.svg +7 -7
- package/dist/index.html +6 -6
- package/package.json +83 -72
- package/server/index.ts +130 -126
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +19 -12
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +1 -0
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +5 -16
- package/server/lib/disk-usage.ts +6 -8
- package/server/lib/export-import-bundle.test.ts +337 -0
- package/server/lib/modified-files.test.ts +280 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +22 -15
- package/server/lib/parse-jsonl.ts +35 -3
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +103 -0
- package/server/lib/update.ts +67 -0
- package/server/lib/version.test.ts +39 -0
- package/server/lib/version.ts +117 -0
- package/server/routes/disk-cleanup.ts +54 -0
- package/server/routes/sessions.ts +49 -0
- package/server/routes/version.ts +34 -0
- package/shared/constants.ts +5 -0
- package/shared/types.ts +152 -0
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/index-DLATR3tZ.js +0 -5
- package/dist/assets/index-DLATR3tZ.js.map +0 -1
- package/dist/assets/index-DLDtbkux.css +0 -1
- package/dist/assets/vendor-CH80ylbS.js +0 -19
- package/dist/assets/vendor-CH80ylbS.js.map +0 -1
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import os from 'node:os';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
|
|
6
|
+
// safeRemove 是 deleteSessions 与 deleteOrphan 共用的"路径校验 + 实际 rm"唯一入口。
|
|
7
|
+
// 这里把 claude-paths.ts mock 到一个独立 tmp root(通过 process.env.CCSM_TEST_ROOT 桥接,
|
|
8
|
+
// 与 delete.test.ts 同套路),校验它只删 root 子树内的东西、逃出去的一律抛错。
|
|
9
|
+
|
|
10
|
+
let fakeRoot: string;
|
|
11
|
+
|
|
12
|
+
vi.mock('./claude-paths.ts', () => {
|
|
13
|
+
return {
|
|
14
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
15
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
16
|
+
const resolved = path.resolve(target);
|
|
17
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
18
|
+
},
|
|
19
|
+
};
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
beforeEach(() => {
|
|
23
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-test-'));
|
|
24
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
afterEach(() => {
|
|
28
|
+
vi.restoreAllMocks();
|
|
29
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
30
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
describe('safeRemove', () => {
|
|
34
|
+
it('删 root 子树内的文件,返回 true', async () => {
|
|
35
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
36
|
+
const f = path.join(fakeRoot, 'a.jsonl');
|
|
37
|
+
fs.writeFileSync(f, 'x');
|
|
38
|
+
|
|
39
|
+
expect(safeRemove(f)).toBe(true);
|
|
40
|
+
expect(fs.existsSync(f)).toBe(false);
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('删 root 子树内的目录(recursive),返回 true', async () => {
|
|
44
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
45
|
+
const dir = path.join(fakeRoot, 'file-history', 'sid-1');
|
|
46
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
47
|
+
fs.writeFileSync(path.join(dir, 'nested.txt'), 'y');
|
|
48
|
+
|
|
49
|
+
expect(safeRemove(dir)).toBe(true);
|
|
50
|
+
expect(fs.existsSync(dir)).toBe(false);
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it('目标不存在返回 false(幂等,不抛)', async () => {
|
|
54
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
55
|
+
expect(safeRemove(path.join(fakeRoot, 'missing'))).toBe(false);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('逃出 ~/.claude 子树的目标一律抛错,且不删任何东西', async () => {
|
|
59
|
+
const { safeRemove } = await import('./safe-remove.ts');
|
|
60
|
+
// 在 fakeRoot 之外铺一个文件,确认它不会被删
|
|
61
|
+
const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-outside-'));
|
|
62
|
+
const victim = path.join(outside, 'do-not-delete.txt');
|
|
63
|
+
fs.writeFileSync(victim, 'keep me');
|
|
64
|
+
try {
|
|
65
|
+
expect(() => safeRemove(victim)).toThrow(/outside ~\/\.claude/);
|
|
66
|
+
expect(fs.existsSync(victim)).toBe(true);
|
|
67
|
+
// 兄弟目录(同前缀但非子树)也必须拒绝
|
|
68
|
+
expect(() => safeRemove(fakeRoot + '_evil')).toThrow(/outside ~\/\.claude/);
|
|
69
|
+
} finally {
|
|
70
|
+
fs.rmSync(outside, { recursive: true, force: true });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import { isUnderClaudeRoot } from './claude-paths.ts';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* ~/.claude/ 下所有删除的唯一入口:先过 isUnderClaudeRoot 路径校验,再真正 rm。
|
|
6
|
+
*
|
|
7
|
+
* deleteSessions(5 处级联)和 deleteOrphan(孤儿目录)共用这一份"路径校验 + 实际 rm",
|
|
8
|
+
* 把"目标必须落在 ~/.claude 子树内"这条安全网集中到一处——以后改删除约束
|
|
9
|
+
* (加路径校验、改 rm 行为、加新防护)只改这里,不会两边各写一份、改一边漏一边。
|
|
10
|
+
*
|
|
11
|
+
* 文件和目录都走 recursive: true(对文件无副作用),所以单一入口能覆盖两种形态。
|
|
12
|
+
*
|
|
13
|
+
* @returns 是否真的删了东西(目标不存在 → false)
|
|
14
|
+
* @throws 目标逃出 ~/.claude 子树 —— 最后一道兜底,绝不 silently 删 root 外的东西。
|
|
15
|
+
* 调用方应在更早处用 isUnderClaudeRoot 做 graceful 预检并给出跳过原因,
|
|
16
|
+
* 走到这里抛错说明前置校验漏了,是 bug 不是正常流程。
|
|
17
|
+
*/
|
|
18
|
+
export function safeRemove(target: string): boolean {
|
|
19
|
+
if (!isUnderClaudeRoot(target)) {
|
|
20
|
+
throw new Error(`refuse to remove path outside ~/.claude: ${target}`);
|
|
21
|
+
}
|
|
22
|
+
if (!fs.existsSync(target)) return false;
|
|
23
|
+
fs.rmSync(target, { recursive: true, force: true });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
package/server/lib/scan.ts
CHANGED
|
@@ -141,6 +141,7 @@ export async function listSessionsForProject(projectId: string): Promise<Session
|
|
|
141
141
|
let firstAt: string | null = null;
|
|
142
142
|
let lastAt: string | null = null;
|
|
143
143
|
let messageCount = 0;
|
|
144
|
+
let lastTurnIncomplete = false;
|
|
144
145
|
|
|
145
146
|
if (fs.existsSync(jsonlPath)) {
|
|
146
147
|
const meta = await parseJsonlMeta(jsonlPath);
|
|
@@ -149,6 +150,7 @@ export async function listSessionsForProject(projectId: string): Promise<Session
|
|
|
149
150
|
firstAt = meta.firstAt;
|
|
150
151
|
lastAt = meta.lastAt;
|
|
151
152
|
messageCount = meta.messageCount;
|
|
153
|
+
lastTurnIncomplete = meta.lastTurnIncomplete;
|
|
152
154
|
}
|
|
153
155
|
|
|
154
156
|
const livePid = activeMap.get(id) ?? null;
|
|
@@ -175,9 +177,110 @@ export async function listSessionsForProject(projectId: string): Promise<Session
|
|
|
175
177
|
isLivePid: livePid !== null,
|
|
176
178
|
isRecentlyActive,
|
|
177
179
|
livePid,
|
|
180
|
+
// "Working" narrows "live" to actively-processing: a live PID, fresh file
|
|
181
|
+
// activity, and an unfinished last turn. The live-PID gate keeps a session
|
|
182
|
+
// that crashed mid-turn (file frozen with a trailing `user` record) from
|
|
183
|
+
// reading as "working".
|
|
184
|
+
isWorking: livePid !== null && isRecentlyActive && lastTurnIncomplete,
|
|
178
185
|
});
|
|
179
186
|
}
|
|
180
187
|
|
|
181
188
|
out.sort((a, b) => (b.lastAt ?? '').localeCompare(a.lastAt ?? ''));
|
|
182
189
|
return out;
|
|
183
190
|
}
|
|
191
|
+
|
|
192
|
+
export interface DiskScanSession {
|
|
193
|
+
id: string;
|
|
194
|
+
title: string;
|
|
195
|
+
customTitle: string | null;
|
|
196
|
+
lastAt: string | null;
|
|
197
|
+
relatedBytes: RelatedBytes;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
export interface DiskScanProject {
|
|
201
|
+
id: string;
|
|
202
|
+
decodedCwd: string;
|
|
203
|
+
cwdResolved: boolean;
|
|
204
|
+
totalBytes: number;
|
|
205
|
+
sessionCount: number;
|
|
206
|
+
sessions: DiskScanSession[];
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* 磁盘视图(disk-usage + cleanup-suggestions)专用的单遍扫描:逐会话算
|
|
211
|
+
* relatedBytes + 解析一次 jsonl meta,项目 totalBytes = 其会话之和。
|
|
212
|
+
*
|
|
213
|
+
* 刻意不做 live-PID / recently-active 探测:两个磁盘视图都不展示这些,而
|
|
214
|
+
* `listSessionsForProject` 会按项目各建一次 active map(Windows 下 = 一次
|
|
215
|
+
* `tasklist` spawn,~400-700ms),被这两个接口按项目数放大成几十次 tasklist,
|
|
216
|
+
* 是磁盘页加载慢的主因。size/meta 原语与 `listSessionsForProject` 复用,只去掉
|
|
217
|
+
* active map 与「listProjects + listSessionsForProject」之间重复的 size 遍历。
|
|
218
|
+
*/
|
|
219
|
+
export async function scanProjectsForDisk(): Promise<DiskScanProject[]> {
|
|
220
|
+
if (!fs.existsSync(PATHS.projects)) return [];
|
|
221
|
+
|
|
222
|
+
const projectIds = fs
|
|
223
|
+
.readdirSync(PATHS.projects, { withFileTypes: true })
|
|
224
|
+
.filter((ent) => ent.isDirectory())
|
|
225
|
+
.map((ent) => ent.name);
|
|
226
|
+
|
|
227
|
+
const result: DiskScanProject[] = [];
|
|
228
|
+
for (const projectId of projectIds) {
|
|
229
|
+
const projectDir = path.join(PATHS.projects, projectId);
|
|
230
|
+
const ids = listSessionIdsInProject(projectDir);
|
|
231
|
+
|
|
232
|
+
const scanned = await Promise.all(ids.map((id) => scanDiskSession(projectDir, id)));
|
|
233
|
+
|
|
234
|
+
let totalBytes = 0;
|
|
235
|
+
let sampleCwd: string | null = null;
|
|
236
|
+
const sessions: DiskScanSession[] = [];
|
|
237
|
+
for (const { session, cwdFromMessages } of scanned) {
|
|
238
|
+
const r = session.relatedBytes;
|
|
239
|
+
totalBytes += r.jsonl + r.subdir + r.fileHistory + r.sessionEnv;
|
|
240
|
+
if (!sampleCwd && cwdFromMessages) sampleCwd = cwdFromMessages;
|
|
241
|
+
sessions.push(session);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const { decoded, resolved } = decodeProjectId(projectId, sampleCwd);
|
|
245
|
+
result.push({
|
|
246
|
+
id: projectId,
|
|
247
|
+
decodedCwd: decoded,
|
|
248
|
+
cwdResolved: resolved,
|
|
249
|
+
totalBytes,
|
|
250
|
+
sessionCount: ids.length,
|
|
251
|
+
sessions,
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
return result;
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
async function scanDiskSession(
|
|
259
|
+
projectDir: string,
|
|
260
|
+
id: string,
|
|
261
|
+
): Promise<{ session: DiskScanSession; cwdFromMessages: string | null }> {
|
|
262
|
+
const jsonlPath = path.join(projectDir, `${id}${JSONL_EXT}`);
|
|
263
|
+
const relatedBytes: RelatedBytes = {
|
|
264
|
+
jsonl: fileSize(jsonlPath),
|
|
265
|
+
subdir: dirSize(path.join(projectDir, id)),
|
|
266
|
+
fileHistory: dirSize(path.join(PATHS.fileHistory, id)),
|
|
267
|
+
sessionEnv: dirSize(path.join(PATHS.sessionEnv, id)),
|
|
268
|
+
};
|
|
269
|
+
|
|
270
|
+
let title = '(no jsonl)';
|
|
271
|
+
let customTitle: string | null = null;
|
|
272
|
+
let lastAt: string | null = null;
|
|
273
|
+
let cwdFromMessages: string | null = null;
|
|
274
|
+
if (fs.existsSync(jsonlPath)) {
|
|
275
|
+
const meta = await parseJsonlMeta(jsonlPath);
|
|
276
|
+
title = meta.title;
|
|
277
|
+
customTitle = meta.customTitle;
|
|
278
|
+
lastAt = meta.lastAt;
|
|
279
|
+
cwdFromMessages = meta.cwdFromMessages;
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
return {
|
|
283
|
+
session: { id, title, customTitle, lastAt, relatedBytes },
|
|
284
|
+
cwdFromMessages,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { spawn } from 'node:child_process';
|
|
2
|
+
import type { VersionUpdateResult } from '../../shared/types.ts';
|
|
3
|
+
import { getCurrentVersion, PACKAGE_NAME } from './version.ts';
|
|
4
|
+
|
|
5
|
+
const MAX_OUTPUT = 64_000;
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Run `npm install -g <PACKAGE_NAME>@latest` to self-update the global CLI.
|
|
9
|
+
*
|
|
10
|
+
* Fixed args, no user input → safe. Windows needs `shell:true` to invoke `npm.cmd`
|
|
11
|
+
* (Node refuses to spawn `.cmd` without a shell since CVE-2024-27980). A non-zero
|
|
12
|
+
* exit still resolves (ok:false) so the caller can surface npm's output instead of
|
|
13
|
+
* swallowing it. The running process keeps serving the OLD code until restarted.
|
|
14
|
+
*/
|
|
15
|
+
export function runSelfUpdate(targetVersion: string | null): Promise<VersionUpdateResult> {
|
|
16
|
+
const fromVersion = getCurrentVersion();
|
|
17
|
+
const isWin = process.platform === 'win32';
|
|
18
|
+
|
|
19
|
+
return new Promise((resolve) => {
|
|
20
|
+
let output = '';
|
|
21
|
+
const append = (buf: Buffer) => {
|
|
22
|
+
output += buf.toString();
|
|
23
|
+
if (output.length > MAX_OUTPUT) output = output.slice(-MAX_OUTPUT);
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
let child: ReturnType<typeof spawn>;
|
|
27
|
+
try {
|
|
28
|
+
child = spawn('npm', ['install', '-g', `${PACKAGE_NAME}@latest`], {
|
|
29
|
+
shell: isWin,
|
|
30
|
+
windowsHide: true,
|
|
31
|
+
});
|
|
32
|
+
} catch (err) {
|
|
33
|
+
resolve({
|
|
34
|
+
ok: false,
|
|
35
|
+
fromVersion,
|
|
36
|
+
toVersion: null,
|
|
37
|
+
output: (err as Error).message,
|
|
38
|
+
restartRequired: false,
|
|
39
|
+
});
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
child.stdout?.on('data', append);
|
|
44
|
+
child.stderr?.on('data', append);
|
|
45
|
+
|
|
46
|
+
child.on('error', (err) => {
|
|
47
|
+
resolve({
|
|
48
|
+
ok: false,
|
|
49
|
+
fromVersion,
|
|
50
|
+
toVersion: null,
|
|
51
|
+
output: `${output}\n${err.message}`.trim(),
|
|
52
|
+
restartRequired: false,
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
child.on('close', (code) => {
|
|
57
|
+
const ok = code === 0;
|
|
58
|
+
resolve({
|
|
59
|
+
ok,
|
|
60
|
+
fromVersion,
|
|
61
|
+
toVersion: ok ? targetVersion : null,
|
|
62
|
+
output: output.trim() || (ok ? 'updated' : `npm exited with code ${code}`),
|
|
63
|
+
restartRequired: ok,
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { compareSemver } from './version.ts';
|
|
3
|
+
|
|
4
|
+
// compareSemver 是 hasUpdate 判定的唯一依据:latest 比 current 新才提示更新。
|
|
5
|
+
// 这里钉死「更新/不更新/相等」三类边界,外加 `v` 前缀与 pre-release 排序。
|
|
6
|
+
|
|
7
|
+
describe('compareSemver', () => {
|
|
8
|
+
it('newer patch / minor / major → positive', () => {
|
|
9
|
+
expect(compareSemver('1.0.1', '1.0.0')).toBeGreaterThan(0);
|
|
10
|
+
expect(compareSemver('1.1.0', '1.0.9')).toBeGreaterThan(0);
|
|
11
|
+
expect(compareSemver('2.0.0', '1.9.9')).toBeGreaterThan(0);
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
it('older → negative', () => {
|
|
15
|
+
expect(compareSemver('1.0.0', '1.0.1')).toBeLessThan(0);
|
|
16
|
+
expect(compareSemver('1.0.9', '1.1.0')).toBeLessThan(0);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it('equal → 0, regardless of leading v', () => {
|
|
20
|
+
expect(compareSemver('1.2.3', '1.2.3')).toBe(0);
|
|
21
|
+
expect(compareSemver('v1.2.3', '1.2.3')).toBe(0);
|
|
22
|
+
expect(compareSemver('1.2.3', 'v1.2.3')).toBe(0);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('plain release outranks a pre-release of the same core', () => {
|
|
26
|
+
expect(compareSemver('1.2.0', '1.2.0-rc.1')).toBeGreaterThan(0);
|
|
27
|
+
expect(compareSemver('1.2.0-rc.1', '1.2.0')).toBeLessThan(0);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('does not flag an update for the same version (the v1.0.0 baseline)', () => {
|
|
31
|
+
// current == latest must yield hasUpdate=false in the route.
|
|
32
|
+
expect(compareSemver('1.0.0', '1.0.0') > 0).toBe(false);
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
it('treats missing patch as 0 (1.2 == 1.2.0)', () => {
|
|
36
|
+
expect(compareSemver('1.2', '1.2.0')).toBe(0);
|
|
37
|
+
expect(compareSemver('1.3', '1.2.0')).toBeGreaterThan(0);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { VersionInfo } from '../../shared/types.ts';
|
|
5
|
+
|
|
6
|
+
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
7
|
+
const PKG_PATH = path.resolve(__dirname, '..', '..', 'package.json');
|
|
8
|
+
|
|
9
|
+
const REPO = 'zzusp/claude-code-session';
|
|
10
|
+
export const REPOSITORY_URL = `https://github.com/${REPO}`;
|
|
11
|
+
export const PACKAGE_NAME = '@zzusp/ccsm';
|
|
12
|
+
const RELEASES_API = `https://api.github.com/repos/${REPO}/releases/latest`;
|
|
13
|
+
|
|
14
|
+
const CACHE_TTL_MS = 60 * 60 * 1000; // 1h — don't hammer GitHub on every page open
|
|
15
|
+
const FETCH_TIMEOUT_MS = 8000;
|
|
16
|
+
|
|
17
|
+
let cached: { at: number; info: VersionInfo } | null = null;
|
|
18
|
+
|
|
19
|
+
/** package.json `version` — the single source of truth, same as `ccsm --version`. */
|
|
20
|
+
export function getCurrentVersion(): string {
|
|
21
|
+
try {
|
|
22
|
+
const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8')) as { version?: string };
|
|
23
|
+
return typeof pkg.version === 'string' ? pkg.version : '0.0.0';
|
|
24
|
+
} catch {
|
|
25
|
+
return '0.0.0';
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
interface ParsedVersion {
|
|
30
|
+
nums: [number, number, number];
|
|
31
|
+
/** Pre-release tag (everything after the first `-`); '' for a plain release. */
|
|
32
|
+
pre: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseVersion(v: string): ParsedVersion {
|
|
36
|
+
const clean = v.trim().replace(/^v/i, '');
|
|
37
|
+
const dash = clean.indexOf('-');
|
|
38
|
+
const core = dash < 0 ? clean : clean.slice(0, dash);
|
|
39
|
+
const pre = dash < 0 ? '' : clean.slice(dash + 1);
|
|
40
|
+
const parts = core.split('.').map((n) => parseInt(n, 10) || 0);
|
|
41
|
+
return { nums: [parts[0] ?? 0, parts[1] ?? 0, parts[2] ?? 0], pre };
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Minimal semver compare: returns >0 if a is newer than b, <0 if older, 0 if equal. */
|
|
45
|
+
export function compareSemver(a: string, b: string): number {
|
|
46
|
+
const pa = parseVersion(a);
|
|
47
|
+
const pb = parseVersion(b);
|
|
48
|
+
const [a0, a1, a2] = pa.nums;
|
|
49
|
+
const [b0, b1, b2] = pb.nums;
|
|
50
|
+
if (a0 !== b0) return a0 - b0;
|
|
51
|
+
if (a1 !== b1) return a1 - b1;
|
|
52
|
+
if (a2 !== b2) return a2 - b2;
|
|
53
|
+
if (pa.pre === pb.pre) return 0;
|
|
54
|
+
// A plain release outranks any pre-release of the same core (1.2.0 > 1.2.0-rc.1).
|
|
55
|
+
if (pa.pre === '') return 1;
|
|
56
|
+
if (pb.pre === '') return -1;
|
|
57
|
+
return pa.pre < pb.pre ? -1 : 1;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
interface GithubRelease {
|
|
61
|
+
tag_name?: string;
|
|
62
|
+
name?: string;
|
|
63
|
+
body?: string;
|
|
64
|
+
html_url?: string;
|
|
65
|
+
published_at?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* VersionInfo for the UI. Cached for an hour; pass `force` to bypass.
|
|
70
|
+
* A failed lookup is never cached — it degrades to current-version-only with
|
|
71
|
+
* `checkError` set, so a later open retries.
|
|
72
|
+
*/
|
|
73
|
+
export async function getVersionInfo(force = false): Promise<VersionInfo> {
|
|
74
|
+
const current = getCurrentVersion();
|
|
75
|
+
if (!force && cached && Date.now() - cached.at < CACHE_TTL_MS) {
|
|
76
|
+
return { ...cached.info, current };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const info: VersionInfo = {
|
|
80
|
+
current,
|
|
81
|
+
latest: null,
|
|
82
|
+
hasUpdate: false,
|
|
83
|
+
releaseName: null,
|
|
84
|
+
releaseNotes: null,
|
|
85
|
+
releaseUrl: null,
|
|
86
|
+
publishedAt: null,
|
|
87
|
+
repositoryUrl: REPOSITORY_URL,
|
|
88
|
+
checkError: null,
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
try {
|
|
92
|
+
const res = await fetch(RELEASES_API, {
|
|
93
|
+
headers: {
|
|
94
|
+
accept: 'application/vnd.github+json',
|
|
95
|
+
'user-agent': `ccsm/${current}`,
|
|
96
|
+
},
|
|
97
|
+
signal: AbortSignal.timeout(FETCH_TIMEOUT_MS),
|
|
98
|
+
});
|
|
99
|
+
if (!res.ok) {
|
|
100
|
+
info.checkError = `GitHub API ${res.status}`;
|
|
101
|
+
return info;
|
|
102
|
+
}
|
|
103
|
+
const data = (await res.json()) as GithubRelease;
|
|
104
|
+
const latest = (data.tag_name ?? '').trim().replace(/^v/i, '') || null;
|
|
105
|
+
info.latest = latest;
|
|
106
|
+
info.releaseName = data.name?.trim() || data.tag_name || null;
|
|
107
|
+
info.releaseNotes = data.body ?? null;
|
|
108
|
+
info.releaseUrl = data.html_url ?? null;
|
|
109
|
+
info.publishedAt = data.published_at ?? null;
|
|
110
|
+
info.hasUpdate = latest !== null && compareSemver(latest, current) > 0;
|
|
111
|
+
cached = { at: Date.now(), info };
|
|
112
|
+
return info;
|
|
113
|
+
} catch (err) {
|
|
114
|
+
info.checkError = (err as Error).message || 'version check failed';
|
|
115
|
+
return info;
|
|
116
|
+
}
|
|
117
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import {
|
|
3
|
+
computeCleanupSuggestions,
|
|
4
|
+
deleteOrphan,
|
|
5
|
+
} from '../lib/cleanup-suggestions.ts';
|
|
6
|
+
import { isSafeId } from '../lib/safe-id.ts';
|
|
7
|
+
import type { DiskOrphanDeleteResult, DiskOrphanKind } from '../types.ts';
|
|
8
|
+
|
|
9
|
+
export const diskCleanupRoute = new Hono();
|
|
10
|
+
|
|
11
|
+
diskCleanupRoute.get('/suggestions', async (c) => {
|
|
12
|
+
const data = await computeCleanupSuggestions();
|
|
13
|
+
return c.json(data);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
diskCleanupRoute.delete('/orphan/:kind/:sid', async (c) => {
|
|
17
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
18
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
19
|
+
}
|
|
20
|
+
const kindParam = c.req.param('kind');
|
|
21
|
+
const sid = c.req.param('sid');
|
|
22
|
+
if (!isOrphanKind(kindParam)) {
|
|
23
|
+
return c.json({ error: 'invalid kind' }, 400);
|
|
24
|
+
}
|
|
25
|
+
if (!isSafeId(sid)) {
|
|
26
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
27
|
+
}
|
|
28
|
+
const result = deleteOrphan(kindParam, sid);
|
|
29
|
+
if (!result.ok) {
|
|
30
|
+
const status = result.reason === 'orphan no longer exists' ? 404 : 409;
|
|
31
|
+
return c.json({ error: result.reason }, status);
|
|
32
|
+
}
|
|
33
|
+
const payload: DiskOrphanDeleteResult = {
|
|
34
|
+
sessionId: sid,
|
|
35
|
+
kind: kindParam,
|
|
36
|
+
freedBytes: result.freedBytes,
|
|
37
|
+
};
|
|
38
|
+
return c.json(payload);
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
function isOrphanKind(v: string): v is DiskOrphanKind {
|
|
42
|
+
return v === 'file-history' || v === 'session-env';
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
46
|
+
if (!origin) return false;
|
|
47
|
+
try {
|
|
48
|
+
const url = new URL(origin);
|
|
49
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
50
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
51
|
+
} catch {
|
|
52
|
+
return false;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
@@ -1,11 +1,60 @@
|
|
|
1
1
|
import { Hono } from 'hono';
|
|
2
2
|
import { deleteSessions, type DeleteRequestItem } from '../lib/delete.ts';
|
|
3
3
|
import { loadSessionDetail } from '../lib/load-session.ts';
|
|
4
|
+
import { loadModifiedFiles } from '../lib/modified-files.ts';
|
|
5
|
+
import { openFile } from '../lib/open-folder.ts';
|
|
4
6
|
import { renameSession } from '../lib/rename-session.ts';
|
|
5
7
|
import { isSafeId } from '../lib/safe-id.ts';
|
|
6
8
|
|
|
7
9
|
export const sessionsRoute = new Hono();
|
|
8
10
|
|
|
11
|
+
// 注意:放在 /:projectId/:sessionId 之前——Hono trie 对静态后缀的具体路由优先匹配,
|
|
12
|
+
// 但显式按"更具体的路由先注册"是最稳的写法,避免日后引入其他通配段时被错位拦截。
|
|
13
|
+
sessionsRoute.get('/:projectId/:sessionId/modified-files', async (c) => {
|
|
14
|
+
const projectId = c.req.param('projectId');
|
|
15
|
+
const sessionId = c.req.param('sessionId');
|
|
16
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
17
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
18
|
+
}
|
|
19
|
+
const result = await loadModifiedFiles(projectId, sessionId);
|
|
20
|
+
if (!result) return c.json({ error: 'not found' }, 404);
|
|
21
|
+
return c.json(result);
|
|
22
|
+
});
|
|
23
|
+
|
|
24
|
+
sessionsRoute.post('/:projectId/:sessionId/open-file', async (c) => {
|
|
25
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
26
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
27
|
+
}
|
|
28
|
+
const projectId = c.req.param('projectId');
|
|
29
|
+
const sessionId = c.req.param('sessionId');
|
|
30
|
+
if (!isSafeId(projectId) || !isSafeId(sessionId)) {
|
|
31
|
+
return c.json({ error: 'invalid id' }, 400);
|
|
32
|
+
}
|
|
33
|
+
let body: { filePath?: unknown };
|
|
34
|
+
try {
|
|
35
|
+
body = (await c.req.json()) as { filePath?: unknown };
|
|
36
|
+
} catch {
|
|
37
|
+
return c.json({ error: 'invalid json body' }, 400);
|
|
38
|
+
}
|
|
39
|
+
if (typeof body.filePath !== 'string' || body.filePath === '') {
|
|
40
|
+
return c.json({ error: 'filePath (string) required' }, 400);
|
|
41
|
+
}
|
|
42
|
+
// 只允许打开"本会话确实改过的文件"——从 jsonl 重新聚合校验成员资格,
|
|
43
|
+
// 杜绝客户端传任意路径来打开系统中任意文件。
|
|
44
|
+
const modified = await loadModifiedFiles(projectId, sessionId);
|
|
45
|
+
if (!modified) return c.json({ error: 'session not found' }, 404);
|
|
46
|
+
if (!modified.files.some((f) => f.filePath === body.filePath)) {
|
|
47
|
+
return c.json({ error: 'file is not part of this session' }, 400);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const result = openFile(body.filePath);
|
|
51
|
+
if (!result.ok) {
|
|
52
|
+
const status = result.error === 'path not found' ? 404 : 500;
|
|
53
|
+
return c.json({ error: result.error ?? 'failed to open file' }, status);
|
|
54
|
+
}
|
|
55
|
+
return c.json({ ok: true, path: body.filePath });
|
|
56
|
+
});
|
|
57
|
+
|
|
9
58
|
sessionsRoute.get('/:projectId/:sessionId', async (c) => {
|
|
10
59
|
const projectId = c.req.param('projectId');
|
|
11
60
|
const sessionId = c.req.param('sessionId');
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
import { Hono } from 'hono';
|
|
2
|
+
import { runSelfUpdate } from '../lib/update.ts';
|
|
3
|
+
import { getVersionInfo } from '../lib/version.ts';
|
|
4
|
+
|
|
5
|
+
export const versionRoute = new Hono();
|
|
6
|
+
|
|
7
|
+
versionRoute.get('/', async (c) => {
|
|
8
|
+
const refresh = c.req.query('refresh') === '1';
|
|
9
|
+
const info = await getVersionInfo(refresh);
|
|
10
|
+
return c.json(info);
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
versionRoute.post('/update', async (c) => {
|
|
14
|
+
if (!isAcceptableOrigin(c.req.header('origin'))) {
|
|
15
|
+
return c.json({ error: 'origin not allowed' }, 403);
|
|
16
|
+
}
|
|
17
|
+
const info = await getVersionInfo();
|
|
18
|
+
if (!info.hasUpdate) {
|
|
19
|
+
return c.json({ error: 'already up to date' }, 400);
|
|
20
|
+
}
|
|
21
|
+
const result = await runSelfUpdate(info.latest);
|
|
22
|
+
return c.json(result);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
function isAcceptableOrigin(origin: string | undefined): boolean {
|
|
26
|
+
if (!origin) return false;
|
|
27
|
+
try {
|
|
28
|
+
const url = new URL(origin);
|
|
29
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') return false;
|
|
30
|
+
return url.hostname === 'localhost' || url.hostname === '127.0.0.1';
|
|
31
|
+
} catch {
|
|
32
|
+
return false;
|
|
33
|
+
}
|
|
34
|
+
}
|
package/shared/constants.ts
CHANGED
|
@@ -1,2 +1,7 @@
|
|
|
1
1
|
export const RECENT_ACTIVITY_WINDOW_MIN = 5;
|
|
2
2
|
export const MAX_SESSION_MESSAGES = 5000;
|
|
3
|
+
|
|
4
|
+
// Claude Code writes this synthetic `user` record when the operator aborts a turn
|
|
5
|
+
// (Esc / Ctrl-C). It means the turn was *stopped*, not that Claude is still
|
|
6
|
+
// working — so the "working" heuristic treats a trailing interrupt as idle.
|
|
7
|
+
export const INTERRUPTED_MARKER_RE = /^\s*\[Request interrupted by user/;
|