@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,139 +1,160 @@
1
- import fs from 'node:fs';
2
- import readline from 'node:readline';
3
- import { INTERRUPTED_MARKER_RE } from './constants.ts';
4
- import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
5
-
6
- export interface JsonlMeta {
7
- title: string;
8
- /** Latest `custom-title` record value, or null if never renamed. */
9
- customTitle: string | null;
10
- firstAt: string | null;
11
- lastAt: string | null;
12
- messageCount: number;
13
- cwdFromMessages: string | null;
14
- /**
15
- * The last conversation turn is unfinished — Claude still owes output. True when
16
- * the final `user`/`assistant` record is either a `user` message (and not an
17
- * abort marker) or an `assistant` message that ends on a `tool_use` block. This
18
- * is the structural half of "working"; liveness gating happens in the caller.
19
- */
20
- lastTurnIncomplete: boolean;
21
- }
22
-
23
- export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
24
- let firstUserTitle = '';
25
- let aiTitle: string | null = null;
26
- let customTitle: string | null = null;
27
- let firstAt: string | null = null;
28
- let lastAt: string | null = null;
29
- let messageCount = 0;
30
- let cwdFromMessages: string | null = null;
31
- // Re-evaluated on every conversation record so it reflects the *last* turn once
32
- // the scan finishes.
33
- let lastTurnIncomplete = false;
34
-
35
- const rl = readline.createInterface({
36
- input: fs.createReadStream(filePath, { encoding: 'utf8' }),
37
- crlfDelay: Infinity,
38
- });
39
-
40
- for await (const raw of rl) {
41
- const line = raw.trim();
42
- if (!line) continue;
43
- let obj: Record<string, unknown>;
44
- try {
45
- obj = JSON.parse(line) as Record<string, unknown>;
46
- } catch {
47
- continue;
48
- }
49
-
50
- const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
51
- if (ts) {
52
- if (!firstAt) firstAt = ts;
53
- lastAt = ts;
54
- }
55
-
56
- if (obj.cwd && typeof obj.cwd === 'string' && !cwdFromMessages) {
57
- cwdFromMessages = obj.cwd;
58
- }
59
-
60
- if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
61
- customTitle = obj.customTitle;
62
- }
63
-
64
- // Claude Code rewrites this record every turn; the latest copy is canonical.
65
- if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
66
- aiTitle = obj.aiTitle;
67
- }
68
-
69
- if (obj.type === 'user' || obj.type === 'assistant') {
70
- messageCount += 1;
71
- const msg = obj.message as { content?: unknown } | undefined;
72
-
73
- if (obj.type === 'assistant') {
74
- lastTurnIncomplete = endsWithToolUse(msg?.content);
75
- } else {
76
- const candidate = extractUserText(msg?.content);
77
- // A trailing user record means Claude owes a reply — unless it is the
78
- // synthetic abort marker, which means the operator stopped the turn.
79
- lastTurnIncomplete = !INTERRUPTED_MARKER_RE.test(candidate);
80
-
81
- if (!firstUserTitle && candidate && !SYSTEM_TAG_RE.test(candidate)) {
82
- const usable = pickTitleText(candidate);
83
- if (usable) {
84
- firstUserTitle = usable.slice(0, 80).replace(/\s+/g, ' ').trim();
85
- }
86
- }
87
- }
88
- }
89
- }
90
-
91
- // `claude code resume` keys off file mtime, which advances even when Claude Code
92
- // rewrites untimestamped meta records (ai-title rotate, custom-title/agent-name on
93
- // rename, last-prompt, permission-mode). Reconcile so the UI agrees with resume.
94
- const mtimeIso = fs.statSync(filePath).mtime.toISOString();
95
- const reconciledLastAt = !lastAt || mtimeIso > lastAt ? mtimeIso : lastAt;
96
-
97
- return {
98
- title: aiTitle || firstUserTitle || '(untitled)',
99
- customTitle,
100
- firstAt,
101
- lastAt: reconciledLastAt,
102
- messageCount,
103
- cwdFromMessages,
104
- lastTurnIncomplete,
105
- };
106
- }
107
-
108
- // An assistant message that ends on a `tool_use` block (Anthropic `stop_reason:
109
- // "tool_use"`) is mid-work: a tool is pending and Claude will continue once it
110
- // returns. Verified 1:1 against `stop_reason` across real sessions.
111
- function endsWithToolUse(content: unknown): boolean {
112
- if (!Array.isArray(content)) return false;
113
- for (let i = content.length - 1; i >= 0; i--) {
114
- const block = content[i];
115
- if (block && typeof block === 'object' && typeof (block as { type?: unknown }).type === 'string') {
116
- return (block as { type: string }).type === 'tool_use';
117
- }
118
- }
119
- return false;
120
- }
121
-
122
- function extractUserText(content: unknown): string {
123
- if (typeof content === 'string') return content;
124
- if (Array.isArray(content)) {
125
- for (const block of content) {
126
- if (
127
- block &&
128
- typeof block === 'object' &&
129
- 'type' in block &&
130
- block.type === 'text' &&
131
- 'text' in block &&
132
- typeof (block as { text: unknown }).text === 'string'
133
- ) {
134
- return (block as { text: string }).text;
135
- }
136
- }
137
- }
138
- return '';
139
- }
1
+ import fs from 'node:fs';
2
+ import readline from 'node:readline';
3
+ import { INTERRUPTED_MARKER_RE } from './constants.ts';
4
+ import { SYSTEM_TAG_RE, pickTitleText } from './system-tags.ts';
5
+
6
+ export interface JsonlMeta {
7
+ title: string;
8
+ /** Latest `custom-title` record value, or null if never renamed. */
9
+ customTitle: string | null;
10
+ firstAt: string | null;
11
+ lastAt: string | null;
12
+ messageCount: number;
13
+ /** Count of tool_result blocks flagged `is_error` across the session. */
14
+ errorCount: number;
15
+ cwdFromMessages: string | null;
16
+ /**
17
+ * The last conversation turn is unfinished Claude still owes output. True when
18
+ * the final `user`/`assistant` record is either a `user` message (and not an
19
+ * abort marker) or an `assistant` message that ends on a `tool_use` block. This
20
+ * is the structural half of "working"; liveness gating happens in the caller.
21
+ */
22
+ lastTurnIncomplete: boolean;
23
+ }
24
+
25
+ export async function parseJsonlMeta(filePath: string): Promise<JsonlMeta> {
26
+ let firstUserTitle = '';
27
+ let aiTitle: string | null = null;
28
+ let customTitle: string | null = null;
29
+ let firstAt: string | null = null;
30
+ let lastAt: string | null = null;
31
+ let messageCount = 0;
32
+ let errorCount = 0;
33
+ let cwdFromMessages: string | null = null;
34
+ // Re-evaluated on every conversation record so it reflects the *last* turn once
35
+ // the scan finishes.
36
+ let lastTurnIncomplete = false;
37
+
38
+ const rl = readline.createInterface({
39
+ input: fs.createReadStream(filePath, { encoding: 'utf8' }),
40
+ crlfDelay: Infinity,
41
+ });
42
+
43
+ for await (const raw of rl) {
44
+ const line = raw.trim();
45
+ if (!line) continue;
46
+ let obj: Record<string, unknown>;
47
+ try {
48
+ obj = JSON.parse(line) as Record<string, unknown>;
49
+ } catch {
50
+ continue;
51
+ }
52
+
53
+ const ts = typeof obj.timestamp === 'string' ? obj.timestamp : null;
54
+ if (ts) {
55
+ if (!firstAt) firstAt = ts;
56
+ lastAt = ts;
57
+ }
58
+
59
+ if (obj.cwd && typeof obj.cwd === 'string' && !cwdFromMessages) {
60
+ cwdFromMessages = obj.cwd;
61
+ }
62
+
63
+ if (obj.type === 'custom-title' && typeof obj.customTitle === 'string') {
64
+ customTitle = obj.customTitle;
65
+ }
66
+
67
+ // Claude Code rewrites this record every turn; the latest copy is canonical.
68
+ if (obj.type === 'ai-title' && typeof obj.aiTitle === 'string') {
69
+ aiTitle = obj.aiTitle;
70
+ }
71
+
72
+ if (obj.type === 'user' || obj.type === 'assistant') {
73
+ messageCount += 1;
74
+ const msg = obj.message as { content?: unknown } | undefined;
75
+ errorCount += countErrorResults(msg?.content);
76
+
77
+ if (obj.type === 'assistant') {
78
+ lastTurnIncomplete = endsWithToolUse(msg?.content);
79
+ } else {
80
+ const candidate = extractUserText(msg?.content);
81
+ // A trailing user record means Claude owes a reply — unless it is the
82
+ // synthetic abort marker, which means the operator stopped the turn.
83
+ lastTurnIncomplete = !INTERRUPTED_MARKER_RE.test(candidate);
84
+
85
+ if (!firstUserTitle && candidate && !SYSTEM_TAG_RE.test(candidate)) {
86
+ const usable = pickTitleText(candidate);
87
+ if (usable) {
88
+ firstUserTitle = usable.slice(0, 80).replace(/\s+/g, ' ').trim();
89
+ }
90
+ }
91
+ }
92
+ }
93
+ }
94
+
95
+ // `claude code resume` keys off file mtime, which advances even when Claude Code
96
+ // rewrites untimestamped meta records (ai-title rotate, custom-title/agent-name on
97
+ // rename, last-prompt, permission-mode). Reconcile so the UI agrees with resume.
98
+ const mtimeIso = fs.statSync(filePath).mtime.toISOString();
99
+ const reconciledLastAt = !lastAt || mtimeIso > lastAt ? mtimeIso : lastAt;
100
+
101
+ return {
102
+ title: aiTitle || firstUserTitle || '(untitled)',
103
+ customTitle,
104
+ firstAt,
105
+ lastAt: reconciledLastAt,
106
+ messageCount,
107
+ errorCount,
108
+ cwdFromMessages,
109
+ lastTurnIncomplete,
110
+ };
111
+ }
112
+
113
+ // An assistant message that ends on a `tool_use` block (Anthropic `stop_reason:
114
+ // "tool_use"`) is mid-work: a tool is pending and Claude will continue once it
115
+ // returns. Verified 1:1 against `stop_reason` across real sessions.
116
+ function endsWithToolUse(content: unknown): boolean {
117
+ if (!Array.isArray(content)) return false;
118
+ for (let i = content.length - 1; i >= 0; i--) {
119
+ const block = content[i];
120
+ if (block && typeof block === 'object' && typeof (block as { type?: unknown }).type === 'string') {
121
+ return (block as { type: string }).type === 'tool_use';
122
+ }
123
+ }
124
+ return false;
125
+ }
126
+
127
+ function extractUserText(content: unknown): string {
128
+ if (typeof content === 'string') return content;
129
+ if (Array.isArray(content)) {
130
+ for (const block of content) {
131
+ if (
132
+ block &&
133
+ typeof block === 'object' &&
134
+ 'type' in block &&
135
+ block.type === 'text' &&
136
+ 'text' in block &&
137
+ typeof (block as { text: unknown }).text === 'string'
138
+ ) {
139
+ return (block as { text: string }).text;
140
+ }
141
+ }
142
+ }
143
+ return '';
144
+ }
145
+
146
+ function countErrorResults(content: unknown): number {
147
+ if (!Array.isArray(content)) return 0;
148
+ let n = 0;
149
+ for (const block of content) {
150
+ if (
151
+ block &&
152
+ typeof block === 'object' &&
153
+ (block as { type?: unknown }).type === 'tool_result' &&
154
+ (block as { is_error?: unknown }).is_error === true
155
+ ) {
156
+ n += 1;
157
+ }
158
+ }
159
+ return n;
160
+ }
@@ -1,23 +1,23 @@
1
- import { createServer } from 'node:net';
2
-
3
- export async function findAvailablePort(
4
- start: number,
5
- end: number,
6
- host = '127.0.0.1',
7
- ): Promise<number> {
8
- for (let port = start; port <= end; port++) {
9
- if (await isPortFree(port, host)) return port;
10
- }
11
- throw new Error(`No free port in range ${start}..${end} on ${host}`);
12
- }
13
-
14
- function isPortFree(port: number, host: string): Promise<boolean> {
15
- return new Promise((resolve) => {
16
- const server = createServer();
17
- server.once('error', () => resolve(false));
18
- server.once('listening', () => {
19
- server.close(() => resolve(true));
20
- });
21
- server.listen(port, host);
22
- });
23
- }
1
+ import { createServer } from 'node:net';
2
+
3
+ export async function findAvailablePort(
4
+ start: number,
5
+ end: number,
6
+ host = '127.0.0.1',
7
+ ): Promise<number> {
8
+ for (let port = start; port <= end; port++) {
9
+ if (await isPortFree(port, host)) return port;
10
+ }
11
+ throw new Error(`No free port in range ${start}..${end} on ${host}`);
12
+ }
13
+
14
+ function isPortFree(port: number, host: string): Promise<boolean> {
15
+ return new Promise((resolve) => {
16
+ const server = createServer();
17
+ server.once('error', () => resolve(false));
18
+ server.once('listening', () => {
19
+ server.close(() => resolve(true));
20
+ });
21
+ server.listen(port, host);
22
+ });
23
+ }
@@ -1,41 +1,41 @@
1
- import { describe, expect, it } from 'vitest';
2
- import { isSafeId } from './safe-id.ts';
3
-
4
- // 守门:URL 参数里的 sessionId / projectId 走到任何 fs.* 之前必须先过这一关,
5
- // 漏一类就会让攻击者用 ../ 跳出 ~/.claude,所以四个拒绝点逐条钉死。
6
- describe('isSafeId', () => {
7
- it('接受常规 uuid / 编码后的 cwd', () => {
8
- expect(isSafeId('019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d')).toBe(true);
9
- expect(isSafeId('-Users-sunpeng-workspace-claude-code-session')).toBe(true);
10
- expect(isSafeId('C--Users-sunpeng')).toBe(true);
11
- });
12
-
13
- it('拒绝空字符串', () => {
14
- expect(isSafeId('')).toBe(false);
15
- });
16
-
17
- it('拒绝包含正斜杠的 id(path-traversal 入口)', () => {
18
- expect(isSafeId('a/b')).toBe(false);
19
- expect(isSafeId('../etc/passwd')).toBe(false);
20
- });
21
-
22
- it('拒绝包含反斜杠的 id(Windows path-traversal)', () => {
23
- expect(isSafeId('a\\b')).toBe(false);
24
- expect(isSafeId('..\\windows')).toBe(false);
25
- });
26
-
27
- it('拒绝包含 .. 的 id(即便不带分隔符)', () => {
28
- expect(isSafeId('foo..bar')).toBe(false);
29
- expect(isSafeId('..')).toBe(false);
30
- });
31
-
32
- it('拒绝以 . 开头的 id(屏蔽 dotfile)', () => {
33
- expect(isSafeId('.hidden')).toBe(false);
34
- expect(isSafeId('.bak-1700000000')).toBe(false);
35
- });
36
-
37
- it('单点开头的拒绝不影响中间含 . 的合法 id', () => {
38
- expect(isSafeId('memory.md')).toBe(true);
39
- expect(isSafeId('file.imported-abcd1234.md')).toBe(true);
40
- });
41
- });
1
+ import { describe, expect, it } from 'vitest';
2
+ import { isSafeId } from './safe-id.ts';
3
+
4
+ // 守门:URL 参数里的 sessionId / projectId 走到任何 fs.* 之前必须先过这一关,
5
+ // 漏一类就会让攻击者用 ../ 跳出 ~/.claude,所以四个拒绝点逐条钉死。
6
+ describe('isSafeId', () => {
7
+ it('接受常规 uuid / 编码后的 cwd', () => {
8
+ expect(isSafeId('019410ce-49fb-7d5c-b0a4-2d7d2b6a4b7d')).toBe(true);
9
+ expect(isSafeId('-Users-sunpeng-workspace-claude-code-session')).toBe(true);
10
+ expect(isSafeId('C--Users-sunpeng')).toBe(true);
11
+ });
12
+
13
+ it('拒绝空字符串', () => {
14
+ expect(isSafeId('')).toBe(false);
15
+ });
16
+
17
+ it('拒绝包含正斜杠的 id(path-traversal 入口)', () => {
18
+ expect(isSafeId('a/b')).toBe(false);
19
+ expect(isSafeId('../etc/passwd')).toBe(false);
20
+ });
21
+
22
+ it('拒绝包含反斜杠的 id(Windows path-traversal)', () => {
23
+ expect(isSafeId('a\\b')).toBe(false);
24
+ expect(isSafeId('..\\windows')).toBe(false);
25
+ });
26
+
27
+ it('拒绝包含 .. 的 id(即便不带分隔符)', () => {
28
+ expect(isSafeId('foo..bar')).toBe(false);
29
+ expect(isSafeId('..')).toBe(false);
30
+ });
31
+
32
+ it('拒绝以 . 开头的 id(屏蔽 dotfile)', () => {
33
+ expect(isSafeId('.hidden')).toBe(false);
34
+ expect(isSafeId('.bak-1700000000')).toBe(false);
35
+ });
36
+
37
+ it('单点开头的拒绝不影响中间含 . 的合法 id', () => {
38
+ expect(isSafeId('memory.md')).toBe(true);
39
+ expect(isSafeId('file.imported-abcd1234.md')).toBe(true);
40
+ });
41
+ });
@@ -1,6 +1,6 @@
1
- export function isSafeId(id: string): boolean {
2
- if (!id) return false;
3
- if (id.includes('/') || id.includes('\\') || id.includes('..')) return false;
4
- if (id.startsWith('.')) return false;
5
- return true;
6
- }
1
+ export function isSafeId(id: string): boolean {
2
+ if (!id) return false;
3
+ if (id.includes('/') || id.includes('\\') || id.includes('..')) return false;
4
+ if (id.startsWith('.')) return false;
5
+ return true;
6
+ }
@@ -1,73 +1,73 @@
1
- import fs from 'node:fs';
2
- import os from 'node:os';
3
- import path from 'node:path';
4
- import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
-
6
- // safeRemove 是 deleteSessions 与 deleteOrphan 共用的"路径校验 + 实际 rm"唯一入口。
7
- // 这里把 claude-paths.ts mock 到一个独立 tmp root(通过 process.env.CCSM_TEST_ROOT 桥接,
8
- // 与 delete.test.ts 同套路),校验它只删 root 子树内的东西、逃出去的一律抛错。
9
-
10
- let fakeRoot: string;
11
-
12
- vi.mock('./claude-paths.ts', () => {
13
- return {
14
- isUnderClaudeRoot(target: string): boolean {
15
- const root = process.env.CCSM_TEST_ROOT!;
16
- const resolved = path.resolve(target);
17
- return resolved === root || resolved.startsWith(root + path.sep);
18
- },
19
- };
20
- });
21
-
22
- beforeEach(() => {
23
- fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-test-'));
24
- process.env.CCSM_TEST_ROOT = fakeRoot;
25
- });
26
-
27
- afterEach(() => {
28
- vi.restoreAllMocks();
29
- delete process.env.CCSM_TEST_ROOT;
30
- fs.rmSync(fakeRoot, { recursive: true, force: true });
31
- });
32
-
33
- describe('safeRemove', () => {
34
- it('删 root 子树内的文件,返回 true', async () => {
35
- const { safeRemove } = await import('./safe-remove.ts');
36
- const f = path.join(fakeRoot, 'a.jsonl');
37
- fs.writeFileSync(f, 'x');
38
-
39
- expect(safeRemove(f)).toBe(true);
40
- expect(fs.existsSync(f)).toBe(false);
41
- });
42
-
43
- it('删 root 子树内的目录(recursive),返回 true', async () => {
44
- const { safeRemove } = await import('./safe-remove.ts');
45
- const dir = path.join(fakeRoot, 'file-history', 'sid-1');
46
- fs.mkdirSync(dir, { recursive: true });
47
- fs.writeFileSync(path.join(dir, 'nested.txt'), 'y');
48
-
49
- expect(safeRemove(dir)).toBe(true);
50
- expect(fs.existsSync(dir)).toBe(false);
51
- });
52
-
53
- it('目标不存在返回 false(幂等,不抛)', async () => {
54
- const { safeRemove } = await import('./safe-remove.ts');
55
- expect(safeRemove(path.join(fakeRoot, 'missing'))).toBe(false);
56
- });
57
-
58
- it('逃出 ~/.claude 子树的目标一律抛错,且不删任何东西', async () => {
59
- const { safeRemove } = await import('./safe-remove.ts');
60
- // 在 fakeRoot 之外铺一个文件,确认它不会被删
61
- const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-outside-'));
62
- const victim = path.join(outside, 'do-not-delete.txt');
63
- fs.writeFileSync(victim, 'keep me');
64
- try {
65
- expect(() => safeRemove(victim)).toThrow(/outside ~\/\.claude/);
66
- expect(fs.existsSync(victim)).toBe(true);
67
- // 兄弟目录(同前缀但非子树)也必须拒绝
68
- expect(() => safeRemove(fakeRoot + '_evil')).toThrow(/outside ~\/\.claude/);
69
- } finally {
70
- fs.rmSync(outside, { recursive: true, force: true });
71
- }
72
- });
73
- });
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
5
+
6
+ // safeRemove 是 deleteSessions 与 deleteOrphan 共用的"路径校验 + 实际 rm"唯一入口。
7
+ // 这里把 claude-paths.ts mock 到一个独立 tmp root(通过 process.env.CCSM_TEST_ROOT 桥接,
8
+ // 与 delete.test.ts 同套路),校验它只删 root 子树内的东西、逃出去的一律抛错。
9
+
10
+ let fakeRoot: string;
11
+
12
+ vi.mock('./claude-paths.ts', () => {
13
+ return {
14
+ isUnderClaudeRoot(target: string): boolean {
15
+ const root = process.env.CCSM_TEST_ROOT!;
16
+ const resolved = path.resolve(target);
17
+ return resolved === root || resolved.startsWith(root + path.sep);
18
+ },
19
+ };
20
+ });
21
+
22
+ beforeEach(() => {
23
+ fakeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-test-'));
24
+ process.env.CCSM_TEST_ROOT = fakeRoot;
25
+ });
26
+
27
+ afterEach(() => {
28
+ vi.restoreAllMocks();
29
+ delete process.env.CCSM_TEST_ROOT;
30
+ fs.rmSync(fakeRoot, { recursive: true, force: true });
31
+ });
32
+
33
+ describe('safeRemove', () => {
34
+ it('删 root 子树内的文件,返回 true', async () => {
35
+ const { safeRemove } = await import('./safe-remove.ts');
36
+ const f = path.join(fakeRoot, 'a.jsonl');
37
+ fs.writeFileSync(f, 'x');
38
+
39
+ expect(safeRemove(f)).toBe(true);
40
+ expect(fs.existsSync(f)).toBe(false);
41
+ });
42
+
43
+ it('删 root 子树内的目录(recursive),返回 true', async () => {
44
+ const { safeRemove } = await import('./safe-remove.ts');
45
+ const dir = path.join(fakeRoot, 'file-history', 'sid-1');
46
+ fs.mkdirSync(dir, { recursive: true });
47
+ fs.writeFileSync(path.join(dir, 'nested.txt'), 'y');
48
+
49
+ expect(safeRemove(dir)).toBe(true);
50
+ expect(fs.existsSync(dir)).toBe(false);
51
+ });
52
+
53
+ it('目标不存在返回 false(幂等,不抛)', async () => {
54
+ const { safeRemove } = await import('./safe-remove.ts');
55
+ expect(safeRemove(path.join(fakeRoot, 'missing'))).toBe(false);
56
+ });
57
+
58
+ it('逃出 ~/.claude 子树的目标一律抛错,且不删任何东西', async () => {
59
+ const { safeRemove } = await import('./safe-remove.ts');
60
+ // 在 fakeRoot 之外铺一个文件,确认它不会被删
61
+ const outside = fs.mkdtempSync(path.join(os.tmpdir(), 'ccsm-safe-remove-outside-'));
62
+ const victim = path.join(outside, 'do-not-delete.txt');
63
+ fs.writeFileSync(victim, 'keep me');
64
+ try {
65
+ expect(() => safeRemove(victim)).toThrow(/outside ~\/\.claude/);
66
+ expect(fs.existsSync(victim)).toBe(true);
67
+ // 兄弟目录(同前缀但非子树)也必须拒绝
68
+ expect(() => safeRemove(fakeRoot + '_evil')).toThrow(/outside ~\/\.claude/);
69
+ } finally {
70
+ fs.rmSync(outside, { recursive: true, force: true });
71
+ }
72
+ });
73
+ });
@@ -1,25 +1,25 @@
1
- import fs from 'node:fs';
2
- import { isUnderClaudeRoot } from './claude-paths.ts';
3
-
4
- /**
5
- * ~/.claude/ 下所有删除的唯一入口:先过 isUnderClaudeRoot 路径校验,再真正 rm。
6
- *
7
- * deleteSessions(5 处级联)和 deleteOrphan(孤儿目录)共用这一份"路径校验 + 实际 rm",
8
- * 把"目标必须落在 ~/.claude 子树内"这条安全网集中到一处——以后改删除约束
9
- * (加路径校验、改 rm 行为、加新防护)只改这里,不会两边各写一份、改一边漏一边。
10
- *
11
- * 文件和目录都走 recursive: true(对文件无副作用),所以单一入口能覆盖两种形态。
12
- *
13
- * @returns 是否真的删了东西(目标不存在 → false)
14
- * @throws 目标逃出 ~/.claude 子树 —— 最后一道兜底,绝不 silently 删 root 外的东西。
15
- * 调用方应在更早处用 isUnderClaudeRoot 做 graceful 预检并给出跳过原因,
16
- * 走到这里抛错说明前置校验漏了,是 bug 不是正常流程。
17
- */
18
- export function safeRemove(target: string): boolean {
19
- if (!isUnderClaudeRoot(target)) {
20
- throw new Error(`refuse to remove path outside ~/.claude: ${target}`);
21
- }
22
- if (!fs.existsSync(target)) return false;
23
- fs.rmSync(target, { recursive: true, force: true });
24
- return true;
25
- }
1
+ import fs from 'node:fs';
2
+ import { isUnderClaudeRoot } from './claude-paths.ts';
3
+
4
+ /**
5
+ * ~/.claude/ 下所有删除的唯一入口:先过 isUnderClaudeRoot 路径校验,再真正 rm。
6
+ *
7
+ * deleteSessions(5 处级联)和 deleteOrphan(孤儿目录)共用这一份"路径校验 + 实际 rm",
8
+ * 把"目标必须落在 ~/.claude 子树内"这条安全网集中到一处——以后改删除约束
9
+ * (加路径校验、改 rm 行为、加新防护)只改这里,不会两边各写一份、改一边漏一边。
10
+ *
11
+ * 文件和目录都走 recursive: true(对文件无副作用),所以单一入口能覆盖两种形态。
12
+ *
13
+ * @returns 是否真的删了东西(目标不存在 → false)
14
+ * @throws 目标逃出 ~/.claude 子树 —— 最后一道兜底,绝不 silently 删 root 外的东西。
15
+ * 调用方应在更早处用 isUnderClaudeRoot 做 graceful 预检并给出跳过原因,
16
+ * 走到这里抛错说明前置校验漏了,是 bug 不是正常流程。
17
+ */
18
+ export function safeRemove(target: string): boolean {
19
+ if (!isUnderClaudeRoot(target)) {
20
+ throw new Error(`refuse to remove path outside ~/.claude: ${target}`);
21
+ }
22
+ if (!fs.existsSync(target)) return false;
23
+ fs.rmSync(target, { recursive: true, force: true });
24
+ return true;
25
+ }