nstantpage-agent 0.5.2 → 0.5.4

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/cli.js CHANGED
@@ -25,7 +25,7 @@ const program = new Command();
25
25
  program
26
26
  .name('nstantpage')
27
27
  .description('Local development agent for nstantpage.com — run projects on your machine, preview in the cloud')
28
- .version('0.5.2');
28
+ .version('0.5.4');
29
29
  program
30
30
  .command('login')
31
31
  .description('Authenticate with nstantpage.com')
@@ -24,7 +24,7 @@ import { getConfig, getProjectConfig, setProjectConfig, clearProjectConfig, getD
24
24
  import { TunnelClient } from '../tunnel.js';
25
25
  import { LocalServer } from '../localServer.js';
26
26
  import { PackageInstaller } from '../packageInstaller.js';
27
- const VERSION = '0.5.2';
27
+ const VERSION = '0.5.4';
28
28
  /**
29
29
  * Resolve the backend API base URL.
30
30
  * - If --backend is passed, use it
@@ -226,20 +226,20 @@ export async function startCommand(directory, options) {
226
226
  console.log(chalk.gray(` Gateway: ${options.gateway}`));
227
227
  console.log(chalk.gray(` Backend: ${backendUrl}\n`));
228
228
  // 1. Fetch project files from the backend
229
+ const installer = new PackageInstaller({ projectDir });
229
230
  try {
230
231
  const { fileCount, isNew } = await fetchProjectFiles(backendUrl, projectId, projectDir, token);
231
- // 2. Install dependencies if this is a new download or node_modules is missing
232
- const hasNodeModules = fs.existsSync(path.join(projectDir, 'node_modules'));
233
- if (isNew || !hasNodeModules) {
232
+ // 2. Install dependencies if needed (verifies actual packages, not just folder)
233
+ if (!installer.areDependenciesInstalled()) {
234
234
  if (fs.existsSync(path.join(projectDir, 'package.json'))) {
235
235
  console.log(chalk.gray(' Installing dependencies...'));
236
- const installer = new PackageInstaller({ projectDir });
237
- const result = await installer.install([], false);
238
- if (result.success) {
236
+ try {
237
+ await installer.ensureDependencies();
239
238
  console.log(chalk.green(` ✓ Dependencies installed`));
240
239
  }
241
- else {
242
- console.log(chalk.yellow(` ⚠ Dependency installation had issues: ${result.output.slice(0, 200)}`));
240
+ catch (err) {
241
+ console.log(chalk.yellow(` ⚠ Install failed: ${err.message?.slice(0, 200)}`));
242
+ console.log(chalk.gray(` Will retry when dev server starts`));
243
243
  }
244
244
  }
245
245
  }
@@ -376,15 +376,21 @@ export async function startCommand(directory, options) {
376
376
  console.log(chalk.green(` ✓ API server on port ${apiPort}`));
377
377
  // Start dev server unless --no-dev flag
378
378
  if (!options.noDev) {
379
- console.log(chalk.gray(' Starting dev server...'));
380
- try {
381
- const devServer = localServer.getDevServer();
382
- await devServer.start();
383
- console.log(chalk.green(` ✓ Dev server on port ${devPort}`));
379
+ if (installer.areDependenciesInstalled()) {
380
+ console.log(chalk.gray(' Starting dev server...'));
381
+ try {
382
+ const devServer = localServer.getDevServer();
383
+ await devServer.start();
384
+ console.log(chalk.green(` ✓ Dev server on port ${devPort}`));
385
+ }
386
+ catch (err) {
387
+ console.log(chalk.yellow(` ⚠ Dev server failed to start: ${err.message}`));
388
+ console.log(chalk.gray(' You can start it later from the editor'));
389
+ }
384
390
  }
385
- catch (err) {
386
- console.log(chalk.yellow(` ⚠ Dev server failed to start: ${err.message}`));
387
- console.log(chalk.gray(' You can start it later from the editor'));
391
+ else {
392
+ console.log(chalk.yellow(` ⚠ Skipping dev server dependencies not fully installed`));
393
+ console.log(chalk.gray(' Dev server will start when browser opens the project'));
388
394
  }
389
395
  }
390
396
  else {
@@ -557,14 +563,18 @@ async function startAdditionalProject(projectId, opts) {
557
563
  catch (err) {
558
564
  console.log(chalk.yellow(` ⚠ Could not fetch files: ${err.message}`));
559
565
  }
560
- // Install dependencies
561
- const hasNodeModules = fs.existsSync(path.join(projectDir, 'node_modules'));
562
- if (!hasNodeModules && fs.existsSync(path.join(projectDir, 'package.json'))) {
566
+ // Install dependencies (must complete before dev server can start)
567
+ const installer = new PackageInstaller({ projectDir });
568
+ if (!installer.areDependenciesInstalled() && fs.existsSync(path.join(projectDir, 'package.json'))) {
563
569
  console.log(chalk.gray(` Installing dependencies...`));
564
- const installer = new PackageInstaller({ projectDir });
565
- const result = await installer.install([], false);
566
- if (result.success)
570
+ try {
571
+ await installer.ensureDependencies();
567
572
  console.log(chalk.green(` ✓ Dependencies installed`));
573
+ }
574
+ catch (err) {
575
+ console.log(chalk.red(` ✗ Install failed: ${err.message}`));
576
+ console.log(chalk.yellow(` ⚠ Dev server may fail — retrying install on first request`));
577
+ }
568
578
  }
569
579
  // Start local server
570
580
  const localServer = new LocalServer({
@@ -573,14 +583,20 @@ async function startAdditionalProject(projectId, opts) {
573
583
  });
574
584
  await localServer.start();
575
585
  console.log(chalk.green(` ✓ API server on port ${allocated.apiPort}`));
576
- // Start dev server
586
+ // Start dev server (only if dependencies are installed)
577
587
  if (!opts.noDev) {
578
- try {
579
- await localServer.getDevServer().start();
580
- console.log(chalk.green(` ✓ Dev server on port ${allocated.devPort}`));
588
+ if (installer.areDependenciesInstalled()) {
589
+ try {
590
+ await localServer.getDevServer().start();
591
+ console.log(chalk.green(` ✓ Dev server on port ${allocated.devPort}`));
592
+ }
593
+ catch (err) {
594
+ console.log(chalk.yellow(` ⚠ Dev server: ${err.message}`));
595
+ }
581
596
  }
582
- catch (err) {
583
- console.log(chalk.yellow(` ⚠ Dev server: ${err.message}`));
597
+ else {
598
+ console.log(chalk.yellow(` ⚠ Skipping dev server — dependencies not installed`));
599
+ console.log(chalk.gray(` Dev server will start when browser opens the project`));
584
600
  }
585
601
  }
586
602
  // Connect project tunnel
@@ -17,7 +17,8 @@ import { DevServer } from './devServer.js';
17
17
  interface TerminalSession {
18
18
  id: string;
19
19
  projectId: string;
20
- shell: ChildProcess;
20
+ shell: ChildProcess | null;
21
+ ptyProcess: any | null;
21
22
  outputBuffer: string[];
22
23
  createdAt: number;
23
24
  lastActivity: number;
@@ -35,6 +36,14 @@ interface TerminalSession {
35
36
  * Get a terminal session by ID (used by WS relay in tunnel).
36
37
  */
37
38
  export declare function getTerminalSession(sessionId: string): TerminalSession | undefined;
39
+ /**
40
+ * Write data to a terminal session (handles both node-pty and child_process).
41
+ */
42
+ export declare function writeToTerminalSession(session: TerminalSession, data: string): void;
43
+ /**
44
+ * Resize a terminal session (only works with node-pty).
45
+ */
46
+ export declare function resizeTerminalSession(session: TerminalSession, cols: number, rows: number): void;
38
47
  /**
39
48
  * Attach a real-time listener to a terminal session (for WebSocket relay).
40
49
  * Returns a cleanup function to detach.
@@ -14,12 +14,22 @@
14
14
  */
15
15
  import http from 'http';
16
16
  import os from 'os';
17
+ import { createRequire } from 'module';
17
18
  import { spawn } from 'child_process';
18
19
  import { DevServer } from './devServer.js';
19
20
  import { FileManager } from './fileManager.js';
20
21
  import { Checker } from './checker.js';
21
22
  import { ErrorStore, structuredErrorToString } from './errorStore.js';
22
23
  import { PackageInstaller } from './packageInstaller.js';
24
+ // ─── Try to load node-pty for real PTY support ─────────────────
25
+ let ptyModule = null;
26
+ try {
27
+ const _require = createRequire(import.meta.url);
28
+ ptyModule = _require('node-pty');
29
+ }
30
+ catch {
31
+ // node-pty not available — will fall back to 'script' PTY wrapper
32
+ }
23
33
  const terminalSessions = new Map();
24
34
  let sessionCounter = 0;
25
35
  function generateSessionId() {
@@ -31,6 +41,30 @@ function generateSessionId() {
31
41
  export function getTerminalSession(sessionId) {
32
42
  return terminalSessions.get(sessionId);
33
43
  }
44
+ /**
45
+ * Write data to a terminal session (handles both node-pty and child_process).
46
+ */
47
+ export function writeToTerminalSession(session, data) {
48
+ if (session.ptyProcess) {
49
+ session.ptyProcess.write(data);
50
+ }
51
+ else if (session.shell) {
52
+ session.shell.stdin?.write(data);
53
+ }
54
+ }
55
+ /**
56
+ * Resize a terminal session (only works with node-pty).
57
+ */
58
+ export function resizeTerminalSession(session, cols, rows) {
59
+ session.cols = cols;
60
+ session.rows = rows;
61
+ if (session.ptyProcess) {
62
+ try {
63
+ session.ptyProcess.resize(cols, rows);
64
+ }
65
+ catch { }
66
+ }
67
+ }
34
68
  /**
35
69
  * Attach a real-time listener to a terminal session (for WebSocket relay).
36
70
  * Returns a cleanup function to detach.
@@ -366,17 +400,50 @@ export class LocalServer {
366
400
  const isAiSession = parsed.isAiSession || false;
367
401
  // Determine shell
368
402
  const shellCmd = process.platform === 'win32' ? 'cmd.exe' : (process.env.SHELL || '/bin/bash');
369
- const shell = spawn(shellCmd, [], {
370
- cwd: this.options.projectDir,
371
- env: { ...process.env, TERM: 'xterm-256color', COLUMNS: String(cols), LINES: String(rows) },
372
- stdio: ['pipe', 'pipe', 'pipe'],
373
- });
403
+ const shellEnv = { ...process.env, TERM: 'xterm-256color', COLUMNS: String(cols), LINES: String(rows) };
374
404
  sessionCounter++;
375
405
  const label = parsed.label || `Terminal ${sessionCounter}`;
406
+ let shell = null;
407
+ let ptyProcess = null;
408
+ // Prefer node-pty for real PTY (interactive shell, echo, prompt, resize)
409
+ if (ptyModule) {
410
+ ptyProcess = ptyModule.spawn(shellCmd, [], {
411
+ name: 'xterm-256color',
412
+ cols,
413
+ rows,
414
+ cwd: this.options.projectDir,
415
+ env: shellEnv,
416
+ });
417
+ }
418
+ else if (process.platform === 'darwin') {
419
+ // macOS fallback: use 'script' to allocate a PTY
420
+ shell = spawn('script', ['-q', '/dev/null', shellCmd], {
421
+ cwd: this.options.projectDir,
422
+ env: shellEnv,
423
+ stdio: ['pipe', 'pipe', 'pipe'],
424
+ });
425
+ }
426
+ else if (process.platform === 'linux') {
427
+ // Linux fallback: 'script' with -c flag
428
+ shell = spawn('script', ['-qc', shellCmd, '/dev/null'], {
429
+ cwd: this.options.projectDir,
430
+ env: shellEnv,
431
+ stdio: ['pipe', 'pipe', 'pipe'],
432
+ });
433
+ }
434
+ else {
435
+ // Windows / other: raw spawn (limited interactivity)
436
+ shell = spawn(shellCmd, [], {
437
+ cwd: this.options.projectDir,
438
+ env: shellEnv,
439
+ stdio: ['pipe', 'pipe', 'pipe'],
440
+ });
441
+ }
376
442
  const session = {
377
443
  id: sessionId,
378
444
  projectId: this.options.projectId,
379
445
  shell,
446
+ ptyProcess,
380
447
  outputBuffer: [],
381
448
  createdAt: Date.now(),
382
449
  lastActivity: Date.now(),
@@ -389,39 +456,22 @@ export class LocalServer {
389
456
  dataListeners: new Set(),
390
457
  exitListeners: new Set(),
391
458
  };
392
- shell.stdout?.on('data', (data) => {
393
- const str = data.toString('utf-8');
459
+ // Helper to push output to buffer and notify listeners
460
+ const pushOutput = (str) => {
394
461
  session.outputBuffer.push(str);
395
462
  session.lastActivity = Date.now();
396
- // Keep buffer capped at ~100KB
397
463
  while (session.outputBuffer.length > 500)
398
464
  session.outputBuffer.shift();
399
- // Notify real-time listeners (WebSocket relay)
400
465
  for (const listener of session.dataListeners) {
401
466
  try {
402
467
  listener(str);
403
468
  }
404
469
  catch { }
405
470
  }
406
- });
407
- shell.stderr?.on('data', (data) => {
408
- const str = data.toString('utf-8');
409
- session.outputBuffer.push(str);
410
- session.lastActivity = Date.now();
411
- while (session.outputBuffer.length > 500)
412
- session.outputBuffer.shift();
413
- // Notify real-time listeners
414
- for (const listener of session.dataListeners) {
415
- try {
416
- listener(str);
417
- }
418
- catch { }
419
- }
420
- });
421
- shell.on('exit', (code) => {
471
+ };
472
+ const handleExit = (code) => {
422
473
  session.exited = true;
423
474
  session.exitCode = code;
424
- // Notify exit listeners
425
475
  for (const listener of session.exitListeners) {
426
476
  try {
427
477
  listener(code);
@@ -429,7 +479,17 @@ export class LocalServer {
429
479
  catch { }
430
480
  }
431
481
  terminalSessions.delete(sessionId);
432
- });
482
+ };
483
+ if (ptyProcess) {
484
+ // node-pty: single onData for combined stdout+stderr
485
+ ptyProcess.onData((data) => pushOutput(data));
486
+ ptyProcess.onExit(({ exitCode }) => handleExit(exitCode));
487
+ }
488
+ else if (shell) {
489
+ shell.stdout?.on('data', (data) => pushOutput(data.toString('utf-8')));
490
+ shell.stderr?.on('data', (data) => pushOutput(data.toString('utf-8')));
491
+ shell.on('exit', (code) => handleExit(code));
492
+ }
433
493
  terminalSessions.set(sessionId, session);
434
494
  // Return format matching frontend TerminalSessionInfo
435
495
  this.json(res, {
@@ -467,7 +527,7 @@ export class LocalServer {
467
527
  this.json(res, { success: false, error: 'Session not found' });
468
528
  return;
469
529
  }
470
- session.shell.stdin?.write(inputData);
530
+ writeToTerminalSession(session, inputData);
471
531
  session.lastActivity = Date.now();
472
532
  this.json(res, { success: true });
473
533
  }
@@ -490,10 +550,7 @@ export class LocalServer {
490
550
  this.json(res, { success: false, error: 'Session not found' });
491
551
  return;
492
552
  }
493
- session.cols = cols || session.cols;
494
- session.rows = rows || session.rows;
495
- // For real pty we'd call pty.resize(cols, rows), but for child_process
496
- // we just update the env (takes effect on next data)
553
+ resizeTerminalSession(session, cols || session.cols, rows || session.rows);
497
554
  this.json(res, { success: true, cols: session.cols, rows: session.rows });
498
555
  }
499
556
  // ─── /live/agent-status ──────────────────────────────────────
@@ -502,7 +559,7 @@ export class LocalServer {
502
559
  connected: true,
503
560
  projectId: this.options.projectId,
504
561
  agent: {
505
- version: '0.5.2',
562
+ version: '0.5.4',
506
563
  hostname: os.hostname(),
507
564
  platform: `${os.platform()} ${os.arch()}`,
508
565
  },
@@ -625,9 +682,18 @@ export class LocalServer {
625
682
  }
626
683
  // ─── /live/close ─────────────────────────────────────────────
627
684
  async handleClose(_req, res) {
628
- // Don't stop the dev server on close user is still working locally.
629
- // Just acknowledge.
630
- this.json(res, { success: true, message: 'Acknowledged (agent keeps running)' });
685
+ // Stop the dev server when user explicitly clicks Stop in the UI.
686
+ // The frontend only calls /live/close from the Stop button, not on navigation.
687
+ try {
688
+ if (this.devServer.isRunning) {
689
+ await this.devServer.stop();
690
+ }
691
+ this.json(res, { success: true, message: 'Dev server stopped' });
692
+ }
693
+ catch (err) {
694
+ res.statusCode = 500;
695
+ this.json(res, { success: false, error: err.message });
696
+ }
631
697
  }
632
698
  // ─── /live/heartbeat ────────────────────────────────────────
633
699
  async handleHeartbeat(_req, res) {
@@ -7,6 +7,8 @@ export interface PackageInstallerOptions {
7
7
  }
8
8
  export declare class PackageInstaller {
9
9
  private projectDir;
10
+ /** Serializes concurrent install calls */
11
+ private installLock;
10
12
  constructor(options: PackageInstallerOptions);
11
13
  /**
12
14
  * Install packages into the project.
@@ -18,8 +20,14 @@ export declare class PackageInstaller {
18
20
  }>;
19
21
  /**
20
22
  * Ensure all project dependencies are installed.
23
+ * Uses a lock to prevent concurrent installs.
21
24
  */
22
25
  ensureDependencies(): Promise<void>;
26
+ /**
27
+ * Check if dependencies are actually installed (not just that node_modules/ exists).
28
+ * Verifies that at least one key dependency from package.json is present.
29
+ */
30
+ areDependenciesInstalled(): boolean;
23
31
  /**
24
32
  * Detect which package manager is used (pnpm, yarn, npm).
25
33
  */
@@ -4,9 +4,11 @@
4
4
  */
5
5
  import { spawn } from 'child_process';
6
6
  import path from 'path';
7
- import { existsSync } from 'fs';
7
+ import fs, { existsSync } from 'fs';
8
8
  export class PackageInstaller {
9
9
  projectDir;
10
+ /** Serializes concurrent install calls */
11
+ installLock = Promise.resolve();
10
12
  constructor(options) {
11
13
  this.projectDir = options.projectDir;
12
14
  }
@@ -18,7 +20,7 @@ export class PackageInstaller {
18
20
  const args = this.buildInstallArgs(pm, packages, dev);
19
21
  console.log(` [Installer] ${pm} ${args.join(' ')}`);
20
22
  try {
21
- const timeout = packages.length === 0 ? 120_000 : 60_000;
23
+ const timeout = packages.length === 0 ? 300_000 : 120_000;
22
24
  const output = await this.runCommand(pm, args, timeout);
23
25
  return {
24
26
  success: true,
@@ -36,16 +38,51 @@ export class PackageInstaller {
36
38
  }
37
39
  /**
38
40
  * Ensure all project dependencies are installed.
41
+ * Uses a lock to prevent concurrent installs.
39
42
  */
40
43
  async ensureDependencies() {
41
- const nodeModules = path.join(this.projectDir, 'node_modules');
42
- if (existsSync(nodeModules))
44
+ // Serialize with any in-flight install
45
+ await this.installLock;
46
+ if (this.areDependenciesInstalled())
43
47
  return;
44
- console.log(` [Installer] Installing project dependencies...`);
45
- const pm = this.detectPackageManager();
46
- const args = pm === 'pnpm' ? ['install'] : ['install'];
47
- await this.runCommand(pm, args, 120_000);
48
- console.log(` [Installer] Dependencies installed`);
48
+ // Acquire lock for this install
49
+ let releaseLock;
50
+ this.installLock = new Promise(resolve => { releaseLock = resolve; });
51
+ try {
52
+ console.log(` [Installer] Installing project dependencies...`);
53
+ const pm = this.detectPackageManager();
54
+ const args = pm === 'pnpm' ? ['install'] : ['install'];
55
+ await this.runCommand(pm, args, 300_000);
56
+ console.log(` [Installer] Dependencies installed`);
57
+ }
58
+ finally {
59
+ releaseLock();
60
+ }
61
+ }
62
+ /**
63
+ * Check if dependencies are actually installed (not just that node_modules/ exists).
64
+ * Verifies that at least one key dependency from package.json is present.
65
+ */
66
+ areDependenciesInstalled() {
67
+ const nodeModules = path.join(this.projectDir, 'node_modules');
68
+ if (!existsSync(nodeModules))
69
+ return false;
70
+ // Check that at least one real dependency is installed
71
+ try {
72
+ const pkgPath = path.join(this.projectDir, 'package.json');
73
+ if (!existsSync(pkgPath))
74
+ return true; // no package.json → nothing to install
75
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
76
+ const deps = Object.keys(pkg.dependencies || {});
77
+ if (deps.length === 0)
78
+ return true; // no deps → nothing needed
79
+ // Check if the first listed dependency has its folder in node_modules
80
+ const firstDep = deps[0];
81
+ return existsSync(path.join(nodeModules, firstDep));
82
+ }
83
+ catch {
84
+ return existsSync(nodeModules);
85
+ }
49
86
  }
50
87
  /**
51
88
  * Detect which package manager is used (pnpm, yarn, npm).
package/dist/tunnel.js CHANGED
@@ -20,7 +20,7 @@ import http from 'http';
20
20
  import os from 'os';
21
21
  import chalk from 'chalk';
22
22
  import { getDeviceId } from './config.js';
23
- import { getTerminalSession, attachTerminalClient } from './localServer.js';
23
+ import { getTerminalSession, attachTerminalClient, writeToTerminalSession, resizeTerminalSession } from './localServer.js';
24
24
  export class TunnelClient {
25
25
  ws = null;
26
26
  options;
@@ -63,7 +63,7 @@ export class TunnelClient {
63
63
  // Send enhanced agent info with capabilities and deviceId
64
64
  this.send({
65
65
  type: 'agent-info',
66
- version: '0.5.2',
66
+ version: '0.5.4',
67
67
  hostname: os.hostname(),
68
68
  platform: `${os.platform()} ${os.arch()}`,
69
69
  deviceId: getDeviceId(),
@@ -215,16 +215,13 @@ export class TunnelClient {
215
215
  switch (msg.type) {
216
216
  case 'input':
217
217
  if (typeof msg.data === 'string') {
218
- session.shell.stdin?.write(msg.data);
218
+ writeToTerminalSession(session, msg.data);
219
219
  session.lastActivity = Date.now();
220
220
  }
221
221
  break;
222
222
  case 'resize':
223
223
  if (typeof msg.cols === 'number' && typeof msg.rows === 'number') {
224
- session.cols = msg.cols;
225
- session.rows = msg.rows;
226
- // Note: child_process.spawn doesn't support resize natively
227
- // (only node-pty does). We update the stored size for future use.
224
+ resizeTerminalSession(session, msg.cols, msg.rows);
228
225
  }
229
226
  break;
230
227
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.5.2",
3
+ "version": "0.5.4",
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": {
@@ -58,5 +58,8 @@
58
58
  "@types/ws": "^8.5.10",
59
59
  "tsx": "^4.19.0",
60
60
  "typescript": "^5.5.0"
61
+ },
62
+ "optionalDependencies": {
63
+ "node-pty": "^1.1.0"
61
64
  }
62
65
  }