@exreve/exk 1.0.37 → 1.0.38

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.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 (sender)
1549
- socket.on('transfer:send', (data) => {
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] Send request: ${data.sourcePath} → device ${data.destDeviceId.slice(0, 8)}`);
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 (receiver)
1556
- socket.on('transfer:receive', (data) => {
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] Receive request: device ${data.sourceDeviceId.slice(0, 8)} → ${data.destPath}`);
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
  });
@@ -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
- // Handles both sending and receiving folders via tar streaming.
9
- // Binary chunks flow over Socket.IO no HTTP endpoints needed.
10
- const CHUNK_SIZE = 1024 * 1024; // 1MB chunks
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,51 +38,35 @@ 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
- log(`Starting to tar and send: ${sourcePath}`);
38
- // Estimate folder size first with a quick du
41
+ const items = selectedItems || ['.'];
42
+ log(`Packing ${items.length} item(s) from ${sourcePath}`);
43
+ // Estimate size first
39
44
  let totalBytes = 0;
40
- try {
41
- const duResult = spawn('du', ['-sb', sourcePath], { timeout: 30000 });
42
- let duOutput = '';
43
- duResult.stdout.on('data', (d) => { duOutput += d.toString(); });
44
- duResult.on('close', (code) => {
45
- if (code === 0) {
46
- const match = duOutput.match(/^(\d+)/);
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 = parseInt(match[1], 10);
54
+ totalBytes += parseInt(match[1], 10);
49
55
  }
50
- log(`Estimated size: ${formatBytes(totalBytes)}`);
51
- doSend();
52
- });
53
- duResult.on('error', () => doSend());
54
- }
55
- catch {
56
- doSend();
57
- }
58
- function doSend() {
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
- tar.stdout.on('data', (chunk) => {
66
- hash.update(chunk);
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)
@@ -92,19 +80,52 @@ export function startSending(socket, transfer, foreground) {
92
80
  sendError(`tar exited with code ${code}`);
93
81
  return;
94
82
  }
95
- const duration = Date.now() - startTime;
83
+ log(`Packing complete. Final size: ${formatBytes(bytesTransferred)}`);
96
84
  const sha256 = hash.digest('hex');
97
- // Final progress
98
- sendProgress(bytesTransferred, bytesTransferred);
99
- // We don't send transfer:complete here — the dest does that after verification
100
- // Instead, we send a source:done so the dest knows the stream is over
101
- socket.emit('transfer:source:done', {
85
+ const duration = Date.now() - startTime;
86
+ // Notify backend that upload is done
87
+ socket.emit('transfer:uploaded', {
102
88
  transferId,
103
89
  totalBytes: bytesTransferred,
104
90
  sha256,
105
- duration,
91
+ fileCount,
106
92
  });
107
- log(`Sent ${formatBytes(bytesTransferred)} in ${(duration / 1000).toFixed(1)}s (${seq} chunks)`);
93
+ log(`Upload complete: ${formatBytes(bytesTransferred)} in ${(duration / 1000).toFixed(1)}s (sha256: ${sha256.slice(0, 12)}...)`);
94
+ });
95
+ // Stream tar output through hash, then HTTP POST
96
+ const uploadUrl = `${apiUrl}/transfer/${transferId}/upload`;
97
+ // Pipe tar stdout → hash transform → HTTP upload
98
+ const hashTransform = new Transform({
99
+ transform(chunk, _encoding, callback) {
100
+ hash.update(chunk);
101
+ bytesTransferred += chunk.length;
102
+ if (Date.now() - lastProgressTime > 500) {
103
+ sendProgress('uploading', bytesTransferred, totalBytes || bytesTransferred);
104
+ lastProgressTime = Date.now();
105
+ }
106
+ this.push(chunk);
107
+ callback();
108
+ },
109
+ });
110
+ // Use fetch with streaming body for the upload
111
+ const tarStream = tar.stdout.pipe(hashTransform);
112
+ // Node 18+ supports ReadableStream in fetch body
113
+ const nodeStream = Readable.toWeb(tarStream);
114
+ fetch(uploadUrl, {
115
+ method: 'POST',
116
+ body: nodeStream,
117
+ headers: {
118
+ 'Content-Type': 'application/x-tar',
119
+ 'Transfer-Encoding': 'chunked',
120
+ },
121
+ duplex: 'half',
122
+ }).then(async (res) => {
123
+ if (!res.ok) {
124
+ const text = await res.text();
125
+ sendError(`Upload failed: HTTP ${res.status} ${text}`);
126
+ }
127
+ }).catch((err) => {
128
+ sendError(`Upload network error: ${err.message}`);
108
129
  });
109
130
  // Handle cancellation
110
131
  const cancelHandler = (data) => {
@@ -121,18 +142,22 @@ export function startSending(socket, transfer, foreground) {
121
142
  export function startReceiving(socket, transfer, foreground) {
122
143
  const { transferId, destPath } = transfer;
123
144
  const startTime = Date.now();
145
+ const apiUrl = getApiUrl(socket);
146
+ const expectedBytes = transfer.totalBytes || 0;
147
+ const expectedSha256 = transfer.sha256 || '';
124
148
  const log = (message) => {
125
149
  if (foreground)
126
150
  console.log(`[transfer:recv] ${message}`);
127
151
  socket.emit('transfer:log', { transferId, side: 'dest', level: 'info', message });
128
152
  };
129
- const sendProgress = (bytesTransferred, totalBytes) => {
153
+ const sendProgress = (phase, bytesTransferred, totalBytes) => {
130
154
  const elapsed = (Date.now() - startTime) / 1000;
131
155
  const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
132
156
  const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
133
157
  socket.emit('transfer:progress', {
134
158
  transferId,
135
159
  side: 'dest',
160
+ phase,
136
161
  bytesTransferred,
137
162
  totalBytes,
138
163
  speed: Math.round(speed),
@@ -144,103 +169,109 @@ export function startReceiving(socket, transfer, foreground) {
144
169
  socket.emit('transfer:error', { transferId, side: 'dest', message });
145
170
  };
146
171
  const tmpFile = path.join(os.tmpdir(), `ttc-transfer-${transferId}.tar`);
147
- log(`Ready to receive into: ${destPath}`);
148
- // Ensure dest directory exists
149
- mkdir(destPath, { recursive: true }).then(() => {
150
- const writeStream = fsSync.createWriteStream(tmpFile);
151
- const hash = createHash('sha256');
152
- let bytesReceived = 0;
153
- let totalExpected = 0;
154
- let lastProgressTime = Date.now();
155
- let sourceSha256 = '';
156
- // Handle incoming chunks
157
- const chunkHandler = (data) => {
158
- if (data.transferId !== transferId)
172
+ log(`Downloading from backend...`);
173
+ mkdir(destPath, { recursive: true }).then(async () => {
174
+ try {
175
+ const downloadUrl = `${apiUrl}/transfer/${transferId}/download`;
176
+ const res = await fetch(downloadUrl);
177
+ if (!res.ok) {
178
+ sendError(`Download failed: HTTP ${res.status}`);
159
179
  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
180
  }
168
- };
169
- socket.on('transfer:chunk', chunkHandler);
170
- // Handle source:done — source finished sending
171
- const sourceDoneHandler = (data) => {
172
- if (data.transferId !== transferId)
181
+ if (!res.body) {
182
+ sendError('Download failed: no response body');
173
183
  return;
174
- totalExpected = data.totalBytes;
175
- sourceSha256 = data.sha256;
176
- log(`Source finished. Received ${formatBytes(bytesReceived)} / ${formatBytes(totalExpected)}`);
177
- // Finish writing and verify
178
- writeStream.end(async () => {
179
- try {
180
- const receivedHash = hash.digest('hex');
181
- // Verify hash
182
- if (receivedHash !== sourceSha256) {
183
- sendError(`Hash mismatch! Expected ${sourceSha256.slice(0, 16)}... got ${receivedHash.slice(0, 16)}...`);
184
- try {
185
- await unlink(tmpFile);
186
- }
187
- catch { }
188
- return;
189
- }
190
- log(`Hash verified ✓. Extracting to ${destPath}...`);
191
- // Extract tar
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
- });
184
+ }
185
+ // Stream download to temp file while hashing
186
+ const writeStream = fsSync.createWriteStream(tmpFile);
187
+ const hash = createHash('sha256');
188
+ let bytesReceived = 0;
189
+ let lastProgressTime = Date.now();
190
+ const reader = res.body.getReader();
191
+ // Read chunks from the response stream
192
+ while (true) {
193
+ const { done, value } = await reader.read();
194
+ if (done)
195
+ break;
196
+ writeStream.write(value);
197
+ hash.update(value);
198
+ bytesReceived += value.length;
199
+ if (Date.now() - lastProgressTime > 500) {
200
+ sendProgress('downloading', bytesReceived, expectedBytes || bytesReceived);
201
+ lastProgressTime = Date.now();
219
202
  }
220
- catch (err) {
221
- sendError(`Post-processing error: ${err.message}`);
203
+ }
204
+ writeStream.end();
205
+ log(`Downloaded ${formatBytes(bytesReceived)}. Verifying...`);
206
+ // Verify hash
207
+ const receivedHash = hash.digest('hex');
208
+ if (expectedSha256 && receivedHash !== expectedSha256) {
209
+ sendError(`Hash mismatch! Expected ${expectedSha256.slice(0, 16)}... got ${receivedHash.slice(0, 16)}...`);
210
+ try {
211
+ await unlink(tmpFile);
222
212
  }
213
+ catch { }
214
+ return;
215
+ }
216
+ log(`Hash verified ✓. Extracting to ${destPath}...`);
217
+ sendProgress('extracting', bytesReceived, bytesReceived);
218
+ // Extract tar
219
+ const extractProc = spawn('tar', ['xf', tmpFile, '-C', destPath]);
220
+ await new Promise((resolve, reject) => {
221
+ extractProc.on('close', (code) => {
222
+ if (code !== 0)
223
+ reject(new Error(`tar extraction failed with code ${code}`));
224
+ else
225
+ resolve();
226
+ });
227
+ extractProc.on('error', reject);
223
228
  });
224
- };
225
- socket.on('transfer:source:done', sourceDoneHandler);
226
- // Handle cancellation
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(() => { });
229
+ // Cleanup temp file
230
+ try {
231
+ await unlink(tmpFile);
236
232
  }
237
- };
238
- socket.on('transfer:cancel', cancelHandler);
233
+ catch { }
234
+ const duration = Date.now() - startTime;
235
+ socket.emit('transfer:complete', {
236
+ transferId,
237
+ totalBytes: bytesReceived,
238
+ sha256: receivedHash,
239
+ duration,
240
+ fileCount: 0,
241
+ });
242
+ log(`Complete! ${formatBytes(bytesReceived)} extracted to ${destPath} in ${(duration / 1000).toFixed(1)}s`);
243
+ }
244
+ catch (err) {
245
+ sendError(`Download/extract error: ${err.message}`);
246
+ try {
247
+ await unlink(tmpFile);
248
+ }
249
+ catch { }
250
+ }
239
251
  }).catch((err) => {
240
252
  sendError(`Failed to create destination directory: ${err.message}`);
241
253
  });
242
254
  }
243
255
  // ---- Helpers ----
256
+ function getApiUrl(socket) {
257
+ // Extract the base URL from the socket connection
258
+ const io = socket.io;
259
+ const opts = io.opts;
260
+ if (opts?.hostname) {
261
+ const protocol = opts.secure !== false ? 'https' : 'http';
262
+ const port = opts.port ? `:${opts.port}` : '';
263
+ return `${protocol}://${opts.hostname}${port}`;
264
+ }
265
+ // Fallback: read from config
266
+ try {
267
+ const configPath = path.join(os.homedir(), '.talk-to-code', 'config.json');
268
+ const config = JSON.parse(fsSync.readFileSync(configPath, 'utf-8'));
269
+ return config.apiUrl || 'https://api.talk-to-code.com';
270
+ }
271
+ catch {
272
+ return 'https://api.talk-to-code.com';
273
+ }
274
+ }
244
275
  function formatBytes(bytes) {
245
276
  if (bytes === 0)
246
277
  return '0 B';
Binary file
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@exreve/exk",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
4
4
  "description": "exk - Control Claude CLI with voice and programmable interfaces",
5
5
  "type": "module",
6
6
  "bin": {