@cloudless-ai/node-agent 0.0.24 → 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 +23 -5
- package/bin/cloudless-node.js +60 -43
- package/package.json +3 -2
- package/platform/darwin-arm64/CloudlessMenuBar +0 -0
- package/platform/darwin-arm64/cloudless-agent +0 -0
- package/platform/windows-amd64/CloudlessMenuBar.exe +0 -0
- package/platform/windows-amd64/cloudless-agent.exe +0 -0
- package/platform/windows-arm64/CloudlessMenuBar.exe +0 -0
- package/platform/windows-arm64/cloudless-agent.exe +0 -0
- package/scripts/common.js +369 -41
- package/scripts/postinstall.js +132 -38
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.
|
|
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=
|
|
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
|
|
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.
|
package/bin/cloudless-node.js
CHANGED
|
@@ -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
|
-
|
|
179
|
-
|
|
180
|
-
|
|
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 =
|
|
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.
|
|
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"
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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 =
|
|
68
|
-
if (
|
|
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 (
|
|
184
|
+
if (childKeyPattern(indent, "token").test(lines[i])) {
|
|
72
185
|
tokenLine = i;
|
|
73
186
|
}
|
|
74
187
|
}
|
|
75
188
|
|
|
76
|
-
if (
|
|
77
|
-
lines[
|
|
189
|
+
if (tokenLine >= 0) {
|
|
190
|
+
lines[tokenLine] = tokenLineText;
|
|
78
191
|
} else {
|
|
79
|
-
lines.splice(
|
|
80
|
-
|
|
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
|
-
|
|
87
|
-
lines[tokenLine] = ` token: "${token}"`;
|
|
195
|
+
if (addressLine >= 0) {
|
|
196
|
+
lines[addressLine] = addressLineText;
|
|
88
197
|
} else {
|
|
89
|
-
lines.splice(
|
|
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
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
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.
|
|
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, "&")
|
|
264
|
+
.replace(/</g, "<")
|
|
265
|
+
.replace(/>/g, ">")
|
|
266
|
+
.replace(/"/g, """)
|
|
267
|
+
.replace(/'/g, "'");
|
|
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:
|
|
107
|
-
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:
|
|
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:
|
|
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
|
};
|
package/scripts/postinstall.js
CHANGED
|
@@ -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
|
-
} =
|
|
19
|
+
} = common;
|
|
15
20
|
|
|
16
|
-
|
|
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
|
|
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
|
|
27
|
-
|
|
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
|
|
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
|
|
48
|
-
const routerURL =
|
|
49
|
-
let apiKey =
|
|
50
|
-
if (!apiKey &&
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
apiKey = await
|
|
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 (
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
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
|
-
|
|
140
|
+
out(`Installed ${result.menubarPath}\n`);
|
|
72
141
|
}
|
|
73
|
-
await
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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
|
+
}
|