nstantpage-agent 0.4.2 → 0.5.0

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.
@@ -18,15 +19,17 @@ import chalk from 'chalk';
18
19
  import { loginCommand } from './commands/login.js';
19
20
  import { startCommand } from './commands/start.js';
20
21
  import { statusCommand } from './commands/status.js';
21
- import { serviceCommand } from './commands/service.js';
22
+ import { serviceInstallCommand, serviceUninstallCommand, serviceStatusCommand } from './commands/service.js';
22
23
  const program = new Command();
23
24
  program
24
25
  .name('nstantpage')
25
26
  .description('Local development agent for nstantpage.com — run projects on your machine, preview in the cloud')
26
- .version('0.3.0');
27
+ .version('0.5.0');
27
28
  program
28
29
  .command('login')
29
30
  .description('Authenticate with nstantpage.com')
31
+ .option('--gateway <url>', 'Gateway URL (auto-detects local vs production)')
32
+ .option('--force', 'Re-authenticate even if already logged in')
30
33
  .action(loginCommand);
31
34
  program
32
35
  .command('start')
@@ -69,12 +72,21 @@ program
69
72
  .command('status')
70
73
  .description('Show agent status')
71
74
  .action(statusCommand);
72
- program
75
+ const service = program
73
76
  .command('service')
74
- .description('Install/uninstall agent as a background service (starts on boot)')
75
- .option('--project-id <id>', 'Project ID to serve')
77
+ .description('Manage the background service (keeps your machine connected to nstantpage.com)');
78
+ service
79
+ .command('install')
80
+ .description('Install & start the agent as a background service (starts on boot)')
76
81
  .option('--gateway <url>', 'Gateway URL', 'wss://webprev.live')
77
- .option('--uninstall', 'Remove the background service')
78
- .action(serviceCommand);
82
+ .action(serviceInstallCommand);
83
+ service
84
+ .command('uninstall')
85
+ .description('Remove the background service')
86
+ .action(serviceUninstallCommand);
87
+ service
88
+ .command('status')
89
+ .description('Check if the background service is running')
90
+ .action(serviceStatusCommand);
79
91
  program.parse();
80
92
  //# 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,34 @@ 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
+ export async function loginCommand(options = {}) {
9
20
  const conf = getConfig();
10
- // Check if already logged in
21
+ // Check if already logged in (unless --force)
11
22
  const existingToken = conf.get('token');
12
- if (existingToken) {
23
+ if (existingToken && !options.force) {
13
24
  console.log(chalk.green('✓ Already authenticated'));
14
25
  console.log(chalk.gray(' Run "nstantpage login --force" to re-authenticate'));
15
26
  return;
16
27
  }
17
- console.log(chalk.blue('🔐 Authenticating with nstantpage.com...\n'));
28
+ const frontendUrl = resolveFrontendUrl(options.gateway);
29
+ const isLocal = frontendUrl.includes('localhost');
30
+ console.log(chalk.blue(`🔐 Authenticating with ${isLocal ? 'local server' : 'nstantpage.com'}...\n`));
18
31
  // Start local callback server
19
32
  const callbackPort = 18923;
20
33
  const token = await new Promise((resolve, reject) => {
21
34
  const server = http.createServer((req, res) => {
22
- // Allow CORS from nstantpage.com (the auth page uses fetch())
35
+ // Allow CORS from frontend (the auth page uses fetch())
23
36
  res.setHeader('Access-Control-Allow-Origin', '*');
24
37
  res.setHeader('Access-Control-Allow-Methods', 'GET, OPTIONS');
25
38
  res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
@@ -51,7 +64,7 @@ export async function loginCommand() {
51
64
  }
52
65
  });
53
66
  server.listen(callbackPort, () => {
54
- const loginUrl = `https://nstantpage.com/auth/agent?callback=http://localhost:${callbackPort}/callback`;
67
+ const loginUrl = `${frontendUrl}/auth/agent?callback=http://localhost:${callbackPort}/callback`;
55
68
  console.log(chalk.gray(` Opening browser to authenticate...`));
56
69
  console.log(chalk.gray(` If browser doesn't open, visit: ${loginUrl}\n`));
57
70
  open(loginUrl).catch(() => {
@@ -1,17 +1,24 @@
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 {
13
19
  gateway?: string;
14
- uninstall?: boolean;
15
20
  }
16
- export declare function serviceCommand(options: ServiceOptions): Promise<void>;
21
+ export declare function serviceInstallCommand(options?: ServiceInstallOptions): Promise<void>;
22
+ export declare function serviceUninstallCommand(): Promise<void>;
23
+ export declare function serviceStatusCommand(): Promise<void>;
17
24
  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';
@@ -16,21 +23,97 @@ import { execSync } from 'child_process';
16
23
  import { getConfig } from '../config.js';
17
24
  const PLIST_LABEL = 'com.nstantpage.agent';
18
25
  const SYSTEMD_SERVICE = 'nstantpage-agent';
19
- export async function serviceCommand(options) {
20
- if (options.uninstall) {
21
- return uninstallService();
26
+ export async function serviceInstallCommand(options = {}) {
27
+ const conf = getConfig();
28
+ const token = conf.get('token');
29
+ if (!token) {
30
+ console.log(chalk.red('✗ Not authenticated. Run "nstantpage login" first.'));
31
+ process.exit(1);
32
+ }
33
+ const gateway = options.gateway || 'wss://webprev.live';
34
+ const platform = os.platform();
35
+ if (platform === 'darwin') {
36
+ await installLaunchd(gateway, token);
37
+ }
38
+ else if (platform === 'linux') {
39
+ await installSystemd(gateway, token);
40
+ }
41
+ else {
42
+ console.log(chalk.yellow('⚠ Background service not yet supported on this platform.'));
43
+ console.log(chalk.gray(' Use "nstantpage start --project-id X" to run manually.'));
44
+ console.log(chalk.gray(' Windows support coming soon.'));
45
+ process.exit(1);
22
46
  }
23
- return installService(options);
24
47
  }
48
+ export async function serviceUninstallCommand() {
49
+ const platform = os.platform();
50
+ if (platform === 'darwin') {
51
+ await uninstallLaunchd();
52
+ }
53
+ else if (platform === 'linux') {
54
+ await uninstallSystemd();
55
+ }
56
+ else {
57
+ console.log(chalk.yellow(' ⚠ Service uninstall not supported on this platform'));
58
+ }
59
+ }
60
+ export async function serviceStatusCommand() {
61
+ const platform = os.platform();
62
+ if (platform === 'darwin') {
63
+ try {
64
+ const result = execSync(`launchctl list | grep ${PLIST_LABEL}`, { encoding: 'utf-8' }).trim();
65
+ if (result) {
66
+ const parts = result.split('\t');
67
+ const pid = parts[0];
68
+ const lastExit = parts[1];
69
+ console.log(chalk.green(' ✓ Service is installed'));
70
+ console.log(chalk.gray(` PID: ${pid === '-' ? 'not running' : pid}`));
71
+ console.log(chalk.gray(` Last exit: ${lastExit}`));
72
+ }
73
+ else {
74
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
75
+ }
76
+ }
77
+ catch {
78
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
79
+ }
80
+ }
81
+ else if (platform === 'linux') {
82
+ try {
83
+ const result = execSync(`systemctl --user is-active ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' }).trim();
84
+ console.log(chalk.green(` ✓ Service is ${result}`));
85
+ }
86
+ catch {
87
+ console.log(chalk.yellow(' ⚠ Service is not running or not installed'));
88
+ }
89
+ }
90
+ else {
91
+ console.log(chalk.gray(' Service status not available on this platform'));
92
+ }
93
+ // Also check log file
94
+ const logPath = path.join(os.homedir(), '.nstantpage', 'agent.log');
95
+ if (fs.existsSync(logPath)) {
96
+ const stats = fs.statSync(logPath);
97
+ console.log(chalk.gray(` Log file: ${logPath} (${(stats.size / 1024).toFixed(1)}KB)`));
98
+ try {
99
+ const content = fs.readFileSync(logPath, 'utf-8');
100
+ const lines = content.trim().split('\n').slice(-5);
101
+ if (lines.length > 0) {
102
+ console.log(chalk.gray(' Last log lines:'));
103
+ lines.forEach(l => console.log(chalk.gray(` ${l}`)));
104
+ }
105
+ }
106
+ catch { }
107
+ }
108
+ }
109
+ // ─── Helper Functions ─────────────────────────────────
25
110
  function getAgentBinPath() {
26
- // Find the globally installed nstantpage binary
27
111
  try {
28
112
  const resolved = execSync('which nstantpage 2>/dev/null || which nstantpage-agent 2>/dev/null', { encoding: 'utf-8' }).trim();
29
113
  if (resolved)
30
114
  return resolved;
31
115
  }
32
116
  catch { }
33
- // Fallback: try npm root -g
34
117
  try {
35
118
  const npmRoot = execSync('npm root -g', { encoding: 'utf-8' }).trim();
36
119
  const bin = path.join(npmRoot, '.bin', 'nstantpage');
@@ -48,44 +131,18 @@ function getNodePath() {
48
131
  return '/usr/local/bin/node';
49
132
  }
50
133
  }
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) {
134
+ // ─── macOS (launchd) ──────────────────────────────────
135
+ async function installLaunchd(gateway, token) {
80
136
  const binPath = getAgentBinPath();
81
137
  const nodePath = getNodePath();
82
138
  const plistDir = path.join(os.homedir(), 'Library', 'LaunchAgents');
83
139
  const plistPath = path.join(plistDir, `${PLIST_LABEL}.plist`);
84
140
  const logPath = path.join(os.homedir(), '.nstantpage', 'agent.log');
85
141
  const errPath = path.join(os.homedir(), '.nstantpage', 'agent.err.log');
86
- // Ensure dirs exist
87
142
  fs.mkdirSync(plistDir, { recursive: true });
88
143
  fs.mkdirSync(path.dirname(logPath), { recursive: true });
144
+ // Service runs `nstantpage start --gateway <url> --token <token>`
145
+ // No --project-id: agent connects in standby, ready for web commands
89
146
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
90
147
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
91
148
  <plist version="1.0">
@@ -97,8 +154,6 @@ async function installLaunchd(projectId, gateway, token) {
97
154
  <string>${nodePath}</string>
98
155
  <string>${binPath}</string>
99
156
  <string>start</string>
100
- <string>--project-id</string>
101
- <string>${projectId}</string>
102
157
  <string>--gateway</string>
103
158
  <string>${gateway}</string>
104
159
  <string>--token</string>
@@ -125,22 +180,35 @@ async function installLaunchd(projectId, gateway, token) {
125
180
  </dict>
126
181
  </plist>`;
127
182
  fs.writeFileSync(plistPath, plist, 'utf-8');
128
- // Unload if already running
129
183
  try {
130
184
  execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
131
185
  }
132
186
  catch { }
133
- // Load
134
187
  execSync(`launchctl load "${plistPath}"`, { encoding: 'utf-8' });
135
188
  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.'));
189
+ console.log(chalk.gray(` Plist: ${plistPath}`));
190
+ console.log(chalk.gray(` Log: ${logPath}`));
191
+ console.log(chalk.gray(` Status: nstantpage service status`));
192
+ console.log(chalk.gray(` Remove: nstantpage service uninstall\n`));
193
+ console.log(chalk.blue(' The agent will start on login and stay connected to your account.'));
194
+ console.log(chalk.blue(' Open any project on nstantpage.com and click "Connect" to use it.\n'));
195
+ }
196
+ async function uninstallLaunchd() {
197
+ const plistPath = path.join(os.homedir(), 'Library', 'LaunchAgents', `${PLIST_LABEL}.plist`);
198
+ if (!fs.existsSync(plistPath)) {
199
+ console.log(chalk.yellow(' ⚠ Service is not installed'));
200
+ return;
201
+ }
202
+ try {
203
+ execSync(`launchctl unload "${plistPath}" 2>/dev/null`, { encoding: 'utf-8' });
204
+ }
205
+ catch { }
206
+ fs.unlinkSync(plistPath);
207
+ console.log(chalk.green(' ✓ Agent service uninstalled (launchd)'));
208
+ console.log(chalk.gray(' The agent will no longer start automatically.'));
142
209
  }
143
- async function installSystemd(projectId, gateway, token) {
210
+ // ─── Linux (systemd) ─────────────────────────────────
211
+ async function installSystemd(gateway, token) {
144
212
  const binPath = getAgentBinPath();
145
213
  const serviceDir = path.join(os.homedir(), '.config', 'systemd', 'user');
146
214
  const servicePath = path.join(serviceDir, `${SYSTEMD_SERVICE}.service`);
@@ -152,7 +220,7 @@ Wants=network-online.target
152
220
 
153
221
  [Service]
154
222
  Type=simple
155
- ExecStart=${binPath} start --project-id ${projectId} --gateway ${gateway} --token ${token}
223
+ ExecStart=${binPath} start --gateway ${gateway} --token ${token}
156
224
  Restart=on-failure
157
225
  RestartSec=10
158
226
  Environment=PATH=/usr/local/bin:/usr/bin:/bin
@@ -172,44 +240,30 @@ WantedBy=default.target
172
240
  console.log(chalk.gray(' You may need to reload manually: systemctl --user daemon-reload'));
173
241
  }
174
242
  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.'));
243
+ console.log(chalk.gray(` Unit: ${servicePath}`));
244
+ console.log(chalk.gray(` Status: nstantpage service status`));
245
+ console.log(chalk.gray(` Logs: journalctl --user -u ${SYSTEMD_SERVICE} -f`));
246
+ console.log(chalk.gray(` Remove: nstantpage service uninstall\n`));
247
+ console.log(chalk.blue(' The agent will start on login and stay connected to your account.'));
248
+ console.log(chalk.blue(' Open any project on nstantpage.com and click "Connect" to use it.\n'));
181
249
  }
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)'));
250
+ async function uninstallSystemd() {
251
+ const servicePath = path.join(os.homedir(), '.config', 'systemd', 'user', `${SYSTEMD_SERVICE}.service`);
252
+ if (!fs.existsSync(servicePath)) {
253
+ console.log(chalk.yellow(' Service is not installed'));
254
+ return;
194
255
  }
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)'));
256
+ try {
257
+ execSync(`systemctl --user stop ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
258
+ execSync(`systemctl --user disable ${SYSTEMD_SERVICE} 2>/dev/null`, { encoding: 'utf-8' });
210
259
  }
211
- else {
212
- console.log(chalk.yellow(' ⚠ Service uninstall not supported on this platform'));
260
+ catch { }
261
+ fs.unlinkSync(servicePath);
262
+ try {
263
+ execSync('systemctl --user daemon-reload', { encoding: 'utf-8' });
213
264
  }
265
+ catch { }
266
+ console.log(chalk.green(' ✓ Agent service uninstalled (systemd)'));
267
+ console.log(chalk.gray(' The agent will no longer start automatically.'));
214
268
  }
215
269
  //# 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.0';
28
28
  /**
29
29
  * Resolve the backend API base URL.
30
30
  * - If --backend is passed, use it
@@ -175,13 +175,14 @@ export async function startCommand(directory, options) {
175
175
  if (!token && isLocalGateway) {
176
176
  token = 'local-dev';
177
177
  }
178
- // Determine project ID
178
+ // Determine project ID (optional — without it, agent enters standby mode)
179
179
  let projectId = options.projectId || conf.get('projectId');
180
+ const backendUrl = resolveBackendUrl(options);
181
+ const deviceId = getDeviceId();
180
182
  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);
183
+ // No project specified — enter standby mode
184
+ await startStandbyMode(token, options, backendUrl, deviceId);
185
+ return;
185
186
  }
186
187
  // Auto-assign ports per project (unless user explicitly specified them)
187
188
  const userSpecifiedPort = options.port !== '3000';
@@ -207,8 +208,6 @@ export async function startCommand(directory, options) {
207
208
  }
208
209
  // Save project ID
209
210
  conf.set('projectId', projectId);
210
- const backendUrl = resolveBackendUrl(options);
211
- const deviceId = getDeviceId();
212
211
  // Kill any leftover agent for THIS PROJECT only (not other projects)
213
212
  cleanupPreviousAgent(projectId, apiPort, devPort);
214
213
  // Small delay to let ports release
@@ -271,6 +270,11 @@ export async function startCommand(directory, options) {
271
270
  projectId,
272
271
  apiPort,
273
272
  devPort,
273
+ onStartProject: async (pid) => {
274
+ await startAdditionalProject(pid, {
275
+ token, backendUrl, gatewayUrl: options.gateway, deviceId, noDev: options.noDev,
276
+ });
277
+ },
274
278
  });
275
279
  // 5. Register device with backend
276
280
  let heartbeatInterval = null;
@@ -424,4 +428,191 @@ export async function startCommand(directory, options) {
424
428
  process.exit(1);
425
429
  }
426
430
  }
431
+ // ─── Standby Mode ────────────────────────────────────────────────────────
432
+ /**
433
+ * Run the agent in standby mode — no initial project.
434
+ * Connects to gateway and waits for start-project commands from the web UI.
435
+ * This is used by `nstantpage service install` (background service).
436
+ */
437
+ async function startStandbyMode(token, options, backendUrl, deviceId) {
438
+ console.log(chalk.blue(`\n🚀 nstantpage agent v${VERSION} (standby mode)\n`));
439
+ console.log(chalk.gray(` Device ID: ${deviceId.slice(0, 12)}...`));
440
+ console.log(chalk.gray(` Device: ${os.hostname()} (${os.platform()} ${os.arch()})`));
441
+ console.log(chalk.gray(` Gateway: ${options.gateway}`));
442
+ console.log(chalk.gray(` Backend: ${backendUrl}\n`));
443
+ const activeProjects = new Map();
444
+ // Create standby tunnel (for receiving start-project commands)
445
+ const standbyTunnel = new TunnelClient({
446
+ gatewayUrl: options.gateway,
447
+ token,
448
+ projectId: '_standby_',
449
+ apiPort: 0,
450
+ devPort: 0,
451
+ onStartProject: async (pid) => {
452
+ if (activeProjects.has(pid)) {
453
+ console.log(chalk.yellow(` Project ${pid} is already running`));
454
+ return;
455
+ }
456
+ const result = await startAdditionalProject(pid, {
457
+ token, backendUrl, gatewayUrl: options.gateway, deviceId, noDev: options.noDev,
458
+ });
459
+ if (result)
460
+ activeProjects.set(pid, result);
461
+ },
462
+ });
463
+ // Register device with backend (no project)
464
+ try {
465
+ const res = await fetch(`${backendUrl}/api/agent/register`, {
466
+ method: 'POST',
467
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
468
+ body: JSON.stringify({
469
+ deviceId, name: os.hostname(), hostname: os.hostname(),
470
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
471
+ capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
472
+ }),
473
+ });
474
+ if (res.ok)
475
+ console.log(chalk.green(` ✓ Device registered`));
476
+ else
477
+ console.log(chalk.gray(` ⚠ Device registration: ${res.status}`));
478
+ }
479
+ catch (err) {
480
+ console.log(chalk.gray(` ⚠ Device registration: ${err.message}`));
481
+ }
482
+ // Connect standby tunnel
483
+ try {
484
+ await standbyTunnel.connect();
485
+ console.log(chalk.green(` ✓ Connected to gateway\n`));
486
+ }
487
+ catch (err) {
488
+ console.log(chalk.yellow(` ⚠ Gateway connection failed: ${err.message}`));
489
+ standbyTunnel.startBackgroundReconnect();
490
+ }
491
+ // Heartbeat
492
+ setInterval(async () => {
493
+ try {
494
+ await fetch(`${backendUrl}/api/agent/heartbeat`, {
495
+ method: 'POST',
496
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
497
+ body: JSON.stringify({
498
+ deviceId, activeProjectIds: Array.from(activeProjects.keys()), agentVersion: VERSION,
499
+ }),
500
+ });
501
+ }
502
+ catch { }
503
+ }, 60_000);
504
+ // Shutdown handler
505
+ const shutdown = async () => {
506
+ console.log(chalk.yellow('\n Shutting down...'));
507
+ standbyTunnel.disconnect();
508
+ for (const [, proj] of activeProjects) {
509
+ proj.tunnel.disconnect();
510
+ await proj.localServer.stop();
511
+ }
512
+ try {
513
+ await fetch(`${backendUrl}/api/agent/disconnect`, {
514
+ method: 'POST',
515
+ headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' },
516
+ body: JSON.stringify({ deviceId }),
517
+ });
518
+ }
519
+ catch { }
520
+ process.exit(0);
521
+ };
522
+ process.on('SIGTERM', shutdown);
523
+ process.on('SIGINT', shutdown);
524
+ console.log(chalk.blue.bold(` ┌──────────────────────────────────────────────┐`));
525
+ console.log(chalk.blue.bold(` │ Agent ready (standby mode) │`));
526
+ console.log(chalk.blue.bold(` ├──────────────────────────────────────────────┤`));
527
+ console.log(chalk.white(` │ Device: ${os.hostname()}`));
528
+ console.log(chalk.white(` │ Waiting for project assignments...`));
529
+ console.log(chalk.blue.bold(` └──────────────────────────────────────────────┘\n`));
530
+ console.log(chalk.gray(` Open any project on nstantpage.com and click "Connect"`));
531
+ console.log(chalk.gray(` to start it on this machine.`));
532
+ console.log(chalk.gray(` Press Ctrl+C to stop\n`));
533
+ await new Promise(() => { });
534
+ }
535
+ // ─── Multi-Project Helper ────────────────────────────────────────────────
536
+ /**
537
+ * Start an additional project on this agent.
538
+ * Called via the tunnel's onStartProject callback when the web UI
539
+ * sends a start-project command.
540
+ */
541
+ async function startAdditionalProject(projectId, opts) {
542
+ console.log(chalk.blue(`\n 📦 Starting project ${projectId}...`));
543
+ try {
544
+ const allocated = allocatePortsForProject(projectId);
545
+ const projectDir = resolveProjectDir('.', projectId);
546
+ if (!fs.existsSync(projectDir))
547
+ fs.mkdirSync(projectDir, { recursive: true });
548
+ // Fetch project files
549
+ try {
550
+ await fetchProjectFiles(opts.backendUrl, projectId, projectDir, opts.token);
551
+ }
552
+ catch (err) {
553
+ console.log(chalk.yellow(` ⚠ Could not fetch files: ${err.message}`));
554
+ }
555
+ // Install dependencies
556
+ const hasNodeModules = fs.existsSync(path.join(projectDir, 'node_modules'));
557
+ if (!hasNodeModules && fs.existsSync(path.join(projectDir, 'package.json'))) {
558
+ console.log(chalk.gray(` Installing dependencies...`));
559
+ const installer = new PackageInstaller({ projectDir });
560
+ const result = await installer.install([], false);
561
+ if (result.success)
562
+ console.log(chalk.green(` ✓ Dependencies installed`));
563
+ }
564
+ // Start local server
565
+ const localServer = new LocalServer({
566
+ projectDir, projectId,
567
+ apiPort: allocated.apiPort, devPort: allocated.devPort,
568
+ });
569
+ await localServer.start();
570
+ console.log(chalk.green(` ✓ API server on port ${allocated.apiPort}`));
571
+ // Start dev server
572
+ if (!opts.noDev) {
573
+ try {
574
+ await localServer.getDevServer().start();
575
+ console.log(chalk.green(` ✓ Dev server on port ${allocated.devPort}`));
576
+ }
577
+ catch (err) {
578
+ console.log(chalk.yellow(` ⚠ Dev server: ${err.message}`));
579
+ }
580
+ }
581
+ // Connect project tunnel
582
+ const tunnel = new TunnelClient({
583
+ gatewayUrl: opts.gatewayUrl,
584
+ token: opts.token,
585
+ projectId,
586
+ apiPort: allocated.apiPort,
587
+ devPort: allocated.devPort,
588
+ });
589
+ try {
590
+ await tunnel.connect();
591
+ console.log(chalk.green(` ✓ Tunnel connected for project ${projectId}`));
592
+ }
593
+ catch (err) {
594
+ console.log(chalk.yellow(` ⚠ Tunnel: ${err.message}`));
595
+ tunnel.startBackgroundReconnect();
596
+ }
597
+ // Register project with backend
598
+ try {
599
+ await fetch(`${opts.backendUrl}/api/agent/register`, {
600
+ method: 'POST',
601
+ headers: { 'Authorization': `Bearer ${opts.token}`, 'Content-Type': 'application/json' },
602
+ body: JSON.stringify({
603
+ deviceId: opts.deviceId, name: os.hostname(), hostname: os.hostname(),
604
+ platform: `${os.platform()} ${os.arch()}`, agentVersion: VERSION,
605
+ projectId, capabilities: ['file-sync', 'type-check', 'install', 'terminal', 'dev-server'],
606
+ }),
607
+ });
608
+ }
609
+ catch { }
610
+ console.log(chalk.green(` ✓ Project ${projectId} is live!\n`));
611
+ return { localServer, tunnel };
612
+ }
613
+ catch (err) {
614
+ console.error(chalk.red(` ✗ Failed to start project ${projectId}: ${err.message}`));
615
+ return null;
616
+ }
617
+ }
427
618
  //# sourceMappingURL=start.js.map
@@ -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.0',
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.0',
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.0",
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": {