@cloudless-ai/node-agent 0.0.25 → 0.0.26

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/README.md CHANGED
@@ -12,8 +12,9 @@ During install, the script:
12
12
 
13
13
  1. Detects your OS/CPU architecture.
14
14
  2. Installs the matching precompiled binary bundled in this package, including the embedded llama.cpp backend.
15
- 3. On macOS, also installs `CloudlessMenuBar`.
16
- 4. Prompts for your router API key and writes `~/.cloudless/agent.yaml`.
15
+ 3. On macOS and Windows, also installs the `CloudlessMenuBar` menu bar / system tray app and registers it to **start at login** (macOS LaunchAgent `app.cloudless.node-agent`; Windows `HKCU\Software\Microsoft\Windows\CurrentVersion\Run\CloudlessAINodeTray`). Turn it off later from the app's "Start on Login" item or `cloudless-node autostart off`, or skip it at install with `CLOUDLESS_NO_START_ON_LOGIN=1`.
16
+ 4. Points `~/.cloudless/agent.yaml` at the production router (`https://api.cloudless-ai.app`). An existing token is kept only if it was already for production; otherwise it is cleared and the node links itself via browser sign-in on first start (or from the tray's "Connect" item).
17
+ 5. On macOS and Windows, stops any running menu bar app/tray and agent from a previous install, then **starts the menu bar app / tray** right away. It runs the node agent, restarts it if it exits (with backoff if it keeps crashing), and stops it when it quits or is closed for any reason. On a fresh install your browser opens so you can sign in and link the node to Cloudless AI. Set `CLOUDLESS_NO_LAUNCH=1` to skip starting it (it is also skipped when `CI` is set, and on macOS when installing as root, e.g. with `sudo`).
17
18
 
18
19
  ## Commands
19
20
 
@@ -24,19 +25,36 @@ cloudless-node menubar
24
25
  cloudless-node stop
25
26
  cloudless-node set-api-key
26
27
  cloudless-node configure
28
+ cloudless-node autostart on|off
27
29
  ```
28
30
 
31
+ ## Uninstall
32
+
33
+ npm has no uninstall hook, so stop the node and remove the start-at-login registration first:
34
+
35
+ ```bash
36
+ cloudless-node stop # stops the node (on macOS/Windows, the menu bar app/tray and its agent)
37
+ cloudless-node autostart off
38
+ npm uninstall -g @cloudless-ai/node-agent
39
+ ```
40
+
41
+ Binaries, models and config under `~/.cloudless` are left in place; delete that directory to remove them.
42
+
29
43
  ## Non-interactive install
30
44
 
31
45
  ```bash
32
- CLOUDLESS_NODE_API_KEY=your_key CLOUDLESS_ROUTER_URL=http://router-host:8080 npm install -g @cloudless-ai/node-agent
46
+ CLOUDLESS_NODE_API_KEY=your_node_key npm install -g @cloudless-ai/node-agent
33
47
  ```
34
48
 
49
+ Install always targets the production router. To point a node at a different router (for example local development), run `cloudless-node configure` after install.
50
+
35
51
  ## Notes
36
52
 
53
+ - Run the installer/launcher tests with `npm test` (Node's built-in test runner, no dependencies).
37
54
  - By default binaries are installed to `~/.cloudless/bin`.
38
55
  - Configuration is written to `~/.cloudless/agent.yaml`.
39
- - `cloudless-node start` launches the node + menubar in the background by default.
40
- - Use `cloudless-node start --interactive` to keep it attached to your terminal.
56
+ - `cloudless-node start` launches the node in the background by default. On macOS and Windows it starts the menu bar app / tray, which owns the agent (only one runs per user session).
57
+ - Use `cloudless-node start --interactive` to keep it attached to your terminal (this runs the agent alone, without the menu bar app / tray).
41
58
  - Installation has no GitHub release dependency; binaries are bundled in the npm tarball with the embedded backend.
59
+ - Upgrading stops the running menu bar app/tray and agent, installs the new binaries and starts the new menu bar app/tray. On Windows any still-locked binary is moved aside as `*.old-<timestamp>` and cleaned up on the next install; on macOS the old file is unlinked first so the new signed binary isn't killed by the code-signature cache.
42
60
  - For internet demos, set `router.advertise_address` in `~/.cloudless/agent.yaml` (or `CLOUDLESS_AGENT_ADVERTISE_ADDRESS`) to a public tunnel URL so router/dashboard can reach this node.
@@ -8,10 +8,16 @@ const {
8
8
  MENUBAR_BINARY_NAME,
9
9
  BIN_DIR,
10
10
  CLOUDLESS_ROOT,
11
+ HOME,
11
12
  promptSecret,
12
13
  promptLine,
13
14
  writeOrUpdateConfig,
14
15
  CONFIG_PATH,
16
+ PRODUCTION_ROUTER_URL,
17
+ enableStartOnLogin,
18
+ disableStartOnLogin,
19
+ launchTray,
20
+ stopTrayApp,
15
21
  } = require("../scripts/common");
16
22
 
17
23
  const agentBin = path.join(BIN_DIR, AGENT_BINARY_NAME);
@@ -89,21 +95,14 @@ function clearSupervisorPid() {
89
95
  } catch {}
90
96
  }
91
97
 
98
+ // runSupervisor runs the agent in the foreground (`start --interactive`) or
99
+ // for a detached `start` with custom agent args / on platforms without a
100
+ // tray. The tray/menu bar app supervises its own agent, so it isn't started
101
+ // here to avoid two competing supervisors.
92
102
  function runSupervisor(agentArgs) {
93
103
  checkBinary(agentBin);
94
104
 
95
- const supportsMenuBar = process.platform === "darwin" || process.platform === "win32";
96
- const startTime = Date.now();
97
-
98
105
  const agent = spawn(agentBin, withDefaultConfig(agentArgs), { stdio: "inherit" });
99
-
100
- let menubar = null;
101
- if (supportsMenuBar) {
102
- if (fs.existsSync(menubarBin)) {
103
- menubar = spawn(menubarBin, [], { stdio: "ignore" });
104
- }
105
- }
106
-
107
106
  writeSupervisorPid();
108
107
 
109
108
  let shuttingDown = false;
@@ -113,7 +112,6 @@ function runSupervisor(agentArgs) {
113
112
  }
114
113
  shuttingDown = true;
115
114
  agent.kill();
116
- if (menubar) menubar.kill();
117
115
  }
118
116
 
119
117
  process.on("SIGINT", shutdown);
@@ -121,29 +119,9 @@ function runSupervisor(agentArgs) {
121
119
  process.on("exit", clearSupervisorPid);
122
120
 
123
121
  agent.on("exit", (code) => {
124
- const exitedQuickly = Date.now() - startTime < 5000;
125
- if (code !== 0 && code !== null && exitedQuickly) {
126
- process.stderr.write(
127
- "cloudless-node: agent failed to start, keeping menubar attached to existing local agent if available\n"
128
- );
129
- return;
130
- }
131
122
  clearSupervisorPid();
132
123
  process.exit(code ?? 0);
133
124
  });
134
-
135
- if (menubar) {
136
- menubar.on("exit", () => {
137
- agent.kill();
138
- clearSupervisorPid();
139
- process.exit(0);
140
- });
141
- menubar.on("exit", (code) => {
142
- if (code !== 0 && code !== null) {
143
- process.stderr.write(`cloudless-node: menubar exited with code ${code}\n`);
144
- }
145
- });
146
- }
147
125
  }
148
126
 
149
127
  function parseStartArgs(args) {
@@ -159,13 +137,27 @@ function parseStartArgs(args) {
159
137
  return { interactive, agentArgs };
160
138
  }
161
139
 
140
+ const hasTrayApp = process.platform === "win32" || process.platform === "darwin";
141
+ const trayLabel = process.platform === "darwin" ? "menu bar app" : "tray";
142
+
162
143
  function startDetached(agentArgs) {
144
+ // The tray/menu bar app starts the agent, restarts it if it dies and stops
145
+ // it when it quits, so `start` just launches it (a second copy exits
146
+ // immediately if one is already running).
147
+ if (hasTrayApp && fs.existsSync(menubarBin) && agentArgs.length === 0) {
148
+ launchTray(menubarBin);
149
+ process.stdout.write(
150
+ `cloudless-node: started the Cloudless AI ${trayLabel}; it runs the node agent. Quit the ${trayLabel} (or run \`cloudless-node stop\`) to stop it.\n`,
151
+ );
152
+ return;
153
+ }
163
154
  const existing = readSupervisorPid();
164
155
  if (existing) {
165
156
  process.stdout.write(`cloudless-node: already running (supervisor pid ${existing})\n`);
166
157
  return;
167
158
  }
168
159
  const child = spawn(process.execPath, [__filename, "run-supervised", ...agentArgs], {
160
+ cwd: HOME,
169
161
  detached: true,
170
162
  stdio: "ignore",
171
163
  });
@@ -174,21 +166,17 @@ function startDetached(agentArgs) {
174
166
  }
175
167
 
176
168
  function stopSupervisor() {
169
+ // Ending the tray/menu bar app also ends its agent (kill-on-close job on
170
+ // Windows, SIGTERM handler on macOS).
171
+ const trayStopped = hasTrayApp ? stopTrayApp() : false;
177
172
  const pid = readSupervisorPid();
178
- if (!pid) {
179
- process.stdout.write("cloudless-node: not running\n");
180
- return;
181
- }
182
- if (!stopProcess(pid)) {
183
- clearSupervisorPid();
184
- process.stdout.write("cloudless-node: not running\n");
185
- return;
186
- }
187
- process.stdout.write("cloudless-node: stop signal sent\n");
173
+ const supervisorStopped = pid ? stopProcess(pid) : false;
174
+ if (!supervisorStopped) clearSupervisorPid();
175
+ process.stdout.write(trayStopped || supervisorStopped ? "cloudless-node: stopped\n" : "cloudless-node: not running\n");
188
176
  }
189
177
 
190
178
  async function configure() {
191
- const defaultURL = "http://localhost:8080";
179
+ const defaultURL = PRODUCTION_ROUTER_URL;
192
180
  process.stdout.write("Cloudless AI node configuration\n\n");
193
181
 
194
182
  const routerURL =
@@ -210,7 +198,36 @@ async function configure() {
210
198
 
211
199
  const [, , cmd, ...rest] = process.argv;
212
200
 
201
+ function autostart(action) {
202
+ try {
203
+ switch (action) {
204
+ case "on": {
205
+ if (!fs.existsSync(menubarBin)) {
206
+ process.stderr.write(`cloudless-node: ${menubarBin} not found; start on login needs the tray/menu bar app.\n`);
207
+ process.exit(1);
208
+ }
209
+ const where = enableStartOnLogin(menubarBin);
210
+ process.stdout.write(where ? `Start on login enabled (${where})\n` : "Start on login is not supported on this platform\n");
211
+ return;
212
+ }
213
+ case "off":
214
+ process.stdout.write(disableStartOnLogin() ? "Start on login disabled\n" : "Start on login was not enabled\n");
215
+ return;
216
+ default:
217
+ process.stderr.write("Usage: cloudless-node autostart on|off\n");
218
+ process.exit(2);
219
+ }
220
+ } catch (err) {
221
+ process.stderr.write(`cloudless-node: ${err.message}\n`);
222
+ process.exit(1);
223
+ }
224
+ }
225
+
213
226
  switch (cmd) {
227
+ case "autostart":
228
+ autostart(rest[0]);
229
+ break;
230
+
214
231
  case "configure":
215
232
  case "set-api-key":
216
233
  configure().catch((err) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cloudless-ai/node-agent",
3
- "version": "0.0.25",
3
+ "version": "0.0.26",
4
4
  "description": "Cloudless AI node agent installer and launcher",
5
5
  "license": "MIT",
6
6
  "homepage": "https://cloudless-ai.app",
@@ -12,7 +12,8 @@
12
12
  "cloudless-node": "bin/cloudless-node.js"
13
13
  },
14
14
  "scripts": {
15
- "postinstall": "node ./scripts/postinstall.js"
15
+ "postinstall": "node ./scripts/postinstall.js",
16
+ "test": "node --test"
16
17
  },
17
18
  "engines": {
18
19
  "node": ">=18"
package/scripts/common.js CHANGED
@@ -2,6 +2,7 @@ const fs = require("node:fs");
2
2
  const os = require("node:os");
3
3
  const path = require("node:path");
4
4
  const readline = require("node:readline");
5
+ const { spawn, spawnSync } = require("node:child_process");
5
6
 
6
7
  const HOME = os.homedir();
7
8
  const CLOUDLESS_ROOT = path.join(HOME, ".cloudless");
@@ -9,6 +10,16 @@ const BIN_DIR = path.join(CLOUDLESS_ROOT, "bin");
9
10
  const CONFIG_PATH = path.join(CLOUDLESS_ROOT, "agent.yaml");
10
11
  const AGENT_BINARY_NAME = process.platform === "win32" ? "cloudless-agent.exe" : "cloudless-agent";
11
12
  const MENUBAR_BINARY_NAME = process.platform === "win32" ? "CloudlessMenuBar.exe" : "CloudlessMenuBar";
13
+ const AGENT_BINARY_NAME_WINDOWS = "cloudless-agent.exe";
14
+ const MENUBAR_BINARY_NAME_WINDOWS = "CloudlessMenuBar.exe";
15
+ const PRODUCTION_ROUTER_URL = "https://api.cloudless-ai.app";
16
+
17
+ // Must match the names used by the tray/menu bar apps' own "Start on Login"
18
+ // toggles (apps/node-agent/cmd/tray/autostart_windows.go and
19
+ // apps/menubar-macos LaunchAtLoginController) so their checkboxes reflect it.
20
+ const WINDOWS_RUN_KEY = "HKCU\\Software\\Microsoft\\Windows\\CurrentVersion\\Run";
21
+ const WINDOWS_RUN_VALUE = "CloudlessAINodeTray";
22
+ const MACOS_LAUNCH_AGENT_LABEL = "app.cloudless.node-agent";
12
23
 
13
24
  function detectTarget() {
14
25
  const archMap = {
@@ -33,89 +44,393 @@ function ensureDirs() {
33
44
  fs.mkdirSync(path.dirname(CONFIG_PATH), { recursive: true });
34
45
  }
35
46
 
36
- function installBinary(sourcePath, binaryName) {
37
- const targetPath = path.join(BIN_DIR, binaryName);
38
- fs.copyFileSync(sourcePath, targetPath);
39
- fs.chmodSync(targetPath, 0o755);
47
+ // installBinary copies sourcePath into binDir. On Windows a running executable
48
+ // cannot be overwritten (EBUSY/EPERM), but it can be renamed, so an in-use
49
+ // binary (e.g. the tray started at login) is moved aside first. The stale copy
50
+ // is deleted when possible and otherwise cleaned up by a later install.
51
+ // Elsewhere the old file is unlinked first so the copy gets a new inode:
52
+ // overwriting a signed Mach-O in place makes macOS kill the new binary
53
+ // ("Killed: 9") because the kernel's code-signature cache is keyed on it.
54
+ function installBinary(sourcePath, binaryName, { binDir = BIN_DIR, fsImpl = fs, platform = process.platform } = {}) {
55
+ const targetPath = path.join(binDir, binaryName);
56
+ removeStaleBinaries(targetPath, fsImpl);
57
+ if (platform !== "win32") {
58
+ fsImpl.rmSync(targetPath, { force: true });
59
+ }
60
+ try {
61
+ fsImpl.copyFileSync(sourcePath, targetPath);
62
+ } catch (error) {
63
+ if (!["EBUSY", "EPERM", "EACCES"].includes(error.code) || !fsImpl.existsSync(targetPath)) {
64
+ throw error;
65
+ }
66
+ const stale = `${targetPath}.old-${Date.now()}`;
67
+ fsImpl.renameSync(targetPath, stale);
68
+ fsImpl.copyFileSync(sourcePath, targetPath);
69
+ try {
70
+ fsImpl.rmSync(stale, { force: true });
71
+ } catch {}
72
+ }
73
+ fsImpl.chmodSync(targetPath, 0o755);
40
74
  return targetPath;
41
75
  }
42
76
 
43
- function updateRouterConfig(content, { routerURL, token }) {
44
- const lines = content.split(/\r?\n/);
45
- let routerLine = -1;
46
- for (let i = 0; i < lines.length; i++) {
47
- if (/^\s*router:\s*$/.test(lines[i])) {
48
- routerLine = i;
49
- break;
77
+ function removeStaleBinaries(targetPath, fsImpl) {
78
+ const dir = path.dirname(targetPath);
79
+ const prefix = `${path.basename(targetPath)}.old-`;
80
+ let entries = [];
81
+ try {
82
+ entries = fsImpl.readdirSync(dir);
83
+ } catch {
84
+ return;
85
+ }
86
+ for (const name of entries) {
87
+ if (name.startsWith(prefix)) {
88
+ try {
89
+ fsImpl.rmSync(path.join(dir, name), { force: true });
90
+ } catch {}
50
91
  }
51
92
  }
93
+ }
94
+
95
+ // yamlString renders value as a YAML double-quoted scalar. JSON string
96
+ // escaping is a valid subset of YAML's, and it escapes backslashes, which
97
+ // matters for Windows paths ("C:\Users" would otherwise be a \U escape).
98
+ function yamlString(value) {
99
+ return JSON.stringify(String(value));
100
+ }
52
101
 
53
- if (routerLine === -1) {
54
- return `${content.trimEnd()}\n\nrouter:\n address: "${routerURL}"\n token: "${token}"\n`;
102
+ // parseYAMLScalar decodes a simple scalar value (double-quoted, single-quoted
103
+ // or plain) and drops any trailing comment.
104
+ function parseYAMLScalar(raw) {
105
+ const value = raw.trim();
106
+ if (value.startsWith('"')) {
107
+ for (let i = 1; i < value.length; i++) {
108
+ if (value[i] === "\\") {
109
+ i++;
110
+ continue;
111
+ }
112
+ if (value[i] === '"') {
113
+ try {
114
+ return JSON.parse(value.slice(0, i + 1));
115
+ } catch {
116
+ return value.slice(1, i);
117
+ }
118
+ }
119
+ }
120
+ return value.slice(1);
121
+ }
122
+ if (value.startsWith("'")) {
123
+ let out = "";
124
+ for (let i = 1; i < value.length; i++) {
125
+ if (value[i] === "'") {
126
+ if (value[i + 1] === "'") {
127
+ out += "'";
128
+ i++;
129
+ continue;
130
+ }
131
+ return out;
132
+ }
133
+ out += value[i];
134
+ }
135
+ return out;
55
136
  }
137
+ return value.replace(/\s+#.*$/, "").trim();
138
+ }
139
+
140
+ function isTopLevelKey(line) {
141
+ return /^[^\s#]/.test(line);
142
+ }
56
143
 
57
- let blockEnd = lines.length;
58
- for (let i = routerLine + 1; i < lines.length; i++) {
59
- if (/^\S/.test(lines[i])) {
60
- blockEnd = i;
144
+ function findRouterBlock(lines) {
145
+ const start = lines.findIndex((line) => /^router:\s*(#.*)?$/.test(line));
146
+ if (start === -1) {
147
+ return { start: -1, end: -1, indent: " " };
148
+ }
149
+ let end = lines.length;
150
+ let indent = null;
151
+ for (let i = start + 1; i < lines.length; i++) {
152
+ if (isTopLevelKey(lines[i])) {
153
+ end = i;
61
154
  break;
62
155
  }
156
+ if (indent === null && /^\s+[^\s#]/.test(lines[i])) {
157
+ indent = /^(\s+)/.exec(lines[i])[1];
158
+ }
159
+ }
160
+ return { start, end, indent: indent || " " };
161
+ }
162
+
163
+ function childKeyPattern(indent, key) {
164
+ return new RegExp(`^${indent}${key}:(.*)$`);
165
+ }
166
+
167
+ function updateRouterConfig(content, { routerURL, token }) {
168
+ const lines = content.split(/\r?\n/);
169
+ const { start, end, indent } = findRouterBlock(lines);
170
+ const addressLineText = `${indent}address: ${yamlString(routerURL)}`;
171
+ const tokenLineText = `${indent}token: ${yamlString(token)}`;
172
+
173
+ if (start === -1) {
174
+ const prefix = content.trim() === "" ? "" : `${content.trimEnd()}\n\n`;
175
+ return `${prefix}router:\n${addressLineText}\n${tokenLineText}\n`;
63
176
  }
64
177
 
65
178
  let addressLine = -1;
66
179
  let tokenLine = -1;
67
- for (let i = routerLine + 1; i < blockEnd; i++) {
68
- if (/^\s+address:\s*/.test(lines[i])) {
180
+ for (let i = start + 1; i < end; i++) {
181
+ if (childKeyPattern(indent, "address").test(lines[i])) {
69
182
  addressLine = i;
70
183
  }
71
- if (/^\s+token:\s*/.test(lines[i])) {
184
+ if (childKeyPattern(indent, "token").test(lines[i])) {
72
185
  tokenLine = i;
73
186
  }
74
187
  }
75
188
 
76
- if (addressLine >= 0) {
77
- lines[addressLine] = ` address: "${routerURL}"`;
189
+ if (tokenLine >= 0) {
190
+ lines[tokenLine] = tokenLineText;
78
191
  } else {
79
- lines.splice(routerLine + 1, 0, ` address: "${routerURL}"`);
80
- blockEnd += 1;
81
- if (tokenLine >= 0) {
82
- tokenLine += 1;
83
- }
192
+ lines.splice(start + 1, 0, tokenLineText);
193
+ if (addressLine >= 0) addressLine += 1;
84
194
  }
85
-
86
- if (tokenLine >= 0) {
87
- lines[tokenLine] = ` token: "${token}"`;
195
+ if (addressLine >= 0) {
196
+ lines[addressLine] = addressLineText;
88
197
  } else {
89
- lines.splice(routerLine + 1, 0, ` token: "${token}"`);
198
+ lines.splice(start + 1, 0, addressLineText);
90
199
  }
91
200
 
92
201
  return `${lines.join("\n").replace(/\s+$/, "")}\n`;
93
202
  }
94
203
 
95
- function writeOrUpdateConfig({ routerURL, token }) {
96
- let content = defaultAgentConfig(routerURL, token);
97
- if (fs.existsSync(CONFIG_PATH)) {
98
- const existing = fs.readFileSync(CONFIG_PATH, "utf8");
204
+ // readRouterConfig extracts router.address and router.token from agent YAML.
205
+ function readRouterConfig(content) {
206
+ const result = { address: "", token: "" };
207
+ const lines = content.split(/\r?\n/);
208
+ const { start, end, indent } = findRouterBlock(lines);
209
+ if (start === -1) {
210
+ return result;
211
+ }
212
+ for (let i = start + 1; i < end; i++) {
213
+ for (const key of ["address", "token"]) {
214
+ const match = childKeyPattern(indent, key).exec(lines[i]);
215
+ if (match) {
216
+ result[key] = parseYAMLScalar(match[1]);
217
+ }
218
+ }
219
+ }
220
+ return result;
221
+ }
222
+
223
+ function writeOrUpdateConfig({ routerURL, token }, { configPath = CONFIG_PATH, home = HOME } = {}) {
224
+ let content = defaultAgentConfig(routerURL, token, home);
225
+ if (fs.existsSync(configPath)) {
226
+ const existing = fs.readFileSync(configPath, "utf8");
99
227
  content = updateRouterConfig(existing, { routerURL, token });
100
228
  }
101
- fs.writeFileSync(CONFIG_PATH, content, "utf8");
229
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
230
+ fs.writeFileSync(configPath, content, "utf8");
231
+ }
232
+
233
+ function normalizeURL(value) {
234
+ return String(value || "").trim().replace(/\/+$/, "").toLowerCase();
235
+ }
236
+
237
+ // pointConfigAtRouter makes the agent config target routerURL. A token issued
238
+ // by a different router is useless against this one, so it is only kept when
239
+ // the address is unchanged; otherwise it is cleared and the node re-links via
240
+ // the browser login flow.
241
+ function pointConfigAtRouter(routerURL, { configPath = CONFIG_PATH, home = HOME } = {}) {
242
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
243
+ if (!fs.existsSync(configPath)) {
244
+ fs.writeFileSync(configPath, defaultAgentConfig(routerURL, "", home), "utf8");
245
+ return { tokenKept: false };
246
+ }
247
+ const existing = fs.readFileSync(configPath, "utf8");
248
+ const current = readRouterConfig(existing);
249
+ const keepToken = current.token !== "" && normalizeURL(current.address) === normalizeURL(routerURL);
250
+ const token = keepToken ? current.token : "";
251
+ fs.writeFileSync(configPath, updateRouterConfig(existing, { routerURL, token }), "utf8");
252
+ return { tokenKept: keepToken };
253
+ }
254
+
255
+ // isOptOutSet treats unset, empty, "0", "false", "no" and "off" as not set.
256
+ function isOptOutSet(value) {
257
+ if (value === undefined || value === null) return false;
258
+ return !["", "0", "false", "no", "off"].includes(String(value).trim().toLowerCase());
259
+ }
260
+
261
+ function escapeXML(value) {
262
+ return value
263
+ .replace(/&/g, "&amp;")
264
+ .replace(/</g, "&lt;")
265
+ .replace(/>/g, "&gt;")
266
+ .replace(/"/g, "&quot;")
267
+ .replace(/'/g, "&apos;");
268
+ }
269
+
270
+ function macOSLaunchAgentPlist(menubarPath) {
271
+ return `<?xml version="1.0" encoding="UTF-8"?>
272
+ <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
273
+ <plist version="1.0">
274
+ <dict>
275
+ <key>KeepAlive</key>
276
+ <false/>
277
+ <key>Label</key>
278
+ <string>${MACOS_LAUNCH_AGENT_LABEL}</string>
279
+ <key>ProgramArguments</key>
280
+ <array>
281
+ <string>${escapeXML(menubarPath)}</string>
282
+ </array>
283
+ <key>RunAtLoad</key>
284
+ <true/>
285
+ </dict>
286
+ </plist>
287
+ `;
288
+ }
289
+
290
+ // enableStartOnLogin registers the tray/menu bar app to launch at login. It
291
+ // returns where the registration was written, null on unsupported platforms,
292
+ // or throws on failure.
293
+ function enableStartOnLogin(menubarPath, { platform = process.platform, home = HOME, run = spawnSync } = {}) {
294
+ if (platform === "win32") {
295
+ const result = run(
296
+ "reg",
297
+ ["add", WINDOWS_RUN_KEY, "/v", WINDOWS_RUN_VALUE, "/t", "REG_SZ", "/d", `"${menubarPath}"`, "/f"],
298
+ { stdio: "pipe", windowsHide: true },
299
+ );
300
+ if (result.error || result.status !== 0) {
301
+ const detail = result.error ? result.error.message : String(result.stderr || "").trim();
302
+ throw new Error(`reg add failed: ${detail}`);
303
+ }
304
+ return `${WINDOWS_RUN_KEY}\\${WINDOWS_RUN_VALUE}`;
305
+ }
306
+ if (platform === "darwin") {
307
+ const plistPath = path.join(home, "Library", "LaunchAgents", `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
308
+ fs.mkdirSync(path.dirname(plistPath), { recursive: true });
309
+ fs.writeFileSync(plistPath, macOSLaunchAgentPlist(menubarPath), "utf8");
310
+ return plistPath;
311
+ }
312
+ return null;
313
+ }
314
+
315
+ // disableStartOnLogin removes the login registration written by
316
+ // enableStartOnLogin (or by the tray/menu bar toggle). It returns true when a
317
+ // registration was removed and false when none existed.
318
+ function disableStartOnLogin({ platform = process.platform, home = HOME, run = spawnSync } = {}) {
319
+ if (platform === "win32") {
320
+ const query = run("reg", ["query", WINDOWS_RUN_KEY, "/v", WINDOWS_RUN_VALUE], { stdio: "pipe", windowsHide: true });
321
+ if (query.error) {
322
+ throw new Error(`reg query failed: ${query.error.message}`);
323
+ }
324
+ if (query.status !== 0) {
325
+ return false;
326
+ }
327
+ const result = run("reg", ["delete", WINDOWS_RUN_KEY, "/v", WINDOWS_RUN_VALUE, "/f"], { stdio: "pipe", windowsHide: true });
328
+ if (result.error || result.status !== 0) {
329
+ const detail = result.error ? result.error.message : String(result.stderr || "").trim();
330
+ throw new Error(`reg delete failed: ${detail}`);
331
+ }
332
+ return true;
333
+ }
334
+ if (platform === "darwin") {
335
+ const plistPath = path.join(home, "Library", "LaunchAgents", `${MACOS_LAUNCH_AGENT_LABEL}.plist`);
336
+ if (!fs.existsSync(plistPath)) {
337
+ return false;
338
+ }
339
+ fs.rmSync(plistPath, { force: true });
340
+ return true;
341
+ }
342
+ return false;
343
+ }
344
+
345
+ // stopTrayApp terminates any running tray/menu bar app and, with includeAgent,
346
+ // any installed cloudless-agent (e.g. one left by an older tray or a
347
+ // supervisor), so an upgrade doesn't leave the previous version running.
348
+ // Returns true when something was stopped.
349
+ //
350
+ // Windows: taskkill exits 128 when no process matches, which is not an error.
351
+ // macOS: the processes are matched by their full path in binDir and sent
352
+ // SIGTERM (the menu bar app stops its agent on SIGTERM), then SIGKILL if they
353
+ // are still running after timeoutMs.
354
+ function stopTrayApp({
355
+ includeAgent = false,
356
+ platform = process.platform,
357
+ run = spawnSync,
358
+ binDir = BIN_DIR,
359
+ timeoutMs = 5000,
360
+ sleep = sleepSync,
361
+ } = {}) {
362
+ if (platform === "win32") {
363
+ const images = [MENUBAR_BINARY_NAME_WINDOWS, ...(includeAgent ? [AGENT_BINARY_NAME_WINDOWS] : [])];
364
+ let stopped = false;
365
+ for (const image of images) {
366
+ // /T also ends the tray's agent child; the tray's kill-on-close job
367
+ // would do that anyway, but older trays didn't have one.
368
+ const result = run("taskkill", ["/F", "/T", "/IM", image], { stdio: "pipe", windowsHide: true });
369
+ if (!result.error && result.status === 0) stopped = true;
370
+ }
371
+ return stopped;
372
+ }
373
+ if (platform === "darwin") {
374
+ const names = ["CloudlessMenuBar", ...(includeAgent ? ["cloudless-agent"] : [])];
375
+ const patterns = names.map((name) => `^${escapeRegExp(path.posix.join(binDir.replace(/\\/g, "/"), name))}( |$)`);
376
+ // pkill/pgrep exit 0 when something matched and 1 when nothing did.
377
+ const signal = (sig, pattern) => run("pkill", [`-${sig}`, "-f", pattern], { stdio: "pipe" });
378
+ const running = (pattern) => run("pgrep", ["-f", pattern], { stdio: "pipe" }).status === 0;
379
+ let stopped = false;
380
+ for (const pattern of patterns) {
381
+ const result = signal("TERM", pattern);
382
+ if (!result.error && result.status === 0) stopped = true;
383
+ }
384
+ if (!stopped) return false;
385
+ const step = 100;
386
+ for (let waited = 0; waited < timeoutMs && patterns.some(running); waited += step) {
387
+ sleep(step);
388
+ }
389
+ for (const pattern of patterns) {
390
+ if (running(pattern)) signal("KILL", pattern);
391
+ }
392
+ return true;
393
+ }
394
+ return false;
395
+ }
396
+
397
+ function escapeRegExp(value) {
398
+ return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
399
+ }
400
+
401
+ function sleepSync(ms) {
402
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
403
+ }
404
+
405
+ // launchTray starts the tray/menu bar app detached from this process so it
406
+ // keeps running after npm (or the CLI) exits. The tray owns the agent: it
407
+ // starts it, restarts it if it dies, and stops it when the tray quits.
408
+ // cwd defaults to home: postinstall runs inside the npm package directory, and
409
+ // on Windows a process whose working directory is in there makes npm's next
410
+ // upgrade fail with EBUSY when it renames the package directory.
411
+ function launchTray(menubarPath, { spawnImpl = spawn, cwd = HOME } = {}) {
412
+ const child = spawnImpl(menubarPath, [], { cwd, detached: true, stdio: "ignore", windowsHide: false });
413
+ if (typeof child.on === "function") child.on("error", () => {});
414
+ if (typeof child.unref === "function") child.unref();
415
+ return child.pid;
102
416
  }
103
417
 
104
- function defaultAgentConfig(routerURL, token) {
418
+ function defaultAgentConfig(routerURL, token, home = HOME) {
419
+ const cloudlessDir = path.join(home, ".cloudless");
105
420
  return `router:
106
- address: "${routerURL}"
107
- token: "${token}"
421
+ address: ${yamlString(routerURL)}
422
+ token: ${yamlString(token)}
108
423
  heartbeat_interval: "10s"
109
424
  system_load_ceiling: 2.5
110
425
  mdns: true
111
426
  mdns_service: "_cloudless-router._tcp"
112
427
  discover_interval: "1m"
113
- servers_file: "${HOME}/.cloudless/router-servers.json"
428
+ servers_file: ${yamlString(path.join(cloudlessDir, "router-servers.json"))}
114
429
  manual_servers: []
115
430
  # advertise_address: "https://my-node-demo.example.com"
116
431
 
117
432
  inference:
118
- models_dir: "${HOME}/.cloudless/models"
433
+ models_dir: ${yamlString(path.join(cloudlessDir, "models"))}
119
434
  preload_models: []
120
435
  max_concurrent_requests: 4
121
436
  unload_on_system_load: true
@@ -194,12 +509,25 @@ module.exports = {
194
509
  BIN_DIR,
195
510
  CONFIG_PATH,
196
511
  CLOUDLESS_ROOT,
512
+ HOME,
513
+ MACOS_LAUNCH_AGENT_LABEL,
514
+ PRODUCTION_ROUTER_URL,
515
+ WINDOWS_RUN_KEY,
516
+ WINDOWS_RUN_VALUE,
197
517
  defaultAgentConfig,
198
518
  detectTarget,
519
+ disableStartOnLogin,
520
+ enableStartOnLogin,
199
521
  ensureDirs,
200
522
  installBinary,
523
+ isOptOutSet,
524
+ launchTray,
525
+ parseYAMLScalar,
526
+ pointConfigAtRouter,
201
527
  promptLine,
202
528
  promptSecret,
529
+ readRouterConfig,
530
+ stopTrayApp,
203
531
  updateRouterConfig,
204
532
  writeOrUpdateConfig,
205
533
  };
@@ -2,82 +2,176 @@
2
2
  const fs = require("node:fs");
3
3
  const path = require("node:path");
4
4
 
5
+ const common = require("./common");
5
6
  const {
6
7
  AGENT_BINARY_NAME,
7
8
  MENUBAR_BINARY_NAME,
9
+ BIN_DIR,
8
10
  CONFIG_PATH,
11
+ HOME,
12
+ PRODUCTION_ROUTER_URL,
9
13
  detectTarget,
10
- ensureDirs,
11
14
  installBinary,
15
+ isOptOutSet,
16
+ pointConfigAtRouter,
12
17
  promptSecret,
13
18
  writeOrUpdateConfig,
14
- } = require("./common");
19
+ } = common;
15
20
 
16
- function resolveBundledPlatformDir(target) {
21
+ const DEFAULT_PLATFORM_ROOT = path.resolve(__dirname, "..", "platform");
22
+
23
+ function resolveBundledPlatformDir(target, platformRoot) {
17
24
  const key = `${target.goos}/${target.goarch}`;
18
- const dirName = `${target.goos}-${target.goarch}`;
19
- const platformDir = path.resolve(__dirname, "..", "platform", dirName);
25
+ const platformDir = path.join(platformRoot, `${target.goos}-${target.goarch}`);
20
26
  if (!fs.existsSync(platformDir)) {
21
27
  throw new Error(`Unsupported platform/arch: ${key}`);
22
28
  }
23
29
  return platformDir;
24
30
  }
25
31
 
26
- function installFromPlatformPackage(target) {
27
- const platformDir = resolveBundledPlatformDir(target);
32
+ function hasMenuBarApp(target) {
33
+ return target.goos === "darwin" || target.goos === "windows";
34
+ }
35
+
36
+ function installFromPlatformPackage(target, { platformRoot, binDir }) {
37
+ const platformDir = resolveBundledPlatformDir(target, platformRoot);
28
38
  const agentSource = path.join(platformDir, AGENT_BINARY_NAME);
29
39
 
30
40
  if (!fs.existsSync(agentSource)) {
31
41
  throw new Error(`Bundled binaries are missing ${AGENT_BINARY_NAME} for ${target.goos}/${target.goarch}`);
32
42
  }
33
- const agentPath = installBinary(agentSource, AGENT_BINARY_NAME);
43
+ const agentPath = installBinary(agentSource, AGENT_BINARY_NAME, { binDir });
34
44
 
35
45
  let menubarPath = null;
36
- if (target.goos === "darwin") {
46
+ if (hasMenuBarApp(target)) {
37
47
  const menuBarSource = path.join(platformDir, MENUBAR_BINARY_NAME);
38
48
  if (!fs.existsSync(menuBarSource)) {
39
49
  throw new Error(`Bundled binaries are missing ${MENUBAR_BINARY_NAME} for ${target.goos}/${target.goarch}`);
40
50
  }
41
- menubarPath = installBinary(menuBarSource, MENUBAR_BINARY_NAME);
51
+ menubarPath = installBinary(menuBarSource, MENUBAR_BINARY_NAME, { binDir });
42
52
  }
43
53
 
44
54
  return { agentPath, menubarPath };
45
55
  }
46
56
 
47
- async function promptConfig() {
48
- const routerURL = process.env.CLOUDLESS_ROUTER_URL?.trim() || "http://localhost:8080";
49
- let apiKey = process.env.CLOUDLESS_NODE_API_KEY?.trim() || "";
50
- if (!apiKey && process.stdin.isTTY && process.stdout.isTTY) {
51
- process.stdout.write("\nCloudless AI node setup\n");
52
- process.stdout.write(`Router URL: ${routerURL}\n`);
53
- apiKey = await promptSecret("Enter Cloudless router API key (hidden): ");
57
+ async function configureProductionRouter({ configPath, home, env, interactive, out, prompt }) {
58
+ const routerURL = PRODUCTION_ROUTER_URL;
59
+ let apiKey = env.CLOUDLESS_NODE_API_KEY?.trim() || "";
60
+ if (!apiKey && interactive) {
61
+ out("\nCloudless AI node setup\n");
62
+ out(`Router URL: ${routerURL}\n`);
63
+ apiKey = await prompt("Enter a node API key, or press Enter to sign in from the browser later (hidden): ");
54
64
  }
55
- if (!apiKey) {
56
- process.stdout.write("\nNo API key provided during install.\n");
57
- process.stdout.write("Run `cloudless-node configure` to set up your router URL and API key.\n");
58
- return;
65
+ if (apiKey) {
66
+ writeOrUpdateConfig({ routerURL, token: apiKey }, { configPath, home });
67
+ out(`Saved node config for ${routerURL} to ${configPath}\n`);
68
+ return { tokenKept: true };
59
69
  }
60
- writeOrUpdateConfig({ routerURL, token: apiKey });
61
- process.stdout.write(`Saved node config to ${CONFIG_PATH}\n`);
70
+ const { tokenKept } = pointConfigAtRouter(routerURL, { configPath, home });
71
+ out(`Configured ${configPath} to register with ${routerURL}\n`);
72
+ if (!tokenKept) {
73
+ out("On first start you'll be asked to sign in from your browser to link this node.\n");
74
+ }
75
+ return { tokenKept };
76
+ }
77
+
78
+ function configureStartOnLogin(menubarPath, { env, out, enableStartOnLogin }) {
79
+ if (!menubarPath) return false;
80
+ if (isOptOutSet(env.CLOUDLESS_NO_START_ON_LOGIN)) {
81
+ out("Skipping start-on-login (CLOUDLESS_NO_START_ON_LOGIN is set).\n");
82
+ return false;
83
+ }
84
+ try {
85
+ const where = enableStartOnLogin(menubarPath);
86
+ if (where) {
87
+ out(`Enabled start on login (${where}).\n`);
88
+ return true;
89
+ }
90
+ } catch (error) {
91
+ out(`Warning: could not enable start on login: ${error.message}\n`);
92
+ }
93
+ return false;
94
+ }
95
+
96
+ // trayLabel names the tray/menu bar app in installer output.
97
+ function trayLabel(target) {
98
+ return target.goos === "darwin" ? "menu bar app" : "tray";
62
99
  }
63
100
 
64
- async function main() {
65
- ensureDirs();
66
- const target = detectTarget();
67
- process.stdout.write(`Installing Cloudless node agent for ${target.goos}/${target.goarch}...\n`);
68
- const result = installFromPlatformPackage(target);
69
- process.stdout.write(`Installed ${result.agentPath}\n`);
101
+ // shouldLaunchTray decides whether postinstall starts the tray/menu bar app,
102
+ // which runs the agent itself. CI and headless installs opt out, and a root
103
+ // install on macOS (`sudo npm install -g`) must not start a GUI app as root.
104
+ function shouldLaunchTray(target, menubarPath, env, { isRoot = false } = {}) {
105
+ if (!menubarPath || !hasMenuBarApp(target)) return false;
106
+ if (target.goos === "darwin" && isRoot) return false;
107
+ return !isOptOutSet(env.CLOUDLESS_NO_LAUNCH) && !isOptOutSet(env.CI);
108
+ }
109
+
110
+ async function main(options = {}) {
111
+ const {
112
+ target = detectTarget(),
113
+ platformRoot = DEFAULT_PLATFORM_ROOT,
114
+ binDir = BIN_DIR,
115
+ configPath = CONFIG_PATH,
116
+ home = HOME,
117
+ env = process.env,
118
+ interactive = Boolean(process.stdin.isTTY && process.stdout.isTTY),
119
+ out = (text) => process.stdout.write(text),
120
+ prompt = promptSecret,
121
+ enableStartOnLogin = (menubarPath) => common.enableStartOnLogin(menubarPath, { home }),
122
+ stopRunning = () => common.stopTrayApp({ includeAgent: true, binDir }),
123
+ launchTray = (menubarPath) => common.launchTray(menubarPath),
124
+ isRoot = typeof process.getuid === "function" && process.getuid() === 0,
125
+ } = options;
126
+ const label = trayLabel(target);
127
+
128
+ fs.mkdirSync(binDir, { recursive: true });
129
+ fs.mkdirSync(path.dirname(configPath), { recursive: true });
130
+ out(`Installing Cloudless node agent for ${target.goos}/${target.goarch}...\n`);
131
+ // Stop the previous tray/menu bar app and agent first so the new binaries
132
+ // are the ones that run (Windows can't overwrite a running .exe, only
133
+ // rename it).
134
+ if (hasMenuBarApp(target) && stopRunning()) {
135
+ out(`Stopped the running Cloudless AI ${label} and node agent.\n`);
136
+ }
137
+ const result = installFromPlatformPackage(target, { platformRoot, binDir });
138
+ out(`Installed ${result.agentPath}\n`);
70
139
  if (result.menubarPath) {
71
- process.stdout.write(`Installed ${result.menubarPath}\n`);
140
+ out(`Installed ${result.menubarPath}\n`);
72
141
  }
73
- await promptConfig();
74
- process.stdout.write("Done. Run `cloudless-node start` to launch the node agent.\n");
75
- if (target.goos === "darwin") {
76
- process.stdout.write("Run `cloudless-node menubar` to start the menu bar app.\n");
142
+ const config = await configureProductionRouter({ configPath, home, env, interactive, out, prompt });
143
+ const autostart = configureStartOnLogin(result.menubarPath, { env, out, enableStartOnLogin });
144
+
145
+ let launched = false;
146
+ if (shouldLaunchTray(target, result.menubarPath, env, { isRoot })) {
147
+ try {
148
+ launchTray(result.menubarPath);
149
+ launched = true;
150
+ out(`Started the Cloudless AI ${label}; it runs the node agent and restarts it if it stops.\n`);
151
+ if (!config.tokenKept) {
152
+ out("Your browser will open so you can sign in and link this node to Cloudless AI.\n");
153
+ }
154
+ } catch (error) {
155
+ out(`Warning: could not start the ${label}: ${error.message}\n`);
156
+ }
77
157
  }
158
+
159
+ if (!launched) {
160
+ out("Done. Run `cloudless-node start` to launch the node agent.\n");
161
+ } else {
162
+ out(`Done. Quit the ${label} to stop the node agent.\n`);
163
+ }
164
+ if (autostart) {
165
+ out(`The ${label} will start automatically ${launched ? "at every login" : "at your next login"}.\n`);
166
+ }
167
+ return { ...result, ...config, autostart, launched };
78
168
  }
79
169
 
80
- main().catch((error) => {
81
- process.stderr.write(`Installation failed: ${error.message}\n`);
82
- process.exit(1);
83
- });
170
+ module.exports = { main, shouldLaunchTray };
171
+
172
+ if (require.main === module) {
173
+ main().catch((error) => {
174
+ process.stderr.write(`Installation failed: ${error.message}\n`);
175
+ process.exit(1);
176
+ });
177
+ }