@zzusp/ccsm 1.0.0 → 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.
- package/LICENSE +21 -21
- package/README.md +236 -232
- package/dist/assets/DiskUsage-BY6XwffG.js +2 -0
- package/dist/assets/DiskUsage-BY6XwffG.js.map +1 -0
- package/dist/assets/{ImportPage-b8NORa8b.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-aSV8UzQ9.js → ProjectMemory-CcE3KbUK.js} +2 -2
- package/dist/assets/ProjectMemory-CcE3KbUK.js.map +1 -0
- 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-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/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 +30 -30
- package/package.json +24 -11
- package/server/index.ts +4 -0
- package/server/lib/active-sessions.test.ts +119 -0
- package/server/lib/active-sessions.ts +95 -95
- package/server/lib/bundle.test.ts +182 -0
- package/server/lib/bundle.ts +86 -86
- package/server/lib/claude-paths.test.ts +126 -0
- package/server/lib/claude-paths.ts +43 -36
- package/server/lib/cleanup-suggestions.ts +131 -0
- package/server/lib/constants.ts +8 -7
- package/server/lib/delete-project.ts +100 -100
- package/server/lib/delete.test.ts +244 -0
- package/server/lib/delete.ts +192 -203
- package/server/lib/disk-usage.ts +81 -83
- 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 -0
- 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 -0
- package/server/lib/modified-files.ts +228 -0
- package/server/lib/open-folder.ts +47 -40
- package/server/lib/parse-jsonl.ts +160 -107
- package/server/lib/port.ts +23 -23
- package/server/lib/safe-id.test.ts +41 -0
- package/server/lib/safe-id.ts +6 -6
- package/server/lib/safe-remove.test.ts +73 -0
- package/server/lib/safe-remove.ts +25 -0
- package/server/lib/scan.ts +289 -183
- 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 -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/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 -81
- package/server/routes/version.ts +34 -0
- package/server/types.ts +1 -1
- package/shared/constants.ts +7 -2
- package/shared/types.ts +513 -359
- package/dist/assets/DiskUsage-Bq4VaoUA.js +0 -2
- package/dist/assets/DiskUsage-Bq4VaoUA.js.map +0 -1
- package/dist/assets/ImportPage-b8NORa8b.js.map +0 -1
- package/dist/assets/ProjectMemory-aSV8UzQ9.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
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
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
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
|
+
// claude-paths.ts 在模块加载期就锁定 `os.homedir() + .claude` 作为根,
|
|
7
|
+
// 所以这里全部用动态 import + vi.resetModules,让每个 case 拿到一份新评估的常量。
|
|
8
|
+
|
|
9
|
+
let tmpHome: string;
|
|
10
|
+
|
|
11
|
+
beforeEach(() => {
|
|
12
|
+
tmpHome = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-test-home-'));
|
|
13
|
+
vi.resetModules();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
afterEach(() => {
|
|
17
|
+
vi.restoreAllMocks();
|
|
18
|
+
vi.unstubAllEnvs();
|
|
19
|
+
fs.rmSync(tmpHome, { recursive: true, force: true });
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
describe('isUnderClaudeRoot (POSIX 语义)', () => {
|
|
23
|
+
it('对 ~/.claude/ 内的路径返回 true', async () => {
|
|
24
|
+
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
|
|
25
|
+
const { isUnderClaudeRoot, PATHS } = await import('./claude-paths.ts');
|
|
26
|
+
|
|
27
|
+
expect(isUnderClaudeRoot(PATHS.root)).toBe(true);
|
|
28
|
+
expect(isUnderClaudeRoot(PATHS.projects)).toBe(true);
|
|
29
|
+
expect(isUnderClaudeRoot(path.join(PATHS.projects, 'some-proj', 'a.jsonl'))).toBe(true);
|
|
30
|
+
expect(isUnderClaudeRoot(PATHS.history)).toBe(true);
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it('对 ~/.claude/ 外的路径返回 false', async () => {
|
|
34
|
+
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
|
|
35
|
+
const { isUnderClaudeRoot } = await import('./claude-paths.ts');
|
|
36
|
+
|
|
37
|
+
expect(isUnderClaudeRoot(path.join(tmpHome, 'other'))).toBe(false);
|
|
38
|
+
expect(isUnderClaudeRoot('/etc/passwd')).toBe(false);
|
|
39
|
+
expect(isUnderClaudeRoot(tmpHome)).toBe(false); // 父目录本身不算
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('挡住 prefix-only 但实为兄弟目录的路径(防 .claude_evil 形态)', async () => {
|
|
43
|
+
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
|
|
44
|
+
const { isUnderClaudeRoot } = await import('./claude-paths.ts');
|
|
45
|
+
|
|
46
|
+
// ~/.claude_evil 与 ~/.claude 同前缀,但不是子目录,必须拒绝
|
|
47
|
+
expect(isUnderClaudeRoot(path.join(tmpHome, '.claude_evil', 'x'))).toBe(false);
|
|
48
|
+
expect(isUnderClaudeRoot(path.join(tmpHome, '.claude-other'))).toBe(false);
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it('包含 .. 的目标先 path.resolve 再判断,逃逸出 root 的会被拒绝', async () => {
|
|
52
|
+
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
|
|
53
|
+
const { isUnderClaudeRoot, PATHS } = await import('./claude-paths.ts');
|
|
54
|
+
|
|
55
|
+
// .claude/projects/../../escape 解析后位于 tmpHome 之外
|
|
56
|
+
const escaped = path.join(PATHS.projects, '..', '..', 'escape');
|
|
57
|
+
expect(isUnderClaudeRoot(escaped)).toBe(false);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
describe('isUnderClaudeRoot (Windows 大小写折叠语义)', () => {
|
|
62
|
+
// claude-paths 现在按 process.platform 显式选 path.win32 / path.posix,
|
|
63
|
+
// 所以在 POSIX runtime 上把 platform 设成 'win32' + 喂真实 Windows 路径形式
|
|
64
|
+
// (盘符 + 反斜杠 / UNC),就能跑通真实的 path.win32 盘符正规化 + 大小写折叠分支,
|
|
65
|
+
// 不再靠 POSIX 假根模拟。isUnderClaudeRoot 是纯字符串比较,不碰 fs,无需铺真实目录。
|
|
66
|
+
// async 包装:必须 `return await fn()`,让 finally 的平台还原发生在 await import + 断言
|
|
67
|
+
// 全部完成之后。若写成同步 `return fn()`,finally 会在动态 import 真正求值模块前就还原,
|
|
68
|
+
// 模块顶层读到的就不是 'win32' 了。
|
|
69
|
+
async function withWin32<T>(fn: () => Promise<T>): Promise<T> {
|
|
70
|
+
const origPlatform = process.platform;
|
|
71
|
+
Object.defineProperty(process, 'platform', { value: 'win32', configurable: true });
|
|
72
|
+
try {
|
|
73
|
+
return await fn();
|
|
74
|
+
} finally {
|
|
75
|
+
Object.defineProperty(process, 'platform', { value: origPlatform, configurable: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
it('真实 C:\\ 路径:盘符大小写 + 路径大小写折叠后判同一子树', async () => {
|
|
80
|
+
await withWin32(async () => {
|
|
81
|
+
vi.spyOn(os, 'homedir').mockReturnValue('C:\\Users\\Foo');
|
|
82
|
+
const { isUnderClaudeRoot, PATHS } = await import('./claude-paths.ts');
|
|
83
|
+
|
|
84
|
+
expect(PATHS.root).toBe('C:\\Users\\Foo\\.claude');
|
|
85
|
+
|
|
86
|
+
// root 自身 + 子路径
|
|
87
|
+
expect(isUnderClaudeRoot('C:\\Users\\Foo\\.claude')).toBe(true);
|
|
88
|
+
expect(isUnderClaudeRoot('C:\\Users\\Foo\\.claude\\projects\\bar')).toBe(true);
|
|
89
|
+
// 盘符小写 + 整段大小写互换仍判为同一子树
|
|
90
|
+
expect(isUnderClaudeRoot('c:\\users\\foo\\.claude\\projects\\bar')).toBe(true);
|
|
91
|
+
expect(isUnderClaudeRoot('C:\\USERS\\FOO\\.CLAUDE')).toBe(true);
|
|
92
|
+
// 兄弟目录(同前缀非子树)拒绝
|
|
93
|
+
expect(isUnderClaudeRoot('C:\\Users\\Foo\\.claude_evil')).toBe(false);
|
|
94
|
+
// 父目录本身不算
|
|
95
|
+
expect(isUnderClaudeRoot('C:\\Users\\Foo')).toBe(false);
|
|
96
|
+
// 含 .. 逃逸出 root 的被 path.win32.resolve 解析后拒绝
|
|
97
|
+
expect(isUnderClaudeRoot('C:\\Users\\Foo\\.claude\\..\\..\\escape')).toBe(false);
|
|
98
|
+
});
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('UNC 路径(\\\\server\\share\\...)同样走 win32 正规化 + 折叠', async () => {
|
|
102
|
+
await withWin32(async () => {
|
|
103
|
+
vi.spyOn(os, 'homedir').mockReturnValue('\\\\server\\share\\Foo');
|
|
104
|
+
const { isUnderClaudeRoot, PATHS } = await import('./claude-paths.ts');
|
|
105
|
+
|
|
106
|
+
expect(PATHS.root).toBe('\\\\server\\share\\Foo\\.claude');
|
|
107
|
+
expect(isUnderClaudeRoot('\\\\server\\share\\Foo\\.claude')).toBe(true);
|
|
108
|
+
expect(isUnderClaudeRoot('\\\\SERVER\\SHARE\\FOO\\.claude\\file-history\\sid')).toBe(true);
|
|
109
|
+
expect(isUnderClaudeRoot('\\\\server\\share\\Foo\\.claude_evil')).toBe(false);
|
|
110
|
+
});
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
describe('PATHS 派生项', () => {
|
|
115
|
+
it('所有子路径都基于 root 拼接,方便集中改 layout', async () => {
|
|
116
|
+
vi.spyOn(os, 'homedir').mockReturnValue(tmpHome);
|
|
117
|
+
const { PATHS } = await import('./claude-paths.ts');
|
|
118
|
+
|
|
119
|
+
expect(PATHS.root).toBe(path.join(tmpHome, '.claude'));
|
|
120
|
+
expect(PATHS.projects).toBe(path.join(PATHS.root, 'projects'));
|
|
121
|
+
expect(PATHS.fileHistory).toBe(path.join(PATHS.root, 'file-history'));
|
|
122
|
+
expect(PATHS.sessionEnv).toBe(path.join(PATHS.root, 'session-env'));
|
|
123
|
+
expect(PATHS.sessions).toBe(path.join(PATHS.root, 'sessions'));
|
|
124
|
+
expect(PATHS.history).toBe(path.join(PATHS.root, 'history.jsonl'));
|
|
125
|
+
});
|
|
126
|
+
});
|
|
@@ -1,36 +1,43 @@
|
|
|
1
|
-
import os from 'node:os';
|
|
2
|
-
import path from 'node:path';
|
|
3
|
-
|
|
4
|
-
const
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
const
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
const
|
|
26
|
-
return
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
1
|
+
import os from 'node:os';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
const isWin = process.platform === 'win32';
|
|
5
|
+
|
|
6
|
+
// 显式按平台选 path 实现,而不是用 node:path 默认导出。
|
|
7
|
+
// 真实运行时这与默认导出等价(Windows 上默认就是 path.win32,POSIX 上是 path.posix),
|
|
8
|
+
// 行为零变化;好处是单测能在 macOS / Linux 上把 process.platform 设成 'win32' 后喂真实
|
|
9
|
+
// `C:\...` / UNC 形式,跑通 Windows 盘符正规化 + 大小写折叠的全分支——
|
|
10
|
+
// 默认导出在 POSIX runtime 会把 `C:\...` 当相对路径,没法测真实 Windows 路径形态。
|
|
11
|
+
const platformPath = isWin ? path.win32 : path.posix;
|
|
12
|
+
|
|
13
|
+
const claudeRoot = platformPath.join(os.homedir(), '.claude');
|
|
14
|
+
|
|
15
|
+
export const PATHS = {
|
|
16
|
+
root: claudeRoot,
|
|
17
|
+
projects: platformPath.join(claudeRoot, 'projects'),
|
|
18
|
+
fileHistory: platformPath.join(claudeRoot, 'file-history'),
|
|
19
|
+
sessionEnv: platformPath.join(claudeRoot, 'session-env'),
|
|
20
|
+
sessions: platformPath.join(claudeRoot, 'sessions'),
|
|
21
|
+
history: platformPath.join(claudeRoot, 'history.jsonl'),
|
|
22
|
+
} as const;
|
|
23
|
+
|
|
24
|
+
function normalizeForCompare(p: string): string {
|
|
25
|
+
const resolved = platformPath.resolve(p);
|
|
26
|
+
return isWin ? resolved.toLowerCase() : resolved;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const claudeRootNorm = normalizeForCompare(claudeRoot);
|
|
30
|
+
|
|
31
|
+
export function isUnderClaudeRoot(target: string): boolean {
|
|
32
|
+
const norm = normalizeForCompare(target);
|
|
33
|
+
return norm === claudeRootNorm || norm.startsWith(claudeRootNorm + platformPath.sep);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function getCacheDir(): string {
|
|
37
|
+
const env = process.env;
|
|
38
|
+
const base =
|
|
39
|
+
env.XDG_CACHE_HOME ??
|
|
40
|
+
env.LOCALAPPDATA ??
|
|
41
|
+
platformPath.join(os.homedir(), '.cache');
|
|
42
|
+
return platformPath.join(base, 'claude-session-viewer');
|
|
43
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { isUnderClaudeRoot, PATHS } from './claude-paths.ts';
|
|
4
|
+
import { dirSize } from './fs-size.ts';
|
|
5
|
+
import { isSafeId } from './safe-id.ts';
|
|
6
|
+
import { safeRemove } from './safe-remove.ts';
|
|
7
|
+
import { scanProjectsForDisk } from './scan.ts';
|
|
8
|
+
import type {
|
|
9
|
+
DiskCleanupLargeSession,
|
|
10
|
+
DiskCleanupOrphan,
|
|
11
|
+
DiskCleanupSuggestions,
|
|
12
|
+
} from '../types.ts';
|
|
13
|
+
|
|
14
|
+
const TOP_N_SESSIONS = 10;
|
|
15
|
+
const JSONL_EXT = '.jsonl';
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 扫描所有 projects/<encoded>/*.jsonl 收集 sid 全集——后面 file-history/<sid>/、
|
|
19
|
+
* session-env/<sid>/ 与该集合做差,剩下的就是孤儿。
|
|
20
|
+
*/
|
|
21
|
+
function collectKnownSessionIds(): Set<string> {
|
|
22
|
+
const known = new Set<string>();
|
|
23
|
+
if (!fs.existsSync(PATHS.projects)) return known;
|
|
24
|
+
for (const proj of fs.readdirSync(PATHS.projects, { withFileTypes: true })) {
|
|
25
|
+
if (!proj.isDirectory()) continue;
|
|
26
|
+
const projectDir = path.join(PATHS.projects, proj.name);
|
|
27
|
+
if (!isUnderClaudeRoot(projectDir)) continue;
|
|
28
|
+
let entries: fs.Dirent[];
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(projectDir, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
continue;
|
|
33
|
+
}
|
|
34
|
+
for (const ent of entries) {
|
|
35
|
+
if (ent.isFile() && ent.name.endsWith(JSONL_EXT)) {
|
|
36
|
+
known.add(ent.name.slice(0, -JSONL_EXT.length));
|
|
37
|
+
} else if (ent.isDirectory()) {
|
|
38
|
+
// projects/<encoded>/<sid>/ 子目录也是 session 主体的一部分(即使 jsonl 已被外部删),
|
|
39
|
+
// 也算"已知",不当孤儿处理。
|
|
40
|
+
known.add(ent.name);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return known;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* 扫 file-history/ 或 session-env/ 下的子目录,过滤掉已知 sid,剩下的就是孤儿。
|
|
49
|
+
* 每条返回大小(用 fs-size.dirSize 复用统一 du 逻辑)。
|
|
50
|
+
*/
|
|
51
|
+
function scanOrphans(rootDir: string, known: Set<string>): DiskCleanupOrphan[] {
|
|
52
|
+
if (!fs.existsSync(rootDir)) return [];
|
|
53
|
+
const out: DiskCleanupOrphan[] = [];
|
|
54
|
+
for (const ent of fs.readdirSync(rootDir, { withFileTypes: true })) {
|
|
55
|
+
if (!ent.isDirectory()) continue;
|
|
56
|
+
const sid = ent.name;
|
|
57
|
+
if (!isSafeId(sid)) continue;
|
|
58
|
+
if (known.has(sid)) continue;
|
|
59
|
+
const full = path.join(rootDir, sid);
|
|
60
|
+
if (!isUnderClaudeRoot(full)) continue;
|
|
61
|
+
out.push({ sessionId: sid, sizeBytes: dirSize(full) });
|
|
62
|
+
}
|
|
63
|
+
out.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
64
|
+
return out;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
export async function computeCleanupSuggestions(): Promise<DiskCleanupSuggestions> {
|
|
68
|
+
const projects = await scanProjectsForDisk();
|
|
69
|
+
|
|
70
|
+
// 大会话:扫每个项目的会话,按总占用排序取 top 10
|
|
71
|
+
const flat: DiskCleanupLargeSession[] = [];
|
|
72
|
+
for (const p of projects) {
|
|
73
|
+
for (const s of p.sessions) {
|
|
74
|
+
const r = s.relatedBytes;
|
|
75
|
+
const total = r.jsonl + r.subdir + r.fileHistory + r.sessionEnv;
|
|
76
|
+
if (total <= 0) continue;
|
|
77
|
+
flat.push({
|
|
78
|
+
sessionId: s.id,
|
|
79
|
+
projectId: p.id,
|
|
80
|
+
projectPath: p.decodedCwd,
|
|
81
|
+
title: s.title,
|
|
82
|
+
customTitle: s.customTitle,
|
|
83
|
+
sizeBytes: total,
|
|
84
|
+
lastActivity: s.lastAt,
|
|
85
|
+
});
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
flat.sort((a, b) => b.sizeBytes - a.sizeBytes);
|
|
89
|
+
const largeSessions = flat.slice(0, TOP_N_SESSIONS);
|
|
90
|
+
|
|
91
|
+
// 孤儿:file-history/<sid>/ 或 session-env/<sid>/ 但 sid 不在任何 .jsonl 中
|
|
92
|
+
const known = collectKnownSessionIds();
|
|
93
|
+
const orphanFileHistory = scanOrphans(PATHS.fileHistory, known);
|
|
94
|
+
const orphanSessionEnv = scanOrphans(PATHS.sessionEnv, known);
|
|
95
|
+
|
|
96
|
+
return { largeSessions, orphanFileHistory, orphanSessionEnv };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* 删一个孤儿目录(file-history/<sid>/ 或 session-env/<sid>/)。
|
|
101
|
+
*
|
|
102
|
+
* 这里不复用 deleteSessions 的主流程:那条路径以 projects/<pid>/<sid>.jsonl 为锚点,
|
|
103
|
+
* jsonl + subdir 都不存在时会被 "no files for this session" 早退出,没法处理"主体已没了
|
|
104
|
+
* 但侧 store 还在"的纯孤儿场景。但「路径校验 + 实际 rm」这道安全网两条删除路径必须一致,
|
|
105
|
+
* 所以共用 safeRemove —— 差异只在前置判定(这里是"二次确认仍是孤儿",deleteSessions 是
|
|
106
|
+
* "跳过 live PID / 5 分钟内活跃")。
|
|
107
|
+
*
|
|
108
|
+
* 调用方负责 sid 已过 isSafeId、kind 已经过白名单校验。
|
|
109
|
+
*/
|
|
110
|
+
export function deleteOrphan(
|
|
111
|
+
kind: 'file-history' | 'session-env',
|
|
112
|
+
sessionId: string,
|
|
113
|
+
): { ok: true; freedBytes: number } | { ok: false; reason: string } {
|
|
114
|
+
const rootDir = kind === 'file-history' ? PATHS.fileHistory : PATHS.sessionEnv;
|
|
115
|
+
const target = path.join(rootDir, sessionId);
|
|
116
|
+
if (!isUnderClaudeRoot(target)) {
|
|
117
|
+
return { ok: false, reason: `path escapes ~/.claude: ${target}` };
|
|
118
|
+
}
|
|
119
|
+
if (!fs.existsSync(target)) {
|
|
120
|
+
return { ok: false, reason: 'orphan no longer exists' };
|
|
121
|
+
}
|
|
122
|
+
// 二次保险:确认它现在确实还是孤儿(避免并发场景下用户先点了"导入"再点"删除")
|
|
123
|
+
const known = collectKnownSessionIds();
|
|
124
|
+
if (known.has(sessionId)) {
|
|
125
|
+
return { ok: false, reason: 'session is no longer orphaned' };
|
|
126
|
+
}
|
|
127
|
+
const freedBytes = dirSize(target);
|
|
128
|
+
safeRemove(target);
|
|
129
|
+
return { ok: true, freedBytes };
|
|
130
|
+
}
|
|
131
|
+
|
package/server/lib/constants.ts
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
|
-
export {
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
1
|
+
export {
|
|
2
|
+
INTERRUPTED_MARKER_RE,
|
|
3
|
+
MAX_SESSION_MESSAGES,
|
|
4
|
+
RECENT_ACTIVITY_WINDOW_MIN,
|
|
5
|
+
} from '../../shared/constants.ts';
|
|
6
|
+
import { RECENT_ACTIVITY_WINDOW_MIN } from '../../shared/constants.ts';
|
|
7
|
+
|
|
8
|
+
export const RECENT_ACTIVITY_WINDOW_MS = RECENT_ACTIVITY_WINDOW_MIN * 60 * 1000;
|