@hera-al/standardnode 1.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +241 -0
- package/config.stdnode.example.yaml +44 -0
- package/dist/commands/browser-proxy.d.ts +22 -0
- package/dist/commands/browser-proxy.js +164 -0
- package/dist/commands/shell.d.ts +25 -0
- package/dist/commands/shell.js +62 -0
- package/dist/config.d.ts +41 -0
- package/dist/config.js +141 -0
- package/dist/gateway-link.d.ts +30 -0
- package/dist/gateway-link.js +315 -0
- package/dist/index.d.ts +3 -0
- package/dist/index.js +170 -0
- package/dist/logger.d.ts +12 -0
- package/dist/logger.js +64 -0
- package/package.json +64 -0
package/dist/config.js
ADDED
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, existsSync } from "node:fs";
|
|
2
|
+
import { resolve, join, dirname } from "node:path";
|
|
3
|
+
import { hostname as osHostname } from "node:os";
|
|
4
|
+
import { randomUUID, randomBytes } from "node:crypto";
|
|
5
|
+
import { parse as yamlParse, stringify as yamlStringify } from "yaml";
|
|
6
|
+
import { z } from "zod";
|
|
7
|
+
const ConfigSchema = z.object({
|
|
8
|
+
node: z.object({
|
|
9
|
+
id: z.string().default(""),
|
|
10
|
+
displayName: z.string().default(""),
|
|
11
|
+
}),
|
|
12
|
+
gateway: z.object({
|
|
13
|
+
enabled: z.boolean().default(true),
|
|
14
|
+
url: z.string().default("ws://localhost:3001/nostromo/ws/nodes"),
|
|
15
|
+
token: z.string().default(""),
|
|
16
|
+
signature: z.string().default(""),
|
|
17
|
+
reconnectMs: z.number().default(5000),
|
|
18
|
+
}),
|
|
19
|
+
logs: z.object({
|
|
20
|
+
dir: z.string().default("./logs-stdnode"),
|
|
21
|
+
}).default({ dir: "./logs-stdnode" }),
|
|
22
|
+
commands: z.object({
|
|
23
|
+
shell: z.object({
|
|
24
|
+
enabled: z.boolean().default(true),
|
|
25
|
+
allowlist: z.array(z.string()).default([]),
|
|
26
|
+
timeout: z.number().default(30000),
|
|
27
|
+
}),
|
|
28
|
+
}),
|
|
29
|
+
browser: z.object({
|
|
30
|
+
enabled: z.boolean().default(false),
|
|
31
|
+
controlPort: z.number().default(3002),
|
|
32
|
+
headless: z.boolean().default(false),
|
|
33
|
+
noSandbox: z.boolean().default(false),
|
|
34
|
+
attachOnly: z.boolean().default(false),
|
|
35
|
+
executablePath: z.string().optional(),
|
|
36
|
+
allowProfiles: z.array(z.string()).default([]),
|
|
37
|
+
}).default({
|
|
38
|
+
enabled: false,
|
|
39
|
+
controlPort: 3002,
|
|
40
|
+
headless: false,
|
|
41
|
+
noSandbox: false,
|
|
42
|
+
attachOnly: false,
|
|
43
|
+
allowProfiles: [],
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
const DEFAULT_CONFIG_NAME = "config.stdnode.yaml";
|
|
47
|
+
const DEFAULT_GATEWAY_URL = "ws://localhost:3001/nostromo/ws/nodes";
|
|
48
|
+
/**
|
|
49
|
+
* Try to read the Hera server config.yaml from the parent directory
|
|
50
|
+
* and extract Nostromo port + basePath to build the gateway URL.
|
|
51
|
+
* Returns the auto-detected URL or the default.
|
|
52
|
+
*/
|
|
53
|
+
function detectGatewayUrl(configDir) {
|
|
54
|
+
const serverConfigPath = join(dirname(configDir), "config.yaml");
|
|
55
|
+
try {
|
|
56
|
+
if (!existsSync(serverConfigPath))
|
|
57
|
+
return DEFAULT_GATEWAY_URL;
|
|
58
|
+
const raw = readFileSync(serverConfigPath, "utf-8");
|
|
59
|
+
const parsed = yamlParse(raw);
|
|
60
|
+
const port = parsed?.nostromo?.port ?? 3001;
|
|
61
|
+
const basePath = parsed?.nostromo?.basePath ?? "/nostromo";
|
|
62
|
+
const host = parsed?.host ?? "localhost";
|
|
63
|
+
return `ws://${host}:${port}${basePath}/ws/nodes`;
|
|
64
|
+
}
|
|
65
|
+
catch {
|
|
66
|
+
return DEFAULT_GATEWAY_URL;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
export function loadConfig(opts) {
|
|
70
|
+
const path = resolve(opts?.configPath ?? DEFAULT_CONFIG_NAME);
|
|
71
|
+
// If config doesn't exist, create a minimal one
|
|
72
|
+
if (!existsSync(path)) {
|
|
73
|
+
writeFileSync(path, yamlStringify({
|
|
74
|
+
node: { id: "", displayName: "" },
|
|
75
|
+
gateway: { enabled: true, url: "ws://localhost:3001/nostromo/ws/nodes" },
|
|
76
|
+
logs: { dir: "./logs-stdnode" },
|
|
77
|
+
commands: { shell: { enabled: true, allowlist: [], timeout: 30000 } },
|
|
78
|
+
browser: { enabled: false, controlPort: 3002 },
|
|
79
|
+
}), "utf-8");
|
|
80
|
+
}
|
|
81
|
+
const raw = readFileSync(path, "utf-8");
|
|
82
|
+
const parsed = yamlParse(raw);
|
|
83
|
+
const config = ConfigSchema.parse(parsed);
|
|
84
|
+
// Auto-generate node ID and signature if empty
|
|
85
|
+
let needsWrite = false;
|
|
86
|
+
const updated = yamlParse(raw) ?? {};
|
|
87
|
+
if (!config.node.id) {
|
|
88
|
+
config.node.id = randomUUID();
|
|
89
|
+
if (!updated.node)
|
|
90
|
+
updated.node = {};
|
|
91
|
+
updated.node.id = config.node.id;
|
|
92
|
+
needsWrite = true;
|
|
93
|
+
}
|
|
94
|
+
if (!config.gateway.signature) {
|
|
95
|
+
config.gateway.signature = randomBytes(64).toString("hex");
|
|
96
|
+
if (!updated.gateway)
|
|
97
|
+
updated.gateway = {};
|
|
98
|
+
updated.gateway.signature = config.gateway.signature;
|
|
99
|
+
needsWrite = true;
|
|
100
|
+
}
|
|
101
|
+
// Default displayName to hostname
|
|
102
|
+
if (!config.node.displayName) {
|
|
103
|
+
config.node.displayName = osHostname();
|
|
104
|
+
if (!updated.node)
|
|
105
|
+
updated.node = {};
|
|
106
|
+
updated.node.displayName = config.node.displayName;
|
|
107
|
+
needsWrite = true;
|
|
108
|
+
}
|
|
109
|
+
// Auto-detect gateway URL from ../config.yaml if still default
|
|
110
|
+
if (!opts?.ws && config.gateway.url === DEFAULT_GATEWAY_URL) {
|
|
111
|
+
const detected = detectGatewayUrl(resolve(path, ".."));
|
|
112
|
+
if (detected !== DEFAULT_GATEWAY_URL) {
|
|
113
|
+
config.gateway.url = detected;
|
|
114
|
+
if (!updated.gateway)
|
|
115
|
+
updated.gateway = {};
|
|
116
|
+
updated.gateway.url = detected;
|
|
117
|
+
needsWrite = true;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// CLI overrides: --ws (takes priority over auto-detect)
|
|
121
|
+
if (opts?.ws) {
|
|
122
|
+
config.gateway.url = opts.ws;
|
|
123
|
+
if (!updated.gateway)
|
|
124
|
+
updated.gateway = {};
|
|
125
|
+
updated.gateway.url = opts.ws;
|
|
126
|
+
needsWrite = true;
|
|
127
|
+
}
|
|
128
|
+
// CLI overrides: --name
|
|
129
|
+
if (opts?.name) {
|
|
130
|
+
config.node.displayName = opts.name;
|
|
131
|
+
if (!updated.node)
|
|
132
|
+
updated.node = {};
|
|
133
|
+
updated.node.displayName = opts.name;
|
|
134
|
+
needsWrite = true;
|
|
135
|
+
}
|
|
136
|
+
if (needsWrite) {
|
|
137
|
+
writeFileSync(path, yamlStringify(updated), "utf-8");
|
|
138
|
+
}
|
|
139
|
+
return config;
|
|
140
|
+
}
|
|
141
|
+
//# sourceMappingURL=config.js.map
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { NodeConfig } from "./config.js";
|
|
2
|
+
import type { Logger } from "./logger.js";
|
|
3
|
+
export declare class GatewayLink {
|
|
4
|
+
private config;
|
|
5
|
+
private log;
|
|
6
|
+
private ws;
|
|
7
|
+
private heartbeatTimer;
|
|
8
|
+
private reconnectTimer;
|
|
9
|
+
private stopped;
|
|
10
|
+
private paired;
|
|
11
|
+
private shell;
|
|
12
|
+
private browserProcess;
|
|
13
|
+
private browserProxy;
|
|
14
|
+
private browserReady;
|
|
15
|
+
constructor(config: NodeConfig, log: Logger);
|
|
16
|
+
start(): void;
|
|
17
|
+
stop(): void;
|
|
18
|
+
private startBrowserServer;
|
|
19
|
+
private stopBrowserServer;
|
|
20
|
+
private connect;
|
|
21
|
+
private sendHello;
|
|
22
|
+
private startHeartbeat;
|
|
23
|
+
private handleMessage;
|
|
24
|
+
private handlePairingStatus;
|
|
25
|
+
private handleCommand;
|
|
26
|
+
private send;
|
|
27
|
+
private scheduleReconnect;
|
|
28
|
+
private clearTimers;
|
|
29
|
+
}
|
|
30
|
+
//# sourceMappingURL=gateway-link.d.ts.map
|
|
@@ -0,0 +1,315 @@
|
|
|
1
|
+
import WebSocket from "ws";
|
|
2
|
+
import { spawn } from "node:child_process";
|
|
3
|
+
import { existsSync } from "node:fs";
|
|
4
|
+
import { resolve, dirname } from "node:path";
|
|
5
|
+
import { fileURLToPath } from "node:url";
|
|
6
|
+
import { platform, hostname, arch } from "node:os";
|
|
7
|
+
import { createShellCommands } from "./commands/shell.js";
|
|
8
|
+
import { createBrowserProxy } from "./commands/browser-proxy.js";
|
|
9
|
+
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
10
|
+
const TAG = "Gateway";
|
|
11
|
+
const RESET = "\x1b[0m";
|
|
12
|
+
const BOLD = "\x1b[1m";
|
|
13
|
+
const DIM = "\x1b[2m";
|
|
14
|
+
const fg = (n) => `\x1b[38;5;${n}m`;
|
|
15
|
+
const GREEN = fg(82);
|
|
16
|
+
const RED = fg(196);
|
|
17
|
+
const YELLOW = fg(220);
|
|
18
|
+
export class GatewayLink {
|
|
19
|
+
config;
|
|
20
|
+
log;
|
|
21
|
+
ws = null;
|
|
22
|
+
heartbeatTimer = null;
|
|
23
|
+
reconnectTimer = null;
|
|
24
|
+
stopped = false;
|
|
25
|
+
paired = false;
|
|
26
|
+
shell;
|
|
27
|
+
browserProcess = null;
|
|
28
|
+
browserProxy = null;
|
|
29
|
+
browserReady = false;
|
|
30
|
+
constructor(config, log) {
|
|
31
|
+
this.config = config;
|
|
32
|
+
this.log = log;
|
|
33
|
+
this.shell = createShellCommands(config, log);
|
|
34
|
+
}
|
|
35
|
+
start() {
|
|
36
|
+
if (this.config.browser.enabled) {
|
|
37
|
+
this.startBrowserServer();
|
|
38
|
+
}
|
|
39
|
+
if (!this.config.gateway.enabled)
|
|
40
|
+
return;
|
|
41
|
+
this.stopped = false;
|
|
42
|
+
this.connect();
|
|
43
|
+
}
|
|
44
|
+
stop() {
|
|
45
|
+
this.stopped = true;
|
|
46
|
+
this.clearTimers();
|
|
47
|
+
this.stopBrowserServer();
|
|
48
|
+
if (this.ws) {
|
|
49
|
+
this.ws.close();
|
|
50
|
+
this.ws = null;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
// ---------- Browser server subprocess ----------
|
|
54
|
+
startBrowserServer() {
|
|
55
|
+
const cfg = this.config.browser;
|
|
56
|
+
// Try to resolve @hera-al/browser-server package
|
|
57
|
+
let scriptPath = null;
|
|
58
|
+
try {
|
|
59
|
+
// import.meta.resolve returns a file:// URL pointing to the main entry
|
|
60
|
+
// (dist/server/browser-server.js) — standalone is in the same directory
|
|
61
|
+
const resolved = import.meta.resolve("@hera-al/browser-server");
|
|
62
|
+
const entryDir = dirname(fileURLToPath(resolved));
|
|
63
|
+
const ext = resolved.endsWith(".ts") ? ".ts" : ".js";
|
|
64
|
+
// The main export points to dist/server/browser-server.js,
|
|
65
|
+
// so standalone.js is in the same directory (not server/ below it).
|
|
66
|
+
scriptPath = resolve(entryDir, `standalone${ext}`);
|
|
67
|
+
}
|
|
68
|
+
catch {
|
|
69
|
+
// Fallback: monorepo / dev layout
|
|
70
|
+
const isTs = import.meta.url.endsWith(".ts");
|
|
71
|
+
const ext = isTs ? ".ts" : ".js";
|
|
72
|
+
const srcOrDist = isTs ? "src" : "dist";
|
|
73
|
+
const fallback = resolve(__dirname, `../../browser-server/${srcOrDist}/server/standalone${ext}`);
|
|
74
|
+
if (existsSync(fallback))
|
|
75
|
+
scriptPath = fallback;
|
|
76
|
+
}
|
|
77
|
+
if (!scriptPath || !existsSync(scriptPath)) {
|
|
78
|
+
this.log.warn(TAG, "Browser capability requires @hera-al/browser-server. Install it with: npm install @hera-al/browser-server");
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const args = [
|
|
82
|
+
...process.execArgv,
|
|
83
|
+
scriptPath,
|
|
84
|
+
"--port", String(cfg.controlPort),
|
|
85
|
+
];
|
|
86
|
+
if (cfg.headless)
|
|
87
|
+
args.push("--headless", "true");
|
|
88
|
+
if (cfg.noSandbox)
|
|
89
|
+
args.push("--noSandbox", "true");
|
|
90
|
+
if (cfg.attachOnly)
|
|
91
|
+
args.push("--attachOnly", "true");
|
|
92
|
+
if (cfg.executablePath)
|
|
93
|
+
args.push("--executablePath", cfg.executablePath);
|
|
94
|
+
this.log.info(TAG, `Starting browser server on port ${cfg.controlPort}...`);
|
|
95
|
+
const child = spawn(process.execPath, args, {
|
|
96
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
97
|
+
env: process.env,
|
|
98
|
+
});
|
|
99
|
+
child.stdout?.on("data", (data) => {
|
|
100
|
+
const line = data.toString().trim();
|
|
101
|
+
if (line)
|
|
102
|
+
this.log.info("Browser", line);
|
|
103
|
+
if (line.includes("listening")) {
|
|
104
|
+
this.browserReady = true;
|
|
105
|
+
this.log.info(TAG, "Browser server is ready");
|
|
106
|
+
console.log(` ${GREEN}${BOLD}🌐 Browser server${RESET} listening on 127.0.0.1:${cfg.controlPort}`);
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
child.stderr?.on("data", (data) => {
|
|
110
|
+
const line = data.toString().trim();
|
|
111
|
+
if (line)
|
|
112
|
+
this.log.warn("Browser", line);
|
|
113
|
+
});
|
|
114
|
+
child.on("exit", (code) => {
|
|
115
|
+
this.log.info(TAG, `Browser server exited with code ${code}`);
|
|
116
|
+
this.browserProcess = null;
|
|
117
|
+
this.browserReady = false;
|
|
118
|
+
});
|
|
119
|
+
// Safety net: kill the child if this process exits for any reason
|
|
120
|
+
// (crash, uncaught exception, etc.). The 'exit' event is synchronous
|
|
121
|
+
// and fires even on unhandled errors — only SIGKILL bypasses it.
|
|
122
|
+
const exitHandler = () => {
|
|
123
|
+
try {
|
|
124
|
+
child.kill("SIGTERM");
|
|
125
|
+
}
|
|
126
|
+
catch { /* already dead */ }
|
|
127
|
+
};
|
|
128
|
+
process.on("exit", exitHandler);
|
|
129
|
+
child.on("exit", () => {
|
|
130
|
+
process.removeListener("exit", exitHandler);
|
|
131
|
+
});
|
|
132
|
+
this.browserProcess = child;
|
|
133
|
+
this.browserProxy = createBrowserProxy(cfg.controlPort, cfg.allowProfiles, this.log);
|
|
134
|
+
}
|
|
135
|
+
stopBrowserServer() {
|
|
136
|
+
if (this.browserProcess) {
|
|
137
|
+
this.browserProcess.kill("SIGTERM");
|
|
138
|
+
this.browserProcess = null;
|
|
139
|
+
this.browserReady = false;
|
|
140
|
+
this.browserProxy = null;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
connect() {
|
|
144
|
+
if (this.stopped)
|
|
145
|
+
return;
|
|
146
|
+
const url = this.config.gateway.url;
|
|
147
|
+
this.log.info(TAG, `Connecting to ${url}...`);
|
|
148
|
+
this.ws = new WebSocket(url);
|
|
149
|
+
this.ws.on("open", () => {
|
|
150
|
+
this.log.info(TAG, "Connected to gateway");
|
|
151
|
+
this.sendHello();
|
|
152
|
+
this.startHeartbeat();
|
|
153
|
+
});
|
|
154
|
+
this.ws.on("message", (data) => {
|
|
155
|
+
try {
|
|
156
|
+
const msg = JSON.parse(data.toString());
|
|
157
|
+
this.handleMessage(msg);
|
|
158
|
+
}
|
|
159
|
+
catch {
|
|
160
|
+
// Ignore malformed messages
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
this.ws.on("close", () => {
|
|
164
|
+
this.log.warn(TAG, "Disconnected from gateway");
|
|
165
|
+
this.paired = false;
|
|
166
|
+
this.clearTimers();
|
|
167
|
+
this.scheduleReconnect();
|
|
168
|
+
});
|
|
169
|
+
this.ws.on("error", (err) => {
|
|
170
|
+
this.log.error(TAG, `WebSocket error: ${err.message}`);
|
|
171
|
+
});
|
|
172
|
+
}
|
|
173
|
+
sendHello() {
|
|
174
|
+
const capabilities = [];
|
|
175
|
+
const commands = [];
|
|
176
|
+
if (this.config.commands.shell.enabled) {
|
|
177
|
+
capabilities.push("shell");
|
|
178
|
+
commands.push("shell.run", "shell.which");
|
|
179
|
+
}
|
|
180
|
+
if (this.config.browser.enabled) {
|
|
181
|
+
capabilities.push("browser");
|
|
182
|
+
commands.push("browser.proxy");
|
|
183
|
+
}
|
|
184
|
+
this.send({
|
|
185
|
+
type: "hello",
|
|
186
|
+
nodeId: this.config.node.id,
|
|
187
|
+
displayName: this.config.node.displayName,
|
|
188
|
+
platform: platform(),
|
|
189
|
+
arch: arch(),
|
|
190
|
+
hostname: hostname(),
|
|
191
|
+
signature: this.config.gateway.signature,
|
|
192
|
+
capabilities,
|
|
193
|
+
commands,
|
|
194
|
+
});
|
|
195
|
+
}
|
|
196
|
+
startHeartbeat() {
|
|
197
|
+
this.heartbeatTimer = setInterval(() => {
|
|
198
|
+
this.send({ type: "ping" });
|
|
199
|
+
}, 30000);
|
|
200
|
+
}
|
|
201
|
+
handleMessage(msg) {
|
|
202
|
+
switch (msg.type) {
|
|
203
|
+
case "pong":
|
|
204
|
+
break;
|
|
205
|
+
case "pairing_status":
|
|
206
|
+
this.handlePairingStatus(msg.status);
|
|
207
|
+
break;
|
|
208
|
+
case "command":
|
|
209
|
+
this.handleCommand(msg);
|
|
210
|
+
break;
|
|
211
|
+
default:
|
|
212
|
+
this.log.debug(TAG, `Received: ${msg.type}`);
|
|
213
|
+
break;
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
handlePairingStatus(status) {
|
|
217
|
+
switch (status) {
|
|
218
|
+
case "pending":
|
|
219
|
+
this.log.info(TAG, "Pairing pending — waiting for admin approval");
|
|
220
|
+
console.log(` ${YELLOW}${BOLD}⏳ Pairing pending${RESET} ${DIM}— waiting for admin approval on Nostromo${RESET}`);
|
|
221
|
+
this.paired = false;
|
|
222
|
+
break;
|
|
223
|
+
case "approved":
|
|
224
|
+
this.log.info(TAG, "Pairing approved — node is active");
|
|
225
|
+
console.log(` ${GREEN}${BOLD}✔ Paired!${RESET} Node is active and ready to receive commands.`);
|
|
226
|
+
this.paired = true;
|
|
227
|
+
break;
|
|
228
|
+
case "revoked":
|
|
229
|
+
this.log.warn(TAG, "Pairing revoked — disconnecting");
|
|
230
|
+
console.log(` ${RED}${BOLD}✖ Pairing revoked.${RESET} The node has been disconnected by the admin.`);
|
|
231
|
+
this.paired = false;
|
|
232
|
+
this.stop();
|
|
233
|
+
break;
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
async handleCommand(msg) {
|
|
237
|
+
const id = msg.id;
|
|
238
|
+
const command = msg.command;
|
|
239
|
+
const params = msg.params;
|
|
240
|
+
if (!id || !command) {
|
|
241
|
+
return;
|
|
242
|
+
}
|
|
243
|
+
if (!this.paired) {
|
|
244
|
+
this.send({
|
|
245
|
+
type: "command_result",
|
|
246
|
+
id,
|
|
247
|
+
ok: false,
|
|
248
|
+
error: "Node is not paired — awaiting approval",
|
|
249
|
+
});
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
this.log.info(TAG, `Command: ${command} (${id})`);
|
|
253
|
+
try {
|
|
254
|
+
let result;
|
|
255
|
+
switch (command) {
|
|
256
|
+
case "shell.run":
|
|
257
|
+
result = await this.shell.shellRun(params);
|
|
258
|
+
break;
|
|
259
|
+
case "shell.which":
|
|
260
|
+
result = await this.shell.shellWhich(params);
|
|
261
|
+
break;
|
|
262
|
+
case "browser.proxy":
|
|
263
|
+
if (!this.browserProxy) {
|
|
264
|
+
this.send({ type: "command_result", id, ok: false, error: "Browser is not enabled" });
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
if (!this.browserReady) {
|
|
268
|
+
this.send({ type: "command_result", id, ok: false, error: "Browser server not ready" });
|
|
269
|
+
return;
|
|
270
|
+
}
|
|
271
|
+
result = await this.browserProxy.handleProxy(params);
|
|
272
|
+
break;
|
|
273
|
+
default:
|
|
274
|
+
this.log.warn(TAG, `Unknown command: ${command}`);
|
|
275
|
+
this.send({
|
|
276
|
+
type: "command_result",
|
|
277
|
+
id,
|
|
278
|
+
ok: false,
|
|
279
|
+
error: `Unknown command: ${command}`,
|
|
280
|
+
});
|
|
281
|
+
return;
|
|
282
|
+
}
|
|
283
|
+
this.log.info(TAG, `Command ${id} completed`);
|
|
284
|
+
this.send({ type: "command_result", id, ok: true, result });
|
|
285
|
+
}
|
|
286
|
+
catch (err) {
|
|
287
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
288
|
+
this.log.error(TAG, `Command ${id} failed: ${message}`);
|
|
289
|
+
this.send({ type: "command_result", id, ok: false, error: message });
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
send(data) {
|
|
293
|
+
if (this.ws?.readyState === WebSocket.OPEN) {
|
|
294
|
+
this.ws.send(JSON.stringify(data));
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
scheduleReconnect() {
|
|
298
|
+
if (this.stopped)
|
|
299
|
+
return;
|
|
300
|
+
const delay = this.config.gateway.reconnectMs;
|
|
301
|
+
this.log.info(TAG, `Reconnecting in ${delay}ms...`);
|
|
302
|
+
this.reconnectTimer = setTimeout(() => this.connect(), delay);
|
|
303
|
+
}
|
|
304
|
+
clearTimers() {
|
|
305
|
+
if (this.heartbeatTimer) {
|
|
306
|
+
clearInterval(this.heartbeatTimer);
|
|
307
|
+
this.heartbeatTimer = null;
|
|
308
|
+
}
|
|
309
|
+
if (this.reconnectTimer) {
|
|
310
|
+
clearTimeout(this.reconnectTimer);
|
|
311
|
+
this.reconnectTimer = null;
|
|
312
|
+
}
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
//# sourceMappingURL=gateway-link.js.map
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
import { copyFileSync, existsSync } from "node:fs";
|
|
3
|
+
import { resolve, dirname, join } from "node:path";
|
|
4
|
+
import { fileURLToPath } from "node:url";
|
|
5
|
+
import { loadConfig } from "./config.js";
|
|
6
|
+
import { GatewayLink } from "./gateway-link.js";
|
|
7
|
+
import { Logger } from "./logger.js";
|
|
8
|
+
// ─── ANSI helpers ──────────────────────────────────────────────
|
|
9
|
+
const RESET = "\x1b[0m";
|
|
10
|
+
const BOLD = "\x1b[1m";
|
|
11
|
+
const DIM = "\x1b[2m";
|
|
12
|
+
const fg = (n) => `\x1b[38;5;${n}m`;
|
|
13
|
+
// ─── Palette (same gem tones as installer) ─────────────────────
|
|
14
|
+
const C = {
|
|
15
|
+
pk: 205,
|
|
16
|
+
hp: 199,
|
|
17
|
+
mg: 200,
|
|
18
|
+
dp: 197,
|
|
19
|
+
rd: 196,
|
|
20
|
+
dr: 161,
|
|
21
|
+
};
|
|
22
|
+
// ─── Banner ────────────────────────────────────────────────────
|
|
23
|
+
function printBanner(name, id, ws, browserPort) {
|
|
24
|
+
console.log("");
|
|
25
|
+
console.log(` ${fg(C.hp)}${BOLD} ███████╗████████╗██████╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗${RESET}`);
|
|
26
|
+
console.log(` ${fg(C.hp)}${BOLD} ██╔════╝╚══██╔══╝██╔══██╗ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝${RESET}`);
|
|
27
|
+
console.log(` ${fg(C.mg)}${BOLD} ███████╗ ██║ ██║ ██║ ██╔██╗ ██║██║ ██║██║ ██║█████╗ ${RESET}`);
|
|
28
|
+
console.log(` ${fg(C.dp)}${BOLD} ╚════██║ ██║ ██║ ██║ ██║╚██╗██║██║ ██║██║ ██║██╔══╝ ${RESET}`);
|
|
29
|
+
console.log(` ${fg(C.rd)}${BOLD} ███████║ ██║ ██████╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗${RESET}`);
|
|
30
|
+
console.log(` ${fg(C.dr)}${BOLD} ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝${RESET}`);
|
|
31
|
+
console.log("");
|
|
32
|
+
console.log(` ${fg(C.pk)}${BOLD}Hera StandardNode${RESET}`);
|
|
33
|
+
console.log(` ${DIM}${"─".repeat(50)}${RESET}`);
|
|
34
|
+
console.log(` ${DIM}Name${RESET} ${BOLD}${name}${RESET}`);
|
|
35
|
+
console.log(` ${DIM}ID${RESET} ${DIM}${id}${RESET}`);
|
|
36
|
+
console.log(` ${DIM}Gateway${RESET} ${DIM}${ws}${RESET}`);
|
|
37
|
+
if (browserPort) {
|
|
38
|
+
console.log(` ${DIM}Browser${RESET} ${DIM}127.0.0.1:${browserPort}${RESET}`);
|
|
39
|
+
}
|
|
40
|
+
console.log("");
|
|
41
|
+
}
|
|
42
|
+
// ─── Help ──────────────────────────────────────────────────────
|
|
43
|
+
function printHelp() {
|
|
44
|
+
console.log("");
|
|
45
|
+
console.log(` ${fg(C.pk)}${BOLD}Hera StandardNode${RESET}`);
|
|
46
|
+
console.log("");
|
|
47
|
+
console.log(` ${BOLD}Usage:${RESET} npx tsx src/index.ts [options]`);
|
|
48
|
+
console.log("");
|
|
49
|
+
console.log(` ${BOLD}Options:${RESET}`);
|
|
50
|
+
console.log(` --ws <url> WebSocket URL of the Hera gateway`);
|
|
51
|
+
console.log(` --name <name> Display name for this node (default: hostname)`);
|
|
52
|
+
console.log(` --config <path> Path to config file (default: config.stdnode.yaml)`);
|
|
53
|
+
console.log(` --init Copy example config to current directory and exit`);
|
|
54
|
+
console.log(` --help Show this help message`);
|
|
55
|
+
console.log("");
|
|
56
|
+
console.log(` ${BOLD}WebSocket URL format:${RESET}`);
|
|
57
|
+
console.log(` ws[s]://<hostname>:<port><basePath>/ws/nodes`);
|
|
58
|
+
console.log("");
|
|
59
|
+
console.log(` The ${BOLD}<port>${RESET} is the Nostromo UI port configured on the server`);
|
|
60
|
+
console.log(` (default 3001). The ${BOLD}<basePath>${RESET} is the Nostromo base path`);
|
|
61
|
+
console.log(` (default /nostromo).`);
|
|
62
|
+
console.log("");
|
|
63
|
+
console.log(` ${BOLD}Examples:${RESET}`);
|
|
64
|
+
console.log(` --ws ws://localhost:3001/nostromo/ws/nodes`);
|
|
65
|
+
console.log(` --ws wss://myhost.tail12345.ts.net:3001/nostromo/ws/nodes`);
|
|
66
|
+
console.log("");
|
|
67
|
+
console.log(` ${BOLD}Auto-detect:${RESET}`);
|
|
68
|
+
console.log(` If ../config.yaml exists (Hera server config), the node reads`);
|
|
69
|
+
console.log(` the Nostromo port and basePath from it and auto-configures the`);
|
|
70
|
+
console.log(` gateway URL. The --ws flag overrides auto-detection.`);
|
|
71
|
+
console.log("");
|
|
72
|
+
}
|
|
73
|
+
// ─── CLI arg parsing ───────────────────────────────────────────
|
|
74
|
+
function parseArgs() {
|
|
75
|
+
const args = process.argv.slice(2);
|
|
76
|
+
const opts = {};
|
|
77
|
+
for (let i = 0; i < args.length; i++) {
|
|
78
|
+
const arg = args[i];
|
|
79
|
+
if (arg === "--help" || arg === "-h") {
|
|
80
|
+
opts.help = true;
|
|
81
|
+
}
|
|
82
|
+
else if (arg === "--init") {
|
|
83
|
+
opts.init = true;
|
|
84
|
+
}
|
|
85
|
+
else if (arg === "--config" && args[i + 1]) {
|
|
86
|
+
opts.configPath = args[++i];
|
|
87
|
+
}
|
|
88
|
+
else if (arg === "--ws" && args[i + 1]) {
|
|
89
|
+
opts.ws = args[++i];
|
|
90
|
+
}
|
|
91
|
+
else if (arg === "--name" && args[i + 1]) {
|
|
92
|
+
opts.name = args[++i];
|
|
93
|
+
}
|
|
94
|
+
else if (!arg.startsWith("--") && !opts.configPath) {
|
|
95
|
+
// Legacy: first positional arg is config path
|
|
96
|
+
opts.configPath = arg;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return opts;
|
|
100
|
+
}
|
|
101
|
+
// ─── Init ─────────────────────────────────────────────────────
|
|
102
|
+
function initConfig() {
|
|
103
|
+
const dest = resolve("config.stdnode.yaml");
|
|
104
|
+
if (existsSync(dest)) {
|
|
105
|
+
console.log(`\n ${fg(C.rd)}${BOLD}config.stdnode.yaml already exists${RESET} — not overwriting.\n`);
|
|
106
|
+
return;
|
|
107
|
+
}
|
|
108
|
+
// Resolve the example config bundled with the package
|
|
109
|
+
const thisFile = fileURLToPath(import.meta.url);
|
|
110
|
+
const pkgRoot = dirname(dirname(thisFile)); // up from dist/ or src/
|
|
111
|
+
const example = join(pkgRoot, "config.stdnode.example.yaml");
|
|
112
|
+
if (!existsSync(example)) {
|
|
113
|
+
console.log(`\n ${fg(C.rd)}${BOLD}Example config not found${RESET} — run from a proper install.\n`);
|
|
114
|
+
return;
|
|
115
|
+
}
|
|
116
|
+
copyFileSync(example, dest);
|
|
117
|
+
console.log(`\n ${fg(C.pk)}${BOLD}Created${RESET} config.stdnode.yaml`);
|
|
118
|
+
console.log(` ${DIM}Edit it, then run: hera-stdnode${RESET}\n`);
|
|
119
|
+
}
|
|
120
|
+
// ─── Main ──────────────────────────────────────────────────────
|
|
121
|
+
function main() {
|
|
122
|
+
const opts = parseArgs();
|
|
123
|
+
if (opts.help) {
|
|
124
|
+
printHelp();
|
|
125
|
+
process.exit(0);
|
|
126
|
+
}
|
|
127
|
+
if (opts.init) {
|
|
128
|
+
initConfig();
|
|
129
|
+
process.exit(0);
|
|
130
|
+
}
|
|
131
|
+
const config = loadConfig(opts);
|
|
132
|
+
const log = new Logger(config.logs.dir);
|
|
133
|
+
printBanner(config.node.displayName, config.node.id, config.gateway.url, config.browser.enabled ? config.browser.controlPort : undefined);
|
|
134
|
+
log.info("Node", `Starting — ${config.node.displayName} (${config.node.id})`);
|
|
135
|
+
log.info("Node", `Gateway: ${config.gateway.url}`);
|
|
136
|
+
log.info("Node", `Logs dir: ${config.logs.dir}`);
|
|
137
|
+
// Connect to gateway
|
|
138
|
+
const gateway = new GatewayLink(config, log);
|
|
139
|
+
gateway.start();
|
|
140
|
+
// Graceful shutdown
|
|
141
|
+
const shutdown = () => {
|
|
142
|
+
console.log(`\n ${DIM}Shutting down...${RESET}`);
|
|
143
|
+
log.info("Node", "Shutting down");
|
|
144
|
+
gateway.stop();
|
|
145
|
+
process.exit(0);
|
|
146
|
+
};
|
|
147
|
+
process.on("SIGINT", shutdown);
|
|
148
|
+
process.on("SIGTERM", shutdown);
|
|
149
|
+
// Parent-liveness monitor: when launched from OSXNode, OSXNODE_PARENT=1
|
|
150
|
+
// is set. Poll the parent PID — when it disappears, shut down so
|
|
151
|
+
// browser-server and Chrome are cleaned up. Standalone launches are
|
|
152
|
+
// unaffected. Polling is more reliable than stdin-pipe EOF because
|
|
153
|
+
// posix_spawn may leak the pipe's write-end to the child.
|
|
154
|
+
if (process.env.OSXNODE_PARENT) {
|
|
155
|
+
const parentPid = process.ppid;
|
|
156
|
+
const parentCheck = setInterval(() => {
|
|
157
|
+
try {
|
|
158
|
+
process.kill(parentPid, 0); // signal 0 = existence check
|
|
159
|
+
}
|
|
160
|
+
catch {
|
|
161
|
+
clearInterval(parentCheck);
|
|
162
|
+
log.info("Node", `Parent (pid ${parentPid}) exited, shutting down`);
|
|
163
|
+
shutdown();
|
|
164
|
+
}
|
|
165
|
+
}, 2000);
|
|
166
|
+
parentCheck.unref(); // don't keep the event loop alive just for this
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
main();
|
|
170
|
+
//# sourceMappingURL=index.js.map
|
package/dist/logger.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export declare class Logger {
|
|
2
|
+
private dir;
|
|
3
|
+
private filePath;
|
|
4
|
+
constructor(logsDir: string);
|
|
5
|
+
info(tag: string, msg: string): void;
|
|
6
|
+
warn(tag: string, msg: string): void;
|
|
7
|
+
error(tag: string, msg: string): void;
|
|
8
|
+
debug(tag: string, msg: string): void;
|
|
9
|
+
private write;
|
|
10
|
+
private rotate;
|
|
11
|
+
}
|
|
12
|
+
//# sourceMappingURL=logger.d.ts.map
|