@zzusp/ccsm 1.0.1 → 1.0.3
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/LICENSE +21 -21
- package/README.md +236 -236
- package/bin/cli.mjs +52 -52
- package/dist/assets/{DiskUsage-CKhggLs5.js → DiskUsage-BY6XwffG.js} +2 -2
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-wge4VhZ-.js → ImportPage-Cwq5bx7G.js} +2 -2
- package/dist/assets/ImportPage-Cwq5bx7G.js.map +1 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js +2 -0
- package/dist/assets/MarkdownContent-BFu7Nkk_.js.map +1 -0
- package/dist/assets/{ProjectMemory-Q4XX40j_.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- package/dist/assets/index-CrWxV6sb.css +1 -0
- package/dist/assets/index-DTbWl1jb.js +11 -0
- package/dist/assets/index-DTbWl1jb.js.map +1 -0
- package/dist/assets/markdown-Bag5rX3T.js +30 -0
- package/dist/assets/markdown-Bag5rX3T.js.map +1 -0
- package/dist/index.html +26 -26
- package/package.json +81 -83
- package/server/index.ts +130 -130
- package/server/lib/active-sessions.test.ts +119 -119
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -182
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -126
- package/server/lib/claude-paths.ts +43 -43
- package/server/lib/cleanup-suggestions.ts +131 -131
- package/server/lib/constants.ts +8 -8
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -244
- package/server/lib/delete.ts +192 -192
- package/server/lib/disk-usage.ts +81 -81
- package/server/lib/encode-cwd.ts +24 -24
- package/server/lib/export-bundle.ts +236 -236
- package/server/lib/export-import-bundle.test.ts +337 -337
- package/server/lib/fs-size.ts +38 -38
- package/server/lib/import-bundle.ts +488 -488
- package/server/lib/load-memory.ts +120 -120
- package/server/lib/load-session.ts +209 -209
- package/server/lib/modified-files.test.ts +280 -280
- package/server/lib/modified-files.ts +228 -228
- package/server/lib/open-folder.ts +47 -47
- package/server/lib/parse-jsonl.ts +160 -139
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -41
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -73
- package/server/lib/safe-remove.ts +25 -25
- package/server/lib/scan.ts +289 -286
- package/server/lib/search-all.ts +130 -130
- package/server/lib/search-session.ts +203 -203
- package/server/lib/system-tags.ts +20 -20
- package/server/lib/update.ts +67 -67
- package/server/lib/version.test.ts +39 -39
- package/server/lib/version.ts +117 -117
- package/server/routes/disk-cleanup.ts +54 -54
- package/server/routes/disk.ts +9 -9
- package/server/routes/import.ts +87 -87
- package/server/routes/projects.ts +104 -104
- package/server/routes/search.ts +79 -79
- package/server/routes/sessions.ts +130 -130
- package/server/routes/version.ts +34 -34
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -7
- package/shared/types.ts +513 -511
- package/dist/assets/DiskUsage-CKhggLs5.js.map +0 -1
- package/dist/assets/ImportPage-wge4VhZ-.js.map +0 -1
- package/dist/assets/ProjectMemory-Q4XX40j_.js.map +0 -1
- package/dist/assets/index-7aMrnHJG.js +0 -7
- package/dist/assets/index-7aMrnHJG.js.map +0 -1
- package/dist/assets/index-BOeI_J4B.css +0 -1
|
@@ -1,182 +1,182 @@
|
|
|
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 } from 'vitest';
|
|
5
|
-
import { rewriteLineField, SENTINEL, sha256, transformFile } from './bundle.ts';
|
|
6
|
-
|
|
7
|
-
// bundle.ts 是 export/import 共用的"占位符替换 + 流式重写"原语。
|
|
8
|
-
// 这里关心的不是某条字段被改了,而是不该改的一律不能动:
|
|
9
|
-
// 消息正文 / gitBranch / version / 不匹配的 fromValue 全部原样保留。
|
|
10
|
-
|
|
11
|
-
let tmp: string;
|
|
12
|
-
|
|
13
|
-
beforeEach(() => {
|
|
14
|
-
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-bundle-test-'));
|
|
15
|
-
});
|
|
16
|
-
|
|
17
|
-
afterEach(() => {
|
|
18
|
-
fs.rmSync(tmp, { recursive: true, force: true });
|
|
19
|
-
});
|
|
20
|
-
|
|
21
|
-
describe('rewriteLineField', () => {
|
|
22
|
-
it('精确匹配 fromValue 时替换为 toValue', () => {
|
|
23
|
-
const line = JSON.stringify({ type: 'user', cwd: '/Users/alice/proj', message: 'hi' });
|
|
24
|
-
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
25
|
-
expect(JSON.parse(out)).toEqual({
|
|
26
|
-
type: 'user',
|
|
27
|
-
cwd: SENTINEL,
|
|
28
|
-
message: 'hi',
|
|
29
|
-
});
|
|
30
|
-
});
|
|
31
|
-
|
|
32
|
-
it('字段不存在则走快速路径原样返回(按字节相等)', () => {
|
|
33
|
-
const line = JSON.stringify({ type: 'user', message: 'no cwd here' });
|
|
34
|
-
const out = rewriteLineField(line, 'cwd', '/whatever', SENTINEL);
|
|
35
|
-
expect(out).toBe(line);
|
|
36
|
-
});
|
|
37
|
-
|
|
38
|
-
it('字段值不等于 fromValue 时不改', () => {
|
|
39
|
-
const line = JSON.stringify({ cwd: '/Users/bob/other' });
|
|
40
|
-
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
41
|
-
expect(JSON.parse(out)).toEqual({ cwd: '/Users/bob/other' });
|
|
42
|
-
});
|
|
43
|
-
|
|
44
|
-
it('恰好是 fromValue 的子串但不是字段值时不改', () => {
|
|
45
|
-
// message 里出现源 cwd 不能被殃及(消息正文必须保持归档原貌)
|
|
46
|
-
const sourceCwd = '/Users/alice/proj';
|
|
47
|
-
const line = JSON.stringify({
|
|
48
|
-
type: 'assistant',
|
|
49
|
-
cwd: '/Users/bob/other',
|
|
50
|
-
message: `traceback at ${sourceCwd}/src/foo.ts`,
|
|
51
|
-
gitBranch: 'main',
|
|
52
|
-
version: '1.2.3',
|
|
53
|
-
});
|
|
54
|
-
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
55
|
-
// cwd 不等于 sourceCwd,整行原样
|
|
56
|
-
expect(out).toBe(line);
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
it('改 cwd 字段时 message / gitBranch / version 完全不动', () => {
|
|
60
|
-
const sourceCwd = '/Users/alice/proj';
|
|
61
|
-
const line = JSON.stringify({
|
|
62
|
-
type: 'assistant',
|
|
63
|
-
cwd: sourceCwd,
|
|
64
|
-
message: `look at ${sourceCwd}/src/foo.ts please`,
|
|
65
|
-
gitBranch: sourceCwd, // 故意挑事:值跟 sourceCwd 一致
|
|
66
|
-
version: sourceCwd,
|
|
67
|
-
});
|
|
68
|
-
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
69
|
-
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
70
|
-
expect(obj.cwd).toBe(SENTINEL);
|
|
71
|
-
expect(obj.message).toBe(`look at ${sourceCwd}/src/foo.ts please`);
|
|
72
|
-
expect(obj.gitBranch).toBe(sourceCwd);
|
|
73
|
-
expect(obj.version).toBe(sourceCwd);
|
|
74
|
-
});
|
|
75
|
-
|
|
76
|
-
it('JSON 解析失败的行原样保留(容错)', () => {
|
|
77
|
-
const raw = '{this is not valid json but mentions "cwd" key';
|
|
78
|
-
const out = rewriteLineField(raw, 'cwd', '/x', SENTINEL);
|
|
79
|
-
expect(out).toBe(raw);
|
|
80
|
-
});
|
|
81
|
-
|
|
82
|
-
it('export/import 双向对称:cwd -> sentinel -> cwd 还原到原值', () => {
|
|
83
|
-
const sourceCwd = '/Users/alice/proj';
|
|
84
|
-
const targetCwd = '/Users/alice/proj'; // roundtrip 到同一台机器
|
|
85
|
-
const line = JSON.stringify({ cwd: sourceCwd, type: 'user' });
|
|
86
|
-
|
|
87
|
-
const exported = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
88
|
-
const reimported = rewriteLineField(exported, 'cwd', SENTINEL, targetCwd);
|
|
89
|
-
|
|
90
|
-
expect(JSON.parse(reimported)).toEqual({ cwd: targetCwd, type: 'user' });
|
|
91
|
-
});
|
|
92
|
-
|
|
93
|
-
it('import 到新设备:sentinel -> 新路径', () => {
|
|
94
|
-
const exported = JSON.stringify({ cwd: SENTINEL, type: 'user' });
|
|
95
|
-
const out = rewriteLineField(exported, 'cwd', SENTINEL, '/home/bob/proj');
|
|
96
|
-
expect(JSON.parse(out)).toEqual({ cwd: '/home/bob/proj', type: 'user' });
|
|
97
|
-
});
|
|
98
|
-
|
|
99
|
-
it('history.jsonl 的 project 字段(不是 cwd)也走同一原语', () => {
|
|
100
|
-
const sourceCwd = '/Users/alice/proj';
|
|
101
|
-
const line = JSON.stringify({
|
|
102
|
-
project: sourceCwd,
|
|
103
|
-
cwd: sourceCwd, // history 行里如果有 cwd 不能动,目标字段是 project
|
|
104
|
-
sessionId: 'sid-1',
|
|
105
|
-
display: 'prompt',
|
|
106
|
-
});
|
|
107
|
-
const out = rewriteLineField(line, 'project', sourceCwd, SENTINEL);
|
|
108
|
-
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
109
|
-
expect(obj.project).toBe(SENTINEL);
|
|
110
|
-
expect(obj.cwd).toBe(sourceCwd); // 被显式保留
|
|
111
|
-
});
|
|
112
|
-
});
|
|
113
|
-
|
|
114
|
-
describe('transformFile (流式重写整文件)', () => {
|
|
115
|
-
it('流式把每行的 cwd 替换为 sentinel,并报告行数 + sha256', async () => {
|
|
116
|
-
const src = path.join(tmp, 'src.jsonl');
|
|
117
|
-
const dest = path.join(tmp, 'dest.jsonl');
|
|
118
|
-
const sourceCwd = '/Users/alice/proj';
|
|
119
|
-
const lines = [
|
|
120
|
-
JSON.stringify({ type: 'user', cwd: sourceCwd, message: 'a' }),
|
|
121
|
-
JSON.stringify({ type: 'assistant', cwd: sourceCwd, message: 'b' }),
|
|
122
|
-
JSON.stringify({ type: 'summary' }), // 没 cwd 字段
|
|
123
|
-
];
|
|
124
|
-
fs.writeFileSync(src, lines.join('\n') + '\n');
|
|
125
|
-
|
|
126
|
-
const res = await transformFile(src, dest, 'cwd', sourceCwd, SENTINEL);
|
|
127
|
-
expect(res.lines).toBe(3);
|
|
128
|
-
|
|
129
|
-
const out = fs.readFileSync(dest, 'utf8').split('\n').filter(Boolean);
|
|
130
|
-
expect(out).toHaveLength(3);
|
|
131
|
-
const parsed = out.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
132
|
-
expect(parsed[0]!.cwd).toBe(SENTINEL);
|
|
133
|
-
expect(parsed[1]!.cwd).toBe(SENTINEL);
|
|
134
|
-
expect(parsed[2]!).toEqual({ type: 'summary' });
|
|
135
|
-
|
|
136
|
-
// sha256 必须对应实际写入字节
|
|
137
|
-
expect(res.sha256).toBe(sha256(fs.readFileSync(dest)));
|
|
138
|
-
});
|
|
139
|
-
|
|
140
|
-
it('丢空行(不携带记录),其他原样', async () => {
|
|
141
|
-
const src = path.join(tmp, 'src.jsonl');
|
|
142
|
-
const dest = path.join(tmp, 'dest.jsonl');
|
|
143
|
-
fs.writeFileSync(src, '\n' + JSON.stringify({ cwd: '/x' }) + '\n\n');
|
|
144
|
-
const res = await transformFile(src, dest, 'cwd', '/x', SENTINEL);
|
|
145
|
-
expect(res.lines).toBe(1);
|
|
146
|
-
expect(fs.readFileSync(dest, 'utf8')).toBe(JSON.stringify({ cwd: SENTINEL }) + '\n');
|
|
147
|
-
});
|
|
148
|
-
|
|
149
|
-
it('roundtrip:export 写入 sentinel 后再 import 回新路径,非目标字段保持字节一致', async () => {
|
|
150
|
-
const src = path.join(tmp, 'src.jsonl');
|
|
151
|
-
const exported = path.join(tmp, 'bundle.jsonl');
|
|
152
|
-
const imported = path.join(tmp, 'imported.jsonl');
|
|
153
|
-
|
|
154
|
-
const sourceCwd = '/Users/alice/proj';
|
|
155
|
-
const targetCwd = '/Users/alice/proj'; // 同机 roundtrip
|
|
156
|
-
const original = [
|
|
157
|
-
JSON.stringify({
|
|
158
|
-
type: 'user',
|
|
159
|
-
cwd: sourceCwd,
|
|
160
|
-
message: `stack at ${sourceCwd}/foo.ts`,
|
|
161
|
-
gitBranch: 'main',
|
|
162
|
-
version: '1.0.0',
|
|
163
|
-
}),
|
|
164
|
-
JSON.stringify({ type: 'summary', cwd: sourceCwd, message: 'done' }),
|
|
165
|
-
];
|
|
166
|
-
fs.writeFileSync(src, original.join('\n') + '\n');
|
|
167
|
-
|
|
168
|
-
await transformFile(src, exported, 'cwd', sourceCwd, SENTINEL);
|
|
169
|
-
await transformFile(exported, imported, 'cwd', SENTINEL, targetCwd);
|
|
170
|
-
|
|
171
|
-
const back = fs.readFileSync(imported, 'utf8').split('\n').filter(Boolean);
|
|
172
|
-
const parsed = back.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
173
|
-
expect(parsed[0]).toEqual({
|
|
174
|
-
type: 'user',
|
|
175
|
-
cwd: targetCwd,
|
|
176
|
-
message: `stack at ${sourceCwd}/foo.ts`, // 消息正文里的源路径保留为归档原貌
|
|
177
|
-
gitBranch: 'main',
|
|
178
|
-
version: '1.0.0',
|
|
179
|
-
});
|
|
180
|
-
expect(parsed[1]).toEqual({ type: 'summary', cwd: targetCwd, message: 'done' });
|
|
181
|
-
});
|
|
182
|
-
});
|
|
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 } from 'vitest';
|
|
5
|
+
import { rewriteLineField, SENTINEL, sha256, transformFile } from './bundle.ts';
|
|
6
|
+
|
|
7
|
+
// bundle.ts 是 export/import 共用的"占位符替换 + 流式重写"原语。
|
|
8
|
+
// 这里关心的不是某条字段被改了,而是不该改的一律不能动:
|
|
9
|
+
// 消息正文 / gitBranch / version / 不匹配的 fromValue 全部原样保留。
|
|
10
|
+
|
|
11
|
+
let tmp: string;
|
|
12
|
+
|
|
13
|
+
beforeEach(() => {
|
|
14
|
+
tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-bundle-test-'));
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
afterEach(() => {
|
|
18
|
+
fs.rmSync(tmp, { recursive: true, force: true });
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
describe('rewriteLineField', () => {
|
|
22
|
+
it('精确匹配 fromValue 时替换为 toValue', () => {
|
|
23
|
+
const line = JSON.stringify({ type: 'user', cwd: '/Users/alice/proj', message: 'hi' });
|
|
24
|
+
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
25
|
+
expect(JSON.parse(out)).toEqual({
|
|
26
|
+
type: 'user',
|
|
27
|
+
cwd: SENTINEL,
|
|
28
|
+
message: 'hi',
|
|
29
|
+
});
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('字段不存在则走快速路径原样返回(按字节相等)', () => {
|
|
33
|
+
const line = JSON.stringify({ type: 'user', message: 'no cwd here' });
|
|
34
|
+
const out = rewriteLineField(line, 'cwd', '/whatever', SENTINEL);
|
|
35
|
+
expect(out).toBe(line);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('字段值不等于 fromValue 时不改', () => {
|
|
39
|
+
const line = JSON.stringify({ cwd: '/Users/bob/other' });
|
|
40
|
+
const out = rewriteLineField(line, 'cwd', '/Users/alice/proj', SENTINEL);
|
|
41
|
+
expect(JSON.parse(out)).toEqual({ cwd: '/Users/bob/other' });
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('恰好是 fromValue 的子串但不是字段值时不改', () => {
|
|
45
|
+
// message 里出现源 cwd 不能被殃及(消息正文必须保持归档原貌)
|
|
46
|
+
const sourceCwd = '/Users/alice/proj';
|
|
47
|
+
const line = JSON.stringify({
|
|
48
|
+
type: 'assistant',
|
|
49
|
+
cwd: '/Users/bob/other',
|
|
50
|
+
message: `traceback at ${sourceCwd}/src/foo.ts`,
|
|
51
|
+
gitBranch: 'main',
|
|
52
|
+
version: '1.2.3',
|
|
53
|
+
});
|
|
54
|
+
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
55
|
+
// cwd 不等于 sourceCwd,整行原样
|
|
56
|
+
expect(out).toBe(line);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('改 cwd 字段时 message / gitBranch / version 完全不动', () => {
|
|
60
|
+
const sourceCwd = '/Users/alice/proj';
|
|
61
|
+
const line = JSON.stringify({
|
|
62
|
+
type: 'assistant',
|
|
63
|
+
cwd: sourceCwd,
|
|
64
|
+
message: `look at ${sourceCwd}/src/foo.ts please`,
|
|
65
|
+
gitBranch: sourceCwd, // 故意挑事:值跟 sourceCwd 一致
|
|
66
|
+
version: sourceCwd,
|
|
67
|
+
});
|
|
68
|
+
const out = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
69
|
+
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
70
|
+
expect(obj.cwd).toBe(SENTINEL);
|
|
71
|
+
expect(obj.message).toBe(`look at ${sourceCwd}/src/foo.ts please`);
|
|
72
|
+
expect(obj.gitBranch).toBe(sourceCwd);
|
|
73
|
+
expect(obj.version).toBe(sourceCwd);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
it('JSON 解析失败的行原样保留(容错)', () => {
|
|
77
|
+
const raw = '{this is not valid json but mentions "cwd" key';
|
|
78
|
+
const out = rewriteLineField(raw, 'cwd', '/x', SENTINEL);
|
|
79
|
+
expect(out).toBe(raw);
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('export/import 双向对称:cwd -> sentinel -> cwd 还原到原值', () => {
|
|
83
|
+
const sourceCwd = '/Users/alice/proj';
|
|
84
|
+
const targetCwd = '/Users/alice/proj'; // roundtrip 到同一台机器
|
|
85
|
+
const line = JSON.stringify({ cwd: sourceCwd, type: 'user' });
|
|
86
|
+
|
|
87
|
+
const exported = rewriteLineField(line, 'cwd', sourceCwd, SENTINEL);
|
|
88
|
+
const reimported = rewriteLineField(exported, 'cwd', SENTINEL, targetCwd);
|
|
89
|
+
|
|
90
|
+
expect(JSON.parse(reimported)).toEqual({ cwd: targetCwd, type: 'user' });
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
it('import 到新设备:sentinel -> 新路径', () => {
|
|
94
|
+
const exported = JSON.stringify({ cwd: SENTINEL, type: 'user' });
|
|
95
|
+
const out = rewriteLineField(exported, 'cwd', SENTINEL, '/home/bob/proj');
|
|
96
|
+
expect(JSON.parse(out)).toEqual({ cwd: '/home/bob/proj', type: 'user' });
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('history.jsonl 的 project 字段(不是 cwd)也走同一原语', () => {
|
|
100
|
+
const sourceCwd = '/Users/alice/proj';
|
|
101
|
+
const line = JSON.stringify({
|
|
102
|
+
project: sourceCwd,
|
|
103
|
+
cwd: sourceCwd, // history 行里如果有 cwd 不能动,目标字段是 project
|
|
104
|
+
sessionId: 'sid-1',
|
|
105
|
+
display: 'prompt',
|
|
106
|
+
});
|
|
107
|
+
const out = rewriteLineField(line, 'project', sourceCwd, SENTINEL);
|
|
108
|
+
const obj = JSON.parse(out) as Record<string, unknown>;
|
|
109
|
+
expect(obj.project).toBe(SENTINEL);
|
|
110
|
+
expect(obj.cwd).toBe(sourceCwd); // 被显式保留
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('transformFile (流式重写整文件)', () => {
|
|
115
|
+
it('流式把每行的 cwd 替换为 sentinel,并报告行数 + sha256', async () => {
|
|
116
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
117
|
+
const dest = path.join(tmp, 'dest.jsonl');
|
|
118
|
+
const sourceCwd = '/Users/alice/proj';
|
|
119
|
+
const lines = [
|
|
120
|
+
JSON.stringify({ type: 'user', cwd: sourceCwd, message: 'a' }),
|
|
121
|
+
JSON.stringify({ type: 'assistant', cwd: sourceCwd, message: 'b' }),
|
|
122
|
+
JSON.stringify({ type: 'summary' }), // 没 cwd 字段
|
|
123
|
+
];
|
|
124
|
+
fs.writeFileSync(src, lines.join('\n') + '\n');
|
|
125
|
+
|
|
126
|
+
const res = await transformFile(src, dest, 'cwd', sourceCwd, SENTINEL);
|
|
127
|
+
expect(res.lines).toBe(3);
|
|
128
|
+
|
|
129
|
+
const out = fs.readFileSync(dest, 'utf8').split('\n').filter(Boolean);
|
|
130
|
+
expect(out).toHaveLength(3);
|
|
131
|
+
const parsed = out.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
132
|
+
expect(parsed[0]!.cwd).toBe(SENTINEL);
|
|
133
|
+
expect(parsed[1]!.cwd).toBe(SENTINEL);
|
|
134
|
+
expect(parsed[2]!).toEqual({ type: 'summary' });
|
|
135
|
+
|
|
136
|
+
// sha256 必须对应实际写入字节
|
|
137
|
+
expect(res.sha256).toBe(sha256(fs.readFileSync(dest)));
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('丢空行(不携带记录),其他原样', async () => {
|
|
141
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
142
|
+
const dest = path.join(tmp, 'dest.jsonl');
|
|
143
|
+
fs.writeFileSync(src, '\n' + JSON.stringify({ cwd: '/x' }) + '\n\n');
|
|
144
|
+
const res = await transformFile(src, dest, 'cwd', '/x', SENTINEL);
|
|
145
|
+
expect(res.lines).toBe(1);
|
|
146
|
+
expect(fs.readFileSync(dest, 'utf8')).toBe(JSON.stringify({ cwd: SENTINEL }) + '\n');
|
|
147
|
+
});
|
|
148
|
+
|
|
149
|
+
it('roundtrip:export 写入 sentinel 后再 import 回新路径,非目标字段保持字节一致', async () => {
|
|
150
|
+
const src = path.join(tmp, 'src.jsonl');
|
|
151
|
+
const exported = path.join(tmp, 'bundle.jsonl');
|
|
152
|
+
const imported = path.join(tmp, 'imported.jsonl');
|
|
153
|
+
|
|
154
|
+
const sourceCwd = '/Users/alice/proj';
|
|
155
|
+
const targetCwd = '/Users/alice/proj'; // 同机 roundtrip
|
|
156
|
+
const original = [
|
|
157
|
+
JSON.stringify({
|
|
158
|
+
type: 'user',
|
|
159
|
+
cwd: sourceCwd,
|
|
160
|
+
message: `stack at ${sourceCwd}/foo.ts`,
|
|
161
|
+
gitBranch: 'main',
|
|
162
|
+
version: '1.0.0',
|
|
163
|
+
}),
|
|
164
|
+
JSON.stringify({ type: 'summary', cwd: sourceCwd, message: 'done' }),
|
|
165
|
+
];
|
|
166
|
+
fs.writeFileSync(src, original.join('\n') + '\n');
|
|
167
|
+
|
|
168
|
+
await transformFile(src, exported, 'cwd', sourceCwd, SENTINEL);
|
|
169
|
+
await transformFile(exported, imported, 'cwd', SENTINEL, targetCwd);
|
|
170
|
+
|
|
171
|
+
const back = fs.readFileSync(imported, 'utf8').split('\n').filter(Boolean);
|
|
172
|
+
const parsed = back.map((l) => JSON.parse(l) as Record<string, unknown>);
|
|
173
|
+
expect(parsed[0]).toEqual({
|
|
174
|
+
type: 'user',
|
|
175
|
+
cwd: targetCwd,
|
|
176
|
+
message: `stack at ${sourceCwd}/foo.ts`, // 消息正文里的源路径保留为归档原貌
|
|
177
|
+
gitBranch: 'main',
|
|
178
|
+
version: '1.0.0',
|
|
179
|
+
});
|
|
180
|
+
expect(parsed[1]).toEqual({ type: 'summary', cwd: targetCwd, message: 'done' });
|
|
181
|
+
});
|
|
182
|
+
});
|
package/server/lib/bundle.ts
CHANGED
|
@@ -1,86 +1,86 @@
|
|
|
1
|
-
import crypto from 'node:crypto';
|
|
2
|
-
import fs from 'node:fs';
|
|
3
|
-
import readline from 'node:readline';
|
|
4
|
-
|
|
5
|
-
/**
|
|
6
|
-
* The literal placeholder that stands in for the project root inside a bundle.
|
|
7
|
-
* Export replaces the device-specific absolute path with this; import swaps it
|
|
8
|
-
* back to the local target path. Single-quoted on purpose — it is a literal
|
|
9
|
-
* string, NOT a template interpolation.
|
|
10
|
-
*/
|
|
11
|
-
export const SENTINEL = '${CLAUDE_PROJECT_ROOT}';
|
|
12
|
-
|
|
13
|
-
/**
|
|
14
|
-
* Rewrite a single top-level string field of a JSONL line, only when its value
|
|
15
|
-
* exactly equals `fromValue`. Lines that don't carry the field (the fast path),
|
|
16
|
-
* fail to parse, or hold a different value pass through byte-for-byte unchanged —
|
|
17
|
-
* so message bodies and unrelated records are never touched. Re-serialization
|
|
18
|
-
* via JSON.stringify changes key order/whitespace only, which is semantically
|
|
19
|
-
* irrelevant to every consumer (Claude Code and this app both re-parse).
|
|
20
|
-
*/
|
|
21
|
-
export function rewriteLineField(
|
|
22
|
-
raw: string,
|
|
23
|
-
field: string,
|
|
24
|
-
fromValue: string,
|
|
25
|
-
toValue: string,
|
|
26
|
-
): string {
|
|
27
|
-
if (!raw.includes(`"${field}"`)) return raw;
|
|
28
|
-
let obj: Record<string, unknown>;
|
|
29
|
-
try {
|
|
30
|
-
obj = JSON.parse(raw) as Record<string, unknown>;
|
|
31
|
-
} catch {
|
|
32
|
-
return raw;
|
|
33
|
-
}
|
|
34
|
-
if (obj[field] !== fromValue) return raw;
|
|
35
|
-
obj[field] = toValue;
|
|
36
|
-
return JSON.stringify(obj);
|
|
37
|
-
}
|
|
38
|
-
|
|
39
|
-
/**
|
|
40
|
-
* Stream a JSONL/NDJSON file line-by-line, rewriting `field` from `fromValue` to
|
|
41
|
-
* `toValue` where present, into `destPath`. Never slurps the whole file. Returns
|
|
42
|
-
* the line count and the sha256 of the exact bytes written. Blank lines are
|
|
43
|
-
* dropped (they carry no record).
|
|
44
|
-
*/
|
|
45
|
-
export async function transformFile(
|
|
46
|
-
srcPath: string,
|
|
47
|
-
destPath: string,
|
|
48
|
-
field: string,
|
|
49
|
-
fromValue: string,
|
|
50
|
-
toValue: string,
|
|
51
|
-
): Promise<{ lines: number; sha256: string }> {
|
|
52
|
-
const hash = crypto.createHash('sha256');
|
|
53
|
-
const out = fs.createWriteStream(destPath, { encoding: 'utf8' });
|
|
54
|
-
const rl = readline.createInterface({
|
|
55
|
-
input: fs.createReadStream(srcPath, { encoding: 'utf8' }),
|
|
56
|
-
crlfDelay: Infinity,
|
|
57
|
-
});
|
|
58
|
-
|
|
59
|
-
let lines = 0;
|
|
60
|
-
try {
|
|
61
|
-
for await (const raw of rl) {
|
|
62
|
-
if (!raw) continue;
|
|
63
|
-
const chunk = rewriteLineField(raw, field, fromValue, toValue) + '\n';
|
|
64
|
-
hash.update(chunk);
|
|
65
|
-
out.write(chunk);
|
|
66
|
-
lines += 1;
|
|
67
|
-
}
|
|
68
|
-
await new Promise<void>((resolve, reject) => {
|
|
69
|
-
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
70
|
-
});
|
|
71
|
-
} catch (err) {
|
|
72
|
-
out.destroy();
|
|
73
|
-
throw err;
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
return { lines, sha256: hash.digest('hex') };
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
export function sha256(data: string | Buffer): string {
|
|
80
|
-
return crypto.createHash('sha256').update(data).digest('hex');
|
|
81
|
-
}
|
|
82
|
-
|
|
83
|
-
/** sha256 of a file's raw bytes. For small files (memory entries); sync read. */
|
|
84
|
-
export function sha256File(p: string): string {
|
|
85
|
-
return sha256(fs.readFileSync(p));
|
|
86
|
-
}
|
|
1
|
+
import crypto from 'node:crypto';
|
|
2
|
+
import fs from 'node:fs';
|
|
3
|
+
import readline from 'node:readline';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* The literal placeholder that stands in for the project root inside a bundle.
|
|
7
|
+
* Export replaces the device-specific absolute path with this; import swaps it
|
|
8
|
+
* back to the local target path. Single-quoted on purpose — it is a literal
|
|
9
|
+
* string, NOT a template interpolation.
|
|
10
|
+
*/
|
|
11
|
+
export const SENTINEL = '${CLAUDE_PROJECT_ROOT}';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Rewrite a single top-level string field of a JSONL line, only when its value
|
|
15
|
+
* exactly equals `fromValue`. Lines that don't carry the field (the fast path),
|
|
16
|
+
* fail to parse, or hold a different value pass through byte-for-byte unchanged —
|
|
17
|
+
* so message bodies and unrelated records are never touched. Re-serialization
|
|
18
|
+
* via JSON.stringify changes key order/whitespace only, which is semantically
|
|
19
|
+
* irrelevant to every consumer (Claude Code and this app both re-parse).
|
|
20
|
+
*/
|
|
21
|
+
export function rewriteLineField(
|
|
22
|
+
raw: string,
|
|
23
|
+
field: string,
|
|
24
|
+
fromValue: string,
|
|
25
|
+
toValue: string,
|
|
26
|
+
): string {
|
|
27
|
+
if (!raw.includes(`"${field}"`)) return raw;
|
|
28
|
+
let obj: Record<string, unknown>;
|
|
29
|
+
try {
|
|
30
|
+
obj = JSON.parse(raw) as Record<string, unknown>;
|
|
31
|
+
} catch {
|
|
32
|
+
return raw;
|
|
33
|
+
}
|
|
34
|
+
if (obj[field] !== fromValue) return raw;
|
|
35
|
+
obj[field] = toValue;
|
|
36
|
+
return JSON.stringify(obj);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Stream a JSONL/NDJSON file line-by-line, rewriting `field` from `fromValue` to
|
|
41
|
+
* `toValue` where present, into `destPath`. Never slurps the whole file. Returns
|
|
42
|
+
* the line count and the sha256 of the exact bytes written. Blank lines are
|
|
43
|
+
* dropped (they carry no record).
|
|
44
|
+
*/
|
|
45
|
+
export async function transformFile(
|
|
46
|
+
srcPath: string,
|
|
47
|
+
destPath: string,
|
|
48
|
+
field: string,
|
|
49
|
+
fromValue: string,
|
|
50
|
+
toValue: string,
|
|
51
|
+
): Promise<{ lines: number; sha256: string }> {
|
|
52
|
+
const hash = crypto.createHash('sha256');
|
|
53
|
+
const out = fs.createWriteStream(destPath, { encoding: 'utf8' });
|
|
54
|
+
const rl = readline.createInterface({
|
|
55
|
+
input: fs.createReadStream(srcPath, { encoding: 'utf8' }),
|
|
56
|
+
crlfDelay: Infinity,
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
let lines = 0;
|
|
60
|
+
try {
|
|
61
|
+
for await (const raw of rl) {
|
|
62
|
+
if (!raw) continue;
|
|
63
|
+
const chunk = rewriteLineField(raw, field, fromValue, toValue) + '\n';
|
|
64
|
+
hash.update(chunk);
|
|
65
|
+
out.write(chunk);
|
|
66
|
+
lines += 1;
|
|
67
|
+
}
|
|
68
|
+
await new Promise<void>((resolve, reject) => {
|
|
69
|
+
out.end((err?: Error | null) => (err ? reject(err) : resolve()));
|
|
70
|
+
});
|
|
71
|
+
} catch (err) {
|
|
72
|
+
out.destroy();
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return { lines, sha256: hash.digest('hex') };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export function sha256(data: string | Buffer): string {
|
|
80
|
+
return crypto.createHash('sha256').update(data).digest('hex');
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** sha256 of a file's raw bytes. For small files (memory entries); sync read. */
|
|
84
|
+
export function sha256File(p: string): string {
|
|
85
|
+
return sha256(fs.readFileSync(p));
|
|
86
|
+
}
|