nstantpage-agent 0.4.2 → 0.5.1

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.d.ts CHANGED
@@ -3,12 +3,13 @@
3
3
  * nstantpage-agent CLI
4
4
  *
5
5
  * Usage:
6
- * nstantpage login — Authenticate with nstantpage.com
7
- * nstantpage start [dir] — Start agent for a project directory
8
- * nstantpage stop — Stop running agent
9
- * nstantpage status — Show agent status
10
- * nstantpage service — Install as background service (auto-start on boot)
11
- * nstantpage service --uninstall — Remove background service
6
+ * nstantpage login — Authenticate with nstantpage.com
7
+ * nstantpage start [dir] — Start agent for a project directory
8
+ * nstantpage stop — Stop running agent
9
+ * nstantpage status — Show agent status
10
+ * nstantpage service install — Install as background service (auto-start on boot)
11
+ * nstantpage service uninstall — Remove background service
12
+ * nstantpage service status — Check if service is running
12
13
  *
13
14
  * The agent replaces Docker containers — your project runs natively on your
14
15
  * machine with full performance, and the cloud editor connects through a tunnel.
package/dist/cli.js CHANGED
@@ -3,12 +3,13 @@
3
3
  * nstantpage-agent CLI
4
4
  *
5
5
  * Usage:
6
- * nstantpage login — Authenticate with nstantpage.com
7
- * nstantpage start [dir] — Start agent for a project directory
8
- * nstantpage stop — Stop running agent
9
- * nstantpage status — Show agent status
10
- * nstantpage service — Install as background service (auto-start on boot)
11
- * nstantpage service --uninstall — Remove background service
6
+ * nstantpage login — Authenticate with nstantpage.com
7
+ * nstantpage start [dir] — Start agent for a project directory
8
+ * nstantpage stop — Stop running agent
9
+ * nstantpage status — Show agent status
10
+ * nstantpage service install — Install as background service (auto-start on boot)
11
+ * nstantpage service uninstall — Remove background service
12
+ * nstantpage service status — Check if service is running
12
13
  *
13
14
  * The agent replaces Docker containers — your project runs natively on your
14
15
  * machine with full performance, and the cloud editor connects through a tunnel.
@@ -16,18 +17,25 @@
16
17
  import { Command } from 'commander';
17
18
  import chalk from 'chalk';
18
19
  import { loginCommand } from './commands/login.js';
20
+ import { logoutCommand } from './commands/logout.js';
19
21
  import { startCommand } from './commands/start.js';
20
22
  import { statusCommand } from './commands/status.js';
21
- import { serviceCommand } from './commands/service.js';
23
+ import { serviceInstallCommand, serviceUninstallCommand, serviceStatusCommand, serviceStartCommand, serviceStopCommand } from './commands/service.js';
22
24
  const program = new Command();
23
25
  program
24
26
  .name('nstantpage')
25
27
  .description('Local development agent for nstantpage.com — run projects on your machine, preview in the cloud')
26
- .version('0.3.0');
28
+ .version('0.5.1');
27
29
  program
28
30
  .command('login')
29
31
  .description('Authenticate with nstantpage.com')
32
+ .option('--gateway <url>', 'Gateway URL (auto-detects local vs production)')
33
+ .option('--force', 'Re-authenticate even if already logged in')
30
34
  .action(loginCommand);
35
+ program
36
+ .command('logout')
37
+ .description('Sign out and clear stored credentials')
38
+ .action(logoutCommand);
31
39
  program
32
40
  .command('start')
33
41
  .description('Start the agent for a project (replaces cloud containers)')
@@ -69,12 +77,30 @@ program
69
77
  .command('status')
70
78
  .description('Show agent status')
71
79
  .action(statusCommand);
72
- program
80
+ const service = program
73
81
  .command('service')
74
- .description('Install/uninstall agent as a background service (starts on boot)')
75
- .option('--project-id <id>', 'Project ID to serve')
82
+ .description('Manage the background service (keeps your machine connected to nstantpage.com)');
83
+ service
84
+ .command('start')
85
+ .description('Start the agent in standby mode (foreground — keeps your machine online)')
76
86
  .option('--gateway <url>', 'Gateway URL', 'wss://webprev.live')
77
- .option('--uninstall', 'Remove the background service')
78
- .action(serviceCommand);
87
+ .action(serviceStartCommand);
88
+ service
89
+ .command('stop')
90
+ .description('Stop the running agent')
91
+ .action(serviceStopCommand);
92
+ service
93
+ .command('install')
94
+ .description('Install as a background service (auto-starts on boot)')
95
+ .option('--gateway <url>', 'Gateway URL', 'wss://webprev.live')
96
+ .action(serviceInstallCommand);
97
+ service
98
+ .command('uninstall')
99
+ .description('Remove the background service')
100
+ .action(serviceUninstallCommand);
101
+ service
102
+ .command('status')
103
+ .description('Check if the background service is running')
104
+ .action(serviceStatusCommand);
79
105
  program.parse();
80
106
  //# sourceMappingURL=cli.js.map
@@ -1,4 +1,9 @@
1
1
  /**
2
2
  * Login command — authenticate with nstantpage.com
3
3
  */
4
- export declare function loginCommand(): Promise<void>;
4
+ interface LoginOptions {
5
+ gateway?: string;
6
+ force?: boolean;
7
+ }
8
+ export declare function loginCommand(options?: LoginOptions): Promise<void>;
9
+ export {};
@@ -5,21 +5,66 @@ import chalk from 'chalk';
5
5
  import open from 'open';
6
6
  import http from 'http';
7
7
  import { getConfig } from '../config.js';
8
- export async function loginCommand() {
8
+ /**
9
+ * Resolve the frontend URL based on gateway.
10
+ * - If gateway points to localhost → frontend is http://localhost:5001
11
+ * - Otherwise → https://nstantpage.com
12
+ */
13
+ function resolveFrontendUrl(gateway) {
14
+ if (gateway && /^wss?:\/\/(localhost|127\.0\.0\.1)/.test(gateway)) {
15
+ return 'http://localhost:5001';
16
+ }
17
+ return 'https://nstantpage.com';
18
+ }
19
+ /**
20
+ * Decode a JWT token payload (without verification — just reading claims).
21
+ */
22
+ function decodeJwtPayload(token) {
23
+ try {
24
+ const parts = token.split('.');
25
+ if (parts.length !== 3)
26
+ return null;
27
+ const payload = Buffer.from(parts[1], 'base64').toString('utf-8');
28
+ return JSON.parse(payload);
29
+ }
30
+ catch {
31
+ return null;
32
+ }
33
+ }
34
+ /**
35
+ * Extract email from JWT claims.
36
+ * .NET uses long claim type URIs by default.
37
+ */
38
+ function getEmailFromToken(token) {
39
+ const payload = decodeJwtPayload(token);
40
+ if (!payload)
41
+ return null;
42
+ return payload['http://schemas.xmlsoap.org/ws/2005/05/identity/claims/emailaddress']
43
+ || payload['email']
44
+ || payload['sub']
45
+ || null;
46
+ }
47
+ export async function loginCommand(options = {}) {
9
48
  const conf = getConfig();
10
- // Check if already logged in
49
+ // Check if already logged in (unless --force)
11
50
  const existingToken = conf.get('token');
12
- if (existingToken) {
51
+ if (existingToken && !options.force) {
52
+ const email = conf.get('email') || getEmailFromToken(existingToken);
13
53
  console.log(chalk.green('✓ Already authenticated'));
54
+ if (email)
55
+ console.log(chalk.gray(` Account: ${email}`));
14
56
  console.log(chalk.gray(' Run "nstantpage login --force" to re-authenticate'));
57
+ console.log(chalk.gray(' Run "nstantpage logout" to sign out.'));
15
58
  return;
16
59
  }
17
- console.log(chalk.blue('🔐 Authenticating with nstantpage.com...\n'));
60
+ const frontendUrl = resolveFrontendUrl(options.gateway);
61
+ const isLocal = frontendUrl.includes('localhost');
62
+ console.log(chalk.blue(`🔐 Authenticating with ${isLocal ? 'local server' : 'nstantpage.com'}...\n`));
18
63
  // Start local callback server
19
64
  const callbackPort = 18923;
20
65
  const token = await new Promise((resolve, reject) => {
21
66
  const server = http.createServer((req, res) => {
22
- // Allow CORS from nstantpage.com (the auth page uses fetch())
67
+ // Allow CORS from frontend (the auth page uses fetch())
23
68
  res.setHeader('Access-Control-Allow-Origin', '*');
24
69
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
25
70
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -51,7 +96,7 @@ export async function loginCommand() {
51
96
  }
52
97
  });
53
98
  server.listen(callbackPort, () => {
54
- const loginUrl = `https://nstantpage.com/auth/agent?callback=http://localhost:${callbackPort}/callback`;
99
+ const loginUrl = `${frontendUrl}/auth/agent?callback=http://localhost:${callbackPort}/callback`;
55
100
  console.log(chalk.gray(` Opening browser to authenticate...`));
56
101
  console.log(chalk.gray(` If browser doesn't open, visit: ${loginUrl}\n`));
57
102
  open(loginUrl).catch(() => {
@@ -65,7 +110,12 @@ export async function loginCommand() {
65
110
  }, 5 * 60 * 1000);
66
111
  });
67
112
  conf.set('token', token);
113
+ const email = getEmailFromToken(token);
114
+ if (email)
115
+ conf.set('email', email);
68
116
  console.log(chalk.green('\n✓ Successfully authenticated!'));
69
- console.log(chalk.gray(' Run "nstantpage start" to connect a project'));
117
+ if (email)
118
+ console.log(chalk.gray(` Account: ${email}`));
119
+ console.log(chalk.gray(' Run "nstantpage start" to connect your machine'));
70
120
  }
71
121
  //# sourceMappingURL=login.js.map
@@ -0,0 +1,4 @@
1
+ /**
2
+ * Logout command — clear stored credentials
3
+ */
4
+ export declare function logoutCommand(): Promise<void>;
@@ -0,0 +1,20 @@
1
+ /**
2
+ * Logout command — clear stored credentials
3
+ */
4
+ import chalk from 'chalk';
5
+ import { getConfig } from '../config.js';
6
+ export async function logoutCommand() {
7
+ const conf = getConfig();
8
+ const email = conf.get('email');
9
+ conf.delete('token');
10
+ conf.delete('email');
11
+ conf.delete('projectId');
12
+ if (email) {
13
+ console.log(chalk.green(`✓ Logged out (was: ${email})`));
14
+ }
15
+ else {
16
+ console.log(chalk.green('✓ Logged out'));
17
+ }
18
+ console.log(chalk.gray(' Run "nstantpage login" to authenticate again.'));
19
+ }
20
+ //# sourceMappingURL=logout.js.map
@@ -1,17 +1,37 @@
1
1
  /**
2
2
  * Service command — install/uninstall the nstantpage agent as a background service.
3
3
  *
4
+ * The service keeps the agent connected to your nstantpage.com account,
5
+ * ready to accept project connections from the web editor at any time.
6
+ * No project-id needed — projects are assigned from the web when you click "Connect".
7
+ *
4
8
  * macOS: Uses launchd (~/Library/LaunchAgents/com.nstantpage.agent.plist)
5
9
  * Linux: Uses systemd (--user mode: ~/.config/systemd/user/nstantpage-agent.service)
6
- * Windows: Uses node-windows / Task Scheduler (future)
10
+ * Windows: Uses Task Scheduler (future)
7
11
  *
8
- * This allows the agent to run on boot, always ready to accept connections
9
- * from the web editor without manually starting it each time.
12
+ * Usage:
13
+ * nstantpage service install — Install & start the background service
14
+ * nstantpage service install --gateway URL — Use a custom gateway
15
+ * nstantpage service uninstall — Remove the background service
16
+ * nstantpage service status — Check if service is running
10
17
  */
11
- interface ServiceOptions {
12
- projectId?: string;
18
+ interface ServiceInstallOptions {
19
+ gateway?: string;
20
+ }
21
+ interface ServiceStartOptions {
13
22
  gateway?: string;
14
- uninstall?: boolean;
15
23
  }
16
- export declare function serviceCommand(options: ServiceOptions): Promise<void>;
24
+ /**
25
+ * `nstantpage service start` — run agent in standby mode (foreground).
26
+ * This keeps your machine online and visible from the web UI.
27
+ * Projects are started via the web when you click "Connect".
28
+ */
29
+ export declare function serviceStartCommand(options?: ServiceStartOptions): Promise<void>;
30
+ /**
31
+ * `nstantpage service stop` — stop any running agent (PID-based or OS service).
32
+ */
33
+ export declare function serviceStopCommand(): Promise<void>;
34
+ export declare function serviceInstallCommand(options?: ServiceInstallOptions): Promise<void>;
35
+ export declare function serviceUninstallCommand(): Promise<void>;
36
+ export declare function serviceStatusCommand(): Promise<void>;
17
37
  export {};
@@ -1,12 +1,19 @@
1
1
  /**
2
2
  * Service command — install/uninstall the nstantpage agent as a background service.
3
3
  *
4
+ * The service keeps the agent connected to your nstantpage.com account,
5
+ * ready to accept project connections from the web editor at any time.
6
+ * No project-id needed — projects are assigned from the web when you click "Connect".
7
+ *
4
8
  * macOS: Uses launchd (~/Library/LaunchAgents/com.nstantpage.agent.plist)
5
9
  * Linux: Uses systemd (--user mode: ~/.config/systemd/user/nstantpage-agent.service)
6
- * Windows: Uses node-windows / Task Scheduler (future)
10
+ * Windows: Uses Task Scheduler (future)
7
11
  *
8
- * This allows the agent to run on boot, always ready to accept connections
9
- * from the web editor without manually starting it each time.
12
+ * Usage:
13
+ * nstantpage service install — Install & start the background service
14
+ * nstantpage service install --gateway URL — Use a custom gateway
15
+ * nstantpage service uninstall — Remove the background service
16
+ * nstantpage service status — Check if service is running
10
17
  */
11
18
  import chalk from 'chalk';
12
19
  import fs from 'fs';
@@ -14,23 +21,169 @@ import path from 'path';
14
21
  import os from 'os';
15
22
  import { execSync } from 'child_process';
16
23
  import { getConfig } from '../config.js';
24
+ import { startCommand } from './start.js';
17
25
  const PLIST_LABEL = 'com.nstantpage.agent';
18
26
  const SYSTEMD_SERVICE = 'nstantpage-agent';
19
- export async function serviceCommand(options) {
20
- if (options.uninstall) {
21
- return uninstallService();
27
+ /**
28
+ * `nstantpage service start` — run agent in standby mode (foreground).
29
+ * This keeps your machine online and visible from the web UI.
30
+ * Projects are started via the web when you click "Connect".
31
+ */
32
+ export async function serviceStartCommand(options = {}) {
33
+ const conf = getConfig();
34
+ const token = conf.get('token');
35
+ if (!token) {
36
+ console.log(chalk.red('✗ Not authenticated. Run "nstantpage login" first.'));
37
+ process.exit(1);
38
+ }
39
+ const email = conf.get('email');
40
+ if (email) {
41
+ console.log(chalk.gray(` Account: ${email}`));
42
+ }
43
+ // Delegate to start command with no project-id → enters standby mode
44
+ const gateway = options.gateway || 'wss://webprev.live';
45
+ await startCommand('.', {
46
+ port: '3000',
47
+ apiPort: '18924',
48
+ gateway,
49
+ token,
50
+ });
51
+ }
52
+ /**
53
+ * `nstantpage service stop` — stop any running agent (PID-based or OS service).
54
+ */
55
+ export async function serviceStopCommand() {
56
+ const platform = os.platform();
57
+ let stopped = false;
58
+ // Try stopping OS service first
59
+ if (platform === 'darwin') {
60
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
61
+ if (fs.existsSync(plistPath)) {
62
+ try {
63
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
64
+ console.log(chalk.green(' ✓ Background service stopped'));
65
+ stopped = true;
66
+ }
67
+ catch { }
68
+ }
69
+ }
70
+ else if (platform === 'linux') {
71
+ try {
72
+ execSync(`systemctl --user stop ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
73
+ console.log(chalk.green(' ✓ Background service stopped'));
74
+ stopped = true;
75
+ }
76
+ catch { }
77
+ }
78
+ // Also try PID-based stop from global config
79
+ const conf = getConfig();
80
+ const pid = conf.get('agentPid');
81
+ if (pid) {
82
+ try {
83
+ process.kill(pid, 'SIGTERM');
84
+ conf.delete('agentPid');
85
+ console.log(chalk.green(' ✓ Agent process stopped'));
86
+ stopped = true;
87
+ }
88
+ catch {
89
+ conf.delete('agentPid');
90
+ }
91
+ }
92
+ if (!stopped) {
93
+ console.log(chalk.yellow(' No running agent found.'));
22
94
  }
23
- return installService(options);
24
95
  }
96
+ export async function serviceInstallCommand(options = {}) {
97
+ const conf = getConfig();
98
+ const token = conf.get('token');
99
+ if (!token) {
100
+ console.log(chalk.red('✗ Not authenticated. Run "nstantpage login" first.'));
101
+ process.exit(1);
102
+ }
103
+ const gateway = options.gateway || 'wss://webprev.live';
104
+ const platform = os.platform();
105
+ if (platform === 'darwin') {
106
+ await installLaunchd(gateway, token);
107
+ }
108
+ else if (platform === 'linux') {
109
+ await installSystemd(gateway, token);
110
+ }
111
+ else {
112
+ console.log(chalk.yellow('⚠ Background service not yet supported on this platform.'));
113
+ console.log(chalk.gray(' Use "nstantpage start --project-id X" to run manually.'));
114
+ console.log(chalk.gray(' Windows support coming soon.'));
115
+ process.exit(1);
116
+ }
117
+ }
118
+ export async function serviceUninstallCommand() {
119
+ const platform = os.platform();
120
+ if (platform === 'darwin') {
121
+ await uninstallLaunchd();
122
+ }
123
+ else if (platform === 'linux') {
124
+ await uninstallSystemd();
125
+ }
126
+ else {
127
+ console.log(chalk.yellow(' ⚠ Service uninstall not supported on this platform'));
128
+ }
129
+ }
130
+ export async function serviceStatusCommand() {
131
+ const platform = os.platform();
132
+ if (platform === 'darwin') {
133
+ try {
134
+ const result = execSync(`launchctl list | grep ${PLIST_LABEL}`, { encoding: 'utf-8' }).trim();
135
+ if (result) {
136
+ const parts = result.split('\t');
137
+ const pid = parts[0];
138
+ const lastExit = parts[1];
139
+ console.log(chalk.green(' ✓ Service is installed'));
140
+ console.log(chalk.gray(` PID: ${pid === '-' ? 'not running' : pid}`));
141
+ console.log(chalk.gray(` Last exit: ${lastExit}`));
142
+ }
143
+ else {
144
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
145
+ }
146
+ }
147
+ catch {
148
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
149
+ }
150
+ }
151
+ else if (platform === 'linux') {
152
+ try {
153
+ const result = execSync(`systemctl --user is-active ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' }).trim();
154
+ console.log(chalk.green(` ✓ Service is ${result}`));
155
+ }
156
+ catch {
157
+ console.log(chalk.yellow(' ⚠ Service is not running or not installed'));
158
+ }
159
+ }
160
+ else {
161
+ console.log(chalk.gray(' Service status not available on this platform'));
162
+ }
163
+ // Also check log file
164
+ const logPath = path.join(os.homedir(), '.nstantpage', 'agent.log');
165
+ if (fs.existsSync(logPath)) {
166
+ const stats = fs.statSync(logPath);
167
+ console.log(chalk.gray(` Log file: ${logPath} (${(stats.size / 1024).toFixed(1)}KB)`));
168
+ try {
169
+ const content = fs.readFileSync(logPath, 'utf-8');
170
+ const lines = content.trim().split('\n').slice(-5);
171
+ if (lines.length > 0) {
172
+ console.log(chalk.gray(' Last log lines:'));
173
+ lines.forEach(l => console.log(chalk.gray(` ${l}`)));
174
+ }
175
+ }
176
+ catch { }
177
+ }
178
+ }
179
+ // ─── Helper Functions ─────────────────────────────────
25
180
  function getAgentBinPath() {
26
- // Find the globally installed nstantpage binary
27
181
  try {
28
182
  const resolved = execSync('which nstantpage 2>/dev/null || which nstantpage-agent 2>/dev/null', { encoding: 'utf-8' }).trim();
29
183
  if (resolved)
30
184
  return resolved;
31
185
  }
32
186
  catch { }
33
- // Fallback: try npm root -g
34
187
  try {
35
188
  const npmRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim();
36
189
  const bin = path.join(npmRoot, '.bin', 'nstantpage');
@@ -48,44 +201,18 @@ function getNodePath() {
48
201
  return '/usr/local/bin/node';
49
202
  }
50
203
  }
51
- async function installService(options) {
52
- const conf = getConfig();
53
- const token = conf.get('token');
54
- if (!token) {
55
- console.log(chalk.red('✗ Not authenticated. Run "nstantpage login" first.'));
56
- process.exit(1);
57
- }
58
- const projectId = options.projectId;
59
- if (!projectId) {
60
- console.log(chalk.red('✗ --project-id is required for service mode.'));
61
- console.log(chalk.gray(' Example: nstantpage service --project-id 1234'));
62
- process.exit(1);
63
- }
64
- const gateway = options.gateway || 'wss://webprev.live';
65
- const platform = os.platform();
66
- if (platform === 'darwin') {
67
- await installLaunchd(projectId, gateway, token);
68
- }
69
- else if (platform === 'linux') {
70
- await installSystemd(projectId, gateway, token);
71
- }
72
- else {
73
- console.log(chalk.yellow('⚠ Background service not yet supported on this platform.'));
74
- console.log(chalk.gray(' Use "nstantpage start" to run manually.'));
75
- console.log(chalk.gray(' Windows support coming soon.'));
76
- process.exit(1);
77
- }
78
- }
79
- async function installLaunchd(projectId, gateway, token) {
204
+ // ─── macOS (launchd) ──────────────────────────────────
205
+ async function installLaunchd(gateway, token) {
80
206
  const binPath = getAgentBinPath();
81
207
  const nodePath = getNodePath();
82
208
  const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
83
209
  const plistPath = path.join(plistDir, `${PLIST_LABEL}.plist`);
84
210
  const logPath = path.join(os.homedir(), '.nstantpage', 'agent.log');
85
211
  const errPath = path.join(os.homedir(), '.nstantpage', 'agent.err.log');
86
- // Ensure dirs exist
87
212
  fs.mkdirSync(plistDir, { recursive: true });
88
213
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
214
+ // Service runs `nstantpage start --gateway <url> --token <token>`
215
+ // No --project-id: agent connects in standby, ready for web commands
89
216
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
90
217
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
91
218
  <plist version="1.0">
@@ -97,8 +224,6 @@ async function installLaunchd(projectId, gateway, token) {
97
224
  <string>${nodePath}</string>
98
225
  <string>${binPath}</string>
99
226
  <string>start</string>
100
- <string>--project-id</string>
101
- <string>${projectId}</string>
102
227
  <string>--gateway</string>
103
228
  <string>${gateway}</string>
104
229
  <string>--token</string>
@@ -125,22 +250,35 @@ async function installLaunchd(projectId, gateway, token) {
125
250
  </dict>
126
251
  </plist>`;
127
252
  fs.writeFileSync(plistPath, plist, 'utf-8');
128
- // Unload if already running
129
253
  try {
130
254
  execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
131
255
  }
132
256
  catch { }
133
- // Load
134
257
  execSync(`launchctl load "${plistPath}"`, { encoding: 'utf-8' });
135
258
  console.log(chalk.green('\n ✓ Agent installed as background service (launchd)\n'));
136
- console.log(chalk.gray(` Plist: ${plistPath}`));
137
- console.log(chalk.gray(` Project: ${projectId}`));
138
- console.log(chalk.gray(` Log: ${logPath}`));
139
- console.log(chalk.gray(` Status: launchctl list | grep nstantpage`));
140
- console.log(chalk.gray(` Stop: nstantpage service --uninstall\n`));
141
- console.log(chalk.blue(' The agent will now start automatically on login.'));
259
+ console.log(chalk.gray(` Plist: ${plistPath}`));
260
+ console.log(chalk.gray(` Log: ${logPath}`));
261
+ console.log(chalk.gray(` Status: nstantpage service status`));
262
+ console.log(chalk.gray(` Remove: nstantpage service uninstall\n`));
263
+ console.log(chalk.blue(' The agent will start on login and stay connected to your account.'));
264
+ console.log(chalk.blue(' Open any project on nstantpage.com and click "Connect" to use it.\n'));
265
+ }
266
+ async function uninstallLaunchd() {
267
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
268
+ if (!fs.existsSync(plistPath)) {
269
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
270
+ return;
271
+ }
272
+ try {
273
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
274
+ }
275
+ catch { }
276
+ fs.unlinkSync(plistPath);
277
+ console.log(chalk.green(' ✓ Agent service uninstalled (launchd)'));
278
+ console.log(chalk.gray(' The agent will no longer start automatically.'));
142
279
  }
143
- async function installSystemd(projectId, gateway, token) {
280
+ // ─── Linux (systemd) ─────────────────────────────────
281
+ async function installSystemd(gateway, token) {
144
282
  const binPath = getAgentBinPath();
145
283
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
146
284
  const servicePath = path.join(serviceDir, `${SYSTEMD_SERVICE}.service`);
@@ -152,7 +290,7 @@ Wants=network-online.target
152
290
 
153
291
  [Service]
154
292
  Type=simple
155
- ExecStart=${binPath} start --project-id ${projectId} --gateway ${gateway} --token ${token}
293
+ ExecStart=${binPath} start --gateway ${gateway} --token ${token}
156
294
  Restart=on-failure
157
295
  RestartSec=10
158
296
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
@@ -172,44 +310,30 @@ WantedBy=default.target
172
310
  console.log(chalk.gray(' You may need to reload manually: systemctl --user daemon-reload'));
173
311
  }
174
312
  console.log(chalk.green('\n ✓ Agent installed as background service (systemd --user)\n'));
175
- console.log(chalk.gray(` Unit: ${servicePath}`));
176
- console.log(chalk.gray(` Project: ${projectId}`));
177
- console.log(chalk.gray(` Status: systemctl --user status ${SYSTEMD_SERVICE}`));
178
- console.log(chalk.gray(` Logs: journalctl --user -u ${SYSTEMD_SERVICE} -f`));
179
- console.log(chalk.gray(` Stop: nstantpage service --uninstall\n`));
180
- console.log(chalk.blue(' The agent will now start automatically on login.'));
313
+ console.log(chalk.gray(` Unit: ${servicePath}`));
314
+ console.log(chalk.gray(` Status: nstantpage service status`));
315
+ console.log(chalk.gray(` Logs: journalctl --user -u ${SYSTEMD_SERVICE} -f`));
316
+ console.log(chalk.gray(` Remove: nstantpage service uninstall\n`));
317
+ console.log(chalk.blue(' The agent will start on login and stay connected to your account.'));
318
+ console.log(chalk.blue(' Open any project on nstantpage.com and click "Connect" to use it.\n'));
181
319
  }
182
- async function uninstallService() {
183
- const platform = os.platform();
184
- if (platform === 'darwin') {
185
- const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
186
- try {
187
- execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
188
- }
189
- catch { }
190
- if (fs.existsSync(plistPath)) {
191
- fs.unlinkSync(plistPath);
192
- }
193
- console.log(chalk.green(' ✓ Agent service uninstalled (launchd)'));
320
+ async function uninstallSystemd() {
321
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', `${SYSTEMD_SERVICE}.service`);
322
+ if (!fs.existsSync(servicePath)) {
323
+ console.log(chalk.yellow(' Service is not installed'));
324
+ return;
194
325
  }
195
- else if (platform === 'linux') {
196
- try {
197
- execSync(`systemctl --user stop ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
198
- execSync(`systemctl --user disable ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
199
- }
200
- catch { }
201
- const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', `${SYSTEMD_SERVICE}.service`);
202
- if (fs.existsSync(servicePath)) {
203
- fs.unlinkSync(servicePath);
204
- try {
205
- execSync('systemctl --user daemon-reload', { encoding: 'utf-8' });
206
- }
207
- catch { }
208
- }
209
- console.log(chalk.green(' ✓ Agent service uninstalled (systemd)'));
326
+ try {
327
+ execSync(`systemctl --user stop ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
328
+ execSync(`systemctl --user disable ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
210
329
  }
211
- else {
212
- console.log(chalk.yellow(' ⚠ Service uninstall not supported on this platform'));
330
+ catch { }
331
+ fs.unlinkSync(servicePath);
332
+ try {
333
+ execSync('systemctl --user daemon-reload', { encoding: 'utf-8' });
213
334
  }
335
+ catch { }
336
+ console.log(chalk.green(' ✓ Agent service uninstalled (systemd)'));
337
+ console.log(chalk.gray(' The agent will no longer start automatically.'));
214
338
  }
215
339
  //# sourceMappingURL=service.js.map
@@ -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.4.1';
27
+ const VERSION = '0.5.1';
28
28
  /**
29
29
  * Resolve the backend API base URL.
30
30
  * - If --backend is passed, use it
@@ -175,13 +175,15 @@ export async function startCommand(directory, options) {
175
175
  if (!token && isLocalGateway) {
176
176
  token = 'local-dev';
177
177
  }
178
- // Determine project ID
179
- let projectId = options.projectId || conf.get('projectId');
178
+ // Determine project ID (optional — without it, agent enters standby mode)
179
+ // Only use explicitly passed --project-id, never fall back to stored value
180
+ let projectId = options.projectId;
181
+ const backendUrl = resolveBackendUrl(options);
182
+ const deviceId = getDeviceId();
180
183
  if (!projectId) {
181
- console.log(chalk.yellow('⚠ No project ID specified.'));
182
- console.log(chalk.gray(' Use --project-id <id>'));
183
- console.log(chalk.gray(' Example: npx nstantpage-agent start --project-id 1234'));
184
- process.exit(1);
184
+ // No project specified — enter standby mode
185
+ await startStandbyMode(token, options, backendUrl, deviceId);
186
+ return;
185
187
  }
186
188
  // Auto-assign ports per project (unless user explicitly specified them)
187
189
  const userSpecifiedPort = options.port !== '3000';
@@ -207,8 +209,6 @@ export async function startCommand(directory, options) {
207
209
  }
208
210
  // Save project ID
209
211
  conf.set('projectId', projectId);
210
- const backendUrl = resolveBackendUrl(options);
211
- const deviceId = getDeviceId();
212
212
  // Kill any leftover agent for THIS PROJECT only (not other projects)
213
213
  cleanupPreviousAgent(projectId, apiPort, devPort);
214
214
  // Small delay to let ports release
@@ -271,6 +271,11 @@ export async function startCommand(directory, options) {
271
271
  projectId,
272
272
  apiPort,
273
273
  devPort,
274
+ onStartProject: async (pid) => {
275
+ await startAdditionalProject(pid, {
276
+ token, backendUrl, gatewayUrl: options.gateway, deviceId, noDev: options.noDev,
277
+ });
278
+ },
274
279
  });
275
280
  // 5. Register device with backend
276
281
  let heartbeatInterval = null;
@@ -424,4 +429,191 @@ export async function startCommand(directory, options) {
424
429
  process.exit(1);
425
430
  }
426
431
  }
432
+ // ─── Standby Mode ────────────────────────────────────────────────────────
433
+ /**
434
+ * Run the agent in standby mode — no initial project.
435
+ * Connects to gateway and waits for start-project commands from the web UI.
436
+ * This is used by `nstantpage service install` (background service).
437
+ */
438
+ async function startStandbyMode(token, options, backendUrl, deviceId) {
439
+ console.log(chalk.blue(`\n🚀 nstantpage agent v${VERSION} (standby mode)\n`));
440
+ console.log(chalk.gray(` Device ID: ${deviceId.slice(0, 12)}...`));
441
+ console.log(chalk.gray(` Device: ${os.hostname()} (${os.platform()} ${os.arch()})`));
442
+ console.log(chalk.gray(` Gateway: ${options.gateway}`));
443
+ console.log(chalk.gray(` Backend: ${backendUrl}\n`));
444
+ const activeProjects = new Map();
445
+ // Create standby tunnel (for receiving start-project commands)
446
+ const standbyTunnel = new TunnelClient({
447
+ gatewayUrl: options.gateway,
448
+ token,
449
+ projectId: '_standby_',
450
+ apiPort: 0,
451
+ devPort: 0,
452
+ onStartProject: async (pid) => {
453
+ if (activeProjects.has(pid)) {
454
+ console.log(chalk.yellow(` Project ${pid} is already running`));
455
+ return;
456
+ }
457
+ const result = await startAdditionalProject(pid, {
458
+ token, backendUrl, gatewayUrl: options.gateway, deviceId, noDev: options.noDev,
459
+ });
460
+ if (result)
461
+ activeProjects.set(pid, result);
462
+ },
463
+ });
464
+ // Register device with backend (no project)
465
+ try {
466
+ const res = await fetch(`${backendUrl}/api/agent/register`, {
467
+ method: 'POST',
468
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
469
+ body: JSON.stringify({
470
+ deviceId, name: os.hostname(), hostname: os.hostname(),
471
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
472
+ capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
473
+ }),
474
+ });
475
+ if (res.ok)
476
+ console.log(chalk.green(` ✓ Device registered`));
477
+ else
478
+ console.log(chalk.gray(` ⚠ Device registration: ${res.status}`));
479
+ }
480
+ catch (err) {
481
+ console.log(chalk.gray(` ⚠ Device registration: ${err.message}`));
482
+ }
483
+ // Connect standby tunnel
484
+ try {
485
+ await standbyTunnel.connect();
486
+ console.log(chalk.green(` ✓ Connected to gateway\n`));
487
+ }
488
+ catch (err) {
489
+ console.log(chalk.yellow(` ⚠ Gateway connection failed: ${err.message}`));
490
+ standbyTunnel.startBackgroundReconnect();
491
+ }
492
+ // Heartbeat
493
+ setInterval(async () => {
494
+ try {
495
+ await fetch(`${backendUrl}/api/agent/heartbeat`, {
496
+ method: 'POST',
497
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
498
+ body: JSON.stringify({
499
+ deviceId, activeProjectIds: Array.from(activeProjects.keys()), agentVersion: VERSION,
500
+ }),
501
+ });
502
+ }
503
+ catch { }
504
+ }, 60_000);
505
+ // Shutdown handler
506
+ const shutdown = async () => {
507
+ console.log(chalk.yellow('\n Shutting down...'));
508
+ standbyTunnel.disconnect();
509
+ for (const [, proj] of activeProjects) {
510
+ proj.tunnel.disconnect();
511
+ await proj.localServer.stop();
512
+ }
513
+ try {
514
+ await fetch(`${backendUrl}/api/agent/disconnect`, {
515
+ method: 'POST',
516
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
517
+ body: JSON.stringify({ deviceId }),
518
+ });
519
+ }
520
+ catch { }
521
+ process.exit(0);
522
+ };
523
+ process.on('SIGTERM', shutdown);
524
+ process.on('SIGINT', shutdown);
525
+ console.log(chalk.blue.bold(` ┌──────────────────────────────────────────────┐`));
526
+ console.log(chalk.blue.bold(` │ Agent ready (standby mode) │`));
527
+ console.log(chalk.blue.bold(` ├──────────────────────────────────────────────┤`));
528
+ console.log(chalk.white(` │ Device: ${os.hostname()}`));
529
+ console.log(chalk.white(` │ Waiting for project assignments...`));
530
+ console.log(chalk.blue.bold(` └──────────────────────────────────────────────┘\n`));
531
+ console.log(chalk.gray(` Open any project on nstantpage.com and click "Connect"`));
532
+ console.log(chalk.gray(` to start it on this machine.`));
533
+ console.log(chalk.gray(` Press Ctrl+C to stop\n`));
534
+ await new Promise(() => { });
535
+ }
536
+ // ─── Multi-Project Helper ────────────────────────────────────────────────
537
+ /**
538
+ * Start an additional project on this agent.
539
+ * Called via the tunnel's onStartProject callback when the web UI
540
+ * sends a start-project command.
541
+ */
542
+ async function startAdditionalProject(projectId, opts) {
543
+ console.log(chalk.blue(`\n 📦 Starting project ${projectId}...`));
544
+ try {
545
+ const allocated = allocatePortsForProject(projectId);
546
+ const projectDir = resolveProjectDir('.', projectId);
547
+ if (!fs.existsSync(projectDir))
548
+ fs.mkdirSync(projectDir, { recursive: true });
549
+ // Fetch project files
550
+ try {
551
+ await fetchProjectFiles(opts.backendUrl, projectId, projectDir, opts.token);
552
+ }
553
+ catch (err) {
554
+ console.log(chalk.yellow(` ⚠ Could not fetch files: ${err.message}`));
555
+ }
556
+ // Install dependencies
557
+ const hasNodeModules = fs.existsSync(path.join(projectDir, 'node_modules'));
558
+ if (!hasNodeModules && fs.existsSync(path.join(projectDir, 'package.json'))) {
559
+ console.log(chalk.gray(` Installing dependencies...`));
560
+ const installer = new PackageInstaller({ projectDir });
561
+ const result = await installer.install([], false);
562
+ if (result.success)
563
+ console.log(chalk.green(` ✓ Dependencies installed`));
564
+ }
565
+ // Start local server
566
+ const localServer = new LocalServer({
567
+ projectDir, projectId,
568
+ apiPort: allocated.apiPort, devPort: allocated.devPort,
569
+ });
570
+ await localServer.start();
571
+ console.log(chalk.green(` ✓ API server on port ${allocated.apiPort}`));
572
+ // Start dev server
573
+ if (!opts.noDev) {
574
+ try {
575
+ await localServer.getDevServer().start();
576
+ console.log(chalk.green(` ✓ Dev server on port ${allocated.devPort}`));
577
+ }
578
+ catch (err) {
579
+ console.log(chalk.yellow(` ⚠ Dev server: ${err.message}`));
580
+ }
581
+ }
582
+ // Connect project tunnel
583
+ const tunnel = new TunnelClient({
584
+ gatewayUrl: opts.gatewayUrl,
585
+ token: opts.token,
586
+ projectId,
587
+ apiPort: allocated.apiPort,
588
+ devPort: allocated.devPort,
589
+ });
590
+ try {
591
+ await tunnel.connect();
592
+ console.log(chalk.green(` ✓ Tunnel connected for project ${projectId}`));
593
+ }
594
+ catch (err) {
595
+ console.log(chalk.yellow(` ⚠ Tunnel: ${err.message}`));
596
+ tunnel.startBackgroundReconnect();
597
+ }
598
+ // Register project with backend
599
+ try {
600
+ await fetch(`${opts.backendUrl}/api/agent/register`, {
601
+ method: 'POST',
602
+ headers: { 'Authorization': `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
603
+ body: JSON.stringify({
604
+ deviceId: opts.deviceId, name: os.hostname(), hostname: os.hostname(),
605
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
606
+ projectId, capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
607
+ }),
608
+ });
609
+ }
610
+ catch { }
611
+ console.log(chalk.green(` ✓ Project ${projectId} is live!\n`));
612
+ return { localServer, tunnel };
613
+ }
614
+ catch (err) {
615
+ console.error(chalk.red(` ✗ Failed to start project ${projectId}: ${err.message}`));
616
+ return null;
617
+ }
618
+ }
427
619
  //# sourceMappingURL=start.js.map
package/dist/config.js CHANGED
@@ -20,6 +20,7 @@ export function getConfig() {
20
20
  projectName: 'nstantpage-agent',
21
21
  schema: {
22
22
  token: { type: 'string', default: '' },
23
+ email: { type: 'string', default: '' },
23
24
  gatewayUrl: { type: 'string', default: 'wss://webprev.live' },
24
25
  projectId: { type: 'string', default: '' },
25
26
  // Legacy fields (kept for backward compat, but per-project config is preferred)
@@ -12,7 +12,37 @@
12
12
  *
13
13
  * Requests arrive through the tunnel from the gateway.
14
14
  */
15
+ import { ChildProcess } from 'child_process';
15
16
  import { DevServer } from './devServer.js';
17
+ interface TerminalSession {
18
+ id: string;
19
+ projectId: string;
20
+ shell: ChildProcess;
21
+ outputBuffer: string[];
22
+ createdAt: number;
23
+ lastActivity: number;
24
+ cols: number;
25
+ rows: number;
26
+ isAiSession: boolean;
27
+ label: string;
28
+ exited: boolean;
29
+ exitCode: number | null;
30
+ /** Real-time output listeners for WebSocket relay */
31
+ dataListeners: Set<(data: string) => void>;
32
+ exitListeners: Set<(code: number | null) => void>;
33
+ }
34
+ /**
35
+ * Get a terminal session by ID (used by WS relay in tunnel).
36
+ */
37
+ export declare function getTerminalSession(sessionId: string): TerminalSession | undefined;
38
+ /**
39
+ * Attach a real-time listener to a terminal session (for WebSocket relay).
40
+ * Returns a cleanup function to detach.
41
+ */
42
+ export declare function attachTerminalClient(sessionId: string, callbacks: {
43
+ onData: (data: string) => void;
44
+ onExit: (code: number | null) => void;
45
+ }): (() => void) | null;
16
46
  export interface LocalServerOptions {
17
47
  projectDir: string;
18
48
  projectId: string;
@@ -72,3 +102,4 @@ export declare class LocalServer {
72
102
  private json;
73
103
  private collectBody;
74
104
  }
105
+ export {};
@@ -21,9 +21,41 @@ import { Checker } from './checker.js';
21
21
  import { ErrorStore, structuredErrorToString } from './errorStore.js';
22
22
  import { PackageInstaller } from './packageInstaller.js';
23
23
  const terminalSessions = new Map();
24
+ let sessionCounter = 0;
24
25
  function generateSessionId() {
25
26
  return Math.random().toString(16).slice(2, 10);
26
27
  }
28
+ /**
29
+ * Get a terminal session by ID (used by WS relay in tunnel).
30
+ */
31
+ export function getTerminalSession(sessionId) {
32
+ return terminalSessions.get(sessionId);
33
+ }
34
+ /**
35
+ * Attach a real-time listener to a terminal session (for WebSocket relay).
36
+ * Returns a cleanup function to detach.
37
+ */
38
+ export function attachTerminalClient(sessionId, callbacks) {
39
+ const session = terminalSessions.get(sessionId);
40
+ if (!session)
41
+ return null;
42
+ session.dataListeners.add(callbacks.onData);
43
+ session.exitListeners.add(callbacks.onExit);
44
+ // Send scrollback immediately
45
+ if (session.outputBuffer.length > 0) {
46
+ const scrollback = session.outputBuffer.join('');
47
+ callbacks.onData(scrollback);
48
+ }
49
+ // If session already exited, notify immediately
50
+ if (session.exited) {
51
+ callbacks.onExit(session.exitCode);
52
+ }
53
+ // Return cleanup function
54
+ return () => {
55
+ session.dataListeners.delete(callbacks.onData);
56
+ session.exitListeners.delete(callbacks.onExit);
57
+ };
58
+ }
27
59
  export class LocalServer {
28
60
  server = null;
29
61
  options;
@@ -303,13 +335,16 @@ export class LocalServer {
303
335
  const sessions = Array.from(terminalSessions.values())
304
336
  .filter(s => s.projectId === this.options.projectId)
305
337
  .map(s => ({
306
- sessionId: s.id,
338
+ id: s.id,
307
339
  projectId: s.projectId,
340
+ isAiSession: s.isAiSession,
341
+ label: s.label,
308
342
  createdAt: s.createdAt,
309
343
  lastActivity: s.lastActivity,
344
+ exited: s.exited,
345
+ exitCode: s.exitCode,
310
346
  cols: s.cols,
311
347
  rows: s.rows,
312
- isAiSession: s.isAiSession,
313
348
  }));
314
349
  this.json(res, { success: true, sessions });
315
350
  return;
@@ -336,6 +371,8 @@ export class LocalServer {
336
371
  env: { ...process.env, TERM: 'xterm-256color', COLUMNS: String(cols), LINES: String(rows) },
337
372
  stdio: ['pipe', 'pipe', 'pipe'],
338
373
  });
374
+ sessionCounter++;
375
+ const label = parsed.label || `Terminal ${sessionCounter}`;
339
376
  const session = {
340
377
  id: sessionId,
341
378
  projectId: this.options.projectId,
@@ -346,31 +383,69 @@ export class LocalServer {
346
383
  cols,
347
384
  rows,
348
385
  isAiSession,
386
+ label,
387
+ exited: false,
388
+ exitCode: null,
389
+ dataListeners: new Set(),
390
+ exitListeners: new Set(),
349
391
  };
350
392
  shell.stdout?.on('data', (data) => {
351
- session.outputBuffer.push(data.toString('utf-8'));
393
+ const str = data.toString('utf-8');
394
+ session.outputBuffer.push(str);
352
395
  session.lastActivity = Date.now();
353
396
  // Keep buffer capped at ~100KB
354
397
  while (session.outputBuffer.length > 500)
355
398
  session.outputBuffer.shift();
399
+ // Notify real-time listeners (WebSocket relay)
400
+ for (const listener of session.dataListeners) {
401
+ try {
402
+ listener(str);
403
+ }
404
+ catch { }
405
+ }
356
406
  });
357
407
  shell.stderr?.on('data', (data) => {
358
- session.outputBuffer.push(data.toString('utf-8'));
408
+ const str = data.toString('utf-8');
409
+ session.outputBuffer.push(str);
359
410
  session.lastActivity = Date.now();
360
411
  while (session.outputBuffer.length > 500)
361
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
+ }
362
420
  });
363
- shell.on('exit', () => {
421
+ shell.on('exit', (code) => {
422
+ session.exited = true;
423
+ session.exitCode = code;
424
+ // Notify exit listeners
425
+ for (const listener of session.exitListeners) {
426
+ try {
427
+ listener(code);
428
+ }
429
+ catch { }
430
+ }
364
431
  terminalSessions.delete(sessionId);
365
432
  });
366
433
  terminalSessions.set(sessionId, session);
434
+ // Return format matching frontend TerminalSessionInfo
367
435
  this.json(res, {
368
436
  success: true,
369
- sessionId,
370
- projectId: this.options.projectId,
371
- cols,
372
- rows,
373
- isAiSession,
437
+ session: {
438
+ id: sessionId,
439
+ projectId: this.options.projectId,
440
+ isAiSession,
441
+ label,
442
+ createdAt: session.createdAt,
443
+ lastActivity: session.lastActivity,
444
+ exited: false,
445
+ exitCode: null,
446
+ cols,
447
+ rows,
448
+ },
374
449
  });
375
450
  }
376
451
  // ─── /live/terminal/write ────────────────────────────────────
@@ -427,7 +502,7 @@ export class LocalServer {
427
502
  connected: true,
428
503
  projectId: this.options.projectId,
429
504
  agent: {
430
- version: '0.4.1',
505
+ version: '0.5.1',
431
506
  hostname: os.hostname(),
432
507
  platform: `${os.platform()} ${os.arch()}`,
433
508
  },
package/dist/tunnel.d.ts CHANGED
@@ -23,6 +23,8 @@ interface TunnelClientOptions {
23
23
  apiPort: number;
24
24
  /** Port where the dev server (Vite/Next.js) runs */
25
25
  devPort: number;
26
+ /** Callback when gateway requests starting a new project on this device */
27
+ onStartProject?: (projectId: string) => Promise<void>;
26
28
  }
27
29
  export declare class TunnelClient {
28
30
  private ws;
@@ -34,6 +36,8 @@ export declare class TunnelClient {
34
36
  private pingInterval;
35
37
  private requestsForwarded;
36
38
  private connectedAt;
39
+ /** Active WebSocket channels (terminal WS relay through tunnel) */
40
+ private wsChannels;
37
41
  constructor(options: TunnelClientOptions);
38
42
  get isConnected(): boolean;
39
43
  get stats(): {
@@ -59,6 +63,25 @@ export declare class TunnelClient {
59
63
  lintErrors: string[];
60
64
  runtimeErrors: string[];
61
65
  }): void;
66
+ /**
67
+ * Handle ws-open: gateway wants to open a virtual WebSocket channel
68
+ * for a terminal session. Attach to the session's I/O.
69
+ */
70
+ private handleWsOpen;
71
+ /**
72
+ * Handle ws-data: frontend sent a message through the terminal WebSocket.
73
+ * Parse it and route to the terminal session.
74
+ */
75
+ private handleWsData;
76
+ /**
77
+ * Handle ws-close: frontend disconnected the terminal WebSocket.
78
+ */
79
+ private handleWsClose;
80
+ /**
81
+ * Handle start-project: gateway wants this agent to start serving a new project.
82
+ * Called when user clicks "Connect" in the web editor's Cloud panel.
83
+ */
84
+ private handleStartProject;
62
85
  private send;
63
86
  private handleMessage;
64
87
  /**
package/dist/tunnel.js CHANGED
@@ -19,6 +19,7 @@ import WebSocket from 'ws';
19
19
  import http from 'http';
20
20
  import os from 'os';
21
21
  import { getDeviceId } from './config.js';
22
+ import { getTerminalSession, attachTerminalClient } from './localServer.js';
22
23
  export class TunnelClient {
23
24
  ws = null;
24
25
  options;
@@ -29,6 +30,8 @@ export class TunnelClient {
29
30
  pingInterval = null;
30
31
  requestsForwarded = 0;
31
32
  connectedAt = 0;
33
+ /** Active WebSocket channels (terminal WS relay through tunnel) */
34
+ wsChannels = new Map();
32
35
  constructor(options) {
33
36
  this.options = options;
34
37
  }
@@ -59,7 +62,7 @@ export class TunnelClient {
59
62
  // Send enhanced agent info with capabilities and deviceId
60
63
  this.send({
61
64
  type: 'agent-info',
62
- version: '0.4.0',
65
+ version: '0.5.1',
63
66
  hostname: os.hostname(),
64
67
  platform: `${os.platform()} ${os.arch()}`,
65
68
  deviceId: getDeviceId(),
@@ -138,6 +141,119 @@ export class TunnelClient {
138
141
  errors,
139
142
  });
140
143
  }
144
+ // ─── WebSocket Relay (terminal WS through tunnel) ──────────
145
+ /**
146
+ * Handle ws-open: gateway wants to open a virtual WebSocket channel
147
+ * for a terminal session. Attach to the session's I/O.
148
+ */
149
+ handleWsOpen(wsId, sessionId, _projectId) {
150
+ const session = getTerminalSession(sessionId);
151
+ if (!session) {
152
+ // Session not found — send error and close
153
+ this.send({
154
+ type: 'ws-data',
155
+ wsId,
156
+ data: JSON.stringify({ type: 'error', message: `Session ${sessionId} not found` }),
157
+ });
158
+ this.send({ type: 'ws-close', wsId });
159
+ return;
160
+ }
161
+ const cleanup = attachTerminalClient(sessionId, {
162
+ onData: (data) => {
163
+ // Send terminal output as WS protocol message
164
+ this.send({
165
+ type: 'ws-data',
166
+ wsId,
167
+ data: JSON.stringify({ type: 'output', data }),
168
+ });
169
+ },
170
+ onExit: (exitCode) => {
171
+ this.send({
172
+ type: 'ws-data',
173
+ wsId,
174
+ data: JSON.stringify({ type: 'exit', exitCode }),
175
+ });
176
+ this.send({ type: 'ws-close', wsId });
177
+ this.wsChannels.delete(wsId);
178
+ },
179
+ });
180
+ this.wsChannels.set(wsId, { sessionId, cleanup });
181
+ // Send connection confirmation
182
+ this.send({
183
+ type: 'ws-data',
184
+ wsId,
185
+ data: JSON.stringify({ type: 'connected', sessionId, projectId: session.projectId }),
186
+ });
187
+ }
188
+ /**
189
+ * Handle ws-data: frontend sent a message through the terminal WebSocket.
190
+ * Parse it and route to the terminal session.
191
+ */
192
+ handleWsData(wsId, data) {
193
+ const channel = this.wsChannels.get(wsId);
194
+ if (!channel)
195
+ return;
196
+ try {
197
+ const msg = JSON.parse(data);
198
+ const session = getTerminalSession(channel.sessionId);
199
+ if (!session)
200
+ return;
201
+ switch (msg.type) {
202
+ case 'input':
203
+ if (typeof msg.data === 'string') {
204
+ session.shell.stdin?.write(msg.data);
205
+ session.lastActivity = Date.now();
206
+ }
207
+ break;
208
+ case 'resize':
209
+ if (typeof msg.cols === 'number' && typeof msg.rows === 'number') {
210
+ session.cols = msg.cols;
211
+ session.rows = msg.rows;
212
+ // Note: child_process.spawn doesn't support resize natively
213
+ // (only node-pty does). We update the stored size for future use.
214
+ }
215
+ break;
216
+ }
217
+ }
218
+ catch {
219
+ // Invalid JSON — ignore
220
+ }
221
+ }
222
+ /**
223
+ * Handle ws-close: frontend disconnected the terminal WebSocket.
224
+ */
225
+ handleWsClose(wsId) {
226
+ const channel = this.wsChannels.get(wsId);
227
+ if (channel) {
228
+ if (channel.cleanup)
229
+ channel.cleanup();
230
+ this.wsChannels.delete(wsId);
231
+ }
232
+ }
233
+ /**
234
+ * Handle start-project: gateway wants this agent to start serving a new project.
235
+ * Called when user clicks "Connect" in the web editor's Cloud panel.
236
+ */
237
+ async handleStartProject(projectId) {
238
+ console.log(` [Tunnel] Received start-project command for ${projectId}`);
239
+ if (!projectId) {
240
+ this.send({ type: 'start-project-result', success: false, error: 'Missing projectId' });
241
+ return;
242
+ }
243
+ if (this.options.onStartProject) {
244
+ try {
245
+ await this.options.onStartProject(projectId);
246
+ this.send({ type: 'start-project-result', projectId, success: true });
247
+ }
248
+ catch (err) {
249
+ console.error(` [Tunnel] Failed to start project ${projectId}:`, err.message);
250
+ this.send({ type: 'start-project-result', projectId, success: false, error: err.message });
251
+ }
252
+ }
253
+ else {
254
+ this.send({ type: 'start-project-result', success: false, error: 'Agent does not support remote project start' });
255
+ }
256
+ }
141
257
  send(msg) {
142
258
  if (this.ws?.readyState === WebSocket.OPEN) {
143
259
  this.ws.send(JSON.stringify(msg));
@@ -151,6 +267,18 @@ export class TunnelClient {
151
267
  case 'http-request':
152
268
  this.handleHttpRequest(msg);
153
269
  break;
270
+ case 'ws-open':
271
+ this.handleWsOpen(msg.wsId, msg.sessionId, msg.projectId);
272
+ break;
273
+ case 'ws-data':
274
+ this.handleWsData(msg.wsId, msg.data);
275
+ break;
276
+ case 'ws-close':
277
+ this.handleWsClose(msg.wsId);
278
+ break;
279
+ case 'start-project':
280
+ this.handleStartProject(msg.projectId);
281
+ break;
154
282
  default:
155
283
  console.warn(` [Tunnel] Unknown message type: ${msg.type}`);
156
284
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "nstantpage-agent",
3
- "version": "0.4.2",
3
+ "version": "0.5.1",
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": {