@exreve/exk 1.0.37 → 1.0.39
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/agentSession.js +10 -2
- package/dist/index.js +6 -6
- package/dist/moduleMcpServer.js +110 -10
- package/dist/transferService.js +172 -138
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +1 -1
package/dist/agentSession.js
CHANGED
|
@@ -101,9 +101,17 @@ function extractToolName(toolResult) {
|
|
|
101
101
|
}
|
|
102
102
|
if (toolResult.stdout !== undefined || toolResult.stderr !== undefined)
|
|
103
103
|
return 'Bash';
|
|
104
|
-
//
|
|
105
|
-
if (toolResult.content && typeof toolResult.content === 'string' && toolResult.type === 'text')
|
|
104
|
+
// send_file tool: content is JSON with _type marker — detect before Bash fallback
|
|
105
|
+
if (toolResult.content && typeof toolResult.content === 'string' && toolResult.type === 'text') {
|
|
106
|
+
try {
|
|
107
|
+
const parsed = JSON.parse(toolResult.content);
|
|
108
|
+
if (parsed._type === 'send_file')
|
|
109
|
+
return 'send_file';
|
|
110
|
+
}
|
|
111
|
+
catch { /* not JSON, fall through */ }
|
|
112
|
+
// SDK 0.2.x: content-only results from nested tool calls (no stdout/stderr wrapper)
|
|
106
113
|
return 'Bash';
|
|
114
|
+
}
|
|
107
115
|
return 'unknown';
|
|
108
116
|
}
|
|
109
117
|
// Look up tool name from the most recent assistant message's tool_use blocks by tool_use_id
|
package/dist/index.js
CHANGED
|
@@ -1545,17 +1545,17 @@ async function runDaemon(foreground = false, email) {
|
|
|
1545
1545
|
console.log(`[CLI] Emitted error app:control:response`);
|
|
1546
1546
|
}
|
|
1547
1547
|
});
|
|
1548
|
-
// Handle folder transfer — this device is the source (
|
|
1549
|
-
socket.on('transfer:
|
|
1548
|
+
// Handle folder transfer — this device is the source (pack & upload)
|
|
1549
|
+
socket.on('transfer:pack', (data) => {
|
|
1550
1550
|
if (foreground) {
|
|
1551
|
-
console.log(`[transfer]
|
|
1551
|
+
console.log(`[transfer] Pack request: ${data.sourcePath} (${data.selectedItems?.length || 0} items) → device ${data.destDeviceId.slice(0, 8)}`);
|
|
1552
1552
|
}
|
|
1553
1553
|
startSending(socket, data, foreground);
|
|
1554
1554
|
});
|
|
1555
|
-
// Handle folder transfer — this device is the destination (
|
|
1556
|
-
socket.on('transfer:
|
|
1555
|
+
// Handle folder transfer — this device is the destination (download & extract)
|
|
1556
|
+
socket.on('transfer:pull', (data) => {
|
|
1557
1557
|
if (foreground) {
|
|
1558
|
-
console.log(`[transfer]
|
|
1558
|
+
console.log(`[transfer] Pull request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath} (${(data.totalBytes / (1024 * 1024)).toFixed(1)} MB)`);
|
|
1559
1559
|
}
|
|
1560
1560
|
startReceiving(socket, data, foreground);
|
|
1561
1561
|
});
|
package/dist/moduleMcpServer.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Module MCP Server
|
|
3
3
|
*
|
|
4
|
-
* Provides built-in tools like analyze_image for vision capabilities
|
|
4
|
+
* Provides built-in tools like analyze_image for vision capabilities
|
|
5
|
+
* and send_file for displaying files to the user in chat.
|
|
5
6
|
*/
|
|
6
7
|
import { createSdkMcpServer, tool } from '@anthropic-ai/claude-agent-sdk';
|
|
7
8
|
import { z } from 'zod';
|
|
@@ -9,19 +10,54 @@ import * as fs from 'fs';
|
|
|
9
10
|
import * as path from 'path';
|
|
10
11
|
import * as os from 'os';
|
|
11
12
|
import { getOpenrouterApiKey } from './agentSession.js';
|
|
13
|
+
const MAX_FILE_SIZE = 10 * 1024 * 1024; // 10 MB
|
|
14
|
+
/** Comprehensive MIME type map for file extension detection */
|
|
15
|
+
const MIME_MAP = {
|
|
16
|
+
// Images
|
|
17
|
+
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
18
|
+
gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
|
|
19
|
+
svg: 'image/svg+xml', ico: 'image/x-icon', tiff: 'image/tiff', tif: 'image/tiff',
|
|
20
|
+
avif: 'image/avif',
|
|
21
|
+
// Audio
|
|
22
|
+
mp3: 'audio/mpeg', wav: 'audio/wav', ogg: 'audio/ogg',
|
|
23
|
+
m4a: 'audio/mp4', flac: 'audio/flac', aac: 'audio/aac',
|
|
24
|
+
wma: 'audio/x-ms-wma', opus: 'audio/opus',
|
|
25
|
+
// Video
|
|
26
|
+
mp4: 'video/mp4', webm: 'video/webm', mkv: 'video/x-matroska',
|
|
27
|
+
avi: 'video/x-msvideo', mov: 'video/quicktime', wmv: 'video/x-ms-wmv',
|
|
28
|
+
m4v: 'video/mp4', '3gp': 'video/3gpp',
|
|
29
|
+
// Documents
|
|
30
|
+
pdf: 'application/pdf',
|
|
31
|
+
// Text / Code
|
|
32
|
+
txt: 'text/plain', md: 'text/markdown', csv: 'text/csv',
|
|
33
|
+
json: 'application/json', xml: 'text/xml', yaml: 'text/yaml', yml: 'text/yaml',
|
|
34
|
+
toml: 'text/plain', html: 'text/html', htm: 'text/html',
|
|
35
|
+
css: 'text/css', scss: 'text/x-scss', less: 'text/x-less',
|
|
36
|
+
js: 'text/javascript', mjs: 'text/javascript', cjs: 'text/javascript',
|
|
37
|
+
ts: 'text/typescript', tsx: 'text/typescript',
|
|
38
|
+
jsx: 'text/javascript', py: 'text/x-python', rs: 'text/x-rust',
|
|
39
|
+
go: 'text/x-go', java: 'text/x-java', c: 'text/x-c', cpp: 'text/x-c++',
|
|
40
|
+
h: 'text/x-c', hpp: 'text/x-c++', rb: 'text/x-ruby', php: 'text/x-php',
|
|
41
|
+
sh: 'text/x-shellscript', bash: 'text/x-shellscript', zsh: 'text/x-shellscript',
|
|
42
|
+
sql: 'text/x-sql', graphql: 'text/graphql', vue: 'text/x-vue',
|
|
43
|
+
svelte: 'text/x-svelte', dart: 'text/x-dart', swift: 'text/x-swift',
|
|
44
|
+
kt: 'text/x-kotlin', scala: 'text/x-scala', lua: 'text/x-lua',
|
|
45
|
+
r: 'text/x-r', dockerfile: 'text/x-dockerfile',
|
|
46
|
+
};
|
|
12
47
|
/**
|
|
13
|
-
*
|
|
48
|
+
* Get MIME type from file extension
|
|
49
|
+
*/
|
|
50
|
+
function getMimeType(filePath) {
|
|
51
|
+
const ext = path.extname(filePath).toLowerCase().replace('.', '');
|
|
52
|
+
return MIME_MAP[ext] || 'application/octet-stream';
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Convert a file to a data URI (base64 encoded)
|
|
14
56
|
*/
|
|
15
57
|
function fileToDataUri(filePath) {
|
|
16
58
|
try {
|
|
17
59
|
const buf = fs.readFileSync(filePath);
|
|
18
|
-
const
|
|
19
|
-
const mimeMap = {
|
|
20
|
-
png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg',
|
|
21
|
-
gif: 'image/gif', webp: 'image/webp', bmp: 'image/bmp',
|
|
22
|
-
svg: 'image/svg+xml',
|
|
23
|
-
};
|
|
24
|
-
const mime = mimeMap[ext] || 'application/octet-stream';
|
|
60
|
+
const mime = getMimeType(filePath);
|
|
25
61
|
return `data:${mime};base64,${buf.toString('base64')}`;
|
|
26
62
|
}
|
|
27
63
|
catch {
|
|
@@ -78,12 +114,76 @@ function createAnalyzeImageTool(attachmentDir) {
|
|
|
78
114
|
});
|
|
79
115
|
}
|
|
80
116
|
/**
|
|
81
|
-
* Create the
|
|
117
|
+
* Create the send_file tool for displaying files to the user in chat.
|
|
118
|
+
* Supports images, audio, video, PDFs, code, and other files.
|
|
119
|
+
*/
|
|
120
|
+
function createSendFileTool(attachmentDir) {
|
|
121
|
+
const workDir = attachmentDir || os.tmpdir();
|
|
122
|
+
return tool('send_file', 'Send a file to the user for display in chat. Supports images (shown inline), audio/video (with player), PDFs, code files (syntax highlighted), and other files (download link). Use file_path for local files or data for base64-encoded content.', {
|
|
123
|
+
file_path: z.string().optional().describe('Path to a local file on this device (absolute or relative to project directory)'),
|
|
124
|
+
data: z.string().optional().describe('Base64-encoded file content (without data: prefix)'),
|
|
125
|
+
mime_type: z.string().optional().describe('MIME type of the file (required when using data, auto-detected from file_path)'),
|
|
126
|
+
filename: z.string().optional().describe('Display name for the file (auto-detected from file_path)'),
|
|
127
|
+
}, async (args) => {
|
|
128
|
+
try {
|
|
129
|
+
let dataUri;
|
|
130
|
+
let mimeType;
|
|
131
|
+
let fileName;
|
|
132
|
+
let fileSize;
|
|
133
|
+
if (args.file_path) {
|
|
134
|
+
// Read from local file
|
|
135
|
+
const filePath = path.resolve(workDir, args.file_path);
|
|
136
|
+
if (!fs.existsSync(filePath)) {
|
|
137
|
+
return { content: [{ type: 'text', text: `Error: File not found: ${args.file_path}` }], isError: true };
|
|
138
|
+
}
|
|
139
|
+
const stat = fs.statSync(filePath);
|
|
140
|
+
fileSize = stat.size;
|
|
141
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
142
|
+
return { content: [{ type: 'text', text: `Error: File too large (${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
143
|
+
}
|
|
144
|
+
const buf = fs.readFileSync(filePath);
|
|
145
|
+
mimeType = args.mime_type || getMimeType(filePath);
|
|
146
|
+
fileName = args.filename || path.basename(filePath);
|
|
147
|
+
dataUri = `data:${mimeType};base64,${buf.toString('base64')}`;
|
|
148
|
+
}
|
|
149
|
+
else if (args.data) {
|
|
150
|
+
// Use provided base64 data
|
|
151
|
+
mimeType = args.mime_type || 'application/octet-stream';
|
|
152
|
+
fileName = args.filename || 'file';
|
|
153
|
+
const rawBase64 = args.data.replace(/^data:[^;]+;base64,/, '');
|
|
154
|
+
fileSize = Math.floor(rawBase64.length * 0.75);
|
|
155
|
+
if (fileSize > MAX_FILE_SIZE) {
|
|
156
|
+
return { content: [{ type: 'text', text: `Error: Data too large (~${(fileSize / (1024 * 1024)).toFixed(1)} MB). Maximum size is ${MAX_FILE_SIZE / (1024 * 1024)} MB.` }], isError: true };
|
|
157
|
+
}
|
|
158
|
+
dataUri = `data:${mimeType};base64,${rawBase64}`;
|
|
159
|
+
}
|
|
160
|
+
else {
|
|
161
|
+
return { content: [{ type: 'text', text: 'Error: Either file_path or data must be provided.' }], isError: true };
|
|
162
|
+
}
|
|
163
|
+
// Return structured result that the frontend will detect
|
|
164
|
+
const result = JSON.stringify({
|
|
165
|
+
_type: 'send_file',
|
|
166
|
+
data: dataUri,
|
|
167
|
+
mime_type: mimeType,
|
|
168
|
+
filename: fileName,
|
|
169
|
+
size: fileSize,
|
|
170
|
+
});
|
|
171
|
+
return { content: [{ type: 'text', text: result }] };
|
|
172
|
+
}
|
|
173
|
+
catch (error) {
|
|
174
|
+
return { content: [{ type: 'text', text: `Error sending file: ${error.message}` }], isError: true };
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
}
|
|
178
|
+
/**
|
|
179
|
+
* Create the MCP server with built-in tools
|
|
82
180
|
*/
|
|
83
181
|
export function createModuleMcpServer(config) {
|
|
84
182
|
const tools = [];
|
|
85
183
|
// Always add analyze_image tool (uses OpenRouter key from ai-config via backend)
|
|
86
184
|
tools.push(createAnalyzeImageTool(config.attachmentDir));
|
|
185
|
+
// Add send_file tool for displaying files to the user in chat
|
|
186
|
+
tools.push(createSendFileTool(config.attachmentDir));
|
|
87
187
|
const server = createSdkMcpServer({
|
|
88
188
|
name: 'claude-voice-modules',
|
|
89
189
|
version: '1.0.0',
|
package/dist/transferService.js
CHANGED
|
@@ -4,26 +4,30 @@ import fsSync from 'fs';
|
|
|
4
4
|
import path from 'path';
|
|
5
5
|
import os from 'os';
|
|
6
6
|
import { createHash } from 'crypto';
|
|
7
|
+
import { Readable, Transform } from 'stream';
|
|
7
8
|
// ============ Transfer Service (CLI side) ============
|
|
8
|
-
//
|
|
9
|
-
//
|
|
10
|
-
|
|
9
|
+
// Two-phase transfer via HTTP:
|
|
10
|
+
// Phase 1 (source): tar selected items → HTTP POST to backend
|
|
11
|
+
// Phase 2 (dest): HTTP GET from backend → tar extract
|
|
12
|
+
// Socket.IO carries control events + progress/log.
|
|
11
13
|
// ---- Sender ----
|
|
12
14
|
export function startSending(socket, transfer, foreground) {
|
|
13
|
-
const { transferId, sourcePath } = transfer;
|
|
15
|
+
const { transferId, sourcePath, selectedItems } = transfer;
|
|
14
16
|
const startTime = Date.now();
|
|
17
|
+
const apiUrl = getApiUrl(socket);
|
|
15
18
|
const log = (message) => {
|
|
16
19
|
if (foreground)
|
|
17
20
|
console.log(`[transfer:send] ${message}`);
|
|
18
21
|
socket.emit('transfer:log', { transferId, side: 'source', level: 'info', message });
|
|
19
22
|
};
|
|
20
|
-
const sendProgress = (bytesTransferred, totalBytes) => {
|
|
23
|
+
const sendProgress = (phase, bytesTransferred, totalBytes) => {
|
|
21
24
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
22
25
|
const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
|
|
23
26
|
const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
|
|
24
27
|
socket.emit('transfer:progress', {
|
|
25
28
|
transferId,
|
|
26
29
|
side: 'source',
|
|
30
|
+
phase,
|
|
27
31
|
bytesTransferred,
|
|
28
32
|
totalBytes,
|
|
29
33
|
speed: Math.round(speed),
|
|
@@ -34,55 +38,41 @@ export function startSending(socket, transfer, foreground) {
|
|
|
34
38
|
console.error(`[transfer:send] Error: ${message}`);
|
|
35
39
|
socket.emit('transfer:error', { transferId, side: 'source', message });
|
|
36
40
|
};
|
|
37
|
-
|
|
38
|
-
|
|
41
|
+
const items = selectedItems || ['.'];
|
|
42
|
+
log(`Packing ${items.length} item(s) from ${sourcePath}`);
|
|
43
|
+
// Estimate size first
|
|
39
44
|
let totalBytes = 0;
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
45
|
+
const sizeEstimate = spawn('du', ['-sb', ...items.map(i => path.join(sourcePath, i))], { cwd: sourcePath });
|
|
46
|
+
let duOutput = '';
|
|
47
|
+
sizeEstimate.stdout.on('data', (d) => { duOutput += d.toString(); });
|
|
48
|
+
sizeEstimate.on('close', (code) => {
|
|
49
|
+
if (code === 0) {
|
|
50
|
+
const lines = duOutput.trim().split('\n');
|
|
51
|
+
for (const line of lines) {
|
|
52
|
+
const match = line.match(/^(\d+)/);
|
|
47
53
|
if (match)
|
|
48
|
-
totalBytes
|
|
54
|
+
totalBytes += parseInt(match[1], 10);
|
|
49
55
|
}
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
// Spawn tar to stream the folder
|
|
60
|
-
const tar = spawn('tar', ['cf', '-', '-C', sourcePath, '.']);
|
|
56
|
+
}
|
|
57
|
+
log(`Estimated size: ${formatBytes(totalBytes)}`);
|
|
58
|
+
doPackAndUpload();
|
|
59
|
+
});
|
|
60
|
+
sizeEstimate.on('error', () => doPackAndUpload());
|
|
61
|
+
function doPackAndUpload() {
|
|
62
|
+
// Create tar with selected items
|
|
63
|
+
const tarArgs = ['cf', '-', '-C', sourcePath, ...items];
|
|
64
|
+
const tar = spawn('tar', tarArgs);
|
|
61
65
|
const hash = createHash('sha256');
|
|
62
66
|
let bytesTransferred = 0;
|
|
63
|
-
let seq = 0;
|
|
64
67
|
let lastProgressTime = Date.now();
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
bytesTransferred += chunk.length;
|
|
68
|
-
// Emit binary chunk
|
|
69
|
-
socket.emit('transfer:chunk', {
|
|
70
|
-
transferId,
|
|
71
|
-
seq,
|
|
72
|
-
data: chunk,
|
|
73
|
-
size: chunk.length,
|
|
74
|
-
});
|
|
75
|
-
seq++;
|
|
76
|
-
// Throttle progress updates to every 500ms
|
|
77
|
-
if (Date.now() - lastProgressTime > 500) {
|
|
78
|
-
sendProgress(bytesTransferred, totalBytes || bytesTransferred);
|
|
79
|
-
lastProgressTime = Date.now();
|
|
80
|
-
}
|
|
81
|
-
});
|
|
68
|
+
let fileCount = 0;
|
|
69
|
+
// Count files as we go (approximate from tar output)
|
|
82
70
|
tar.stderr.on('data', (data) => {
|
|
83
71
|
const msg = data.toString().trim();
|
|
84
72
|
if (msg)
|
|
85
73
|
log(`tar: ${msg}`);
|
|
74
|
+
// Count lines in tar stderr as approximate file count
|
|
75
|
+
fileCount += msg.split('\n').length;
|
|
86
76
|
});
|
|
87
77
|
tar.on('error', (err) => {
|
|
88
78
|
sendError(`tar process error: ${err.message}`);
|
|
@@ -92,19 +82,53 @@ export function startSending(socket, transfer, foreground) {
|
|
|
92
82
|
sendError(`tar exited with code ${code}`);
|
|
93
83
|
return;
|
|
94
84
|
}
|
|
95
|
-
|
|
85
|
+
log(`Packing complete. Final size: ${formatBytes(bytesTransferred)}`);
|
|
86
|
+
});
|
|
87
|
+
// Stream tar output through hash, then HTTP POST
|
|
88
|
+
const uploadUrl = `${apiUrl}/transfer/${transferId}/upload`;
|
|
89
|
+
// Pipe tar stdout → hash transform → HTTP upload
|
|
90
|
+
const hashTransform = new Transform({
|
|
91
|
+
transform(chunk, _encoding, callback) {
|
|
92
|
+
hash.update(chunk);
|
|
93
|
+
bytesTransferred += chunk.length;
|
|
94
|
+
if (Date.now() - lastProgressTime > 500) {
|
|
95
|
+
sendProgress('uploading', bytesTransferred, totalBytes || bytesTransferred);
|
|
96
|
+
lastProgressTime = Date.now();
|
|
97
|
+
}
|
|
98
|
+
this.push(chunk);
|
|
99
|
+
callback();
|
|
100
|
+
},
|
|
101
|
+
});
|
|
102
|
+
// Use fetch with streaming body for the upload
|
|
103
|
+
const tarStream = tar.stdout.pipe(hashTransform);
|
|
104
|
+
// Node 18+ supports ReadableStream in fetch body
|
|
105
|
+
const nodeStream = Readable.toWeb(tarStream);
|
|
106
|
+
fetch(uploadUrl, {
|
|
107
|
+
method: 'POST',
|
|
108
|
+
body: nodeStream,
|
|
109
|
+
headers: {
|
|
110
|
+
'Content-Type': 'application/x-tar',
|
|
111
|
+
'Transfer-Encoding': 'chunked',
|
|
112
|
+
},
|
|
113
|
+
duplex: 'half',
|
|
114
|
+
}).then(async (res) => {
|
|
115
|
+
if (!res.ok) {
|
|
116
|
+
const text = await res.text();
|
|
117
|
+
sendError(`Upload failed: HTTP ${res.status} ${text}`);
|
|
118
|
+
return;
|
|
119
|
+
}
|
|
96
120
|
const sha256 = hash.digest('hex');
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
// Instead, we send a source:done so the dest knows the stream is over
|
|
101
|
-
socket.emit('transfer:source:done', {
|
|
121
|
+
const duration = Date.now() - startTime;
|
|
122
|
+
// Notify backend that upload is done (only after HTTP confirms success)
|
|
123
|
+
socket.emit('transfer:uploaded', {
|
|
102
124
|
transferId,
|
|
103
125
|
totalBytes: bytesTransferred,
|
|
104
126
|
sha256,
|
|
105
|
-
|
|
127
|
+
fileCount,
|
|
106
128
|
});
|
|
107
|
-
log(`
|
|
129
|
+
log(`Upload complete: ${formatBytes(bytesTransferred)} in ${(duration / 1000).toFixed(1)}s (sha256: ${sha256.slice(0, 12)}...)`);
|
|
130
|
+
}).catch((err) => {
|
|
131
|
+
sendError(`Upload network error: ${err.message}`);
|
|
108
132
|
});
|
|
109
133
|
// Handle cancellation
|
|
110
134
|
const cancelHandler = (data) => {
|
|
@@ -121,18 +145,22 @@ export function startSending(socket, transfer, foreground) {
|
|
|
121
145
|
export function startReceiving(socket, transfer, foreground) {
|
|
122
146
|
const { transferId, destPath } = transfer;
|
|
123
147
|
const startTime = Date.now();
|
|
148
|
+
const apiUrl = getApiUrl(socket);
|
|
149
|
+
const expectedBytes = transfer.totalBytes || 0;
|
|
150
|
+
const expectedSha256 = transfer.sha256 || '';
|
|
124
151
|
const log = (message) => {
|
|
125
152
|
if (foreground)
|
|
126
153
|
console.log(`[transfer:recv] ${message}`);
|
|
127
154
|
socket.emit('transfer:log', { transferId, side: 'dest', level: 'info', message });
|
|
128
155
|
};
|
|
129
|
-
const sendProgress = (bytesTransferred, totalBytes) => {
|
|
156
|
+
const sendProgress = (phase, bytesTransferred, totalBytes) => {
|
|
130
157
|
const elapsed = (Date.now() - startTime) / 1000;
|
|
131
158
|
const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
|
|
132
159
|
const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
|
|
133
160
|
socket.emit('transfer:progress', {
|
|
134
161
|
transferId,
|
|
135
162
|
side: 'dest',
|
|
163
|
+
phase,
|
|
136
164
|
bytesTransferred,
|
|
137
165
|
totalBytes,
|
|
138
166
|
speed: Math.round(speed),
|
|
@@ -144,103 +172,109 @@ export function startReceiving(socket, transfer, foreground) {
|
|
|
144
172
|
socket.emit('transfer:error', { transferId, side: 'dest', message });
|
|
145
173
|
};
|
|
146
174
|
const tmpFile = path.join(os.tmpdir(), `ttc-transfer-${transferId}.tar`);
|
|
147
|
-
log(`
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
let lastProgressTime = Date.now();
|
|
155
|
-
let sourceSha256 = '';
|
|
156
|
-
// Handle incoming chunks
|
|
157
|
-
const chunkHandler = (data) => {
|
|
158
|
-
if (data.transferId !== transferId)
|
|
175
|
+
log(`Downloading from backend...`);
|
|
176
|
+
mkdir(destPath, { recursive: true }).then(async () => {
|
|
177
|
+
try {
|
|
178
|
+
const downloadUrl = `${apiUrl}/transfer/${transferId}/download`;
|
|
179
|
+
const res = await fetch(downloadUrl);
|
|
180
|
+
if (!res.ok) {
|
|
181
|
+
sendError(`Download failed: HTTP ${res.status}`);
|
|
159
182
|
return;
|
|
160
|
-
const chunk = Buffer.isBuffer(data.data) ? data.data : Buffer.from(data.data);
|
|
161
|
-
writeStream.write(chunk);
|
|
162
|
-
hash.update(chunk);
|
|
163
|
-
bytesReceived += chunk.length;
|
|
164
|
-
if (Date.now() - lastProgressTime > 500) {
|
|
165
|
-
sendProgress(bytesReceived, totalExpected || bytesReceived);
|
|
166
|
-
lastProgressTime = Date.now();
|
|
167
183
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
// Handle source:done — source finished sending
|
|
171
|
-
const sourceDoneHandler = (data) => {
|
|
172
|
-
if (data.transferId !== transferId)
|
|
184
|
+
if (!res.body) {
|
|
185
|
+
sendError('Download failed: no response body');
|
|
173
186
|
return;
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
const extractProc = spawn('tar', ['xf', tmpFile, '-C', destPath]);
|
|
193
|
-
extractProc.on('close', async (code) => {
|
|
194
|
-
// Cleanup temp file
|
|
195
|
-
try {
|
|
196
|
-
await unlink(tmpFile);
|
|
197
|
-
}
|
|
198
|
-
catch { }
|
|
199
|
-
if (code !== 0) {
|
|
200
|
-
sendError(`tar extraction failed with code ${code}`);
|
|
201
|
-
return;
|
|
202
|
-
}
|
|
203
|
-
const duration = Date.now() - startTime;
|
|
204
|
-
// Notify completion
|
|
205
|
-
socket.emit('transfer:complete', {
|
|
206
|
-
transferId,
|
|
207
|
-
totalBytes: bytesReceived,
|
|
208
|
-
sha256: receivedHash,
|
|
209
|
-
duration,
|
|
210
|
-
});
|
|
211
|
-
log(`Complete! ${formatBytes(bytesReceived)} extracted to ${destPath} in ${(duration / 1000).toFixed(1)}s`);
|
|
212
|
-
// Cleanup listeners
|
|
213
|
-
socket.off('transfer:chunk', chunkHandler);
|
|
214
|
-
socket.off('transfer:source:done', sourceDoneHandler);
|
|
215
|
-
});
|
|
216
|
-
extractProc.on('error', (err) => {
|
|
217
|
-
sendError(`tar extraction error: ${err.message}`);
|
|
218
|
-
});
|
|
187
|
+
}
|
|
188
|
+
// Stream download to temp file while hashing
|
|
189
|
+
const writeStream = fsSync.createWriteStream(tmpFile);
|
|
190
|
+
const hash = createHash('sha256');
|
|
191
|
+
let bytesReceived = 0;
|
|
192
|
+
let lastProgressTime = Date.now();
|
|
193
|
+
const reader = res.body.getReader();
|
|
194
|
+
// Read chunks from the response stream
|
|
195
|
+
while (true) {
|
|
196
|
+
const { done, value } = await reader.read();
|
|
197
|
+
if (done)
|
|
198
|
+
break;
|
|
199
|
+
writeStream.write(value);
|
|
200
|
+
hash.update(value);
|
|
201
|
+
bytesReceived += value.length;
|
|
202
|
+
if (Date.now() - lastProgressTime > 500) {
|
|
203
|
+
sendProgress('downloading', bytesReceived, expectedBytes || bytesReceived);
|
|
204
|
+
lastProgressTime = Date.now();
|
|
219
205
|
}
|
|
220
|
-
|
|
221
|
-
|
|
206
|
+
}
|
|
207
|
+
writeStream.end();
|
|
208
|
+
log(`Downloaded ${formatBytes(bytesReceived)}. Verifying...`);
|
|
209
|
+
// Verify hash
|
|
210
|
+
const receivedHash = hash.digest('hex');
|
|
211
|
+
if (expectedSha256 && receivedHash !== expectedSha256) {
|
|
212
|
+
sendError(`Hash mismatch! Expected ${expectedSha256.slice(0, 16)}... got ${receivedHash.slice(0, 16)}...`);
|
|
213
|
+
try {
|
|
214
|
+
await unlink(tmpFile);
|
|
222
215
|
}
|
|
216
|
+
catch { }
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
log(`Hash verified ✓. Extracting to ${destPath}...`);
|
|
220
|
+
sendProgress('extracting', bytesReceived, bytesReceived);
|
|
221
|
+
// Extract tar
|
|
222
|
+
const extractProc = spawn('tar', ['xf', tmpFile, '-C', destPath]);
|
|
223
|
+
await new Promise((resolve, reject) => {
|
|
224
|
+
extractProc.on('close', (code) => {
|
|
225
|
+
if (code !== 0)
|
|
226
|
+
reject(new Error(`tar extraction failed with code ${code}`));
|
|
227
|
+
else
|
|
228
|
+
resolve();
|
|
229
|
+
});
|
|
230
|
+
extractProc.on('error', reject);
|
|
223
231
|
});
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
const cancelHandler = (data) => {
|
|
228
|
-
if (data.transferId === transferId) {
|
|
229
|
-
writeStream.destroy();
|
|
230
|
-
socket.off('transfer:chunk', chunkHandler);
|
|
231
|
-
socket.off('transfer:source:done', sourceDoneHandler);
|
|
232
|
-
socket.off('transfer:cancel', cancelHandler);
|
|
233
|
-
log('Transfer cancelled');
|
|
234
|
-
// Cleanup temp file
|
|
235
|
-
unlink(tmpFile).catch(() => { });
|
|
232
|
+
// Cleanup temp file
|
|
233
|
+
try {
|
|
234
|
+
await unlink(tmpFile);
|
|
236
235
|
}
|
|
237
|
-
|
|
238
|
-
|
|
236
|
+
catch { }
|
|
237
|
+
const duration = Date.now() - startTime;
|
|
238
|
+
socket.emit('transfer:complete', {
|
|
239
|
+
transferId,
|
|
240
|
+
totalBytes: bytesReceived,
|
|
241
|
+
sha256: receivedHash,
|
|
242
|
+
duration,
|
|
243
|
+
fileCount: 0,
|
|
244
|
+
});
|
|
245
|
+
log(`Complete! ${formatBytes(bytesReceived)} extracted to ${destPath} in ${(duration / 1000).toFixed(1)}s`);
|
|
246
|
+
}
|
|
247
|
+
catch (err) {
|
|
248
|
+
sendError(`Download/extract error: ${err.message}`);
|
|
249
|
+
try {
|
|
250
|
+
await unlink(tmpFile);
|
|
251
|
+
}
|
|
252
|
+
catch { }
|
|
253
|
+
}
|
|
239
254
|
}).catch((err) => {
|
|
240
255
|
sendError(`Failed to create destination directory: ${err.message}`);
|
|
241
256
|
});
|
|
242
257
|
}
|
|
243
258
|
// ---- Helpers ----
|
|
259
|
+
function getApiUrl(socket) {
|
|
260
|
+
// Extract the base URL from the socket connection
|
|
261
|
+
const io = socket.io;
|
|
262
|
+
const opts = io.opts;
|
|
263
|
+
if (opts?.hostname) {
|
|
264
|
+
const protocol = opts.secure !== false ? 'https' : 'http';
|
|
265
|
+
const port = opts.port ? `:${opts.port}` : '';
|
|
266
|
+
return `${protocol}://${opts.hostname}${port}`;
|
|
267
|
+
}
|
|
268
|
+
// Fallback: read from config
|
|
269
|
+
try {
|
|
270
|
+
const configPath = path.join(os.homedir(), '.talk-to-code', 'config.json');
|
|
271
|
+
const config = JSON.parse(fsSync.readFileSync(configPath, 'utf-8'));
|
|
272
|
+
return config.apiUrl || 'https://api.talk-to-code.com';
|
|
273
|
+
}
|
|
274
|
+
catch {
|
|
275
|
+
return 'https://api.talk-to-code.com';
|
|
276
|
+
}
|
|
277
|
+
}
|
|
244
278
|
function formatBytes(bytes) {
|
|
245
279
|
if (bytes === 0)
|
|
246
280
|
return '0 B';
|
package/dist/ttc-cli.tar.gz
CHANGED
|
Binary file
|