cc-viewer 1.6.18 → 1.6.19

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/dist/index.html CHANGED
@@ -6,8 +6,8 @@
6
6
  <title>Claude Code Viewer</title>
7
7
  <link rel="icon" href="/favicon.ico?v=1">
8
8
  <link rel="shortcut icon" href="/favicon.ico?v=1">
9
- <script type="module" crossorigin src="/assets/index-7Mvpu6NN.js"></script>
10
- <link rel="stylesheet" crossorigin href="/assets/index-_YY_bV5h.css">
9
+ <script type="module" crossorigin src="/assets/index-BU4Xu0xM.js"></script>
10
+ <link rel="stylesheet" crossorigin href="/assets/index-B0PFQOGX.css">
11
11
  </head>
12
12
  <body>
13
13
  <div id="root"></div>
package/i18n.js CHANGED
@@ -1,4 +1,5 @@
1
1
  // ============ i18n 翻译数据 ============
2
+ // 要注意这个文件不是给客户端用的
2
3
 
3
4
  const i18nData = {
4
5
  "cli.alreadyWorking": {
@@ -0,0 +1,128 @@
1
+ /**
2
+ * File API business logic — extracted from server.js
3
+ * Provides path validation, file read/write with security checks.
4
+ */
5
+ import { resolve, join } from 'node:path';
6
+ import { realpathSync, existsSync, statSync, readFileSync, writeFileSync } from 'node:fs';
7
+
8
+ /**
9
+ * Check whether targetPath is contained within the project root directory.
10
+ * Resolves symlinks via realpathSync. Returns false on any error.
11
+ * @param {string} targetPath - absolute path to check
12
+ * @param {string} [root] - project root (defaults to CCV_PROJECT_DIR or cwd)
13
+ * @returns {boolean}
14
+ */
15
+ export function isPathContained(targetPath, root) {
16
+ try {
17
+ const resolvedRoot = realpathSync(resolve(root || process.env.CCV_PROJECT_DIR || process.cwd()));
18
+ const real = realpathSync(resolve(targetPath));
19
+ return real === resolvedRoot || real.startsWith(resolvedRoot + '/');
20
+ } catch { return false; }
21
+ }
22
+
23
+ /** Custom error with a code property for HTTP status mapping */
24
+ class FileApiError extends Error {
25
+ constructor(code, message) {
26
+ super(message);
27
+ this.code = code;
28
+ }
29
+ }
30
+
31
+ /**
32
+ * Resolve and validate a file path. Used by readFileContent and file-raw handler.
33
+ * @param {string} cwd - project working directory
34
+ * @param {string} reqPath - requested path (relative or absolute)
35
+ * @param {boolean} isEditorSession - whether this is an editor session
36
+ * @returns {string} resolved absolute file path
37
+ * @throws {FileApiError} with code 'INVALID_PATH'
38
+ */
39
+ export function resolveFilePath(cwd, reqPath, isEditorSession) {
40
+ if (!reqPath) {
41
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
42
+ }
43
+ if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
44
+ const resolved = resolve(reqPath.startsWith('/') ? reqPath : join(cwd, reqPath));
45
+ if (!isPathContained(resolved, cwd)) {
46
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
47
+ }
48
+ return resolve(resolved);
49
+ }
50
+ return resolve((isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath));
51
+ }
52
+
53
+ /**
54
+ * Read file content with size limit and security checks.
55
+ * @param {string} cwd - project working directory
56
+ * @param {string} reqPath - requested path
57
+ * @param {boolean} isEditorSession
58
+ * @returns {{ path: string, content: string, size: number }}
59
+ */
60
+ export function readFileContent(cwd, reqPath, isEditorSession) {
61
+ if (!reqPath) {
62
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
63
+ }
64
+
65
+ // For non-editor sessions with absolute / ".." paths that are within project dir,
66
+ // return the relative path from project root
67
+ if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
68
+ const resolved = resolve(reqPath);
69
+ if (isPathContained(resolved, cwd)) {
70
+ const root = realpathSync(resolve(cwd));
71
+ const relPath = realpathSync(resolved).slice(root.length + 1);
72
+ const targetFile = realpathSync(resolved);
73
+ return _readAndReturn(targetFile, relPath);
74
+ }
75
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
76
+ }
77
+
78
+ const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
79
+ return _readAndReturn(targetFile, reqPath);
80
+ }
81
+
82
+ function _readAndReturn(targetFile, displayPath) {
83
+ if (!existsSync(targetFile)) {
84
+ throw new FileApiError('NOT_FOUND', `File not found: ${targetFile}`);
85
+ }
86
+ const stat = statSync(targetFile);
87
+ if (!stat.isFile()) {
88
+ throw new FileApiError('NOT_FILE', 'Not a file');
89
+ }
90
+ if (stat.size > 5 * 1024 * 1024) {
91
+ throw new FileApiError('TOO_LARGE', 'File too large');
92
+ }
93
+ const content = readFileSync(targetFile, 'utf-8');
94
+ return { path: displayPath, content, size: stat.size };
95
+ }
96
+
97
+ /**
98
+ * Write file content.
99
+ * @param {string} cwd - project working directory
100
+ * @param {string} reqPath - requested path
101
+ * @param {string} content - file content to write
102
+ * @param {boolean} isEditorSession
103
+ * @returns {{ path: string, size: number }}
104
+ */
105
+ export function writeFileContent(cwd, reqPath, content, isEditorSession) {
106
+ if (!reqPath) {
107
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
108
+ }
109
+ if (!isEditorSession && (reqPath.startsWith('/') || reqPath.includes('..'))) {
110
+ throw new FileApiError('INVALID_PATH', 'Invalid path');
111
+ }
112
+ if (typeof content !== 'string') {
113
+ throw new FileApiError('INVALID_CONTENT', 'Content must be a string');
114
+ }
115
+ const targetFile = (isEditorSession && reqPath.startsWith('/')) ? reqPath : join(cwd, reqPath);
116
+ writeFileSync(targetFile, content, 'utf-8');
117
+ const stat = statSync(targetFile);
118
+ return { path: reqPath, size: stat.size };
119
+ }
120
+
121
+ /** Map FileApiError codes to HTTP status codes */
122
+ export const ERROR_STATUS_MAP = {
123
+ INVALID_PATH: 400,
124
+ NOT_FOUND: 404,
125
+ NOT_FILE: 400,
126
+ TOO_LARGE: 413,
127
+ INVALID_CONTENT: 400,
128
+ };
@@ -0,0 +1,173 @@
1
+ import { readFileSync, writeFileSync, existsSync, statSync, readdirSync, unlinkSync, realpathSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+
4
+ /**
5
+ * Validate that a resolved file path is contained within logDir.
6
+ * Throws on invalid path (not found or path traversal).
7
+ * @param {string} logDir - base log directory
8
+ * @param {string} file - relative file path (e.g. "project/file.jsonl")
9
+ * @returns {string} the real (resolved) path
10
+ */
11
+ export function validateLogPath(logDir, file) {
12
+ const filePath = join(logDir, file);
13
+ if (!existsSync(filePath)) {
14
+ const err = new Error('File not found');
15
+ err.code = 'NOT_FOUND';
16
+ throw err;
17
+ }
18
+ const realPath = realpathSync(filePath);
19
+ const realLogDir = realpathSync(logDir);
20
+ if (!realPath.startsWith(realLogDir)) {
21
+ const err = new Error('Access denied');
22
+ err.code = 'ACCESS_DENIED';
23
+ throw err;
24
+ }
25
+ return realPath;
26
+ }
27
+
28
+ /**
29
+ * List local log files grouped by project.
30
+ * @param {string} logDir - base log directory
31
+ * @param {string} currentProjectName - current project name (may be empty)
32
+ * @returns {{ [project: string]: Array, _currentProject: string }}
33
+ */
34
+ export function listLocalLogs(logDir, currentProjectName) {
35
+ const grouped = {};
36
+ if (existsSync(logDir)) {
37
+ const entries = readdirSync(logDir, { withFileTypes: true });
38
+ for (const entry of entries) {
39
+ if (!entry.isDirectory()) continue;
40
+ const project = entry.name;
41
+ const projectDir = join(logDir, project);
42
+ const files = readdirSync(projectDir)
43
+ .filter(f => f.endsWith('.jsonl'))
44
+ .sort()
45
+ .reverse();
46
+ // 从项目统计缓存中读取 per-file 数据,避免逐文件扫描
47
+ let statsFiles = null;
48
+ try {
49
+ const statsFile = join(projectDir, `${project}.json`);
50
+ if (existsSync(statsFile)) {
51
+ statsFiles = JSON.parse(readFileSync(statsFile, 'utf-8')).files;
52
+ }
53
+ } catch { }
54
+ for (const f of files) {
55
+ const match = f.match(/^(.+?)_(\d{8}_\d{6})\.jsonl$/);
56
+ if (!match) continue;
57
+ const ts = match[2];
58
+ const filePath = join(projectDir, f);
59
+ const size = statSync(filePath).size;
60
+ if (size === 0) continue; // 跳过空文件
61
+ const turns = statsFiles?.[f]?.summary?.sessionCount || 0;
62
+ if (!grouped[project]) grouped[project] = [];
63
+ grouped[project].push({ file: `${project}/${f}`, timestamp: ts, size, turns, preview: statsFiles?.[f]?.preview || [] });
64
+ }
65
+ }
66
+ }
67
+ return { ...grouped, _currentProject: currentProjectName || '' };
68
+ }
69
+
70
+ /**
71
+ * Read and parse a local log file.
72
+ * @param {string} logDir - base log directory
73
+ * @param {string} file - relative file path (e.g. "project/file.jsonl")
74
+ * @returns {Array<Object>} parsed entries
75
+ */
76
+ export function readLocalLog(logDir, file) {
77
+ validateLogPath(logDir, file);
78
+ const filePath = join(logDir, file);
79
+ const content = readFileSync(filePath, 'utf-8');
80
+ const entries = content.split('\n---\n').filter(line => line.trim()).map(entry => {
81
+ try { return JSON.parse(entry); } catch { return null; }
82
+ }).filter(Boolean);
83
+ return entries;
84
+ }
85
+
86
+ /**
87
+ * Delete log files. Returns per-file results.
88
+ * @param {string} logDir - base log directory
89
+ * @param {string[]} files - array of relative file paths
90
+ * @returns {Array<{ file: string, ok?: boolean, error?: string }>}
91
+ */
92
+ export function deleteLogFiles(logDir, files) {
93
+ const results = [];
94
+ for (const file of files) {
95
+ if (!file || file.includes('..') || !file.endsWith('.jsonl')) {
96
+ results.push({ file, error: 'Invalid file name' });
97
+ continue;
98
+ }
99
+ const filePath = join(logDir, file);
100
+ try {
101
+ if (!existsSync(filePath)) {
102
+ results.push({ file, error: 'Not found' });
103
+ continue;
104
+ }
105
+ const realPath = realpathSync(filePath);
106
+ const realLogDir = realpathSync(logDir);
107
+ if (!realPath.startsWith(realLogDir)) {
108
+ results.push({ file, error: 'Access denied' });
109
+ continue;
110
+ }
111
+ unlinkSync(realPath);
112
+ results.push({ file, ok: true });
113
+ } catch (err) {
114
+ results.push({ file, error: err.message });
115
+ }
116
+ }
117
+ return results;
118
+ }
119
+
120
+ /**
121
+ * Merge multiple log files into the first one, deleting the rest.
122
+ * @param {string} logDir - base log directory
123
+ * @param {string[]} files - array of relative file paths (at least 2, same project, chronological order)
124
+ * @returns {string} the merged target file path (relative)
125
+ */
126
+ export function mergeLogFiles(logDir, files) {
127
+ if (!Array.isArray(files) || files.length < 2) {
128
+ const err = new Error('At least 2 files required');
129
+ err.code = 'INVALID_INPUT';
130
+ throw err;
131
+ }
132
+ // 校验所有文件属于同一 project
133
+ const projects = new Set(files.map(f => f.split('/')[0]));
134
+ if (projects.size !== 1) {
135
+ const err = new Error('All files must belong to the same project');
136
+ err.code = 'INVALID_INPUT';
137
+ throw err;
138
+ }
139
+ // 校验文件存在且无路径穿越
140
+ for (const f of files) {
141
+ if (f.includes('..')) {
142
+ const err = new Error('Invalid file path');
143
+ err.code = 'INVALID_INPUT';
144
+ throw err;
145
+ }
146
+ if (!existsSync(join(logDir, f))) {
147
+ const err = new Error(`File not found: ${f}`);
148
+ err.code = 'NOT_FOUND';
149
+ throw err;
150
+ }
151
+ }
152
+ // 校验合并后总大小不超过 300MB
153
+ const MAX_MERGE_SIZE = 300 * 1024 * 1024;
154
+ let totalSize = 0;
155
+ for (const f of files) {
156
+ totalSize += statSync(join(logDir, f)).size;
157
+ }
158
+ if (totalSize > MAX_MERGE_SIZE) {
159
+ const err = new Error(`Merged size (${(totalSize / 1024 / 1024).toFixed(1)}MB) exceeds 300MB limit`);
160
+ err.code = 'INVALID_INPUT';
161
+ throw err;
162
+ }
163
+ // 合并内容写入第一个文件
164
+ const targetFile = files[0];
165
+ const targetPath = join(logDir, targetFile);
166
+ const contents = files.map(f => readFileSync(join(logDir, f), 'utf-8').trimEnd());
167
+ writeFileSync(targetPath, contents.join('\n---\n') + '\n');
168
+ // 删除其余文件
169
+ for (let i = 1; i < files.length; i++) {
170
+ unlinkSync(join(logDir, files[i]));
171
+ }
172
+ return targetFile;
173
+ }
@@ -0,0 +1,118 @@
1
+ import { existsSync, mkdirSync, writeFileSync, unlinkSync } from 'node:fs';
2
+ import { join } from 'node:path';
3
+ import { execFile } from 'node:child_process';
4
+ import { tmpdir } from 'node:os';
5
+
6
+ /**
7
+ * Upload plugin files from a file list.
8
+ * @param {string} pluginsDir - path to plugins directory
9
+ * @param {Array<{name: string, content: string}>} fileList - files to upload
10
+ * @returns {number} number of files written
11
+ * @throws {Error} on validation failure
12
+ */
13
+ export function uploadPlugins(pluginsDir, fileList) {
14
+ if (!Array.isArray(fileList) || fileList.length === 0) {
15
+ throw Object.assign(new Error('No files provided'), { statusCode: 400 });
16
+ }
17
+ if (!existsSync(pluginsDir)) {
18
+ mkdirSync(pluginsDir, { recursive: true });
19
+ }
20
+ let written = 0;
21
+ for (const { name, content } of fileList) {
22
+ if (!name || typeof content !== 'string') continue;
23
+ const filename = name.replace(/.*[/\\]/, '');
24
+ if (!filename.endsWith('.js') && !filename.endsWith('.mjs')) {
25
+ throw Object.assign(new Error('Only .js or .mjs files are allowed'), { statusCode: 400 });
26
+ }
27
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
28
+ throw Object.assign(new Error('Invalid file name'), { statusCode: 400 });
29
+ }
30
+ writeFileSync(join(pluginsDir, filename), content, 'utf-8');
31
+ written++;
32
+ }
33
+ return written;
34
+ }
35
+
36
+ /**
37
+ * Install a plugin by downloading from a URL.
38
+ * @param {string} pluginsDir - path to plugins directory
39
+ * @param {string} fileUrl - URL to download from
40
+ * @param {string} extractNameScript - path to lib/extract-plugin-name.mjs
41
+ * @returns {Promise<{filename: string}>} the saved filename
42
+ * @throws {Error} on validation or download failure
43
+ */
44
+ export async function installPluginFromUrl(pluginsDir, fileUrl, extractNameScript) {
45
+ if (!fileUrl) {
46
+ throw Object.assign(new Error('URL is required'), { statusCode: 400 });
47
+ }
48
+ // Validate URL format
49
+ let parsedUrl;
50
+ try {
51
+ parsedUrl = new URL(fileUrl);
52
+ } catch {
53
+ throw Object.assign(new Error('Invalid URL'), { statusCode: 400 });
54
+ }
55
+ if (parsedUrl.protocol !== 'http:' && parsedUrl.protocol !== 'https:') {
56
+ throw Object.assign(new Error('Invalid URL'), { statusCode: 400 });
57
+ }
58
+
59
+ // Download remote file (5MB limit, 30s timeout)
60
+ const MAX_PLUGIN_SIZE = 5 * 1024 * 1024;
61
+ let content;
62
+ try {
63
+ const resp = await fetch(fileUrl, { signal: AbortSignal.timeout(30000) });
64
+ if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
65
+ const text = await resp.text();
66
+ if (text.length > MAX_PLUGIN_SIZE) throw new Error('File too large (max 5MB)');
67
+ content = text;
68
+ } catch (fetchErr) {
69
+ throw Object.assign(new Error('Failed to fetch: ' + fetchErr.message), { statusCode: 500 });
70
+ }
71
+
72
+ // Extract plugin name via subprocess import()
73
+ let saveName = '';
74
+ const tmpFile = join(tmpdir(), `ccv-install-${Date.now()}.mjs`);
75
+ writeFileSync(tmpFile, content, 'utf-8');
76
+ try {
77
+ const result = await new Promise((resolve, reject) => {
78
+ execFile('node', [extractNameScript, tmpFile], { timeout: 5000 }, (err, stdout) => {
79
+ if (err) return reject(err);
80
+ resolve(stdout);
81
+ });
82
+ });
83
+ const parsed = JSON.parse(result);
84
+ if (parsed.name) saveName = parsed.name;
85
+ } catch { }
86
+ try { unlinkSync(tmpFile); } catch { }
87
+
88
+ // Fallback: extract filename from URL path, excluding generic names
89
+ if (!saveName) {
90
+ const urlFilename = parsedUrl.pathname.split('/').pop();
91
+ if (urlFilename && (urlFilename.endsWith('.js') || urlFilename.endsWith('.mjs'))
92
+ && urlFilename !== 'index.js' && urlFilename !== 'index.mjs') {
93
+ saveName = urlFilename.replace(/\.(js|mjs)$/, '');
94
+ }
95
+ }
96
+ // Final fallback: use plugin-<timestamp>
97
+ if (!saveName) {
98
+ saveName = `plugin-${Date.now()}`;
99
+ }
100
+
101
+ let filename = (saveName.endsWith('.js') || saveName.endsWith('.mjs')) ? saveName : saveName + '.js';
102
+ // Safety check
103
+ if (filename.includes('..') || filename.includes('/') || filename.includes('\\')) {
104
+ filename = `plugin-${Date.now()}.js`;
105
+ }
106
+ // Ensure plugins dir exists
107
+ if (!existsSync(pluginsDir)) {
108
+ mkdirSync(pluginsDir, { recursive: true });
109
+ }
110
+ // Deduplicate: append unique identifier for same-name files
111
+ if (existsSync(join(pluginsDir, filename))) {
112
+ const ext = filename.endsWith('.mjs') ? '.mjs' : '.js';
113
+ const base = filename.slice(0, -ext.length);
114
+ filename = `${base}-${Date.now()}${ext}`;
115
+ }
116
+ writeFileSync(join(pluginsDir, filename), content, 'utf-8');
117
+ return { filename };
118
+ }
@@ -0,0 +1,84 @@
1
+ import { readFileSync, existsSync } from 'node:fs';
2
+ import { detectLanguage } from '../i18n.js';
3
+
4
+ /**
5
+ * Determine the target language for translation.
6
+ * Priority: explicit `to` param > prefs file lang > system locale.
7
+ * @param {string} prefsFile - Path to the preferences JSON file
8
+ * @returns {string} target language code
9
+ */
10
+ export function detectTargetLang(prefsFile) {
11
+ let targetLang;
12
+ try {
13
+ if (prefsFile && existsSync(prefsFile)) {
14
+ const prefs = JSON.parse(readFileSync(prefsFile, 'utf-8'));
15
+ if (prefs.lang) targetLang = prefs.lang;
16
+ }
17
+ } catch { }
18
+ if (!targetLang) targetLang = detectLanguage();
19
+ return targetLang;
20
+ }
21
+
22
+ /**
23
+ * Translate text using the Claude API.
24
+ * @param {Object} opts
25
+ * @param {string|string[]} opts.text - Text or array of texts to translate
26
+ * @param {string} opts.from - Source language code
27
+ * @param {string} opts.to - Target language code
28
+ * @param {string} opts.apiKey - Anthropic API key
29
+ * @param {string} [opts.baseUrl='https://api.anthropic.com'] - API base URL
30
+ * @param {string} [opts.model='claude-haiku-4-5-20251001'] - Model to use
31
+ * @returns {Promise<{text: string|string[], from: string, to: string}>}
32
+ */
33
+ export async function translate({ text, from, to, apiKey, baseUrl, model }) {
34
+ // Same language — no-op
35
+ if (from === to) {
36
+ return { text, from, to };
37
+ }
38
+
39
+ const effectiveBaseUrl = baseUrl || 'https://api.anthropic.com';
40
+ const effectiveModel = model || 'claude-haiku-4-5-20251001';
41
+ const inputText = Array.isArray(text) ? text.join('\n---SPLIT---\n') : text;
42
+
43
+ const reqHeaders = {
44
+ 'Content-Type': 'application/json',
45
+ 'anthropic-version': '2023-06-01',
46
+ 'x-api-key': apiKey,
47
+ 'x-cc-viewer-internal': '1',
48
+ };
49
+
50
+ const apiRes = await fetch(`${effectiveBaseUrl}/v1/messages`, {
51
+ method: 'POST',
52
+ headers: reqHeaders,
53
+ body: JSON.stringify({
54
+ model: effectiveModel,
55
+ max_tokens: 32000,
56
+ tools: [],
57
+ system: [{
58
+ type: "text",
59
+ text: `You are a translator. Translate the following text from ${from} to ${to}. Output only the translated text, nothing else.`
60
+ }],
61
+ messages: [{ role: 'user', content: inputText }],
62
+ stream: false,
63
+ temperature: 1,
64
+ }),
65
+ });
66
+
67
+ if (!apiRes.ok) {
68
+ const errBody = await apiRes.text();
69
+ const err = new Error(`Translation API failed (status ${apiRes.status}): ${errBody}`);
70
+ err.status = apiRes.status;
71
+ err.detail = errBody;
72
+ throw err;
73
+ }
74
+
75
+ const apiData = await apiRes.json();
76
+ let translated = apiData.content?.[0]?.text || '';
77
+
78
+ // If input was an array, split the result back into an array
79
+ if (Array.isArray(text)) {
80
+ translated = translated.split(/\n?---SPLIT---\n?/);
81
+ }
82
+
83
+ return { text: translated, from, to };
84
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "cc-viewer",
3
- "version": "1.6.18",
3
+ "version": "1.6.19",
4
4
  "description": "Claude Code Logger visualization management tool",
5
5
  "license": "MIT",
6
6
  "main": "server.js",