@exreve/exk 1.0.50 → 1.0.52
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 +51 -14
- package/dist/cli/agentSession.js +1456 -0
- package/dist/cli/app-child.js +1038 -0
- package/dist/cli/appHandlers.js +142 -0
- package/dist/cli/appManager.js +212 -0
- package/dist/cli/appRunner.js +383 -0
- package/dist/cli/cloudflaredHandlers.js +279 -0
- package/dist/cli/containerHandlers.js +193 -0
- package/dist/cli/fsHandlers.js +86 -0
- package/dist/cli/githubHandlers.js +525 -0
- package/dist/cli/index.js +1262 -0
- package/dist/cli/moduleMcpServer.js +284 -0
- package/dist/cli/openaiAdapter.js +181 -0
- package/dist/cli/projectAnalyzer.js +330 -0
- package/dist/cli/projectManager.js +69 -0
- package/dist/cli/runnerGenerator.js +210 -0
- package/dist/cli/sessionHandlers.js +271 -0
- package/dist/cli/shared/types.js +3 -0
- package/dist/cli/skills/index.js +117 -0
- package/dist/cli/transferService.js +284 -0
- package/dist/cli/type-checks.js +13 -0
- package/dist/cli/updateHandlers.js +82 -0
- package/dist/cli/updater.js +422 -0
- package/dist/shared/types.js +34 -1
- package/dist/ttc-cli.tar.gz +0 -0
- package/package.json +1 -1
|
@@ -0,0 +1,271 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Handlers Module
|
|
3
|
+
*
|
|
4
|
+
* Handles session:create, session:delete, session:prompt,
|
|
5
|
+
* prompt:cancel, emergency:stop, project:config:analyze.
|
|
6
|
+
*/
|
|
7
|
+
import fs from 'fs/promises';
|
|
8
|
+
import fsSync from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import { agentSessionManager } from './agentSession.js';
|
|
11
|
+
import { analyzeProjectWithClaude, saveProjectConfig } from './projectAnalyzer.js';
|
|
12
|
+
import { generateRunnerCode } from './runnerGenerator.js';
|
|
13
|
+
/**
|
|
14
|
+
* Register session handlers.
|
|
15
|
+
* `getSocket` is a function that returns the current live socket — this ensures
|
|
16
|
+
* that callbacks registered on a previous socket instance still emit on the
|
|
17
|
+
* active socket after a reconnect (fixes in-flight output loss on disconnect).
|
|
18
|
+
*/
|
|
19
|
+
export function registerSessionHandlers(socket, foreground, activeSessions, getSocket) {
|
|
20
|
+
socket.on('session:create', async (data) => {
|
|
21
|
+
try {
|
|
22
|
+
let { sessionId, projectPath } = data;
|
|
23
|
+
// Remap /home/abc to /tmp/abc if /home/abc doesn't exist (container workaround)
|
|
24
|
+
if (projectPath === '/home/abc' && !fsSync.existsSync('/home/abc')) {
|
|
25
|
+
const fallbackPath = '/tmp/abc';
|
|
26
|
+
fsSync.mkdirSync(fallbackPath, { recursive: true });
|
|
27
|
+
projectPath = fallbackPath;
|
|
28
|
+
console.log(`[CLI] Remapped /home/abc -> ${fallbackPath}`);
|
|
29
|
+
}
|
|
30
|
+
activeSessions.set(sessionId, { projectPath });
|
|
31
|
+
if (foreground) {
|
|
32
|
+
console.log(`💬 Session created: ${sessionId}`);
|
|
33
|
+
console.log(` Project: ${projectPath}`);
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
console.log(`Session created: ${sessionId} in project ${projectPath}`);
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
if (foreground) {
|
|
41
|
+
console.error(`✗ Failed to create session: ${error.message}`);
|
|
42
|
+
}
|
|
43
|
+
else {
|
|
44
|
+
console.error('Failed to create session:', error.message);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
});
|
|
48
|
+
socket.on('session:delete', async (data) => {
|
|
49
|
+
try {
|
|
50
|
+
const { sessionId } = data;
|
|
51
|
+
if (foreground) {
|
|
52
|
+
console.log(`🗑️ Deleting session: ${sessionId}`);
|
|
53
|
+
}
|
|
54
|
+
await agentSessionManager.deleteSession(sessionId);
|
|
55
|
+
activeSessions.delete(sessionId);
|
|
56
|
+
if (foreground) {
|
|
57
|
+
console.log(`✓ Session deleted: ${sessionId}`);
|
|
58
|
+
}
|
|
59
|
+
else {
|
|
60
|
+
console.log(`Session deleted: ${sessionId}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (foreground) {
|
|
65
|
+
console.error(`✗ Failed to delete session: ${error.message}`);
|
|
66
|
+
}
|
|
67
|
+
else {
|
|
68
|
+
console.error('Failed to delete session:', error.message);
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
});
|
|
72
|
+
socket.on('project:config:analyze', async (data) => {
|
|
73
|
+
try {
|
|
74
|
+
const { projectId, projectPath, projectName, analysisId } = data;
|
|
75
|
+
if (foreground) {
|
|
76
|
+
console.log(`🔍 Analyzing project: ${projectName} at ${projectPath}`);
|
|
77
|
+
}
|
|
78
|
+
const config = await analyzeProjectWithClaude(projectPath, projectName);
|
|
79
|
+
await saveProjectConfig(projectPath, config);
|
|
80
|
+
for (const app of config.apps) {
|
|
81
|
+
const runnerCode = generateRunnerCode(app, projectPath);
|
|
82
|
+
const runnerFileName = `${app.name}_runner.ts`;
|
|
83
|
+
const runnerPath = path.join(projectPath, app.directory || '', runnerFileName);
|
|
84
|
+
const runnerDir = path.dirname(runnerPath);
|
|
85
|
+
await fs.mkdir(runnerDir, { recursive: true });
|
|
86
|
+
await fs.writeFile(runnerPath, runnerCode, 'utf-8');
|
|
87
|
+
if (foreground) {
|
|
88
|
+
console.log(`✓ Generated runner: ${runnerFileName}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
if (foreground) {
|
|
92
|
+
console.log(`✓ Analysis complete: Found ${config.apps.length} apps`);
|
|
93
|
+
console.log(`✓ Generated ${config.apps.length} runner files`);
|
|
94
|
+
}
|
|
95
|
+
socket.emit('project:config:analyzed', {
|
|
96
|
+
projectId,
|
|
97
|
+
config,
|
|
98
|
+
analysisId
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
catch (error) {
|
|
102
|
+
if (foreground) {
|
|
103
|
+
console.error(`✗ Analysis error: ${error.message}`);
|
|
104
|
+
}
|
|
105
|
+
socket.emit('project:config:analyze:error', {
|
|
106
|
+
projectId: data.projectId,
|
|
107
|
+
error: error.message,
|
|
108
|
+
analysisId: data.analysisId
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
socket.on('session:prompt', async (data) => {
|
|
113
|
+
try {
|
|
114
|
+
const { sessionId, prompt, projectPath: providedProjectPath, promptId, enhancers, model } = data;
|
|
115
|
+
if (!promptId) {
|
|
116
|
+
if (foreground) {
|
|
117
|
+
console.error(`✗ Missing required promptId for session: ${sessionId}`);
|
|
118
|
+
}
|
|
119
|
+
socket.emit('session:error', { sessionId, error: 'Missing required promptId' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
let projectPath = providedProjectPath || activeSessions.get(sessionId)?.projectPath;
|
|
123
|
+
if (!projectPath) {
|
|
124
|
+
if (foreground) {
|
|
125
|
+
console.error(`✗ Session not found: ${sessionId} (missing projectPath)`);
|
|
126
|
+
}
|
|
127
|
+
socket.emit('session:error', { sessionId, error: 'Session not found or projectPath missing' });
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
activeSessions.set(sessionId, { projectPath, currentPromptId: promptId, model });
|
|
131
|
+
const capturedPromptId = promptId;
|
|
132
|
+
if (foreground) {
|
|
133
|
+
console.log(`\n[CLI] 📤 Received prompt for session: ${sessionId}, promptId: ${capturedPromptId}`);
|
|
134
|
+
console.log(`[CLI] Prompt: ${prompt.substring(0, 100)}${prompt.length > 100 ? '...' : ''}`);
|
|
135
|
+
}
|
|
136
|
+
await agentSessionManager.createSession({
|
|
137
|
+
sessionId,
|
|
138
|
+
projectPath,
|
|
139
|
+
});
|
|
140
|
+
await agentSessionManager.sendPrompt(sessionId, prompt, enhancers || [], {
|
|
141
|
+
sessionId,
|
|
142
|
+
projectPath,
|
|
143
|
+
promptId: capturedPromptId,
|
|
144
|
+
model: model,
|
|
145
|
+
attachments: data.attachments,
|
|
146
|
+
onStatusUpdate: (status) => {
|
|
147
|
+
if (!capturedPromptId) {
|
|
148
|
+
console.error(`[CLI] Missing promptId for status update, cannot emit prompt:updated`);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
console.log(`[CLI] 📊 Status update: promptId=${capturedPromptId}, status=${status}, sessionId=${sessionId}`);
|
|
152
|
+
getSocket().emit('prompt:updated', {
|
|
153
|
+
promptId: capturedPromptId,
|
|
154
|
+
sessionId,
|
|
155
|
+
text: prompt,
|
|
156
|
+
status,
|
|
157
|
+
createdAt: new Date().toISOString(),
|
|
158
|
+
...(status === 'running' ? { startedAt: new Date().toISOString() } : {}),
|
|
159
|
+
...(status === 'completed' || status === 'error' || status === 'cancelled' ? { completedAt: new Date().toISOString() } : {}),
|
|
160
|
+
messages: []
|
|
161
|
+
});
|
|
162
|
+
},
|
|
163
|
+
onOutput: (output) => {
|
|
164
|
+
const dataString = typeof output.data === 'string' ? output.data : JSON.stringify(output.data);
|
|
165
|
+
if (!capturedPromptId) {
|
|
166
|
+
console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit prompt:output`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (foreground && output.type === 'stdout') {
|
|
170
|
+
process.stdout.write(dataString);
|
|
171
|
+
}
|
|
172
|
+
getSocket().emit('prompt:output', {
|
|
173
|
+
sessionId,
|
|
174
|
+
promptId: capturedPromptId,
|
|
175
|
+
type: output.type,
|
|
176
|
+
data: dataString,
|
|
177
|
+
timestamp: output.timestamp,
|
|
178
|
+
metadata: output.metadata
|
|
179
|
+
});
|
|
180
|
+
},
|
|
181
|
+
onError: (error) => {
|
|
182
|
+
if (foreground) {
|
|
183
|
+
console.error(`\n[CLI] ✗ Session error: ${error}`);
|
|
184
|
+
}
|
|
185
|
+
getSocket().emit('session:error', { sessionId, error });
|
|
186
|
+
},
|
|
187
|
+
onComplete: (exitCode) => {
|
|
188
|
+
if (foreground) {
|
|
189
|
+
console.log(`\n[CLI] ✓ Session completed with exit code: ${exitCode ?? 'null'}`);
|
|
190
|
+
}
|
|
191
|
+
if (!capturedPromptId) {
|
|
192
|
+
console.error(`[CLI] Missing promptId for session ${sessionId}, cannot emit session:result`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
getSocket().emit('session:result', {
|
|
196
|
+
sessionId,
|
|
197
|
+
promptId: capturedPromptId,
|
|
198
|
+
exitCode
|
|
199
|
+
});
|
|
200
|
+
}
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
catch (error) {
|
|
204
|
+
if (foreground) {
|
|
205
|
+
console.error(`✗ Error processing prompt: ${error.message}`);
|
|
206
|
+
}
|
|
207
|
+
socket.emit('session:error', { sessionId: data.sessionId, error: error.message });
|
|
208
|
+
}
|
|
209
|
+
});
|
|
210
|
+
socket.on('prompt:cancel', async (data, callback) => {
|
|
211
|
+
try {
|
|
212
|
+
const { promptId, sessionId } = data;
|
|
213
|
+
if (!promptId || !sessionId) {
|
|
214
|
+
callback?.({ success: false, error: 'Missing promptId or sessionId' });
|
|
215
|
+
return;
|
|
216
|
+
}
|
|
217
|
+
if (foreground) {
|
|
218
|
+
console.log(`[CLI] 🛑 Cancelling prompt: ${promptId}`);
|
|
219
|
+
}
|
|
220
|
+
const cancelled = await agentSessionManager.cancelPrompt(promptId, sessionId, (_status) => {
|
|
221
|
+
getSocket().emit('prompt:updated', {
|
|
222
|
+
promptId,
|
|
223
|
+
sessionId,
|
|
224
|
+
text: '',
|
|
225
|
+
status: 'cancelled',
|
|
226
|
+
createdAt: new Date().toISOString(),
|
|
227
|
+
completedAt: new Date().toISOString(),
|
|
228
|
+
messages: []
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
if (cancelled) {
|
|
232
|
+
callback?.({ success: true });
|
|
233
|
+
}
|
|
234
|
+
else {
|
|
235
|
+
callback?.({ success: false, error: 'Prompt not found or already completed' });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
catch (error) {
|
|
239
|
+
if (foreground) {
|
|
240
|
+
console.error(`✗ Error cancelling prompt: ${error.message}`);
|
|
241
|
+
}
|
|
242
|
+
callback?.({ success: false, error: error.message });
|
|
243
|
+
}
|
|
244
|
+
});
|
|
245
|
+
socket.on('emergency:stop', async (data, callback) => {
|
|
246
|
+
try {
|
|
247
|
+
const { sessionId } = data;
|
|
248
|
+
if (!sessionId) {
|
|
249
|
+
callback?.({ success: false, message: 'Missing sessionId' });
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
if (foreground) {
|
|
253
|
+
console.log(`[CLI] ☠️ EMERGENCY STOP for session: ${sessionId}`);
|
|
254
|
+
}
|
|
255
|
+
const result = await agentSessionManager.emergencyStop(sessionId);
|
|
256
|
+
getSocket().emit('emergency:stopped', {
|
|
257
|
+
sessionId,
|
|
258
|
+
success: result.success,
|
|
259
|
+
message: result.message,
|
|
260
|
+
timestamp: new Date().toISOString()
|
|
261
|
+
});
|
|
262
|
+
callback?.(result);
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
if (foreground) {
|
|
266
|
+
console.error(`✗ Error during emergency stop: ${error.message}`);
|
|
267
|
+
}
|
|
268
|
+
callback?.({ success: false, message: error.message });
|
|
269
|
+
}
|
|
270
|
+
});
|
|
271
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
import { readFileSync, readdirSync, existsSync } from 'fs';
|
|
2
|
+
import * as path from 'path';
|
|
3
|
+
import { fileURLToPath } from 'url';
|
|
4
|
+
import { dirname } from 'path';
|
|
5
|
+
const SKILLS_DIR = dirname(fileURLToPath(import.meta.url)); // This file is in the skills directory
|
|
6
|
+
const skillCache = new Map();
|
|
7
|
+
/**
|
|
8
|
+
* Parse frontmatter and content from a skill markdown file
|
|
9
|
+
*/
|
|
10
|
+
function parseSkillFile(content) {
|
|
11
|
+
// Check for YAML frontmatter between --- markers
|
|
12
|
+
const frontmatterRegex = /^---\s*\n([\s\S]*?)\n---\s*\n([\s\S]*)$/;
|
|
13
|
+
const match = content.match(frontmatterRegex);
|
|
14
|
+
if (!match) {
|
|
15
|
+
// No frontmatter, use filename as name
|
|
16
|
+
return null;
|
|
17
|
+
}
|
|
18
|
+
const frontmatter = match[1];
|
|
19
|
+
const skillContent = match[2].trim();
|
|
20
|
+
// Parse name and description from frontmatter
|
|
21
|
+
const nameMatch = frontmatter.match(/name:\s*(.+)/);
|
|
22
|
+
const descriptionMatch = frontmatter.match(/description:\s*(.+)/);
|
|
23
|
+
const name = nameMatch ? nameMatch[1].trim() : '';
|
|
24
|
+
const description = descriptionMatch ? descriptionMatch[1].trim() : '';
|
|
25
|
+
if (!name) {
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
return { name, description, content: skillContent };
|
|
29
|
+
}
|
|
30
|
+
/**
|
|
31
|
+
* Load a single skill by name
|
|
32
|
+
*/
|
|
33
|
+
export function loadSkill(name) {
|
|
34
|
+
// Check cache first
|
|
35
|
+
if (skillCache.has(name)) {
|
|
36
|
+
return skillCache.get(name);
|
|
37
|
+
}
|
|
38
|
+
const skillPath = path.join(SKILLS_DIR, `${name}.md`);
|
|
39
|
+
if (!existsSync(skillPath)) {
|
|
40
|
+
return null;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const content = readFileSync(skillPath, 'utf-8');
|
|
44
|
+
const parsed = parseSkillFile(content);
|
|
45
|
+
if (!parsed) {
|
|
46
|
+
return null;
|
|
47
|
+
}
|
|
48
|
+
const skill = {
|
|
49
|
+
name: parsed.name,
|
|
50
|
+
description: parsed.description,
|
|
51
|
+
content: parsed.content
|
|
52
|
+
};
|
|
53
|
+
skillCache.set(name, skill);
|
|
54
|
+
return skill;
|
|
55
|
+
}
|
|
56
|
+
catch (error) {
|
|
57
|
+
console.error(`Failed to load skill ${name}:`, error);
|
|
58
|
+
return null;
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
/**
|
|
62
|
+
* Load all available skills from the skills directory
|
|
63
|
+
*/
|
|
64
|
+
export function loadAllSkills() {
|
|
65
|
+
const skills = [];
|
|
66
|
+
if (!existsSync(SKILLS_DIR)) {
|
|
67
|
+
return skills;
|
|
68
|
+
}
|
|
69
|
+
try {
|
|
70
|
+
const files = readdirSync(SKILLS_DIR);
|
|
71
|
+
for (const file of files) {
|
|
72
|
+
if (!file.endsWith('.md')) {
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const name = file.replace('.md', '');
|
|
76
|
+
const skill = loadSkill(name);
|
|
77
|
+
if (skill) {
|
|
78
|
+
skills.push(skill);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
catch (error) {
|
|
83
|
+
console.error('Failed to load skills:', error);
|
|
84
|
+
}
|
|
85
|
+
return skills;
|
|
86
|
+
}
|
|
87
|
+
/**
|
|
88
|
+
* Get skill content for injection into prompts
|
|
89
|
+
*/
|
|
90
|
+
export function getSkillContent(names) {
|
|
91
|
+
if (names.length === 0) {
|
|
92
|
+
return '';
|
|
93
|
+
}
|
|
94
|
+
const contents = [];
|
|
95
|
+
for (const name of names) {
|
|
96
|
+
const skill = loadSkill(name);
|
|
97
|
+
if (skill) {
|
|
98
|
+
contents.push(`# Skill: ${skill.name}\n\n${skill.content}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
if (contents.length === 0) {
|
|
102
|
+
return '';
|
|
103
|
+
}
|
|
104
|
+
return `## Active Skills\n\n${contents.join('\n\n---\n\n')}\n\n---\n\n`;
|
|
105
|
+
}
|
|
106
|
+
/**
|
|
107
|
+
* List all available skill names
|
|
108
|
+
*/
|
|
109
|
+
export function listSkillNames() {
|
|
110
|
+
return loadAllSkills().map(s => s.name);
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* Get skill metadata (name, description) for all skills
|
|
114
|
+
*/
|
|
115
|
+
export function getSkillMetadata() {
|
|
116
|
+
return loadAllSkills().map(s => ({ name: s.name, description: s.description }));
|
|
117
|
+
}
|
|
@@ -0,0 +1,284 @@
|
|
|
1
|
+
import { spawn } from 'child_process';
|
|
2
|
+
import { mkdir, unlink } from 'fs/promises';
|
|
3
|
+
import fsSync from 'fs';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import os from 'os';
|
|
6
|
+
import { createHash } from 'crypto';
|
|
7
|
+
import { Readable, Transform } from 'stream';
|
|
8
|
+
// ============ Transfer Service (CLI side) ============
|
|
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.
|
|
13
|
+
// ---- Sender ----
|
|
14
|
+
export function startSending(socket, transfer, foreground) {
|
|
15
|
+
const { transferId, sourcePath, selectedItems } = transfer;
|
|
16
|
+
const startTime = Date.now();
|
|
17
|
+
const apiUrl = getApiUrl(socket);
|
|
18
|
+
const log = (message) => {
|
|
19
|
+
if (foreground)
|
|
20
|
+
console.log(`[transfer:send] ${message}`);
|
|
21
|
+
socket.emit('transfer:log', { transferId, side: 'source', level: 'info', message });
|
|
22
|
+
};
|
|
23
|
+
const sendProgress = (phase, bytesTransferred, totalBytes) => {
|
|
24
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
25
|
+
const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
|
|
26
|
+
const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
|
|
27
|
+
socket.emit('transfer:progress', {
|
|
28
|
+
transferId,
|
|
29
|
+
side: 'source',
|
|
30
|
+
phase,
|
|
31
|
+
bytesTransferred,
|
|
32
|
+
totalBytes,
|
|
33
|
+
speed: Math.round(speed),
|
|
34
|
+
eta: Math.round(eta),
|
|
35
|
+
});
|
|
36
|
+
};
|
|
37
|
+
const sendError = (message) => {
|
|
38
|
+
console.error(`[transfer:send] Error: ${message}`);
|
|
39
|
+
socket.emit('transfer:error', { transferId, side: 'source', message });
|
|
40
|
+
};
|
|
41
|
+
const items = selectedItems || ['.'];
|
|
42
|
+
log(`Packing ${items.length} item(s) from ${sourcePath}`);
|
|
43
|
+
// Estimate size first
|
|
44
|
+
let totalBytes = 0;
|
|
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+)/);
|
|
53
|
+
if (match)
|
|
54
|
+
totalBytes += parseInt(match[1], 10);
|
|
55
|
+
}
|
|
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);
|
|
65
|
+
const hash = createHash('sha256');
|
|
66
|
+
let bytesTransferred = 0;
|
|
67
|
+
let lastProgressTime = Date.now();
|
|
68
|
+
let fileCount = 0;
|
|
69
|
+
// Count files as we go (approximate from tar output)
|
|
70
|
+
tar.stderr.on('data', (data) => {
|
|
71
|
+
const msg = data.toString().trim();
|
|
72
|
+
if (msg)
|
|
73
|
+
log(`tar: ${msg}`);
|
|
74
|
+
// Count lines in tar stderr as approximate file count
|
|
75
|
+
fileCount += msg.split('\n').length;
|
|
76
|
+
});
|
|
77
|
+
tar.on('error', (err) => {
|
|
78
|
+
sendError(`tar process error: ${err.message}`);
|
|
79
|
+
});
|
|
80
|
+
tar.on('close', (code) => {
|
|
81
|
+
if (code !== 0) {
|
|
82
|
+
sendError(`tar exited with code ${code}`);
|
|
83
|
+
return;
|
|
84
|
+
}
|
|
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
|
+
}
|
|
120
|
+
const sha256 = hash.digest('hex');
|
|
121
|
+
const duration = Date.now() - startTime;
|
|
122
|
+
// Notify backend that upload is done (only after HTTP confirms success)
|
|
123
|
+
socket.emit('transfer:uploaded', {
|
|
124
|
+
transferId,
|
|
125
|
+
totalBytes: bytesTransferred,
|
|
126
|
+
sha256,
|
|
127
|
+
fileCount,
|
|
128
|
+
});
|
|
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}`);
|
|
132
|
+
});
|
|
133
|
+
// Handle cancellation
|
|
134
|
+
const cancelHandler = (data) => {
|
|
135
|
+
if (data.transferId === transferId) {
|
|
136
|
+
tar.kill('SIGKILL');
|
|
137
|
+
socket.off('transfer:cancel', cancelHandler);
|
|
138
|
+
log('Transfer cancelled');
|
|
139
|
+
}
|
|
140
|
+
};
|
|
141
|
+
socket.on('transfer:cancel', cancelHandler);
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
// ---- Receiver ----
|
|
145
|
+
export function startReceiving(socket, transfer, foreground) {
|
|
146
|
+
const { transferId, destPath } = transfer;
|
|
147
|
+
const startTime = Date.now();
|
|
148
|
+
const apiUrl = getApiUrl(socket);
|
|
149
|
+
const expectedBytes = transfer.totalBytes || 0;
|
|
150
|
+
const expectedSha256 = transfer.sha256 || '';
|
|
151
|
+
const log = (message) => {
|
|
152
|
+
if (foreground)
|
|
153
|
+
console.log(`[transfer:recv] ${message}`);
|
|
154
|
+
socket.emit('transfer:log', { transferId, side: 'dest', level: 'info', message });
|
|
155
|
+
};
|
|
156
|
+
const sendProgress = (phase, bytesTransferred, totalBytes) => {
|
|
157
|
+
const elapsed = (Date.now() - startTime) / 1000;
|
|
158
|
+
const speed = elapsed > 0 ? bytesTransferred / elapsed : 0;
|
|
159
|
+
const eta = speed > 0 ? (totalBytes - bytesTransferred) / speed : 0;
|
|
160
|
+
socket.emit('transfer:progress', {
|
|
161
|
+
transferId,
|
|
162
|
+
side: 'dest',
|
|
163
|
+
phase,
|
|
164
|
+
bytesTransferred,
|
|
165
|
+
totalBytes,
|
|
166
|
+
speed: Math.round(speed),
|
|
167
|
+
eta: Math.round(eta),
|
|
168
|
+
});
|
|
169
|
+
};
|
|
170
|
+
const sendError = (message) => {
|
|
171
|
+
console.error(`[transfer:recv] Error: ${message}`);
|
|
172
|
+
socket.emit('transfer:error', { transferId, side: 'dest', message });
|
|
173
|
+
};
|
|
174
|
+
const tmpFile = path.join(os.tmpdir(), `ttc-transfer-${transferId}.tar`);
|
|
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}`);
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
if (!res.body) {
|
|
185
|
+
sendError('Download failed: no response body');
|
|
186
|
+
return;
|
|
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();
|
|
205
|
+
}
|
|
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);
|
|
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);
|
|
231
|
+
});
|
|
232
|
+
// Cleanup temp file
|
|
233
|
+
try {
|
|
234
|
+
await unlink(tmpFile);
|
|
235
|
+
}
|
|
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
|
+
}
|
|
254
|
+
}).catch((err) => {
|
|
255
|
+
sendError(`Failed to create destination directory: ${err.message}`);
|
|
256
|
+
});
|
|
257
|
+
}
|
|
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
|
+
}
|
|
278
|
+
function formatBytes(bytes) {
|
|
279
|
+
if (bytes === 0)
|
|
280
|
+
return '0 B';
|
|
281
|
+
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
|
|
282
|
+
const i = Math.floor(Math.log(bytes) / Math.log(1024));
|
|
283
|
+
return `${(bytes / Math.pow(1024, i)).toFixed(1)} ${units[i]}`;
|
|
284
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Force evaluation — if any assertion resolved to a string literal, this will fail
|
|
2
|
+
// @ts-expect-error — intentional compile-time assertions, never used at runtime
|
|
3
|
+
const _checks = [
|
|
4
|
+
null,
|
|
5
|
+
null,
|
|
6
|
+
null,
|
|
7
|
+
null,
|
|
8
|
+
null,
|
|
9
|
+
null,
|
|
10
|
+
null,
|
|
11
|
+
null,
|
|
12
|
+
];
|
|
13
|
+
export {};
|