@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,244 @@
|
|
|
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
|
+
// delete.ts 的关键不变量是"5 处级联 + 2 类安全网":
|
|
7
|
+
// 级联 = projects/<id>.jsonl + projects/<id>/ + file-history/<id>/ + session-env/<id>/
|
|
8
|
+
// + history.jsonl 里的行 + sessions/<pid>.json
|
|
9
|
+
// 安全网 = live PID OR 5 分钟内 mtime → 直接跳过
|
|
10
|
+
// 每次都 mock claude-paths.ts 把 PATHS 指到一个独立 tmp dir,
|
|
11
|
+
// 保证测试不会读到真实 ~/.claude。
|
|
12
|
+
|
|
13
|
+
let fakeRoot: string;
|
|
14
|
+
let fakePaths: {
|
|
15
|
+
root: string;
|
|
16
|
+
projects: string;
|
|
17
|
+
fileHistory: string;
|
|
18
|
+
sessionEnv: string;
|
|
19
|
+
sessions: string;
|
|
20
|
+
history: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
vi.mock('./claude-paths.ts', () => {
|
|
24
|
+
// 注意:vi.mock 是 hoist 的,工厂里不能引用模块作用域变量。
|
|
25
|
+
// 改用 process.env.CCSM_TEST_ROOT 桥接,afterEach 时清掉。
|
|
26
|
+
return {
|
|
27
|
+
get PATHS() {
|
|
28
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
29
|
+
return {
|
|
30
|
+
root,
|
|
31
|
+
projects: path.join(root, 'projects'),
|
|
32
|
+
fileHistory: path.join(root, 'file-history'),
|
|
33
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
34
|
+
sessions: path.join(root, 'sessions'),
|
|
35
|
+
history: path.join(root, 'history.jsonl'),
|
|
36
|
+
};
|
|
37
|
+
},
|
|
38
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
39
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
40
|
+
const resolved = path.resolve(target);
|
|
41
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
42
|
+
},
|
|
43
|
+
getCacheDir(): string {
|
|
44
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
45
|
+
},
|
|
46
|
+
};
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
beforeEach(() => {
|
|
50
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-delete-test-'));
|
|
51
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
52
|
+
fakePaths = {
|
|
53
|
+
root: fakeRoot,
|
|
54
|
+
projects: path.join(fakeRoot, 'projects'),
|
|
55
|
+
fileHistory: path.join(fakeRoot, 'file-history'),
|
|
56
|
+
sessionEnv: path.join(fakeRoot, 'session-env'),
|
|
57
|
+
sessions: path.join(fakeRoot, 'sessions'),
|
|
58
|
+
history: path.join(fakeRoot, 'history.jsonl'),
|
|
59
|
+
};
|
|
60
|
+
fs.mkdirSync(fakePaths.projects, { recursive: true });
|
|
61
|
+
fs.mkdirSync(fakePaths.fileHistory, { recursive: true });
|
|
62
|
+
fs.mkdirSync(fakePaths.sessionEnv, { recursive: true });
|
|
63
|
+
fs.mkdirSync(fakePaths.sessions, { recursive: true });
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
afterEach(() => {
|
|
67
|
+
vi.restoreAllMocks();
|
|
68
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
69
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
interface SessionFixture {
|
|
73
|
+
projectId: string;
|
|
74
|
+
sessionId: string;
|
|
75
|
+
jsonlPath: string;
|
|
76
|
+
subdirPath: string;
|
|
77
|
+
fhPath: string;
|
|
78
|
+
sePath: string;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** 在 fake root 下铺出一份 5 个位置都存在的 session 文件布局。*/
|
|
82
|
+
function makeSession(opts: { sessionId?: string; projectId?: string; mtimeMs?: number } = {}): SessionFixture {
|
|
83
|
+
const sessionId = opts.sessionId ?? 'sid-' + Math.random().toString(36).slice(2, 10);
|
|
84
|
+
const projectId = opts.projectId ?? '-Users-alice-proj';
|
|
85
|
+
const projectDir = path.join(fakePaths.projects, projectId);
|
|
86
|
+
fs.mkdirSync(projectDir, { recursive: true });
|
|
87
|
+
|
|
88
|
+
const jsonlPath = path.join(projectDir, `${sessionId}.jsonl`);
|
|
89
|
+
fs.writeFileSync(jsonlPath, JSON.stringify({ type: 'user', sessionId }) + '\n');
|
|
90
|
+
|
|
91
|
+
const subdirPath = path.join(projectDir, sessionId);
|
|
92
|
+
fs.mkdirSync(subdirPath, { recursive: true });
|
|
93
|
+
fs.writeFileSync(path.join(subdirPath, 'notes.md'), 'sub');
|
|
94
|
+
|
|
95
|
+
const fhPath = path.join(fakePaths.fileHistory, sessionId);
|
|
96
|
+
fs.mkdirSync(fhPath, { recursive: true });
|
|
97
|
+
fs.writeFileSync(path.join(fhPath, 'a.txt'), 'fh');
|
|
98
|
+
|
|
99
|
+
const sePath = path.join(fakePaths.sessionEnv, sessionId);
|
|
100
|
+
fs.mkdirSync(sePath, { recursive: true });
|
|
101
|
+
fs.writeFileSync(path.join(sePath, 'env.json'), '{}');
|
|
102
|
+
|
|
103
|
+
// mtime 拨到一小时前,绕开 5 分钟安全网
|
|
104
|
+
const mtime = opts.mtimeMs ?? Date.now() - 60 * 60 * 1000;
|
|
105
|
+
fs.utimesSync(jsonlPath, mtime / 1000, mtime / 1000);
|
|
106
|
+
|
|
107
|
+
return { projectId, sessionId, jsonlPath, subdirPath, fhPath, sePath };
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function writeHistory(lines: Array<Record<string, unknown>>): void {
|
|
111
|
+
fs.writeFileSync(
|
|
112
|
+
fakePaths.history,
|
|
113
|
+
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
|
114
|
+
);
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function writePidFile(pid: number, sessionId: string, cwd = '/Users/alice/proj'): string {
|
|
118
|
+
const file = path.join(fakePaths.sessions, `${pid}.json`);
|
|
119
|
+
fs.writeFileSync(file, JSON.stringify({ pid, sessionId, cwd }));
|
|
120
|
+
return file;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
describe('deleteSessions:5 处级联清理', () => {
|
|
124
|
+
it('一次 delete 删 jsonl + subdir + file-history + session-env + history 行 + 死 PID 文件', async () => {
|
|
125
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
126
|
+
const s = makeSession();
|
|
127
|
+
|
|
128
|
+
// history.jsonl:留两条同 sid、一条别的
|
|
129
|
+
writeHistory([
|
|
130
|
+
{ sessionId: s.sessionId, project: '/Users/alice/proj', timestamp: 'T1', display: 'q1' },
|
|
131
|
+
{ sessionId: s.sessionId, project: '/Users/alice/proj', timestamp: 'T2', display: 'q2' },
|
|
132
|
+
{ sessionId: 'sid-other', project: '/Users/alice/proj', timestamp: 'T3', display: 'q3' },
|
|
133
|
+
]);
|
|
134
|
+
|
|
135
|
+
// 一个已死 PID 文件:进程不存在,但 sessions/<pid>.json 还在
|
|
136
|
+
const deadPid = 999999;
|
|
137
|
+
const pidFile = writePidFile(deadPid, s.sessionId);
|
|
138
|
+
|
|
139
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
140
|
+
|
|
141
|
+
expect(res.deleted).toHaveLength(1);
|
|
142
|
+
expect(res.skipped).toHaveLength(0);
|
|
143
|
+
expect(res.historyLinesRemoved).toBe(2);
|
|
144
|
+
|
|
145
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(false);
|
|
146
|
+
expect(fs.existsSync(s.subdirPath)).toBe(false);
|
|
147
|
+
expect(fs.existsSync(s.fhPath)).toBe(false);
|
|
148
|
+
expect(fs.existsSync(s.sePath)).toBe(false);
|
|
149
|
+
expect(fs.existsSync(pidFile)).toBe(false);
|
|
150
|
+
|
|
151
|
+
// history.jsonl 的非目标行保留
|
|
152
|
+
const remaining = fs
|
|
153
|
+
.readFileSync(fakePaths.history, 'utf8')
|
|
154
|
+
.split(/\r?\n/)
|
|
155
|
+
.filter(Boolean)
|
|
156
|
+
.map((l) => JSON.parse(l) as { sessionId: string });
|
|
157
|
+
expect(remaining).toHaveLength(1);
|
|
158
|
+
expect(remaining[0]!.sessionId).toBe('sid-other');
|
|
159
|
+
|
|
160
|
+
expect(res.deleted[0]!.cleaned).toEqual(
|
|
161
|
+
expect.arrayContaining([
|
|
162
|
+
'projects/<id>.jsonl',
|
|
163
|
+
'projects/<id>/',
|
|
164
|
+
'file-history/<id>/',
|
|
165
|
+
'session-env/<id>/',
|
|
166
|
+
]),
|
|
167
|
+
);
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
it('history.jsonl 没有目标 sid 时不重写(atomic 三步不触发)', async () => {
|
|
171
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
172
|
+
const s = makeSession();
|
|
173
|
+
writeHistory([{ sessionId: 'sid-other', project: '/x', timestamp: 'T0', display: 'q' }]);
|
|
174
|
+
const before = fs.readFileSync(fakePaths.history, 'utf8');
|
|
175
|
+
|
|
176
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
177
|
+
expect(res.historyLinesRemoved).toBe(0);
|
|
178
|
+
expect(fs.readFileSync(fakePaths.history, 'utf8')).toBe(before);
|
|
179
|
+
|
|
180
|
+
// 没有遗留的 .tmp-clean
|
|
181
|
+
const stray = fs.readdirSync(fakeRoot).filter((n) => n.startsWith('history.jsonl.tmp-'));
|
|
182
|
+
expect(stray).toEqual([]);
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
|
|
186
|
+
describe('deleteSessions:安全网', () => {
|
|
187
|
+
it('id 不合法(含 .. / 斜杠 / 点开头)直接 skip', async () => {
|
|
188
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
189
|
+
const res = await deleteSessions([
|
|
190
|
+
{ projectId: 'p', sessionId: '../escape' },
|
|
191
|
+
{ projectId: 'p', sessionId: '.hidden' },
|
|
192
|
+
]);
|
|
193
|
+
expect(res.deleted).toHaveLength(0);
|
|
194
|
+
expect(res.skipped).toHaveLength(2);
|
|
195
|
+
expect(res.skipped.every((s) => s.reason === 'invalid id')).toBe(true);
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
it('jsonl 在 5 分钟内被改过则跳过(可能仍在用)', async () => {
|
|
199
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
200
|
+
// mtime = now,落在 5 分钟窗口内
|
|
201
|
+
const s = makeSession({ mtimeMs: Date.now() });
|
|
202
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
203
|
+
expect(res.deleted).toHaveLength(0);
|
|
204
|
+
expect(res.skipped).toHaveLength(1);
|
|
205
|
+
expect(res.skipped[0]!.reason).toMatch(/within the last 5 minutes/);
|
|
206
|
+
// 文件仍在
|
|
207
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(true);
|
|
208
|
+
expect(fs.existsSync(s.fhPath)).toBe(true);
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('sessionId 出现在仍存活的 PID 文件中则跳过', async () => {
|
|
212
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
213
|
+
const s = makeSession();
|
|
214
|
+
// 用当前进程的 pid 当"活进程",process.kill(pid, 0) 必为 true
|
|
215
|
+
writePidFile(process.pid, s.sessionId);
|
|
216
|
+
|
|
217
|
+
const res = await deleteSessions([{ projectId: s.projectId, sessionId: s.sessionId }]);
|
|
218
|
+
expect(res.deleted).toHaveLength(0);
|
|
219
|
+
expect(res.skipped).toHaveLength(1);
|
|
220
|
+
expect(res.skipped[0]!.reason).toMatch(/live PID/);
|
|
221
|
+
expect(fs.existsSync(s.jsonlPath)).toBe(true);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
it('一批里混合 OK / live / recent,逐条独立判定', async () => {
|
|
225
|
+
const { deleteSessions } = await import('./delete.ts');
|
|
226
|
+
const ok = makeSession({ sessionId: 'sid-ok' });
|
|
227
|
+
const liveSid = 'sid-live';
|
|
228
|
+
const live = makeSession({ sessionId: liveSid });
|
|
229
|
+
writePidFile(process.pid, liveSid);
|
|
230
|
+
const recent = makeSession({ sessionId: 'sid-recent', mtimeMs: Date.now() });
|
|
231
|
+
|
|
232
|
+
const res = await deleteSessions([
|
|
233
|
+
{ projectId: ok.projectId, sessionId: ok.sessionId },
|
|
234
|
+
{ projectId: live.projectId, sessionId: live.sessionId },
|
|
235
|
+
{ projectId: recent.projectId, sessionId: recent.sessionId },
|
|
236
|
+
]);
|
|
237
|
+
|
|
238
|
+
expect(res.deleted.map((d) => d.sessionId)).toEqual(['sid-ok']);
|
|
239
|
+
expect(res.skipped.map((s) => s.sessionId).sort()).toEqual(['sid-live', 'sid-recent']);
|
|
240
|
+
expect(fs.existsSync(ok.jsonlPath)).toBe(false);
|
|
241
|
+
expect(fs.existsSync(live.jsonlPath)).toBe(true);
|
|
242
|
+
expect(fs.existsSync(recent.jsonlPath)).toBe(true);
|
|
243
|
+
});
|
|
244
|
+
});
|
package/server/lib/delete.ts
CHANGED
|
@@ -6,6 +6,7 @@ import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
|
6
6
|
import { RECENT_ACTIVITY_WINDOW_MS } from './constants.ts';
|
|
7
7
|
import { dirSize, fileSize } from './fs-size.ts';
|
|
8
8
|
import { isSafeId } from './safe-id.ts';
|
|
9
|
+
import { safeRemove } from './safe-remove.ts';
|
|
9
10
|
import {
|
|
10
11
|
buildActiveSessionMap,
|
|
11
12
|
readActivePidEntries,
|
|
@@ -77,10 +78,10 @@ export async function deleteSessions(items: DeleteRequestItem[]): Promise<Delete
|
|
|
77
78
|
};
|
|
78
79
|
const cleaned: string[] = [];
|
|
79
80
|
|
|
80
|
-
if (
|
|
81
|
-
if (
|
|
82
|
-
if (
|
|
83
|
-
if (
|
|
81
|
+
if (safeRemove(jsonlPath)) cleaned.push('projects/<id>.jsonl');
|
|
82
|
+
if (safeRemove(subdirPath)) cleaned.push('projects/<id>/');
|
|
83
|
+
if (safeRemove(fhPath)) cleaned.push('file-history/<id>/');
|
|
84
|
+
if (safeRemove(sePath)) cleaned.push('session-env/<id>/');
|
|
84
85
|
|
|
85
86
|
deleted.push({
|
|
86
87
|
...item,
|
|
@@ -101,18 +102,6 @@ export async function deleteSessions(items: DeleteRequestItem[]): Promise<Delete
|
|
|
101
102
|
return { deleted, skipped, historyLinesRemoved };
|
|
102
103
|
}
|
|
103
104
|
|
|
104
|
-
function rmFile(p: string): boolean {
|
|
105
|
-
if (!fs.existsSync(p)) return false;
|
|
106
|
-
fs.rmSync(p, { force: true });
|
|
107
|
-
return true;
|
|
108
|
-
}
|
|
109
|
-
|
|
110
|
-
function rmDir(p: string): boolean {
|
|
111
|
-
if (!fs.existsSync(p)) return false;
|
|
112
|
-
fs.rmSync(p, { recursive: true, force: true });
|
|
113
|
-
return true;
|
|
114
|
-
}
|
|
115
|
-
|
|
116
105
|
function isRecentlyActive(jsonlPath: string): boolean {
|
|
117
106
|
try {
|
|
118
107
|
return Date.now() - fs.statSync(jsonlPath).mtimeMs < RECENT_ACTIVITY_WINDOW_MS;
|
package/server/lib/disk-usage.ts
CHANGED
|
@@ -1,16 +1,16 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { scanProjectsForDisk } from './scan.ts';
|
|
2
2
|
import type {
|
|
3
3
|
DiskUsage,
|
|
4
4
|
DiskUsageMonthRow,
|
|
5
5
|
DiskUsageProjectRow,
|
|
6
6
|
DiskUsageTopSession,
|
|
7
|
-
|
|
7
|
+
RelatedBytes,
|
|
8
8
|
} from '../types.ts';
|
|
9
9
|
|
|
10
10
|
const TOP_N = 20;
|
|
11
11
|
|
|
12
12
|
export async function computeDiskUsage(): Promise<DiskUsage> {
|
|
13
|
-
const projects = await
|
|
13
|
+
const projects = await scanProjectsForDisk();
|
|
14
14
|
|
|
15
15
|
const byProject: DiskUsageProjectRow[] = [];
|
|
16
16
|
const monthMap = new Map<string, { bytes: number; count: number }>();
|
|
@@ -24,7 +24,6 @@ export async function computeDiskUsage(): Promise<DiskUsage> {
|
|
|
24
24
|
}> = [];
|
|
25
25
|
|
|
26
26
|
for (const p of projects) {
|
|
27
|
-
const sessions = await listSessionsForProject(p.id);
|
|
28
27
|
byProject.push({
|
|
29
28
|
projectId: p.id,
|
|
30
29
|
decodedCwd: p.decodedCwd,
|
|
@@ -32,8 +31,8 @@ export async function computeDiskUsage(): Promise<DiskUsage> {
|
|
|
32
31
|
sessionCount: p.sessionCount,
|
|
33
32
|
});
|
|
34
33
|
|
|
35
|
-
for (const s of sessions) {
|
|
36
|
-
const total = sessionTotal(s);
|
|
34
|
+
for (const s of p.sessions) {
|
|
35
|
+
const total = sessionTotal(s.relatedBytes);
|
|
37
36
|
const month = s.lastAt ? s.lastAt.slice(0, 7) : 'unknown';
|
|
38
37
|
const acc = monthMap.get(month) ?? { bytes: 0, count: 0 };
|
|
39
38
|
acc.bytes += total;
|
|
@@ -77,7 +76,6 @@ export async function computeDiskUsage(): Promise<DiskUsage> {
|
|
|
77
76
|
};
|
|
78
77
|
}
|
|
79
78
|
|
|
80
|
-
function sessionTotal(
|
|
81
|
-
const r = s.relatedBytes;
|
|
79
|
+
function sessionTotal(r: RelatedBytes): number {
|
|
82
80
|
return r.jsonl + r.subdir + r.fileHistory + r.sessionEnv;
|
|
83
81
|
}
|
|
@@ -0,0 +1,337 @@
|
|
|
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
|
+
// 这份测试目标只有一个:**对称性**。
|
|
7
|
+
// export:把项目 jsonl 里的 cwd、history.jsonl 里的 project 替换成 ${CLAUDE_PROJECT_ROOT}
|
|
8
|
+
// import:把同一占位符替换回本机目标路径
|
|
9
|
+
// 不该改的(消息正文 / gitBranch / version)一律按字节保持。
|
|
10
|
+
// roundtrip 后取出的对象图必须与原始相等(cwd 字段视目标路径替换)。
|
|
11
|
+
|
|
12
|
+
let fakeRoot: string;
|
|
13
|
+
let externalDest: string;
|
|
14
|
+
|
|
15
|
+
vi.mock('./claude-paths.ts', () => ({
|
|
16
|
+
get PATHS() {
|
|
17
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
18
|
+
return {
|
|
19
|
+
root,
|
|
20
|
+
projects: path.join(root, 'projects'),
|
|
21
|
+
fileHistory: path.join(root, 'file-history'),
|
|
22
|
+
sessionEnv: path.join(root, 'session-env'),
|
|
23
|
+
sessions: path.join(root, 'sessions'),
|
|
24
|
+
history: path.join(root, 'history.jsonl'),
|
|
25
|
+
};
|
|
26
|
+
},
|
|
27
|
+
isUnderClaudeRoot(target: string): boolean {
|
|
28
|
+
const root = process.env.CCSM_TEST_ROOT!;
|
|
29
|
+
const resolved = path.resolve(target);
|
|
30
|
+
return resolved === root || resolved.startsWith(root + path.sep);
|
|
31
|
+
},
|
|
32
|
+
getCacheDir(): string {
|
|
33
|
+
return path.join(process.env.CCSM_TEST_ROOT!, '_cache');
|
|
34
|
+
},
|
|
35
|
+
}));
|
|
36
|
+
|
|
37
|
+
beforeEach(() => {
|
|
38
|
+
fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-bundle-rt-'));
|
|
39
|
+
externalDest = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-bundle-out-'));
|
|
40
|
+
process.env.CCSM_TEST_ROOT = fakeRoot;
|
|
41
|
+
fs.mkdirSync(path.join(fakeRoot, 'projects'), { recursive: true });
|
|
42
|
+
fs.mkdirSync(path.join(fakeRoot, 'file-history'), { recursive: true });
|
|
43
|
+
fs.mkdirSync(path.join(fakeRoot, 'session-env'), { recursive: true });
|
|
44
|
+
fs.mkdirSync(path.join(fakeRoot, 'sessions'), { recursive: true });
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
afterEach(() => {
|
|
48
|
+
vi.restoreAllMocks();
|
|
49
|
+
delete process.env.CCSM_TEST_ROOT;
|
|
50
|
+
fs.rmSync(fakeRoot, { recursive: true, force: true });
|
|
51
|
+
fs.rmSync(externalDest, { recursive: true, force: true });
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const SOURCE_CWD = '/Users/alice/proj';
|
|
55
|
+
const PROJECT_ID = '-Users-alice-proj';
|
|
56
|
+
const SESSION_ID = '019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d';
|
|
57
|
+
|
|
58
|
+
interface ConvLine {
|
|
59
|
+
type: string;
|
|
60
|
+
sessionId?: string;
|
|
61
|
+
cwd?: string;
|
|
62
|
+
timestamp?: string;
|
|
63
|
+
message?: unknown;
|
|
64
|
+
gitBranch?: string;
|
|
65
|
+
version?: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function seedProject(lines: ConvLine[]): void {
|
|
69
|
+
const projDir = path.join(fakeRoot, 'projects', PROJECT_ID);
|
|
70
|
+
fs.mkdirSync(projDir, { recursive: true });
|
|
71
|
+
fs.writeFileSync(
|
|
72
|
+
path.join(projDir, `${SESSION_ID}.jsonl`),
|
|
73
|
+
lines.map((l) => JSON.stringify(l)).join('\n') + '\n',
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function seedHistory(rows: Array<Record<string, unknown>>): void {
|
|
78
|
+
fs.writeFileSync(
|
|
79
|
+
path.join(fakeRoot, 'history.jsonl'),
|
|
80
|
+
rows.map((r) => JSON.stringify(r)).join('\n') + '\n',
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
describe('export + import bundle roundtrip', () => {
|
|
85
|
+
it('cwd / project 双字段被替换成 sentinel;message / gitBranch / version 不动;reimport 还原到目标路径', async () => {
|
|
86
|
+
const { exportBundle } = await import('./export-bundle.ts');
|
|
87
|
+
const { commitImport } = await import('./import-bundle.ts');
|
|
88
|
+
|
|
89
|
+
const original: ConvLine[] = [
|
|
90
|
+
{
|
|
91
|
+
type: 'user',
|
|
92
|
+
sessionId: SESSION_ID,
|
|
93
|
+
cwd: SOURCE_CWD,
|
|
94
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
95
|
+
message: { content: `look at ${SOURCE_CWD}/src/foo.ts` },
|
|
96
|
+
gitBranch: 'main',
|
|
97
|
+
version: '1.0.0',
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
type: 'assistant',
|
|
101
|
+
sessionId: SESSION_ID,
|
|
102
|
+
cwd: SOURCE_CWD,
|
|
103
|
+
timestamp: '2026-06-09T01:00:05Z',
|
|
104
|
+
message: { content: 'sure' },
|
|
105
|
+
gitBranch: 'main',
|
|
106
|
+
version: '1.0.0',
|
|
107
|
+
},
|
|
108
|
+
];
|
|
109
|
+
seedProject(original);
|
|
110
|
+
seedHistory([
|
|
111
|
+
{
|
|
112
|
+
sessionId: SESSION_ID,
|
|
113
|
+
project: SOURCE_CWD,
|
|
114
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
115
|
+
display: 'look at ${SOURCE_CWD}/src/foo.ts',
|
|
116
|
+
// cwd 字段同源路径,但 history 行的目标字段是 project,cwd 必须保持原样
|
|
117
|
+
cwd: SOURCE_CWD,
|
|
118
|
+
},
|
|
119
|
+
{
|
|
120
|
+
sessionId: 'sid-other',
|
|
121
|
+
project: '/some/other/proj',
|
|
122
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
123
|
+
display: 'unrelated',
|
|
124
|
+
},
|
|
125
|
+
]);
|
|
126
|
+
|
|
127
|
+
// ── export ──────────────────────────────────────────────
|
|
128
|
+
const exportDir = path.join(externalDest, 'bundle');
|
|
129
|
+
const exp = await exportBundle(PROJECT_ID, 'all', exportDir);
|
|
130
|
+
expect(exp.sessionsExported).toBe(1);
|
|
131
|
+
expect(exp.historyLinesExported).toBe(1); // 只有匹配 SID 的那条被打包
|
|
132
|
+
|
|
133
|
+
// 校验 bundle 里 conversation.jsonl:cwd 被替换、消息正文不动
|
|
134
|
+
const conv = fs
|
|
135
|
+
.readFileSync(path.join(exportDir, 'sessions', SESSION_ID, 'conversation.jsonl'), 'utf8')
|
|
136
|
+
.split('\n')
|
|
137
|
+
.filter(Boolean)
|
|
138
|
+
.map((l) => JSON.parse(l) as ConvLine);
|
|
139
|
+
expect(conv).toHaveLength(2);
|
|
140
|
+
for (const line of conv) {
|
|
141
|
+
expect(line.cwd).toBe('${CLAUDE_PROJECT_ROOT}');
|
|
142
|
+
expect(line.gitBranch).toBe('main');
|
|
143
|
+
expect(line.version).toBe('1.0.0');
|
|
144
|
+
}
|
|
145
|
+
// 消息正文里的源路径作为归档原貌保留
|
|
146
|
+
expect((conv[0]!.message as { content: string }).content).toBe(
|
|
147
|
+
`look at ${SOURCE_CWD}/src/foo.ts`,
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
// history.ndjson:project 字段被替换;cwd 字段(即便等于 sourceCwd)保留
|
|
151
|
+
const hist = fs
|
|
152
|
+
.readFileSync(path.join(exportDir, 'sessions', SESSION_ID, 'history.ndjson'), 'utf8')
|
|
153
|
+
.split('\n')
|
|
154
|
+
.filter(Boolean)
|
|
155
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
156
|
+
expect(hist).toHaveLength(1);
|
|
157
|
+
expect(hist[0]!.project).toBe('${CLAUDE_PROJECT_ROOT}');
|
|
158
|
+
expect(hist[0]!.cwd).toBe(SOURCE_CWD); // 不该改
|
|
159
|
+
expect(hist[0]!.sessionId).toBe(SESSION_ID);
|
|
160
|
+
|
|
161
|
+
// bundle 不能写到 ~/.claude 里(再次抽样确认)
|
|
162
|
+
expect(exportDir.startsWith(fakeRoot)).toBe(false);
|
|
163
|
+
|
|
164
|
+
// ── 清理原项目目录 + history,模拟"导入到另一台机器"────
|
|
165
|
+
fs.rmSync(path.join(fakeRoot, 'projects', PROJECT_ID), { recursive: true, force: true });
|
|
166
|
+
// 仅保留无关行,等会儿 import 后看是否正确追加
|
|
167
|
+
seedHistory([
|
|
168
|
+
{
|
|
169
|
+
sessionId: 'sid-other',
|
|
170
|
+
project: '/some/other/proj',
|
|
171
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
172
|
+
display: 'unrelated',
|
|
173
|
+
},
|
|
174
|
+
]);
|
|
175
|
+
|
|
176
|
+
// ── import 到新路径 ────────────────────────────────────
|
|
177
|
+
const targetCwd = '/Users/bob/different-machine-proj';
|
|
178
|
+
const targetProjectId = '-Users-bob-different-machine-proj';
|
|
179
|
+
const imp = await commitImport({
|
|
180
|
+
bundleDir: exportDir,
|
|
181
|
+
targetCwd,
|
|
182
|
+
collisionPolicy: 'skip',
|
|
183
|
+
});
|
|
184
|
+
expect(imp.targetProjectId).toBe(targetProjectId);
|
|
185
|
+
expect(imp.imported).toHaveLength(1);
|
|
186
|
+
expect(imp.imported[0]!.sessionId).toBe(SESSION_ID);
|
|
187
|
+
expect(imp.historyLinesAdded).toBe(1);
|
|
188
|
+
|
|
189
|
+
// 导入后的 jsonl:cwd 替换为目标路径,其它字段按字节回到原状
|
|
190
|
+
const importedJsonl = path.join(
|
|
191
|
+
fakeRoot,
|
|
192
|
+
'projects',
|
|
193
|
+
targetProjectId,
|
|
194
|
+
`${SESSION_ID}.jsonl`,
|
|
195
|
+
);
|
|
196
|
+
expect(fs.existsSync(importedJsonl)).toBe(true);
|
|
197
|
+
const reimported = fs
|
|
198
|
+
.readFileSync(importedJsonl, 'utf8')
|
|
199
|
+
.split('\n')
|
|
200
|
+
.filter(Boolean)
|
|
201
|
+
.map((l) => JSON.parse(l) as ConvLine);
|
|
202
|
+
expect(reimported).toHaveLength(2);
|
|
203
|
+
for (const line of reimported) {
|
|
204
|
+
expect(line.cwd).toBe(targetCwd);
|
|
205
|
+
expect(line.sessionId).toBe(SESSION_ID);
|
|
206
|
+
expect(line.gitBranch).toBe('main');
|
|
207
|
+
expect(line.version).toBe('1.0.0');
|
|
208
|
+
}
|
|
209
|
+
expect((reimported[0]!.message as { content: string }).content).toBe(
|
|
210
|
+
`look at ${SOURCE_CWD}/src/foo.ts`,
|
|
211
|
+
);
|
|
212
|
+
|
|
213
|
+
// history.jsonl:原有无关行保留 + 新追加一条 project=targetCwd
|
|
214
|
+
const historyAfter = fs
|
|
215
|
+
.readFileSync(path.join(fakeRoot, 'history.jsonl'), 'utf8')
|
|
216
|
+
.split(/\r?\n/)
|
|
217
|
+
.filter(Boolean)
|
|
218
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
219
|
+
expect(historyAfter).toHaveLength(2);
|
|
220
|
+
const newHist = historyAfter.find((r) => r.sessionId === SESSION_ID)!;
|
|
221
|
+
expect(newHist.project).toBe(targetCwd);
|
|
222
|
+
expect(newHist.cwd).toBe(SOURCE_CWD); // cwd 字段不在替换范围
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
it('同 bundle 重复 import 到同一目标路径是幂等的(history 去重 key 含 project)', async () => {
|
|
226
|
+
const { exportBundle } = await import('./export-bundle.ts');
|
|
227
|
+
const { commitImport } = await import('./import-bundle.ts');
|
|
228
|
+
|
|
229
|
+
seedProject([
|
|
230
|
+
{
|
|
231
|
+
type: 'user',
|
|
232
|
+
sessionId: SESSION_ID,
|
|
233
|
+
cwd: SOURCE_CWD,
|
|
234
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
235
|
+
message: { content: 'hello' },
|
|
236
|
+
},
|
|
237
|
+
]);
|
|
238
|
+
seedHistory([
|
|
239
|
+
{
|
|
240
|
+
sessionId: SESSION_ID,
|
|
241
|
+
project: SOURCE_CWD,
|
|
242
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
243
|
+
display: 'hello',
|
|
244
|
+
},
|
|
245
|
+
]);
|
|
246
|
+
|
|
247
|
+
const exportDir = path.join(externalDest, 'bundle');
|
|
248
|
+
await exportBundle(PROJECT_ID, 'all', exportDir);
|
|
249
|
+
|
|
250
|
+
// 抹掉本机的项目目录但保留 history 的"原始一行"
|
|
251
|
+
fs.rmSync(path.join(fakeRoot, 'projects', PROJECT_ID), { recursive: true, force: true });
|
|
252
|
+
|
|
253
|
+
const targetCwd = SOURCE_CWD; // 故意 import 回原路径
|
|
254
|
+
const first = await commitImport({
|
|
255
|
+
bundleDir: exportDir,
|
|
256
|
+
targetCwd,
|
|
257
|
+
collisionPolicy: 'skip',
|
|
258
|
+
});
|
|
259
|
+
expect(first.historyLinesAdded).toBe(0); // 原始那行已经在 history 里,去重命中
|
|
260
|
+
|
|
261
|
+
// 再 import 一次:sessionId 跟本地一样 → skip
|
|
262
|
+
const second = await commitImport({
|
|
263
|
+
bundleDir: exportDir,
|
|
264
|
+
targetCwd,
|
|
265
|
+
collisionPolicy: 'skip',
|
|
266
|
+
});
|
|
267
|
+
expect(second.imported).toHaveLength(0);
|
|
268
|
+
expect(second.skipped).toHaveLength(1);
|
|
269
|
+
expect(second.historyLinesAdded).toBe(0);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it('import 到不同目标路径:history 会新增一条(project 字段不同视为不同记录)', async () => {
|
|
273
|
+
const { exportBundle } = await import('./export-bundle.ts');
|
|
274
|
+
const { commitImport } = await import('./import-bundle.ts');
|
|
275
|
+
|
|
276
|
+
seedProject([
|
|
277
|
+
{
|
|
278
|
+
type: 'user',
|
|
279
|
+
sessionId: SESSION_ID,
|
|
280
|
+
cwd: SOURCE_CWD,
|
|
281
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
282
|
+
message: { content: 'hi' },
|
|
283
|
+
},
|
|
284
|
+
]);
|
|
285
|
+
seedHistory([
|
|
286
|
+
{
|
|
287
|
+
sessionId: SESSION_ID,
|
|
288
|
+
project: SOURCE_CWD,
|
|
289
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
290
|
+
display: 'hi',
|
|
291
|
+
},
|
|
292
|
+
]);
|
|
293
|
+
|
|
294
|
+
const exportDir = path.join(externalDest, 'bundle');
|
|
295
|
+
await exportBundle(PROJECT_ID, 'all', exportDir);
|
|
296
|
+
|
|
297
|
+
fs.rmSync(path.join(fakeRoot, 'projects', PROJECT_ID), { recursive: true, force: true });
|
|
298
|
+
const targetCwd = '/Users/bob/elsewhere';
|
|
299
|
+
const res = await commitImport({
|
|
300
|
+
bundleDir: exportDir,
|
|
301
|
+
targetCwd,
|
|
302
|
+
collisionPolicy: 'skip',
|
|
303
|
+
});
|
|
304
|
+
expect(res.historyLinesAdded).toBe(1);
|
|
305
|
+
|
|
306
|
+
const rows = fs
|
|
307
|
+
.readFileSync(path.join(fakeRoot, 'history.jsonl'), 'utf8')
|
|
308
|
+
.split(/\r?\n/)
|
|
309
|
+
.filter(Boolean)
|
|
310
|
+
.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
311
|
+
// 原有一条 + 新增一条
|
|
312
|
+
expect(rows).toHaveLength(2);
|
|
313
|
+
expect(rows.map((r) => r.project).sort()).toEqual([SOURCE_CWD, targetCwd].sort());
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
it('export 拒绝写到 ~/.claude/ 内', async () => {
|
|
317
|
+
const { exportBundle, ExportError } = await import('./export-bundle.ts');
|
|
318
|
+
seedProject([
|
|
319
|
+
{
|
|
320
|
+
type: 'user',
|
|
321
|
+
sessionId: SESSION_ID,
|
|
322
|
+
cwd: SOURCE_CWD,
|
|
323
|
+
timestamp: '2026-06-09T01:00:00Z',
|
|
324
|
+
message: { content: 'hi' },
|
|
325
|
+
},
|
|
326
|
+
]);
|
|
327
|
+
const inside = path.join(fakeRoot, 'sneaky-bundle');
|
|
328
|
+
await expect(exportBundle(PROJECT_ID, 'all', inside)).rejects.toBeInstanceOf(ExportError);
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
it('export 拒绝非法 projectId(path traversal)', async () => {
|
|
332
|
+
const { exportBundle, ExportError } = await import('./export-bundle.ts');
|
|
333
|
+
await expect(
|
|
334
|
+
exportBundle('../etc', 'all', path.join(externalDest, 'b')),
|
|
335
|
+
).rejects.toBeInstanceOf(ExportError);
|
|
336
|
+
});
|
|
337
|
+
});
|