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.
@@ -1,15 +1,14 @@
1
1
  /**
2
- * Sync command — push local files to nstantpage DB and start the agent.
2
+ * Sync command — link a local directory to an nstantpage project.
3
3
  *
4
4
  * Usage:
5
- * nstantpage sync — Sync current directory to nstantpage, then start agent
6
- * nstantpage sync /path/to/dir — Sync a specific directory
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. Reads all source files in the directory (skips node_modules, .git, dist, etc.)
10
- * 2. Creates a LocalRepo project if one doesn't exist (name = folder name)
11
- * 3. Pushes all files to the DB (FullFiles table)
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;
@@ -1,41 +1,20 @@
1
1
  /**
2
- * Sync command — push local files to nstantpage DB and start the agent.
2
+ * Sync command — link a local directory to an nstantpage project.
3
3
  *
4
4
  * Usage:
5
- * nstantpage sync — Sync current directory to nstantpage, then start agent
6
- * nstantpage sync /path/to/dir — Sync a specific directory
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. Reads all source files in the directory (skips node_modules, .git, dist, etc.)
10
- * 2. Creates a LocalRepo project if one doesn't exist (name = folder name)
11
- * 3. Pushes all files to the DB (FullFiles table)
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. Collect all files
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
- // 3. Create or find LocalRepo project
148
- console.log(chalk.gray(' Creating project...'));
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
- // 4. Create new version and clear old files (clean sync)
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
  }
@@ -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;
@@ -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 {
@@ -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
  /**
@@ -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/errors/123, /live/logs/123)
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
- return handler;
426
+ if (path.startsWith(route + '/') && route.length > bestLen) {
427
+ bestMatch = handler;
428
+ bestLen = route.length;
429
+ }
416
430
  }
417
- return null;
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 tree = await this.fileManager.getFileTree();
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.8.17",
3
+ "version": "0.8.19",
4
4
  "description": "Local development agent for nstantpage.com — run your projects locally, preview in the cloud. Replaces cloud containers for faster builds.",
5
5
  "type": "module",
6
6
  "bin": {