@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/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
@@ -0,0 +1,3 @@
1
+ #!/usr/bin/env node
2
+ export {};
3
+ //# sourceMappingURL=index.d.ts.map
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
@@ -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