@zzusp/ccsm 1.0.1 → 1.0.2

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.
Files changed (70) hide show
  1. package/LICENSE +21 -21
  2. package/README.md +236 -236
  3. package/bin/cli.mjs +52 -52
  4. package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
  5. package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
  6. package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
  7. package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
  8. package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
  9. package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
  10. package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
  11. package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
  12. package/dist/assets/index-CrWxV6sb.css +1 -0
  13. package/dist/assets/index-DTbWl1jb.js +11 -0
  14. package/dist/assets/index-DTbWl1jb.js.map +1 -0
  15. package/dist/assets/markdown-Bag5rX3T.js +30 -0
  16. package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
  17. package/dist/index.html +26 -26
  18. package/package.json +85 -83
  19. package/server/index.ts +130 -130
  20. package/server/lib/active-sessions.test.ts +119 -119
  21. package/server/lib/active-sessions.ts +95 -95
  22. package/server/lib/bundle.test.ts +182 -182
  23. package/server/lib/bundle.ts +86 -86
  24. package/server/lib/claude-paths.test.ts +126 -126
  25. package/server/lib/claude-paths.ts +43 -43
  26. package/server/lib/cleanup-suggestions.ts +131 -131
  27. package/server/lib/constants.ts +8 -8
  28. package/server/lib/delete-project.ts +100 -100
  29. package/server/lib/delete.test.ts +244 -244
  30. package/server/lib/delete.ts +192 -192
  31. package/server/lib/disk-usage.ts +81 -81
  32. package/server/lib/encode-cwd.ts +24 -24
  33. package/server/lib/export-bundle.ts +236 -236
  34. package/server/lib/export-import-bundle.test.ts +337 -337
  35. package/server/lib/fs-size.ts +38 -38
  36. package/server/lib/import-bundle.ts +488 -488
  37. package/server/lib/load-memory.ts +120 -120
  38. package/server/lib/load-session.ts +209 -209
  39. package/server/lib/modified-files.test.ts +280 -280
  40. package/server/lib/modified-files.ts +228 -228
  41. package/server/lib/open-folder.ts +47 -47
  42. package/server/lib/parse-jsonl.ts +160 -139
  43. package/server/lib/port.ts +23 -23
  44. package/server/lib/safe-id.test.ts +41 -41
  45. package/server/lib/safe-id.ts +6 -6
  46. package/server/lib/safe-remove.test.ts +73 -73
  47. package/server/lib/safe-remove.ts +25 -25
  48. package/server/lib/scan.ts +289 -286
  49. package/server/lib/search-all.ts +130 -130
  50. package/server/lib/search-session.ts +203 -203
  51. package/server/lib/system-tags.ts +20 -20
  52. package/server/lib/update.ts +67 -67
  53. package/server/lib/version.test.ts +39 -39
  54. package/server/lib/version.ts +117 -117
  55. package/server/routes/disk-cleanup.ts +54 -54
  56. package/server/routes/disk.ts +9 -9
  57. package/server/routes/import.ts +87 -87
  58. package/server/routes/projects.ts +104 -104
  59. package/server/routes/search.ts +79 -79
  60. package/server/routes/sessions.ts +130 -130
  61. package/server/routes/version.ts +34 -34
  62. package/server/types.ts +1 -1
  63. package/shared/constants.ts +7 -7
  64. package/shared/types.ts +513 -511
  65. package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
  66. package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
  67. package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
  68. package/dist/assets/index-7aMrnHJG.js +0 -7
  69. package/dist/assets/index-7aMrnHJG.js.map +0 -1
  70. package/dist/assets/index-BOeI_J4B.css +0 -1
@@ -1,337 +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
- });
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
+ });