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 +7 -6
- package/dist/cli.js +25 -13
- package/dist/commands/login.d.ts +6 -1
- package/dist/commands/login.js +19 -6
- package/dist/commands/service.d.ts +14 -7
- package/dist/commands/service.js +140 -86
- package/dist/commands/start.js +199 -8
- package/dist/localServer.d.ts +31 -0
- package/dist/localServer.js +86 -11
- package/dist/tunnel.d.ts +23 -0
- package/dist/tunnel.js +129 -1
- package/package.json +1 -1
package/dist/cli.d.ts
CHANGED
|
@@ -3,12 +3,13 @@
|
|
|
3
3
|
* nstantpage-agent CLI
|
|
4
4
|
*
|
|
5
5
|
* Usage:
|
|
6
|
-
* nstantpage login
|
|
7
|
-
* nstantpage start [dir]
|
|
8
|
-
* nstantpage stop
|
|
9
|
-
* nstantpage status
|
|
10
|
-
* nstantpage service
|
|
11
|
-
* nstantpage 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
|
|
7
|
-
* nstantpage start [dir]
|
|
8
|
-
* nstantpage stop
|
|
9
|
-
* nstantpage status
|
|
10
|
-
* nstantpage service
|
|
11
|
-
* nstantpage 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 {
|
|
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.
|
|
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('
|
|
75
|
-
|
|
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
|
-
.
|
|
78
|
-
|
|
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
|
package/dist/commands/login.d.ts
CHANGED
|
@@ -1,4 +1,9 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Login command — authenticate with nstantpage.com
|
|
3
3
|
*/
|
|
4
|
-
|
|
4
|
+
interface LoginOptions {
|
|
5
|
+
gateway?: string;
|
|
6
|
+
force?: boolean;
|
|
7
|
+
}
|
|
8
|
+
export declare function loginCommand(options?: LoginOptions): Promise<void>;
|
|
9
|
+
export {};
|
package/dist/commands/login.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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 =
|
|
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
|
|
10
|
+
* Windows: Uses Task Scheduler (future)
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
12
|
-
projectId?: string;
|
|
18
|
+
interface ServiceInstallOptions {
|
|
13
19
|
gateway?: string;
|
|
14
|
-
uninstall?: boolean;
|
|
15
20
|
}
|
|
16
|
-
export declare function
|
|
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 {};
|
package/dist/commands/service.js
CHANGED
|
@@ -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
|
|
10
|
+
* Windows: Uses Task Scheduler (future)
|
|
7
11
|
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
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
|
|
20
|
-
|
|
21
|
-
|
|
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
|
-
|
|
52
|
-
|
|
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:
|
|
137
|
-
console.log(chalk.gray(`
|
|
138
|
-
console.log(chalk.gray(`
|
|
139
|
-
console.log(chalk.gray(`
|
|
140
|
-
console.log(chalk.
|
|
141
|
-
console.log(chalk.blue('
|
|
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
|
-
|
|
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 --
|
|
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:
|
|
176
|
-
console.log(chalk.gray(`
|
|
177
|
-
console.log(chalk.gray(`
|
|
178
|
-
console.log(chalk.gray(`
|
|
179
|
-
console.log(chalk.
|
|
180
|
-
console.log(chalk.blue('
|
|
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
|
|
183
|
-
const
|
|
184
|
-
if (
|
|
185
|
-
|
|
186
|
-
|
|
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
|
-
|
|
196
|
-
|
|
197
|
-
|
|
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
|
-
|
|
212
|
-
|
|
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
|
package/dist/commands/start.js
CHANGED
|
@@ -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.
|
|
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
|
-
|
|
182
|
-
|
|
183
|
-
|
|
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
|
package/dist/localServer.d.ts
CHANGED
|
@@ -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 {};
|
package/dist/localServer.js
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
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.
|
|
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.
|
|
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