nstantpage-agent 0.8.17 → 0.8.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/commands/sync.d.ts +6 -7
- package/dist/commands/sync.js +14 -147
- package/dist/fileManager.d.ts +1 -1
- package/dist/fileManager.js +6 -6
- package/dist/localServer.d.ts +16 -0
- package/dist/localServer.js +305 -6
- package/dist/tunnel.js +1 -1
- package/package.json +1 -1
package/dist/commands/sync.d.ts
CHANGED
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sync command —
|
|
2
|
+
* Sync command — link a local directory to an nstantpage project.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* nstantpage sync —
|
|
6
|
-
* nstantpage sync /path/to/dir —
|
|
5
|
+
* nstantpage sync — Link current directory to nstantpage project
|
|
6
|
+
* nstantpage sync /path/to/dir — Link a specific directory
|
|
7
7
|
*
|
|
8
8
|
* What it does:
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4. Starts the agent (equivalent to `nstantpage start`)
|
|
9
|
+
* 1. Creates a LocalRepo project if one doesn't exist (name = folder name)
|
|
10
|
+
* 2. Links the project to this device (deviceId + deviceName)
|
|
11
|
+
* 3. Files stay on disk — no file pushing to the DB
|
|
13
12
|
*/
|
|
14
13
|
interface SyncOptions {
|
|
15
14
|
local?: boolean | string;
|
package/dist/commands/sync.js
CHANGED
|
@@ -1,41 +1,20 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* Sync command —
|
|
2
|
+
* Sync command — link a local directory to an nstantpage project.
|
|
3
3
|
*
|
|
4
4
|
* Usage:
|
|
5
|
-
* nstantpage sync —
|
|
6
|
-
* nstantpage sync /path/to/dir —
|
|
5
|
+
* nstantpage sync — Link current directory to nstantpage project
|
|
6
|
+
* nstantpage sync /path/to/dir — Link a specific directory
|
|
7
7
|
*
|
|
8
8
|
* What it does:
|
|
9
|
-
* 1.
|
|
10
|
-
* 2.
|
|
11
|
-
* 3.
|
|
12
|
-
* 4. Starts the agent (equivalent to `nstantpage start`)
|
|
9
|
+
* 1. Creates a LocalRepo project if one doesn't exist (name = folder name)
|
|
10
|
+
* 2. Links the project to this device (deviceId + deviceName)
|
|
11
|
+
* 3. Files stay on disk — no file pushing to the DB
|
|
13
12
|
*/
|
|
14
13
|
import chalk from 'chalk';
|
|
14
|
+
import os from 'os';
|
|
15
15
|
import path from 'path';
|
|
16
16
|
import fs from 'fs';
|
|
17
17
|
import { getConfig, getDeviceId } from '../config.js';
|
|
18
|
-
const SKIP_DIRS = new Set([
|
|
19
|
-
'node_modules', 'dist', '.git', '.vite-cache', '.next',
|
|
20
|
-
'__pycache__', '.turbo', '.cache', 'build', 'out',
|
|
21
|
-
'.svelte-kit', '.nuxt', '.output', '.vercel',
|
|
22
|
-
'.angular', '.parcel-cache', '.rollup.cache',
|
|
23
|
-
]);
|
|
24
|
-
const SKIP_FILES = new Set([
|
|
25
|
-
'.DS_Store', 'Thumbs.db', '.env.local',
|
|
26
|
-
]);
|
|
27
|
-
/** Max file size to sync (1MB — skip large binaries) */
|
|
28
|
-
const MAX_FILE_SIZE = 1_048_576;
|
|
29
|
-
/** Binary file extensions to skip */
|
|
30
|
-
const BINARY_EXTENSIONS = new Set([
|
|
31
|
-
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.bmp', '.tiff',
|
|
32
|
-
'.mp4', '.mp3', '.wav', '.ogg', '.webm', '.avi',
|
|
33
|
-
'.zip', '.tar', '.gz', '.rar', '.7z',
|
|
34
|
-
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
35
|
-
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
36
|
-
'.exe', '.dll', '.so', '.dylib',
|
|
37
|
-
'.sqlite', '.db',
|
|
38
|
-
]);
|
|
39
18
|
/**
|
|
40
19
|
* Resolve the backend API base URL (same logic as start.ts).
|
|
41
20
|
*/
|
|
@@ -45,51 +24,6 @@ function resolveBackendUrl(gateway, backend) {
|
|
|
45
24
|
const isLocal = /^wss?:\/\/(localhost|127\.0\.0\.1)/.test(gateway);
|
|
46
25
|
return isLocal ? 'http://localhost:5001' : 'https://nstantpage.com';
|
|
47
26
|
}
|
|
48
|
-
/**
|
|
49
|
-
* Recursively collect all source files in a directory.
|
|
50
|
-
* Returns array of { relativePath, content }.
|
|
51
|
-
*/
|
|
52
|
-
async function collectFiles(baseDir, currentDir) {
|
|
53
|
-
const files = [];
|
|
54
|
-
let entries;
|
|
55
|
-
try {
|
|
56
|
-
entries = await fs.promises.readdir(currentDir, { withFileTypes: true });
|
|
57
|
-
}
|
|
58
|
-
catch {
|
|
59
|
-
return files;
|
|
60
|
-
}
|
|
61
|
-
for (const entry of entries) {
|
|
62
|
-
const fullPath = path.join(currentDir, entry.name);
|
|
63
|
-
const relativePath = path.relative(baseDir, fullPath).replace(/\\/g, '/');
|
|
64
|
-
if (entry.isDirectory()) {
|
|
65
|
-
if (SKIP_DIRS.has(entry.name) || entry.name.startsWith('.nstantpage'))
|
|
66
|
-
continue;
|
|
67
|
-
const subFiles = await collectFiles(baseDir, fullPath);
|
|
68
|
-
files.push(...subFiles);
|
|
69
|
-
}
|
|
70
|
-
else if (entry.isFile()) {
|
|
71
|
-
if (SKIP_FILES.has(entry.name))
|
|
72
|
-
continue;
|
|
73
|
-
const ext = path.extname(entry.name).toLowerCase();
|
|
74
|
-
if (BINARY_EXTENSIONS.has(ext))
|
|
75
|
-
continue;
|
|
76
|
-
try {
|
|
77
|
-
const stat = await fs.promises.stat(fullPath);
|
|
78
|
-
if (stat.size > MAX_FILE_SIZE)
|
|
79
|
-
continue;
|
|
80
|
-
const content = await fs.promises.readFile(fullPath, 'utf-8');
|
|
81
|
-
// Skip files with null bytes (binary)
|
|
82
|
-
if (content.includes('\0'))
|
|
83
|
-
continue;
|
|
84
|
-
files.push({ relativePath, content });
|
|
85
|
-
}
|
|
86
|
-
catch {
|
|
87
|
-
// Can't read — skip
|
|
88
|
-
}
|
|
89
|
-
}
|
|
90
|
-
}
|
|
91
|
-
return files;
|
|
92
|
-
}
|
|
93
27
|
export async function syncCommand(directory, options) {
|
|
94
28
|
const conf = getConfig();
|
|
95
29
|
// Resolve directory
|
|
@@ -117,26 +51,19 @@ export async function syncCommand(directory, options) {
|
|
|
117
51
|
}
|
|
118
52
|
const backendUrl = resolveBackendUrl(gateway, options.backend);
|
|
119
53
|
const deviceId = getDeviceId();
|
|
54
|
+
const deviceName = os.hostname();
|
|
120
55
|
const folderName = path.basename(projectDir);
|
|
121
56
|
console.log(chalk.blue(`\n 📂 nstantpage sync\n`));
|
|
122
57
|
console.log(chalk.gray(` Directory: ${projectDir}`));
|
|
58
|
+
console.log(chalk.gray(` Device: ${deviceName}`));
|
|
123
59
|
console.log(chalk.gray(` Backend: ${backendUrl}\n`));
|
|
124
|
-
// 1.
|
|
125
|
-
console.log(chalk.gray(' Scanning files...'));
|
|
126
|
-
const files = await collectFiles(projectDir, projectDir);
|
|
127
|
-
if (files.length === 0) {
|
|
128
|
-
console.log(chalk.yellow(' ⚠ No source files found in directory'));
|
|
129
|
-
process.exit(1);
|
|
130
|
-
}
|
|
131
|
-
console.log(chalk.green(` ✓ Found ${files.length} files`));
|
|
132
|
-
// 2. Get user info from token
|
|
60
|
+
// 1. Get user info from token
|
|
133
61
|
let userId;
|
|
134
62
|
if (token && token !== 'local-dev') {
|
|
135
63
|
try {
|
|
136
64
|
const parts = token.split('.');
|
|
137
65
|
if (parts.length === 3) {
|
|
138
66
|
const payload = JSON.parse(Buffer.from(parts[1], 'base64').toString('utf-8'));
|
|
139
|
-
// .NET uses full claim type URIs; also check standard 'sub' claim
|
|
140
67
|
userId = payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/nameidentifier']
|
|
141
68
|
|| payload['sub']
|
|
142
69
|
|| payload['nameid'];
|
|
@@ -144,8 +71,8 @@ export async function syncCommand(directory, options) {
|
|
|
144
71
|
}
|
|
145
72
|
catch { }
|
|
146
73
|
}
|
|
147
|
-
//
|
|
148
|
-
console.log(chalk.gray('
|
|
74
|
+
// 2. Create or find LocalRepo project (linked to this device)
|
|
75
|
+
console.log(chalk.gray(' Linking project...'));
|
|
149
76
|
let projectId;
|
|
150
77
|
try {
|
|
151
78
|
const createRes = await fetch(`${backendUrl}/api/projects`, {
|
|
@@ -160,6 +87,7 @@ export async function syncCommand(directory, options) {
|
|
|
160
87
|
localFolderPath: projectDir,
|
|
161
88
|
userId,
|
|
162
89
|
deviceId,
|
|
90
|
+
deviceName,
|
|
163
91
|
}),
|
|
164
92
|
});
|
|
165
93
|
if (!createRes.ok) {
|
|
@@ -174,68 +102,7 @@ export async function syncCommand(directory, options) {
|
|
|
174
102
|
console.log(chalk.red(` ✗ ${err.message}`));
|
|
175
103
|
process.exit(1);
|
|
176
104
|
}
|
|
177
|
-
|
|
178
|
-
console.log(chalk.gray(' Creating new version (clearing old files)...'));
|
|
179
|
-
try {
|
|
180
|
-
const syncRes = await fetch(`${backendUrl}/api/projects/${projectId}/sync`, {
|
|
181
|
-
method: 'POST',
|
|
182
|
-
headers: {
|
|
183
|
-
'Authorization': `Bearer ${token}`,
|
|
184
|
-
'Content-Type': 'application/json',
|
|
185
|
-
},
|
|
186
|
-
});
|
|
187
|
-
if (!syncRes.ok) {
|
|
188
|
-
const text = await syncRes.text().catch(() => '');
|
|
189
|
-
throw new Error(`Sync init failed (${syncRes.status}): ${text}`);
|
|
190
|
-
}
|
|
191
|
-
const syncData = await syncRes.json();
|
|
192
|
-
console.log(chalk.green(` ✓ Version ${syncData.versionNumber} created (old files cleared)`));
|
|
193
|
-
}
|
|
194
|
-
catch (err) {
|
|
195
|
-
console.log(chalk.red(` ✗ ${err.message}`));
|
|
196
|
-
process.exit(1);
|
|
197
|
-
}
|
|
198
|
-
// 5. Push all files to DB
|
|
199
|
-
console.log(chalk.gray(` Pushing ${files.length} files to database...`));
|
|
200
|
-
// Push in batches of 100 to avoid huge payloads
|
|
201
|
-
const BATCH_SIZE = 100;
|
|
202
|
-
let totalPushed = 0;
|
|
203
|
-
for (let i = 0; i < files.length; i += BATCH_SIZE) {
|
|
204
|
-
const batch = files.slice(i, i + BATCH_SIZE);
|
|
205
|
-
const filesMap = {};
|
|
206
|
-
for (const f of batch) {
|
|
207
|
-
filesMap[f.relativePath] = f.content;
|
|
208
|
-
}
|
|
209
|
-
try {
|
|
210
|
-
const pushRes = await fetch(`${backendUrl}/api/sandbox/push-files`, {
|
|
211
|
-
method: 'POST',
|
|
212
|
-
headers: {
|
|
213
|
-
'Authorization': `Bearer ${token}`,
|
|
214
|
-
'Content-Type': 'application/json',
|
|
215
|
-
},
|
|
216
|
-
body: JSON.stringify({
|
|
217
|
-
projectId: parseInt(projectId, 10),
|
|
218
|
-
files: filesMap,
|
|
219
|
-
}),
|
|
220
|
-
});
|
|
221
|
-
if (!pushRes.ok) {
|
|
222
|
-
const text = await pushRes.text().catch(() => '');
|
|
223
|
-
throw new Error(`Push failed (${pushRes.status}): ${text}`);
|
|
224
|
-
}
|
|
225
|
-
totalPushed += batch.length;
|
|
226
|
-
if (files.length > BATCH_SIZE) {
|
|
227
|
-
process.stdout.write(`\r Pushed ${totalPushed}/${files.length} files...`);
|
|
228
|
-
}
|
|
229
|
-
}
|
|
230
|
-
catch (err) {
|
|
231
|
-
console.log(chalk.red(`\n ✗ ${err.message}`));
|
|
232
|
-
process.exit(1);
|
|
233
|
-
}
|
|
234
|
-
}
|
|
235
|
-
if (files.length > BATCH_SIZE)
|
|
236
|
-
process.stdout.write('\n');
|
|
237
|
-
console.log(chalk.green(` ✓ ${totalPushed} files synced to database`));
|
|
238
|
-
console.log(chalk.blue(`\n ✓ Sync complete! Project ID: ${projectId}`));
|
|
105
|
+
console.log(chalk.blue(`\n ✓ Project linked to device "${deviceName}"! Project ID: ${projectId}`));
|
|
239
106
|
console.log(chalk.gray(`\n View on web: https://nstantpage.com/project/${projectId}`));
|
|
240
107
|
console.log(chalk.gray(` Start agent: nstantpage start --project-id ${projectId} --dir "${projectDir}"\n`));
|
|
241
108
|
}
|
package/dist/fileManager.d.ts
CHANGED
|
@@ -40,7 +40,7 @@ export declare class FileManager {
|
|
|
40
40
|
* Build a recursive file tree of the project directory.
|
|
41
41
|
* Returns a structure matching the backend's /api/projects/{id}/tree format.
|
|
42
42
|
*/
|
|
43
|
-
getFileTree(): Promise<{
|
|
43
|
+
getFileTree(showHidden?: boolean): Promise<{
|
|
44
44
|
name: string;
|
|
45
45
|
path: string;
|
|
46
46
|
isDirectory: boolean;
|
package/dist/fileManager.js
CHANGED
|
@@ -105,7 +105,7 @@ export class FileManager {
|
|
|
105
105
|
* Build a recursive file tree of the project directory.
|
|
106
106
|
* Returns a structure matching the backend's /api/projects/{id}/tree format.
|
|
107
107
|
*/
|
|
108
|
-
async getFileTree() {
|
|
108
|
+
async getFileTree(showHidden = false) {
|
|
109
109
|
const root = {
|
|
110
110
|
name: path.basename(this.projectDir),
|
|
111
111
|
path: '',
|
|
@@ -113,11 +113,11 @@ export class FileManager {
|
|
|
113
113
|
children: [],
|
|
114
114
|
};
|
|
115
115
|
if (existsSync(this.projectDir)) {
|
|
116
|
-
root.children = await this.buildTree(this.projectDir, '');
|
|
116
|
+
root.children = await this.buildTree(this.projectDir, '', showHidden);
|
|
117
117
|
}
|
|
118
118
|
return root;
|
|
119
119
|
}
|
|
120
|
-
async buildTree(dir, relativePath) {
|
|
120
|
+
async buildTree(dir, relativePath, showHidden = false) {
|
|
121
121
|
const SKIP_DIRS = new Set(['node_modules', 'dist', '.git', '.vite-cache', '.next', '__pycache__', '.turbo', '.cache']);
|
|
122
122
|
const entries = await fs.readdir(dir, { withFileTypes: true });
|
|
123
123
|
const result = [];
|
|
@@ -130,13 +130,13 @@ export class FileManager {
|
|
|
130
130
|
return a.name.localeCompare(b.name);
|
|
131
131
|
});
|
|
132
132
|
for (const entry of sorted) {
|
|
133
|
-
if (entry.name.startsWith('.') && entry.name !== '.env')
|
|
133
|
+
if (!showHidden && entry.name.startsWith('.') && entry.name !== '.env')
|
|
134
134
|
continue;
|
|
135
135
|
const entryRelPath = relativePath ? `${relativePath}/${entry.name}` : entry.name;
|
|
136
136
|
if (entry.isDirectory()) {
|
|
137
|
-
if (SKIP_DIRS.has(entry.name))
|
|
137
|
+
if (!showHidden && SKIP_DIRS.has(entry.name))
|
|
138
138
|
continue;
|
|
139
|
-
const children = await this.buildTree(path.join(dir, entry.name), entryRelPath);
|
|
139
|
+
const children = await this.buildTree(path.join(dir, entry.name), entryRelPath, showHidden);
|
|
140
140
|
result.push({ name: entry.name, path: entryRelPath, isDirectory: true, children });
|
|
141
141
|
}
|
|
142
142
|
else {
|
package/dist/localServer.d.ts
CHANGED
|
@@ -104,6 +104,12 @@ export declare class LocalServer {
|
|
|
104
104
|
private handleExec;
|
|
105
105
|
private handleTerminal;
|
|
106
106
|
private handleTerminalSessions;
|
|
107
|
+
private handleTerminalDestroy;
|
|
108
|
+
private handleTerminalDestroyAll;
|
|
109
|
+
/**
|
|
110
|
+
* Force-kill a terminal session: send signals, notify listeners, remove from map.
|
|
111
|
+
*/
|
|
112
|
+
private killSession;
|
|
107
113
|
private handleTerminalWrite;
|
|
108
114
|
private handleTerminalResize;
|
|
109
115
|
private handleAgentStatus;
|
|
@@ -129,6 +135,16 @@ export declare class LocalServer {
|
|
|
129
135
|
private handleTree;
|
|
130
136
|
private handleFileContent;
|
|
131
137
|
private handleSaveFile;
|
|
138
|
+
private static readonly BROWSE_SKIP_DIRS;
|
|
139
|
+
private static readonly BROWSE_BINARY_EXTS;
|
|
140
|
+
private collectFilesRecursive;
|
|
141
|
+
private handleBrowseListFiles;
|
|
142
|
+
private handleBrowseFile;
|
|
143
|
+
private handleBrowseTree;
|
|
144
|
+
private handleBrowseSave;
|
|
145
|
+
private handleBrowseSearch;
|
|
146
|
+
private handleBrowseDelete;
|
|
147
|
+
private matchGlob;
|
|
132
148
|
private handleSetDevPort;
|
|
133
149
|
private handleHealth;
|
|
134
150
|
/**
|
package/dist/localServer.js
CHANGED
|
@@ -375,6 +375,8 @@ export class LocalServer {
|
|
|
375
375
|
'/live/terminal/sessions': this.handleTerminalSessions,
|
|
376
376
|
'/live/terminal/write': this.handleTerminalWrite,
|
|
377
377
|
'/live/terminal/resize': this.handleTerminalResize,
|
|
378
|
+
'/live/terminal/destroy': this.handleTerminalDestroy,
|
|
379
|
+
'/live/terminal/destroy-all': this.handleTerminalDestroyAll,
|
|
378
380
|
'/live/agent-status': this.handleAgentStatus,
|
|
379
381
|
'/live/container-status': this.handleContainerStatus,
|
|
380
382
|
'/live/container-stats': this.handleContainerStats,
|
|
@@ -406,15 +408,27 @@ export class LocalServer {
|
|
|
406
408
|
'/live/save-file': this.handleSaveFile,
|
|
407
409
|
'/live/set-dev-port': this.handleSetDevPort,
|
|
408
410
|
'/health': this.handleHealth,
|
|
411
|
+
// ── /browse/* endpoints (same as statusServer, but accessible through tunnel) ──
|
|
412
|
+
'/browse/list-files': this.handleBrowseListFiles,
|
|
413
|
+
'/browse/file': this.handleBrowseFile,
|
|
414
|
+
'/browse/tree': this.handleBrowseTree,
|
|
415
|
+
'/browse/save': this.handleBrowseSave,
|
|
416
|
+
'/browse/search': this.handleBrowseSearch,
|
|
417
|
+
'/browse/delete': this.handleBrowseDelete,
|
|
409
418
|
};
|
|
410
419
|
if (handlers[path])
|
|
411
420
|
return handlers[path];
|
|
412
|
-
// Prefix matches (e.g., /live/
|
|
421
|
+
// Prefix matches — find the LONGEST matching prefix (e.g., /live/terminal/sessions
|
|
422
|
+
// must not match /live/terminal when /live/terminal/sessions is available)
|
|
423
|
+
let bestMatch = null;
|
|
424
|
+
let bestLen = 0;
|
|
413
425
|
for (const [route, handler] of Object.entries(handlers)) {
|
|
414
|
-
if (path.startsWith(route + '/'))
|
|
415
|
-
|
|
426
|
+
if (path.startsWith(route + '/') && route.length > bestLen) {
|
|
427
|
+
bestMatch = handler;
|
|
428
|
+
bestLen = route.length;
|
|
429
|
+
}
|
|
416
430
|
}
|
|
417
|
-
return
|
|
431
|
+
return bestMatch;
|
|
418
432
|
}
|
|
419
433
|
// ─── /live/sync ──────────────────────────────────────────────
|
|
420
434
|
async handleSync(_req, res, body) {
|
|
@@ -584,6 +598,24 @@ export class LocalServer {
|
|
|
584
598
|
cols: s.cols,
|
|
585
599
|
rows: s.rows,
|
|
586
600
|
}));
|
|
601
|
+
// Include dev server process as a virtual session entry
|
|
602
|
+
if (this.devServer.isRunning) {
|
|
603
|
+
const stats = this.devServer.getStats();
|
|
604
|
+
sessions.unshift({
|
|
605
|
+
id: `devserver-${this.options.projectId}`,
|
|
606
|
+
projectId: this.options.projectId,
|
|
607
|
+
isAiSession: false,
|
|
608
|
+
label: 'Dev Server',
|
|
609
|
+
createdAt: Date.now() - this.devServer.uptime,
|
|
610
|
+
lastActivity: Date.now(),
|
|
611
|
+
exited: false,
|
|
612
|
+
exitCode: null,
|
|
613
|
+
devServerReady: true,
|
|
614
|
+
devServerPort: this.devServer.port,
|
|
615
|
+
pid: stats.pid,
|
|
616
|
+
isDevServer: true,
|
|
617
|
+
});
|
|
618
|
+
}
|
|
587
619
|
this.json(res, { success: true, sessions });
|
|
588
620
|
return;
|
|
589
621
|
}
|
|
@@ -779,6 +811,78 @@ export class LocalServer {
|
|
|
779
811
|
},
|
|
780
812
|
});
|
|
781
813
|
}
|
|
814
|
+
// ─── /live/terminal/destroy ──────────────────────────────────
|
|
815
|
+
async handleTerminalDestroy(_req, res, body) {
|
|
816
|
+
let parsed = {};
|
|
817
|
+
try {
|
|
818
|
+
parsed = JSON.parse(body);
|
|
819
|
+
}
|
|
820
|
+
catch { }
|
|
821
|
+
const { sessionId } = parsed;
|
|
822
|
+
if (!sessionId) {
|
|
823
|
+
res.statusCode = 400;
|
|
824
|
+
this.json(res, { success: false, error: 'sessionId required' });
|
|
825
|
+
return;
|
|
826
|
+
}
|
|
827
|
+
// Handle dev server virtual session
|
|
828
|
+
if (sessionId.startsWith('devserver-')) {
|
|
829
|
+
await this.devServer.stop();
|
|
830
|
+
this.json(res, { success: true });
|
|
831
|
+
return;
|
|
832
|
+
}
|
|
833
|
+
const session = terminalSessions.get(sessionId);
|
|
834
|
+
if (!session) {
|
|
835
|
+
// Already gone — treat as success
|
|
836
|
+
this.json(res, { success: true });
|
|
837
|
+
return;
|
|
838
|
+
}
|
|
839
|
+
this.killSession(sessionId, session);
|
|
840
|
+
this.json(res, { success: true });
|
|
841
|
+
}
|
|
842
|
+
// ─── /live/terminal/destroy-all ──────────────────────────────
|
|
843
|
+
async handleTerminalDestroyAll(_req, res) {
|
|
844
|
+
let count = 0;
|
|
845
|
+
for (const [sessionId, session] of Array.from(terminalSessions.entries())) {
|
|
846
|
+
this.killSession(sessionId, session);
|
|
847
|
+
count++;
|
|
848
|
+
}
|
|
849
|
+
// Also stop the dev server
|
|
850
|
+
if (this.devServer.isRunning) {
|
|
851
|
+
await this.devServer.stop();
|
|
852
|
+
count++;
|
|
853
|
+
}
|
|
854
|
+
this.json(res, { success: true, destroyed: count });
|
|
855
|
+
}
|
|
856
|
+
/**
|
|
857
|
+
* Force-kill a terminal session: send signals, notify listeners, remove from map.
|
|
858
|
+
*/
|
|
859
|
+
killSession(sessionId, session) {
|
|
860
|
+
if (!session.exited) {
|
|
861
|
+
if (session.ptyProcess) {
|
|
862
|
+
try {
|
|
863
|
+
session.ptyProcess.kill();
|
|
864
|
+
}
|
|
865
|
+
catch { }
|
|
866
|
+
}
|
|
867
|
+
else if (session.shell) {
|
|
868
|
+
try {
|
|
869
|
+
session.shell.kill('SIGKILL');
|
|
870
|
+
}
|
|
871
|
+
catch { }
|
|
872
|
+
}
|
|
873
|
+
}
|
|
874
|
+
session.exited = true;
|
|
875
|
+
session.exitCode = null;
|
|
876
|
+
for (const listener of session.exitListeners) {
|
|
877
|
+
try {
|
|
878
|
+
listener(null);
|
|
879
|
+
}
|
|
880
|
+
catch { }
|
|
881
|
+
}
|
|
882
|
+
session.dataListeners.clear();
|
|
883
|
+
session.exitListeners.clear();
|
|
884
|
+
terminalSessions.delete(sessionId);
|
|
885
|
+
}
|
|
782
886
|
// ─── /live/terminal/write ────────────────────────────────────
|
|
783
887
|
async handleTerminalWrite(_req, res, body) {
|
|
784
888
|
let parsed = {};
|
|
@@ -1183,9 +1287,10 @@ export class LocalServer {
|
|
|
1183
1287
|
}
|
|
1184
1288
|
}
|
|
1185
1289
|
// ─── /live/tree ──────────────────────────────────────────────
|
|
1186
|
-
async handleTree(_req, res) {
|
|
1290
|
+
async handleTree(_req, res, _body, url) {
|
|
1187
1291
|
try {
|
|
1188
|
-
const
|
|
1292
|
+
const showHidden = url.searchParams.get('showHidden') === 'true';
|
|
1293
|
+
const tree = await this.fileManager.getFileTree(showHidden);
|
|
1189
1294
|
this.json(res, tree);
|
|
1190
1295
|
}
|
|
1191
1296
|
catch (error) {
|
|
@@ -1226,6 +1331,200 @@ export class LocalServer {
|
|
|
1226
1331
|
this.json(res, { error: error.message }, 500);
|
|
1227
1332
|
}
|
|
1228
1333
|
}
|
|
1334
|
+
// ─── /browse/* — Tunnel-accessible file operations ──────────
|
|
1335
|
+
// These mirror the statusServer's /browse/* endpoints but use the
|
|
1336
|
+
// project directory from this LocalServer instance. The backend's
|
|
1337
|
+
// DiskFileService routes through the gateway → tunnel → here.
|
|
1338
|
+
static BROWSE_SKIP_DIRS = new Set([
|
|
1339
|
+
'node_modules', 'dist', '.git', '.vite-cache', '.next',
|
|
1340
|
+
'__pycache__', '.turbo', '.cache', 'build', '.svelte-kit',
|
|
1341
|
+
]);
|
|
1342
|
+
static BROWSE_BINARY_EXTS = new Set([
|
|
1343
|
+
'.png', '.jpg', '.jpeg', '.gif', '.webp', '.ico', '.svg', '.bmp',
|
|
1344
|
+
'.woff', '.woff2', '.ttf', '.eot', '.otf',
|
|
1345
|
+
'.zip', '.tar', '.gz', '.rar', '.7z',
|
|
1346
|
+
'.pdf', '.doc', '.docx', '.xls', '.xlsx',
|
|
1347
|
+
'.mp3', '.mp4', '.wav', '.avi', '.mov',
|
|
1348
|
+
'.exe', '.dll', '.so', '.dylib', '.o',
|
|
1349
|
+
]);
|
|
1350
|
+
collectFilesRecursive(baseDir, currentDir, out) {
|
|
1351
|
+
let entries;
|
|
1352
|
+
try {
|
|
1353
|
+
entries = fs.readdirSync(currentDir, { withFileTypes: true });
|
|
1354
|
+
}
|
|
1355
|
+
catch {
|
|
1356
|
+
return;
|
|
1357
|
+
}
|
|
1358
|
+
for (const entry of entries) {
|
|
1359
|
+
if (entry.name.startsWith('.') && entry.name !== '.env')
|
|
1360
|
+
continue;
|
|
1361
|
+
const fullPath = path.join(currentDir, entry.name);
|
|
1362
|
+
const relativePath = path.relative(baseDir, fullPath);
|
|
1363
|
+
if (entry.isDirectory()) {
|
|
1364
|
+
if (LocalServer.BROWSE_SKIP_DIRS.has(entry.name))
|
|
1365
|
+
continue;
|
|
1366
|
+
this.collectFilesRecursive(baseDir, fullPath, out);
|
|
1367
|
+
}
|
|
1368
|
+
else {
|
|
1369
|
+
const ext = path.extname(entry.name).toLowerCase();
|
|
1370
|
+
if (LocalServer.BROWSE_BINARY_EXTS.has(ext)) {
|
|
1371
|
+
out.push({ filePath: relativePath, totalLines: 0 });
|
|
1372
|
+
}
|
|
1373
|
+
else {
|
|
1374
|
+
try {
|
|
1375
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
1376
|
+
out.push({ filePath: relativePath, totalLines: content.split('\n').length });
|
|
1377
|
+
}
|
|
1378
|
+
catch {
|
|
1379
|
+
out.push({ filePath: relativePath, totalLines: 0 });
|
|
1380
|
+
}
|
|
1381
|
+
}
|
|
1382
|
+
}
|
|
1383
|
+
}
|
|
1384
|
+
}
|
|
1385
|
+
async handleBrowseListFiles(_req, res) {
|
|
1386
|
+
const dir = this.options.projectDir;
|
|
1387
|
+
try {
|
|
1388
|
+
const files = [];
|
|
1389
|
+
this.collectFilesRecursive(dir, dir, files);
|
|
1390
|
+
this.json(res, { files });
|
|
1391
|
+
}
|
|
1392
|
+
catch (error) {
|
|
1393
|
+
this.json(res, { error: error.message }, 500);
|
|
1394
|
+
}
|
|
1395
|
+
}
|
|
1396
|
+
async handleBrowseFile(_req, res, _body, url) {
|
|
1397
|
+
const filePath = url.searchParams.get('path');
|
|
1398
|
+
if (!filePath) {
|
|
1399
|
+
this.json(res, { error: 'path parameter required' }, 400);
|
|
1400
|
+
return;
|
|
1401
|
+
}
|
|
1402
|
+
const dir = this.options.projectDir;
|
|
1403
|
+
const fullPath = path.resolve(dir, filePath);
|
|
1404
|
+
if (!fullPath.startsWith(path.resolve(dir))) {
|
|
1405
|
+
this.json(res, { error: 'Path traversal not allowed' }, 403);
|
|
1406
|
+
return;
|
|
1407
|
+
}
|
|
1408
|
+
try {
|
|
1409
|
+
if (!fs.existsSync(fullPath)) {
|
|
1410
|
+
this.json(res, { error: 'File not found' }, 404);
|
|
1411
|
+
return;
|
|
1412
|
+
}
|
|
1413
|
+
const content = fs.readFileSync(fullPath, 'utf-8');
|
|
1414
|
+
this.json(res, { content });
|
|
1415
|
+
}
|
|
1416
|
+
catch (error) {
|
|
1417
|
+
this.json(res, { error: error.message }, 500);
|
|
1418
|
+
}
|
|
1419
|
+
}
|
|
1420
|
+
async handleBrowseTree(_req, res, _body, url) {
|
|
1421
|
+
try {
|
|
1422
|
+
const showHidden = url.searchParams.get('showHidden') === 'true';
|
|
1423
|
+
const tree = await this.fileManager.getFileTree(showHidden);
|
|
1424
|
+
this.json(res, tree);
|
|
1425
|
+
}
|
|
1426
|
+
catch (error) {
|
|
1427
|
+
this.json(res, { error: error.message }, 500);
|
|
1428
|
+
}
|
|
1429
|
+
}
|
|
1430
|
+
async handleBrowseSave(_req, res, body) {
|
|
1431
|
+
try {
|
|
1432
|
+
const { filePath, content } = JSON.parse(body);
|
|
1433
|
+
if (!filePath || content === undefined) {
|
|
1434
|
+
this.json(res, { error: 'filePath and content required' }, 400);
|
|
1435
|
+
return;
|
|
1436
|
+
}
|
|
1437
|
+
const dir = this.options.projectDir;
|
|
1438
|
+
const fullPath = path.resolve(dir, filePath);
|
|
1439
|
+
if (!fullPath.startsWith(path.resolve(dir))) {
|
|
1440
|
+
this.json(res, { error: 'Path traversal not allowed' }, 403);
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
const parentDir = path.dirname(fullPath);
|
|
1444
|
+
if (!fs.existsSync(parentDir))
|
|
1445
|
+
fs.mkdirSync(parentDir, { recursive: true });
|
|
1446
|
+
fs.writeFileSync(fullPath, content, 'utf-8');
|
|
1447
|
+
this.json(res, { success: true });
|
|
1448
|
+
}
|
|
1449
|
+
catch (error) {
|
|
1450
|
+
this.json(res, { error: error.message }, 500);
|
|
1451
|
+
}
|
|
1452
|
+
}
|
|
1453
|
+
async handleBrowseSearch(_req, res, body) {
|
|
1454
|
+
try {
|
|
1455
|
+
const { query, isRegex, filePattern } = JSON.parse(body);
|
|
1456
|
+
if (!query) {
|
|
1457
|
+
this.json(res, { error: 'query required' }, 400);
|
|
1458
|
+
return;
|
|
1459
|
+
}
|
|
1460
|
+
const dir = this.options.projectDir;
|
|
1461
|
+
const files = [];
|
|
1462
|
+
this.collectFilesRecursive(dir, dir, files);
|
|
1463
|
+
const regex = isRegex ? new RegExp(query, 'gi') : null;
|
|
1464
|
+
const results = [];
|
|
1465
|
+
const maxResults = 100;
|
|
1466
|
+
for (const f of files) {
|
|
1467
|
+
if (results.length >= maxResults)
|
|
1468
|
+
break;
|
|
1469
|
+
if (filePattern && !this.matchGlob(f.filePath, filePattern))
|
|
1470
|
+
continue;
|
|
1471
|
+
const fullPath = path.resolve(dir, f.filePath);
|
|
1472
|
+
let fileContent;
|
|
1473
|
+
try {
|
|
1474
|
+
fileContent = fs.readFileSync(fullPath, 'utf-8');
|
|
1475
|
+
}
|
|
1476
|
+
catch {
|
|
1477
|
+
continue;
|
|
1478
|
+
}
|
|
1479
|
+
const lines = fileContent.split('\n');
|
|
1480
|
+
for (let i = 0; i < lines.length && results.length < maxResults; i++) {
|
|
1481
|
+
const matched = regex ? regex.test(lines[i]) : lines[i].toLowerCase().includes(query.toLowerCase());
|
|
1482
|
+
if (regex)
|
|
1483
|
+
regex.lastIndex = 0;
|
|
1484
|
+
if (matched) {
|
|
1485
|
+
results.push({ filePath: f.filePath, line: i + 1, content: lines[i] });
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
}
|
|
1489
|
+
this.json(res, { results });
|
|
1490
|
+
}
|
|
1491
|
+
catch (error) {
|
|
1492
|
+
this.json(res, { error: error.message }, 500);
|
|
1493
|
+
}
|
|
1494
|
+
}
|
|
1495
|
+
async handleBrowseDelete(_req, res, body) {
|
|
1496
|
+
try {
|
|
1497
|
+
const { filePath } = JSON.parse(body);
|
|
1498
|
+
if (!filePath) {
|
|
1499
|
+
this.json(res, { error: 'filePath required' }, 400);
|
|
1500
|
+
return;
|
|
1501
|
+
}
|
|
1502
|
+
const dir = this.options.projectDir;
|
|
1503
|
+
const fullPath = path.resolve(dir, filePath);
|
|
1504
|
+
if (!fullPath.startsWith(path.resolve(dir))) {
|
|
1505
|
+
this.json(res, { error: 'Path traversal not allowed' }, 403);
|
|
1506
|
+
return;
|
|
1507
|
+
}
|
|
1508
|
+
if (!fs.existsSync(fullPath)) {
|
|
1509
|
+
this.json(res, { error: 'File not found' }, 404);
|
|
1510
|
+
return;
|
|
1511
|
+
}
|
|
1512
|
+
fs.unlinkSync(fullPath);
|
|
1513
|
+
this.json(res, { success: true });
|
|
1514
|
+
}
|
|
1515
|
+
catch (error) {
|
|
1516
|
+
this.json(res, { error: error.message }, 500);
|
|
1517
|
+
}
|
|
1518
|
+
}
|
|
1519
|
+
matchGlob(filePath, pattern) {
|
|
1520
|
+
const regexPattern = '^' + pattern
|
|
1521
|
+
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
1522
|
+
.replace(/\*\*/g, '<<<GLOBSTAR>>>')
|
|
1523
|
+
.replace(/\*/g, '[^/]*')
|
|
1524
|
+
.replace(/<<<GLOBSTAR>>>/g, '.*')
|
|
1525
|
+
.replace(/\?/g, '.') + '$';
|
|
1526
|
+
return new RegExp(regexPattern, 'i').test(filePath);
|
|
1527
|
+
}
|
|
1229
1528
|
// ─── /live/set-dev-port ─────────────────────────────────────
|
|
1230
1529
|
async handleSetDevPort(_req, res, body) {
|
|
1231
1530
|
try {
|
package/dist/tunnel.js
CHANGED
|
@@ -503,7 +503,7 @@ export class TunnelClient {
|
|
|
503
503
|
*/
|
|
504
504
|
handleHttpRequest(request) {
|
|
505
505
|
const { url, headers } = request;
|
|
506
|
-
const isApiRequest = url.startsWith('/live/') || url === '/health';
|
|
506
|
+
const isApiRequest = url.startsWith('/live/') || url.startsWith('/browse/') || url === '/health';
|
|
507
507
|
// Use x-target-port header if present (multi-port tunneling), else fall back to default
|
|
508
508
|
const portHeader = headers?.['x-target-port'];
|
|
509
509
|
const overridePort = portHeader ? parseInt(String(portHeader), 10) : NaN;
|
package/package.json
CHANGED