@hera-al/standardnode 1.0.5 → 1.0.6

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.
@@ -1,164 +1 @@
1
- import { readFile, stat } from "node:fs/promises";
2
- import { extname } from "node:path";
3
- const TAG = "BrowserProxy";
4
- const MAX_FILE_BYTES = 10 * 1024 * 1024; // 10 MB
5
- // ---------- MIME detection (extension-based) ----------
6
- const MIME_MAP = {
7
- ".png": "image/png",
8
- ".jpg": "image/jpeg",
9
- ".jpeg": "image/jpeg",
10
- ".gif": "image/gif",
11
- ".webp": "image/webp",
12
- ".svg": "image/svg+xml",
13
- ".pdf": "application/pdf",
14
- ".html": "text/html",
15
- ".json": "application/json",
16
- ".txt": "text/plain",
17
- ".zip": "application/zip",
18
- ".mp4": "video/mp4",
19
- ".webm": "video/webm",
20
- };
21
- function detectMime(filePath) {
22
- return MIME_MAP[extname(filePath).toLowerCase()] ?? "application/octet-stream";
23
- }
24
- // ---------- Helpers ----------
25
- /** Extract file paths from a browser response (path, imagePath, download.path) */
26
- function collectFilePaths(payload) {
27
- const paths = new Set();
28
- const obj = typeof payload === "object" && payload !== null
29
- ? payload
30
- : null;
31
- if (!obj)
32
- return [];
33
- if (typeof obj.path === "string" && obj.path.trim()) {
34
- paths.add(obj.path.trim());
35
- }
36
- if (typeof obj.imagePath === "string" && obj.imagePath.trim()) {
37
- paths.add(obj.imagePath.trim());
38
- }
39
- const download = obj.download;
40
- if (download && typeof download === "object") {
41
- const dlPath = download.path;
42
- if (typeof dlPath === "string" && dlPath.trim()) {
43
- paths.add(dlPath.trim());
44
- }
45
- }
46
- return [...paths];
47
- }
48
- /** Read a file from disk, check size, encode as base64 */
49
- async function readProxyFile(filePath) {
50
- const st = await stat(filePath).catch(() => null);
51
- if (!st || !st.isFile())
52
- return null;
53
- if (st.size > MAX_FILE_BYTES) {
54
- throw new Error(`File exceeds ${Math.round(MAX_FILE_BYTES / (1024 * 1024))}MB: ${filePath}`);
55
- }
56
- const buffer = await readFile(filePath);
57
- return {
58
- path: filePath,
59
- base64: buffer.toString("base64"),
60
- mimeType: detectMime(filePath),
61
- };
62
- }
63
- // ---------- Proxy handler ----------
64
- export function createBrowserProxy(controlPort, allowProfiles, log) {
65
- const baseUrl = `http://127.0.0.1:${controlPort}`;
66
- function isProfileAllowed(profile) {
67
- if (allowProfiles.length === 0)
68
- return true;
69
- if (!profile)
70
- return false;
71
- return allowProfiles.includes(profile.trim());
72
- }
73
- async function handleProxy(params) {
74
- const pathValue = typeof params.path === "string" ? params.path.trim() : "";
75
- if (!pathValue) {
76
- throw new Error("INVALID_REQUEST: path required");
77
- }
78
- const method = (typeof params.method === "string" ? params.method.toUpperCase() : "GET");
79
- const path = pathValue.startsWith("/") ? pathValue : `/${pathValue}`;
80
- const requestedProfile = typeof params.profile === "string" ? params.profile.trim() : "";
81
- // Profile allowlist check
82
- if (allowProfiles.length > 0 && path !== "/profiles") {
83
- const profileToCheck = requestedProfile || "default";
84
- if (!isProfileAllowed(profileToCheck)) {
85
- throw new Error("INVALID_REQUEST: browser profile not allowed");
86
- }
87
- }
88
- // Build URL with query params
89
- const url = new URL(path, baseUrl);
90
- if (requestedProfile) {
91
- url.searchParams.set("profile", requestedProfile);
92
- }
93
- const rawQuery = params.query ?? {};
94
- for (const [key, value] of Object.entries(rawQuery)) {
95
- if (value === undefined || value === null)
96
- continue;
97
- url.searchParams.set(key, String(value));
98
- }
99
- log.info(TAG, `proxy: ${method} ${url.pathname}${url.search}`);
100
- // HTTP fetch to local browser server
101
- const fetchOpts = { method };
102
- if (params.body !== undefined && method !== "GET") {
103
- fetchOpts.headers = { "Content-Type": "application/json" };
104
- fetchOpts.body = JSON.stringify(params.body);
105
- }
106
- const timeoutMs = Math.max(1000, Math.min(120_000, params.timeoutMs ?? 30_000));
107
- const controller = new AbortController();
108
- const timer = setTimeout(() => controller.abort(), timeoutMs);
109
- fetchOpts.signal = controller.signal;
110
- let response;
111
- try {
112
- response = await fetch(url.toString(), fetchOpts);
113
- }
114
- catch (err) {
115
- throw new Error(`Browser server unreachable: ${err instanceof Error ? err.message : String(err)}`);
116
- }
117
- finally {
118
- clearTimeout(timer);
119
- }
120
- let result;
121
- const contentType = response.headers.get("content-type") ?? "";
122
- if (contentType.includes("application/json")) {
123
- result = await response.json();
124
- }
125
- else {
126
- result = await response.text();
127
- }
128
- if (!response.ok) {
129
- const message = result && typeof result === "object" && "error" in result
130
- ? String(result.error)
131
- : `HTTP ${response.status}`;
132
- throw new Error(message);
133
- }
134
- // Filter profiles by allowlist if applicable
135
- if (allowProfiles.length > 0 && path === "/profiles") {
136
- const obj = typeof result === "object" && result !== null ? result : {};
137
- const profiles = Array.isArray(obj.profiles) ? obj.profiles : [];
138
- obj.profiles = profiles.filter((entry) => {
139
- if (!entry || typeof entry !== "object")
140
- return false;
141
- const name = entry.name;
142
- return typeof name === "string" && allowProfiles.includes(name);
143
- });
144
- }
145
- // Collect and read files (screenshots, PDFs, downloads)
146
- let files;
147
- const paths = collectFilePaths(result);
148
- if (paths.length > 0) {
149
- const loaded = await Promise.all(paths.map(async (p) => {
150
- const file = await readProxyFile(p);
151
- if (!file) {
152
- throw new Error(`Browser proxy file not found: ${p}`);
153
- }
154
- return file;
155
- }));
156
- if (loaded.length > 0) {
157
- files = loaded;
158
- }
159
- }
160
- return files ? { result, files } : { result };
161
- }
162
- return { handleProxy };
163
- }
164
- //# sourceMappingURL=browser-proxy.js.map
1
+ import{readFile as t,stat as e}from"node:fs/promises";import{extname as r}from"node:path";const o=10485760,n={".png":"image/png",".jpg":"image/jpeg",".jpeg":"image/jpeg",".gif":"image/gif",".webp":"image/webp",".svg":"image/svg+xml",".pdf":"application/pdf",".html":"text/html",".json":"application/json",".txt":"text/plain",".zip":"application/zip",".mp4":"video/mp4",".webm":"video/webm"};function i(t){return n[r(t).toLowerCase()]??"application/octet-stream"}export function createBrowserProxy(r,n,a){const s=`http://127.0.0.1:${r}`;return{handleProxy:async function(r){const p="string"==typeof r.path?r.path.trim():"";if(!p)throw new Error("INVALID_REQUEST: path required");const l="string"==typeof r.method?r.method.toUpperCase():"GET",c=p.startsWith("/")?p:`/${p}`,f="string"==typeof r.profile?r.profile.trim():"";if(n.length>0&&"/profiles"!==c){if(m=f||"default",!(0===n.length||m&&n.includes(m.trim())))throw new Error("INVALID_REQUEST: browser profile not allowed")}var m;const h=new URL(c,s);f&&h.searchParams.set("profile",f);const g=r.query??{};for(const[t,e]of Object.entries(g))null!=e&&h.searchParams.set(t,String(e));a.info("BrowserProxy",`proxy: ${l} ${h.pathname}${h.search}`);const u={method:l};void 0!==r.body&&"GET"!==l&&(u.headers={"Content-Type":"application/json"},u.body=JSON.stringify(r.body));const w=Math.max(1e3,Math.min(12e4,r.timeoutMs??3e4)),d=new AbortController,y=setTimeout(()=>d.abort(),w);let b,j,x;u.signal=d.signal;try{b=await fetch(h.toString(),u)}catch(t){throw new Error(`Browser server unreachable: ${t instanceof Error?t.message:String(t)}`)}finally{clearTimeout(y)}if(j=(b.headers.get("content-type")??"").includes("application/json")?await b.json():await b.text(),!b.ok){const t=j&&"object"==typeof j&&"error"in j?String(j.error):`HTTP ${b.status}`;throw new Error(t)}if(n.length>0&&"/profiles"===c){const t="object"==typeof j&&null!==j?j:{},e=Array.isArray(t.profiles)?t.profiles:[];t.profiles=e.filter(t=>{if(!t||"object"!=typeof t)return!1;const e=t.name;return"string"==typeof e&&n.includes(e)})}const E=function(t){const e=new Set,r="object"==typeof t&&null!==t?t:null;if(!r)return[];"string"==typeof r.path&&r.path.trim()&&e.add(r.path.trim()),"string"==typeof r.imagePath&&r.imagePath.trim()&&e.add(r.imagePath.trim());const o=r.download;if(o&&"object"==typeof o){const t=o.path;"string"==typeof t&&t.trim()&&e.add(t.trim())}return[...e]}(j);if(E.length>0){const r=await Promise.all(E.map(async r=>{const n=await async function(r){const n=await e(r).catch(()=>null);if(!n||!n.isFile())return null;if(n.size>o)throw new Error(`File exceeds ${Math.round(10)}MB: ${r}`);return{path:r,base64:(await t(r)).toString("base64"),mimeType:i(r)}}(r);if(!n)throw new Error(`Browser proxy file not found: ${r}`);return n}));r.length>0&&(x=r)}return x?{result:j,files:x}:{result:j}}}}
@@ -1,62 +1 @@
1
- import { spawn, execFile } from "node:child_process";
2
- const TAG = "Shell";
3
- export function createShellCommands(config, log) {
4
- const shellConfig = config.commands.shell;
5
- function validateCommand(cmd) {
6
- if (!shellConfig.enabled) {
7
- throw new Error("Shell commands are disabled");
8
- }
9
- if (shellConfig.allowlist.length > 0 && !shellConfig.allowlist.includes(cmd)) {
10
- throw new Error(`Command "${cmd}" is not in the allowlist`);
11
- }
12
- }
13
- async function shellRun(params) {
14
- validateCommand(params.cmd);
15
- log.info(TAG, `run: ${params.cmd} ${(params.args ?? []).join(" ")}`);
16
- const timeout = params.timeout ?? shellConfig.timeout;
17
- return new Promise((resolve) => {
18
- const proc = spawn(params.cmd, params.args ?? [], {
19
- cwd: params.cwd,
20
- env: params.env ? { ...process.env, ...params.env } : undefined,
21
- timeout,
22
- shell: false,
23
- });
24
- const stdoutChunks = [];
25
- const stderrChunks = [];
26
- proc.stdout.on("data", (chunk) => stdoutChunks.push(chunk));
27
- proc.stderr.on("data", (chunk) => stderrChunks.push(chunk));
28
- proc.on("close", (code) => {
29
- log.info(TAG, `run: ${params.cmd} exited with code ${code}`);
30
- resolve({
31
- stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
32
- stderr: Buffer.concat(stderrChunks).toString("utf-8"),
33
- exitCode: code,
34
- });
35
- });
36
- proc.on("error", (err) => {
37
- log.error(TAG, `run: ${params.cmd} error: ${err.message}`);
38
- resolve({
39
- stdout: Buffer.concat(stdoutChunks).toString("utf-8"),
40
- stderr: err.message,
41
- exitCode: 1,
42
- });
43
- });
44
- });
45
- }
46
- async function shellWhich(params) {
47
- validateCommand(params.cmd);
48
- log.debug(TAG, `which: ${params.cmd}`);
49
- return new Promise((resolve) => {
50
- execFile("which", [params.cmd], (err, stdout) => {
51
- if (err) {
52
- resolve({ path: null });
53
- }
54
- else {
55
- resolve({ path: stdout.trim() });
56
- }
57
- });
58
- });
59
- }
60
- return { shellRun, shellWhich };
61
- }
62
- //# sourceMappingURL=shell.js.map
1
+ import{spawn as e,execFile as t}from"node:child_process";const o="Shell";export function createShellCommands(n,r){const s=n.commands.shell;function c(e){if(!s.enabled)throw new Error("Shell commands are disabled");if(s.allowlist.length>0&&!s.allowlist.includes(e))throw new Error(`Command "${e}" is not in the allowlist`)}return{shellRun:async function(t){c(t.cmd),r.info(o,`run: ${t.cmd} ${(t.args??[]).join(" ")}`);const n=t.timeout??s.timeout;return new Promise(s=>{const c=e(t.cmd,t.args??[],{cwd:t.cwd,env:t.env?{...process.env,...t.env}:void 0,timeout:n,shell:!1}),i=[],d=[];c.stdout.on("data",e=>i.push(e)),c.stderr.on("data",e=>d.push(e)),c.on("close",e=>{r.info(o,`run: ${t.cmd} exited with code ${e}`),s({stdout:Buffer.concat(i).toString("utf-8"),stderr:Buffer.concat(d).toString("utf-8"),exitCode:e})}),c.on("error",e=>{r.error(o,`run: ${t.cmd} error: ${e.message}`),s({stdout:Buffer.concat(i).toString("utf-8"),stderr:e.message,exitCode:1})})})},shellWhich:async function(e){return c(e.cmd),r.debug(o,`which: ${e.cmd}`),new Promise(o=>{t("which",[e.cmd],(e,t)=>{o(e?{path:null}:{path:t.trim()})})})}}}
package/dist/config.js CHANGED
@@ -1,141 +1 @@
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
1
+ import{readFileSync as e,writeFileSync as o,existsSync as a}from"node:fs";import{resolve as t,join as n,dirname as l}from"node:path";import{hostname as s}from"node:os";import{randomUUID as d,randomBytes as r}from"node:crypto";import{parse as i,stringify as u}from"yaml";import{z as g}from"zod";const m=g.object({node:g.object({id:g.string().default(""),displayName:g.string().default("")}),gateway:g.object({enabled:g.boolean().default(!0),url:g.string().default("ws://localhost:3001/nostromo/ws/nodes"),token:g.string().default(""),signature:g.string().default(""),reconnectMs:g.number().default(5e3)}),logs:g.object({dir:g.string().default("./logs-stdnode")}).default({dir:"./logs-stdnode"}),commands:g.object({shell:g.object({enabled:g.boolean().default(!0),allowlist:g.array(g.string()).default([]),timeout:g.number().default(3e4)})}),browser:g.object({enabled:g.boolean().default(!1),controlPort:g.number().default(3002),headless:g.boolean().default(!1),noSandbox:g.boolean().default(!1),attachOnly:g.boolean().default(!1),executablePath:g.string().optional(),allowProfiles:g.array(g.string()).default([])}).default({enabled:!1,controlPort:3002,headless:!1,noSandbox:!1,attachOnly:!1,allowProfiles:[]})}),f="ws://localhost:3001/nostromo/ws/nodes";export function loadConfig(g){const c=t(g?.configPath??"config.stdnode.yaml");a(c)||o(c,u({node:{id:"",displayName:""},gateway:{enabled:!0,url:"ws://localhost:3001/nostromo/ws/nodes"},logs:{dir:"./logs-stdnode"},commands:{shell:{enabled:!0,allowlist:[],timeout:3e4}},browser:{enabled:!1,controlPort:3002}}),"utf-8");const w=e(c,"utf-8"),y=i(w),b=m.parse(y);let p=!1;const h=i(w)??{};if(b.node.id||(b.node.id=d(),h.node||(h.node={}),h.node.id=b.node.id,p=!0),b.gateway.signature||(b.gateway.signature=r(64).toString("hex"),h.gateway||(h.gateway={}),h.gateway.signature=b.gateway.signature,p=!0),b.node.displayName||(b.node.displayName=s(),h.node||(h.node={}),h.node.displayName=b.node.displayName,p=!0),!g?.ws&&b.gateway.url===f){const o=function(o){const t=n(l(o),"config.yaml");try{if(!a(t))return f;const o=e(t,"utf-8"),n=i(o),l=n?.nostromo?.port??3001,s=n?.nostromo?.basePath??"/nostromo";return`ws://${n?.host??"localhost"}:${l}${s}/ws/nodes`}catch{return f}}(t(c,".."));o!==f&&(b.gateway.url=o,h.gateway||(h.gateway={}),h.gateway.url=o,p=!0)}return g?.ws&&(b.gateway.url=g.ws,h.gateway||(h.gateway={}),h.gateway.url=g.ws,p=!0),g?.name&&(b.node.displayName=g.name,h.node||(h.node={}),h.node.displayName=g.name,p=!0),p&&o(c,u(h),"utf-8"),b}
@@ -1,315 +1 @@
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
1
+ import e from"ws";import{spawn as s}from"node:child_process";import{existsSync as r}from"node:fs";import{resolve as t,dirname as o}from"node:path";import{fileURLToPath as i}from"node:url";import{platform as n,hostname as a,arch as l}from"node:os";import{createShellCommands as h}from"./commands/shell.js";import{createBrowserProxy as c}from"./commands/browser-proxy.js";const d=o(i(import.meta.url)),m="Gateway",p="",g="",w=e=>`[38;5;${e}m`,u=w(82),b=w(196),f=w(220);export class GatewayLink{config;log;ws=null;heartbeatTimer=null;reconnectTimer=null;stopped=!1;paired=!1;shell;browserProcess=null;browserProxy=null;browserReady=!1;constructor(e,s){this.config=e,this.log=s,this.shell=h(e,s)}start(){this.config.browser.enabled&&this.startBrowserServer(),this.config.gateway.enabled&&(this.stopped=!1,this.connect())}stop(){this.stopped=!0,this.clearTimers(),this.stopBrowserServer(),this.ws&&(this.ws.close(),this.ws=null)}startBrowserServer(){const e=this.config.browser;let n=null;try{const e=import.meta.resolve("@hera-al/browser-server"),s=o(i(e)),r=e.endsWith(".ts")?".ts":".js";n=t(s,`standalone${r}`)}catch{const e=import.meta.url.endsWith(".ts"),s=t(d,`../../browser-server/${e?"src":"dist"}/server/standalone${e?".ts":".js"}`);r(s)&&(n=s)}if(!n||!r(n))return void this.log.warn(m,"Browser capability requires @hera-al/browser-server. Install it with: npm install @hera-al/browser-server");const a=[...process.execArgv,n,"--port",String(e.controlPort)];e.headless&&a.push("--headless","true"),e.noSandbox&&a.push("--noSandbox","true"),e.attachOnly&&a.push("--attachOnly","true"),e.executablePath&&a.push("--executablePath",e.executablePath),this.log.info(m,`Starting browser server on port ${e.controlPort}...`);const l=s(process.execPath,a,{stdio:["ignore","pipe","pipe"],env:process.env});l.stdout?.on("data",s=>{const r=s.toString().trim();r&&this.log.info("Browser",r),r.includes("listening")&&(this.browserReady=!0,this.log.info(m,"Browser server is ready"),console.log(` ${u}${g}🌐 Browser server${p} listening on 127.0.0.1:${e.controlPort}`))}),l.stderr?.on("data",e=>{const s=e.toString().trim();s&&this.log.warn("Browser",s)}),l.on("exit",e=>{this.log.info(m,`Browser server exited with code ${e}`),this.browserProcess=null,this.browserReady=!1});const h=()=>{try{l.kill("SIGTERM")}catch{}};process.on("exit",h),l.on("exit",()=>{process.removeListener("exit",h)}),this.browserProcess=l,this.browserProxy=c(e.controlPort,e.allowProfiles,this.log)}stopBrowserServer(){this.browserProcess&&(this.browserProcess.kill("SIGTERM"),this.browserProcess=null,this.browserReady=!1,this.browserProxy=null)}connect(){if(this.stopped)return;const s=this.config.gateway.url;this.log.info(m,`Connecting to ${s}...`),this.ws=new e(s),this.ws.on("open",()=>{this.log.info(m,"Connected to gateway"),this.sendHello(),this.startHeartbeat()}),this.ws.on("message",e=>{try{const s=JSON.parse(e.toString());this.handleMessage(s)}catch{}}),this.ws.on("close",()=>{this.log.warn(m,"Disconnected from gateway"),this.paired=!1,this.clearTimers(),this.scheduleReconnect()}),this.ws.on("error",e=>{this.log.error(m,`WebSocket error: ${e.message}`)})}sendHello(){const e=[],s=[];this.config.commands.shell.enabled&&(e.push("shell"),s.push("shell.run","shell.which")),this.config.browser.enabled&&(e.push("browser"),s.push("browser.proxy")),this.send({type:"hello",nodeId:this.config.node.id,displayName:this.config.node.displayName,platform:n(),arch:l(),hostname:a(),signature:this.config.gateway.signature,capabilities:e,commands:s})}startHeartbeat(){this.heartbeatTimer=setInterval(()=>{this.send({type:"ping"})},3e4)}handleMessage(e){switch(e.type){case"pong":break;case"pairing_status":this.handlePairingStatus(e.status);break;case"command":this.handleCommand(e);break;default:this.log.debug(m,`Received: ${e.type}`)}}handlePairingStatus(e){switch(e){case"pending":this.log.info(m,"Pairing pending — waiting for admin approval"),console.log(` ${f}${g}⏳ Pairing pending${p} — waiting for admin approval on Nostromo${p}`),this.paired=!1;break;case"approved":this.log.info(m,"Pairing approved — node is active"),console.log(` ${u}${g}✔ Paired!${p} Node is active and ready to receive commands.`),this.paired=!0;break;case"revoked":this.log.warn(m,"Pairing revoked — disconnecting"),console.log(` ${b}${g}✖ Pairing revoked.${p} The node has been disconnected by the admin.`),this.paired=!1,this.stop()}}async handleCommand(e){const s=e.id,r=e.command,t=e.params;if(s&&r)if(this.paired){this.log.info(m,`Command: ${r} (${s})`);try{let e;switch(r){case"shell.run":e=await this.shell.shellRun(t);break;case"shell.which":e=await this.shell.shellWhich(t);break;case"browser.proxy":if(!this.browserProxy)return void this.send({type:"command_result",id:s,ok:!1,error:"Browser is not enabled"});if(!this.browserReady)return void this.send({type:"command_result",id:s,ok:!1,error:"Browser server not ready"});e=await this.browserProxy.handleProxy(t);break;default:return this.log.warn(m,`Unknown command: ${r}`),void this.send({type:"command_result",id:s,ok:!1,error:`Unknown command: ${r}`})}this.log.info(m,`Command ${s} completed`),this.send({type:"command_result",id:s,ok:!0,result:e})}catch(e){const r=e instanceof Error?e.message:String(e);this.log.error(m,`Command ${s} failed: ${r}`),this.send({type:"command_result",id:s,ok:!1,error:r})}}else this.send({type:"command_result",id:s,ok:!1,error:"Node is not paired — awaiting approval"})}send(s){this.ws?.readyState===e.OPEN&&this.ws.send(JSON.stringify(s))}scheduleReconnect(){if(this.stopped)return;const e=this.config.gateway.reconnectMs;this.log.info(m,`Reconnecting in ${e}ms...`),this.reconnectTimer=setTimeout(()=>this.connect(),e)}clearTimers(){this.heartbeatTimer&&(clearInterval(this.heartbeatTimer),this.heartbeatTimer=null),this.reconnectTimer&&(clearTimeout(this.reconnectTimer),this.reconnectTimer=null)}}
package/dist/index.js CHANGED
@@ -1,170 +1,2 @@
1
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
2
+ import{copyFileSync as o,existsSync as e}from"node:fs";import{resolve as n,dirname as s,join as l}from"node:path";import{fileURLToPath as t}from"node:url";import{loadConfig as c}from"./config.js";import{GatewayLink as r}from"./gateway-link.js";import{Logger as a}from"./logger.js";const i="",g="",$="",d=o=>`[38;5;${o}m`,f=205,m=199,p=200,h=197,w=196,u=161;!function(){const y=function(){const o=process.argv.slice(2),e={};for(let n=0;n<o.length;n++){const s=o[n];"--help"===s||"-h"===s?e.help=!0:"--init"===s?e.init=!0:"--config"===s&&o[n+1]?e.configPath=o[++n]:"--ws"===s&&o[n+1]?e.ws=o[++n]:"--name"===s&&o[n+1]?e.name=o[++n]:s.startsWith("--")||e.configPath||(e.configPath=s)}return e}();y.help&&(console.log(""),console.log(` ${d(f)}${g}Hera StandardNode${i}`),console.log(""),console.log(` ${g}Usage:${i} npx tsx src/index.ts [options]`),console.log(""),console.log(` ${g}Options:${i}`),console.log(" --ws <url> WebSocket URL of the Hera gateway"),console.log(" --name <name> Display name for this node (default: hostname)"),console.log(" --config <path> Path to config file (default: config.stdnode.yaml)"),console.log(" --init Copy example config to current directory and exit"),console.log(" --help Show this help message"),console.log(""),console.log(` ${g}WebSocket URL format:${i}`),console.log(" ws[s]://<hostname>:<port><basePath>/ws/nodes"),console.log(""),console.log(` The ${g}<port>${i} is the Nostromo UI port configured on the server`),console.log(` (default 3001). The ${g}<basePath>${i} is the Nostromo base path`),console.log(" (default /nostromo)."),console.log(""),console.log(` ${g}Examples:${i}`),console.log(" --ws ws://localhost:3001/nostromo/ws/nodes"),console.log(" --ws wss://myhost.tail12345.ts.net:3001/nostromo/ws/nodes"),console.log(""),console.log(` ${g}Auto-detect:${i}`),console.log(" If ../config.yaml exists (Hera server config), the node reads"),console.log(" the Nostromo port and basePath from it and auto-configures the"),console.log(" gateway URL. The --ws flag overrides auto-detection."),console.log(""),process.exit(0)),y.init&&(!function(){const c=n("config.stdnode.yaml");if(e(c))return void console.log(`\n ${d(w)}${g}config.stdnode.yaml already exists${i} — not overwriting.\n`);const r=t(import.meta.url),a=s(s(r)),m=l(a,"config.stdnode.example.yaml");e(m)?(o(m,c),console.log(`\n ${d(f)}${g}Created${i} config.stdnode.yaml`),console.log(` ${$}Edit it, then run: hera-stdnode${i}\n`)):console.log(`\n ${d(w)}${g}Example config not found${i} — run from a proper install.\n`)}(),process.exit(0));const N=c(y),x=new a(N.logs.dir);var v,S,P,b;v=N.node.displayName,S=N.node.id,P=N.gateway.url,b=N.browser.enabled?N.browser.controlPort:void 0,console.log(""),console.log(` ${d(m)}${g} ███████╗████████╗██████╗ ███╗ ██╗ ██████╗ ██████╗ ███████╗${i}`),console.log(` ${d(m)}${g} ██╔════╝╚══██╔══╝██╔══██╗ ████╗ ██║██╔═══██╗██╔══██╗██╔════╝${i}`),console.log(` ${d(p)}${g} ███████╗ ██║ ██║ ██║ ██╔██╗ ██║██║ ██║██║ ██║█████╗ ${i}`),console.log(` ${d(h)}${g} ╚════██║ ██║ ██║ ██║ ██║╚██╗██║██║ ██║██║ ██║██╔══╝ ${i}`),console.log(` ${d(w)}${g} ███████║ ██║ ██████╔╝ ██║ ╚████║╚██████╔╝██████╔╝███████╗${i}`),console.log(` ${d(u)}${g} ╚══════╝ ╚═╝ ╚═════╝ ╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚══════╝${i}`),console.log(""),console.log(` ${d(f)}${g}Hera StandardNode${i}`),console.log(` ${$}${"─".repeat(50)}${i}`),console.log(` ${$}Name${i} ${g}${v}${i}`),console.log(` ${$}ID${i} ${$}${S}${i}`),console.log(` ${$}Gateway${i} ${$}${P}${i}`),b&&console.log(` ${$}Browser${i} ${$}127.0.0.1:${b}${i}`),console.log(""),x.info("Node",`Starting — ${N.node.displayName} (${N.node.id})`),x.info("Node",`Gateway: ${N.gateway.url}`),x.info("Node",`Logs dir: ${N.logs.dir}`);const I=new r(N,x);I.start();const E=()=>{console.log(`\n ${$}Shutting down...${i}`),x.info("Node","Shutting down"),I.stop(),process.exit(0)};if(process.on("SIGINT",E),process.on("SIGTERM",E),process.env.OSXNODE_PARENT){const o=process.ppid,e=setInterval(()=>{try{process.kill(o,0)}catch{clearInterval(e),x.info("Node",`Parent (pid ${o}) exited, shutting down`),E()}},2e3);e.unref()}}();
package/dist/logger.js CHANGED
@@ -1,64 +1 @@
1
- import { existsSync, mkdirSync, appendFileSync, renameSync, statSync } from "node:fs";
2
- import { resolve, join } from "node:path";
3
- const MAX_SIZE = 10 * 1024 * 1024; // 10 MB
4
- const MAX_FILES = 9;
5
- export class Logger {
6
- dir;
7
- filePath;
8
- constructor(logsDir) {
9
- this.dir = resolve(logsDir);
10
- if (!existsSync(this.dir)) {
11
- mkdirSync(this.dir, { recursive: true });
12
- }
13
- this.filePath = join(this.dir, "stdnode.log");
14
- }
15
- info(tag, msg) {
16
- this.write("INFO", tag, msg);
17
- }
18
- warn(tag, msg) {
19
- this.write("WARN", tag, msg);
20
- }
21
- error(tag, msg) {
22
- this.write("ERROR", tag, msg);
23
- }
24
- debug(tag, msg) {
25
- this.write("DEBUG", tag, msg);
26
- }
27
- write(level, tag, msg) {
28
- const ts = new Date().toISOString();
29
- const line = `${ts} [${level}] [${tag}] ${msg}\n`;
30
- try {
31
- this.rotate();
32
- appendFileSync(this.filePath, line, "utf-8");
33
- }
34
- catch {
35
- // Silently ignore write errors
36
- }
37
- }
38
- rotate() {
39
- try {
40
- if (!existsSync(this.filePath))
41
- return;
42
- const stat = statSync(this.filePath);
43
- if (stat.size < MAX_SIZE)
44
- return;
45
- // Shift existing rotated files: stdnode.9.log -> delete, 8->9, ... 1->2
46
- for (let i = MAX_FILES; i >= 1; i--) {
47
- const src = join(this.dir, `stdnode.${i}.log`);
48
- if (!existsSync(src))
49
- continue;
50
- if (i === MAX_FILES) {
51
- // oldest file — just let it be overwritten
52
- }
53
- const dst = join(this.dir, `stdnode.${i + 1}.log`);
54
- renameSync(src, dst);
55
- }
56
- // Current -> .1
57
- renameSync(this.filePath, join(this.dir, "stdnode.1.log"));
58
- }
59
- catch {
60
- // Ignore rotation errors
61
- }
62
- }
63
- }
64
- //# sourceMappingURL=logger.js.map
1
+ import{existsSync as t,mkdirSync as i,appendFileSync as r,renameSync as e,statSync as s}from"node:fs";import{resolve as o,join as h}from"node:path";export class Logger{dir;filePath;constructor(r){this.dir=o(r),t(this.dir)||i(this.dir,{recursive:!0}),this.filePath=h(this.dir,"stdnode.log")}info(t,i){this.write("INFO",t,i)}warn(t,i){this.write("WARN",t,i)}error(t,i){this.write("ERROR",t,i)}debug(t,i){this.write("DEBUG",t,i)}write(t,i,e){const s=`${(new Date).toISOString()} [${t}] [${i}] ${e}\n`;try{this.rotate(),r(this.filePath,s,"utf-8")}catch{}}rotate(){try{if(!t(this.filePath))return;if(s(this.filePath).size<10485760)return;for(let i=9;i>=1;i--){const r=h(this.dir,`stdnode.${i}.log`);if(!t(r))continue;const s=h(this.dir,`stdnode.${i+1}.log`);e(r,s)}e(this.filePath,h(this.dir,"stdnode.1.log"))}catch{}}}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@hera-al/standardnode",
3
- "version": "1.0.5",
3
+ "version": "1.0.6",
4
4
  "description": "Hera StandardNode — remote execution node for Hera gateway",
5
5
  "license": "MIT",
6
6
  "author": "TGP <heralife.dev@gmail.com>",
@@ -38,9 +38,11 @@
38
38
  "start": "tsx src/index.ts",
39
39
  "dev": "tsx watch src/index.ts",
40
40
  "build": "tsc",
41
+ "minify": "find dist -name '*.map' -delete && find dist -name '*.js' -exec terser {} --module --compress --mangle -o {} \\;",
42
+ "build:prod": "rm -rf dist && npm run build && npm run minify",
41
43
  "node": "node dist/index.js",
42
44
  "help": "tsx src/index.ts --help",
43
- "prepublishOnly": "npm run build"
45
+ "prepublishOnly": "npm run build:prod"
44
46
  },
45
47
  "dependencies": {
46
48
  "ws": "^8.18.0",