plum-e2e 2.9.12 → 2.9.13
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/backend/lib/globalRegistry.js +9 -1
- package/backend/lib/nodeRegister.js +80 -9
- package/backend/logs/runner-cmtd4g58m0001s401fcze7ne1.log +10 -0
- package/backend/logs/runner-cmtd4gna40002s401hk2amn5y.log +6 -0
- package/backend/scripts/manage-runners.mjs +74 -181
- package/backend/services/runnerService.js +9 -3
- package/bin/plum.js +258 -239
- package/package.json +1 -1
|
@@ -41,6 +41,14 @@ function registerInstall(type, dir) {
|
|
|
41
41
|
}
|
|
42
42
|
}
|
|
43
43
|
|
|
44
|
+
/** Forgets `dir` as a known `type` install location. */
|
|
45
|
+
function unregisterInstall(type, dir) {
|
|
46
|
+
const data = load();
|
|
47
|
+
if (!data[type]?.includes(dir)) return;
|
|
48
|
+
data[type] = data[type].filter((d) => d !== dir);
|
|
49
|
+
save(data);
|
|
50
|
+
}
|
|
51
|
+
|
|
44
52
|
/** Known install dirs for `type`, pruned of any that no longer exist on disk. */
|
|
45
53
|
function getInstalls(type) {
|
|
46
54
|
const data = load();
|
|
@@ -52,4 +60,4 @@ function getInstalls(type) {
|
|
|
52
60
|
return dirs;
|
|
53
61
|
}
|
|
54
62
|
|
|
55
|
-
module.exports = { REGISTRY_PATH, registerInstall, getInstalls };
|
|
63
|
+
module.exports = { REGISTRY_PATH, registerInstall, unregisterInstall, getInstalls };
|
|
@@ -46,12 +46,74 @@ function loadNodeConfig(dir) {
|
|
|
46
46
|
}
|
|
47
47
|
|
|
48
48
|
function saveNodeConfig(dir, cfg) {
|
|
49
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
49
50
|
fs.writeFileSync(configPath(dir), JSON.stringify(cfg, null, 2) + '\n', 'utf8');
|
|
50
51
|
}
|
|
51
52
|
|
|
53
|
+
// ---------------------------------------------------------------------------
|
|
54
|
+
// Named node store — one dir per node under ~/.plum/nodes/<name>/, so a machine
|
|
55
|
+
// can run several nodes without the operator juggling working directories (a
|
|
56
|
+
// second `plum node start` from the same folder used to overwrite the first
|
|
57
|
+
// node's config and orphan its process).
|
|
58
|
+
// ---------------------------------------------------------------------------
|
|
59
|
+
|
|
60
|
+
function nodesRoot() {
|
|
61
|
+
return path.join(os.homedir(), '.plum', 'nodes');
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
/** The config dir for a node by name (created on save). */
|
|
65
|
+
function nodeHome(name) {
|
|
66
|
+
return path.join(nodesRoot(), name);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Names of every node this machine has a config for. */
|
|
70
|
+
function listNodeNames() {
|
|
71
|
+
try {
|
|
72
|
+
return fs
|
|
73
|
+
.readdirSync(nodesRoot(), { withFileTypes: true })
|
|
74
|
+
.filter(
|
|
75
|
+
(e) => e.isDirectory() && fs.existsSync(path.join(nodesRoot(), e.name, CONFIG_FILENAME))
|
|
76
|
+
)
|
|
77
|
+
.map((e) => e.name);
|
|
78
|
+
} catch {
|
|
79
|
+
return [];
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function loadNodeByName(name) {
|
|
84
|
+
return loadNodeConfig(nodeHome(name));
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function saveNodeByName(name, cfg) {
|
|
88
|
+
saveNodeConfig(nodeHome(name), cfg);
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Removes a node's whole config dir. */
|
|
92
|
+
function deleteNodeByName(name) {
|
|
93
|
+
fs.rmSync(nodeHome(name), { recursive: true, force: true });
|
|
94
|
+
}
|
|
95
|
+
|
|
52
96
|
/**
|
|
53
|
-
*
|
|
54
|
-
*
|
|
97
|
+
* One-time move of any legacy `.plum-node.json` (written into an arbitrary
|
|
98
|
+
* working directory by older `plum node start`) into the named store. Returns
|
|
99
|
+
* the names it imported. Leaves the old files in place.
|
|
100
|
+
*/
|
|
101
|
+
function migrateLegacyNodes(legacyDirs) {
|
|
102
|
+
const imported = [];
|
|
103
|
+
for (const dir of legacyDirs || []) {
|
|
104
|
+
if (dir.startsWith(nodesRoot())) continue;
|
|
105
|
+
const cfg = loadNodeConfig(dir);
|
|
106
|
+
if (!cfg.name || fs.existsSync(configPath(nodeHome(cfg.name)))) continue;
|
|
107
|
+
saveNodeByName(cfg.name, cfg);
|
|
108
|
+
imported.push(cfg.name);
|
|
109
|
+
}
|
|
110
|
+
return imported;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
/**
|
|
114
|
+
* Registers the node with the primary. POST /runners upserts on name+url, so
|
|
115
|
+
* re-running this refreshes the token on the existing runner rather than
|
|
116
|
+
* duplicating it — `reused` is reported for messaging only.
|
|
55
117
|
*
|
|
56
118
|
* @returns {Promise<{ id: string, reused: boolean }>}
|
|
57
119
|
* @throws {Error} when the primary is unreachable or rejects the request
|
|
@@ -59,12 +121,14 @@ function saveNodeConfig(dir, cfg) {
|
|
|
59
121
|
async function registerWithPrimary({ primary, name, url, token, browser }) {
|
|
60
122
|
const base = primary.replace(/\/$/, '');
|
|
61
123
|
|
|
62
|
-
let
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
124
|
+
let reused = false;
|
|
125
|
+
try {
|
|
126
|
+
const listRes = await fetch(`${base}/runners`, { signal: AbortSignal.timeout(10000) });
|
|
127
|
+
if (listRes.ok) {
|
|
128
|
+
const { runners = [] } = await listRes.json();
|
|
129
|
+
reused = runners.some((r) => r.name === name && r.url === url);
|
|
130
|
+
}
|
|
131
|
+
} catch {}
|
|
68
132
|
|
|
69
133
|
const res = await fetch(`${base}/runners`, {
|
|
70
134
|
method: 'POST',
|
|
@@ -76,7 +140,7 @@ async function registerWithPrimary({ primary, name, url, token, browser }) {
|
|
|
76
140
|
if (!res.ok || body.error) {
|
|
77
141
|
throw new Error(body.error || `primary returned HTTP ${res.status}`);
|
|
78
142
|
}
|
|
79
|
-
return { id: body.runner.id, reused
|
|
143
|
+
return { id: body.runner.id, reused };
|
|
80
144
|
}
|
|
81
145
|
|
|
82
146
|
module.exports = {
|
|
@@ -85,5 +149,12 @@ module.exports = {
|
|
|
85
149
|
detectLanIp,
|
|
86
150
|
loadNodeConfig,
|
|
87
151
|
saveNodeConfig,
|
|
152
|
+
nodesRoot,
|
|
153
|
+
nodeHome,
|
|
154
|
+
listNodeNames,
|
|
155
|
+
loadNodeByName,
|
|
156
|
+
saveNodeByName,
|
|
157
|
+
deleteNodeByName,
|
|
158
|
+
migrateLegacyNodes,
|
|
88
159
|
registerWithPrimary
|
|
89
160
|
};
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/*
|
|
2
|
+
This file is part of Plum.
|
|
3
|
+
Licensed under the MIT License. See LICENSE file in the project root for details.
|
|
4
|
+
*/
|
|
5
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
6
|
+
Backend running on port 9600 (node/runner mode)
|
|
7
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
8
|
+
Backend running on port 9600 (node/runner mode)
|
|
9
|
+
📂 Loading tests from: /Users/silverlunah/Projects/plum/backend/tests
|
|
10
|
+
Backend running on port 9600 (node/runner mode)
|
|
@@ -15,41 +15,37 @@
|
|
|
15
15
|
* Env: PLUM_API_URL primary server API base (default http://localhost:3001)
|
|
16
16
|
*/
|
|
17
17
|
|
|
18
|
+
import { execFileSync } from 'node:child_process';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import { fileURLToPath } from 'node:url';
|
|
18
21
|
import * as clack from '@clack/prompts';
|
|
19
22
|
import pc from 'picocolors';
|
|
20
23
|
import runnerProcess from '../lib/runnerProcess.js';
|
|
21
24
|
import nodeRegister from '../lib/nodeRegister.js';
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
const {
|
|
25
|
-
isLocalUrl,
|
|
26
|
-
parsePort,
|
|
27
|
-
pruneDead,
|
|
28
|
-
statusOf,
|
|
29
|
-
prepareEnv,
|
|
30
|
-
startNode,
|
|
31
|
-
stopNode,
|
|
32
|
-
findPidOnPort,
|
|
33
|
-
killPort,
|
|
34
|
-
nodeReachable
|
|
35
|
-
} = runnerProcess;
|
|
36
|
-
const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig, saveNodeConfig } =
|
|
37
|
-
nodeRegister;
|
|
25
|
+
|
|
26
|
+
const { isLocalUrl, parsePort, pruneDead, statusOf, findPidOnPort } = runnerProcess;
|
|
27
|
+
const { generateToken, detectLanIp, loadNodeByName } = nodeRegister;
|
|
38
28
|
|
|
39
29
|
const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
40
30
|
|
|
31
|
+
// This menu is a thin front-end over the `plum node` commands for anything that
|
|
32
|
+
// touches a node on THIS machine (add / start / restart / stop / delete), so
|
|
33
|
+
// there is exactly one code path and it matches `plum node start` exactly.
|
|
34
|
+
const PLUM_BIN = path.resolve(fileURLToPath(import.meta.url), '../../../bin/plum.js');
|
|
35
|
+
function plumNode(...args) {
|
|
36
|
+
execFileSync(process.execPath, [PLUM_BIN, 'node', ...args], { stdio: 'inherit' });
|
|
37
|
+
}
|
|
38
|
+
|
|
41
39
|
const cancelled = (v) => clack.isCancel(v);
|
|
42
40
|
|
|
43
41
|
// The mutating /runners routes accept any registered runner's own token in
|
|
44
42
|
// place of an admin session (runnerOrAdmin.js). Use one: handed in by `plum`
|
|
45
|
-
// (which reads it from the primary's DB
|
|
46
|
-
//
|
|
43
|
+
// (which reads it from the primary's DB on the server host), or any node's own
|
|
44
|
+
// stored token.
|
|
47
45
|
function resolveRunnerToken() {
|
|
48
46
|
if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
for (const dir of globalRegistry.getInstalls('node')) {
|
|
52
|
-
const token = loadNodeConfig(dir).token;
|
|
47
|
+
for (const name of nodeRegister.listNodeNames()) {
|
|
48
|
+
const token = loadNodeByName(name).token;
|
|
53
49
|
if (token) return token;
|
|
54
50
|
}
|
|
55
51
|
return null;
|
|
@@ -162,55 +158,27 @@ function statusBadge(r) {
|
|
|
162
158
|
return `${dot} ${detail}`;
|
|
163
159
|
}
|
|
164
160
|
|
|
165
|
-
/**
|
|
166
|
-
* Installs backend deps + the Playwright browser so a freshly started node can
|
|
167
|
-
* actually launch a browser. Runs with inherited stdio (outside any spinner) so
|
|
168
|
-
* npm/playwright progress is visible. A failure is surfaced but non-fatal — the
|
|
169
|
-
* operator can retry or fix it manually.
|
|
170
|
-
*/
|
|
171
|
-
function prepareNodeEnv() {
|
|
172
|
-
clack.log.step('Preparing node environment (deps + browsers)...');
|
|
173
|
-
try {
|
|
174
|
-
prepareEnv();
|
|
175
|
-
clack.log.success(pc.green('Environment ready.'));
|
|
176
|
-
return true;
|
|
177
|
-
} catch (e) {
|
|
178
|
-
clack.log.warn(pc.yellow(`Environment prep failed: ${e.message}`));
|
|
179
|
-
return false;
|
|
180
|
-
}
|
|
181
|
-
}
|
|
182
|
-
|
|
183
161
|
async function runAction(r) {
|
|
184
|
-
const
|
|
162
|
+
const localCfg = loadNodeByName(r.name);
|
|
163
|
+
const isLocalNode = Boolean(localCfg.id) && localCfg.id === r.id;
|
|
185
164
|
|
|
186
|
-
|
|
187
|
-
|
|
165
|
+
const options = [];
|
|
166
|
+
if (isLocalNode) {
|
|
188
167
|
options.push(
|
|
168
|
+
{ value: 'restart', label: pc.yellow(r.online ? 'Restart' : 'Start') },
|
|
189
169
|
{ value: 'stop', label: pc.red('Stop') },
|
|
190
|
-
{ value: 'restart', label: pc.yellow('Restart') },
|
|
191
170
|
{ value: 'log', label: 'Show log path' },
|
|
192
171
|
{ value: 'ping', label: 'Ping' }
|
|
193
172
|
);
|
|
194
173
|
} else if (r.online) {
|
|
195
|
-
// Remote, or local but started outside this manager (no PID to own) —
|
|
196
|
-
// either way the runner's own /api/shutdown|restart endpoints are
|
|
197
|
-
// reachable over the network via the primary's control routes.
|
|
198
174
|
options.push(
|
|
199
175
|
{ value: 'stop', label: pc.red('Stop') },
|
|
200
176
|
{ value: 'restart', label: pc.yellow('Restart') },
|
|
201
177
|
{ value: 'ping', label: 'Ping' }
|
|
202
178
|
);
|
|
203
179
|
} else {
|
|
204
|
-
|
|
205
|
-
// unconditionally rather than gating on address-based "is this local"
|
|
206
|
-
// detection. That heuristic breaks the moment this machine's address
|
|
207
|
-
// differs from what was registered (DHCP renewal, VPN, multiple NICs),
|
|
208
|
-
// permanently trapping an offline runner with no way back. Starting
|
|
209
|
-
// here is always safe to attempt: it spawns a managed process this menu
|
|
210
|
-
// can immediately Stop again if the address guess was wrong.
|
|
211
|
-
options.push({ value: 'start', label: pc.green('Start') }, { value: 'ping', label: 'Ping' });
|
|
180
|
+
options.push({ value: 'ping', label: 'Ping' });
|
|
212
181
|
}
|
|
213
|
-
|
|
214
182
|
options.push(
|
|
215
183
|
{ value: 'token', label: 'Show token' },
|
|
216
184
|
{ value: 'delete', label: pc.red('Delete') },
|
|
@@ -220,29 +188,26 @@ async function runAction(r) {
|
|
|
220
188
|
const action = await clack.select({ message: `${r.name} — ${r.url}`, options });
|
|
221
189
|
if (cancelled(action) || action === 'back') return;
|
|
222
190
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
|
|
191
|
+
if (action === 'restart') {
|
|
192
|
+
if (isLocalNode) {
|
|
193
|
+
try {
|
|
194
|
+
plumNode('restart', r.name);
|
|
195
|
+
} catch {}
|
|
196
|
+
} else {
|
|
197
|
+
const s = clack.spinner();
|
|
198
|
+
s.start(`Restarting "${r.name}"...`);
|
|
199
|
+
try {
|
|
200
|
+
await controlRunner(r.id, 'restart');
|
|
201
|
+
s.stop(pc.green(`Restarted "${r.name}"`));
|
|
202
|
+
} catch (e) {
|
|
203
|
+
s.stop(pc.red(`Could not restart "${r.name}": ${e.message}`));
|
|
204
|
+
}
|
|
205
|
+
}
|
|
239
206
|
} else if (action === 'stop') {
|
|
240
|
-
if (
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`)
|
|
245
|
-
);
|
|
207
|
+
if (isLocalNode) {
|
|
208
|
+
try {
|
|
209
|
+
plumNode('stop', r.name);
|
|
210
|
+
} catch {}
|
|
246
211
|
} else {
|
|
247
212
|
const s = clack.spinner();
|
|
248
213
|
s.start(`Stopping "${r.name}"...`);
|
|
@@ -251,56 +216,37 @@ async function runAction(r) {
|
|
|
251
216
|
s.stop(pc.green(`Stopped "${r.name}"`));
|
|
252
217
|
} catch (e) {
|
|
253
218
|
s.stop(pc.red(`Could not stop "${r.name}": ${e.message}`));
|
|
254
|
-
} finally {
|
|
255
|
-
// Belt-and-suspenders: if this runner happens to be local (or
|
|
256
|
-
// the network shutdown call above silently failed), make sure
|
|
257
|
-
// nothing is left bound to its port.
|
|
258
|
-
await killPort(Number(port));
|
|
259
219
|
}
|
|
260
220
|
}
|
|
261
|
-
} else if (action === '
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
221
|
+
} else if (action === 'delete') {
|
|
222
|
+
const confirmed = await clack.confirm({
|
|
223
|
+
message: `Delete "${r.name}" — its process, local config, and primary registration?`,
|
|
224
|
+
initialValue: false
|
|
225
|
+
});
|
|
226
|
+
if (cancelled(confirmed) || !confirmed) return;
|
|
227
|
+
if (isLocalNode) {
|
|
228
|
+
try {
|
|
229
|
+
plumNode('delete', r.name);
|
|
230
|
+
} catch {}
|
|
269
231
|
} else {
|
|
270
232
|
const s = clack.spinner();
|
|
271
|
-
s.start(`
|
|
233
|
+
s.start(`Deleting "${r.name}"...`);
|
|
272
234
|
try {
|
|
273
|
-
await
|
|
274
|
-
s.stop(pc.green(`
|
|
235
|
+
await deleteRunner(r.id);
|
|
236
|
+
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
275
237
|
} catch (e) {
|
|
276
|
-
s.stop(pc.red(`Could not
|
|
238
|
+
s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
|
|
277
239
|
}
|
|
278
240
|
}
|
|
279
241
|
} else if (action === 'log') {
|
|
280
|
-
|
|
281
|
-
clack.note(entry?.logFile ?? '(no log file)', 'Log file');
|
|
242
|
+
clack.note(runnerProcess.loadRegistry()[r.id]?.logFile ?? '(no log file)', 'Log file');
|
|
282
243
|
} else if (action === 'token') {
|
|
283
|
-
clack.note(
|
|
244
|
+
clack.note(localCfg.token || '(stored on the node’s own machine)', 'Auth token');
|
|
284
245
|
} else if (action === 'ping') {
|
|
285
246
|
const s = clack.spinner();
|
|
286
247
|
s.start(`Pinging "${r.name}"...`);
|
|
287
248
|
const online = await pingRunner(r.id);
|
|
288
249
|
s.stop(online ? pc.green(`"${r.name}" is reachable`) : pc.red(`"${r.name}" is unreachable`));
|
|
289
|
-
} else if (action === 'delete') {
|
|
290
|
-
const confirmed = await clack.confirm({
|
|
291
|
-
message: `Delete runner "${r.name}"? This removes it from the server.`,
|
|
292
|
-
initialValue: false
|
|
293
|
-
});
|
|
294
|
-
if (cancelled(confirmed) || !confirmed) return;
|
|
295
|
-
const s = clack.spinner();
|
|
296
|
-
s.start(`Deleting "${r.name}"...`);
|
|
297
|
-
if (r.local) stopNode(r.id, Number(parsePort(r.url)));
|
|
298
|
-
try {
|
|
299
|
-
await deleteRunner(r.id);
|
|
300
|
-
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
301
|
-
} catch (e) {
|
|
302
|
-
s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
|
|
303
|
-
}
|
|
304
250
|
}
|
|
305
251
|
}
|
|
306
252
|
|
|
@@ -317,7 +263,7 @@ async function addRunner() {
|
|
|
317
263
|
let port;
|
|
318
264
|
for (;;) {
|
|
319
265
|
port = await clack.text({
|
|
320
|
-
message: 'Local port
|
|
266
|
+
message: 'Local port this node listens on',
|
|
321
267
|
placeholder: '3002',
|
|
322
268
|
defaultValue: '3002'
|
|
323
269
|
});
|
|
@@ -345,79 +291,26 @@ async function addRunner() {
|
|
|
345
291
|
|
|
346
292
|
const url = resolveNodeUrl(urlInput || defaultUrl);
|
|
347
293
|
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
let id, reused;
|
|
294
|
+
// Exactly `plum node start` — register, start on this machine, verify,
|
|
295
|
+
// persist. One code path for both entry points.
|
|
351
296
|
try {
|
|
352
|
-
(
|
|
353
|
-
|
|
297
|
+
plumNode(
|
|
298
|
+
'start',
|
|
354
299
|
name,
|
|
300
|
+
'--primary',
|
|
301
|
+
API_URL,
|
|
302
|
+
'--url',
|
|
355
303
|
url,
|
|
304
|
+
'--port',
|
|
305
|
+
String(port),
|
|
306
|
+
'--token',
|
|
356
307
|
token,
|
|
357
|
-
browser
|
|
358
|
-
|
|
359
|
-
s.stop(
|
|
360
|
-
reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}"`)
|
|
361
|
-
);
|
|
362
|
-
} catch (e) {
|
|
363
|
-
s.stop(pc.red(`Could not register "${name}": ${e.message}`));
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// A node whose URL points at another machine has to be started over there
|
|
368
|
-
// (`plum node start`), so registering it before it's up is normal — just
|
|
369
|
-
// tell the operator what to run. Only a node we can start from here gets
|
|
370
|
-
// started + verified, and rolled back if it never comes up.
|
|
371
|
-
if (!isLocalUrl(url)) {
|
|
372
|
-
s.start(`Checking ${url}...`);
|
|
373
|
-
const up = await nodeReachable(url, token, 5000);
|
|
374
|
-
s.stop(
|
|
375
|
-
up
|
|
376
|
-
? pc.green(`"${name}" registered and answering`)
|
|
377
|
-
: pc.yellow(`"${name}" registered — no node answering there yet`)
|
|
308
|
+
'--browser',
|
|
309
|
+
'chromium'
|
|
378
310
|
);
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
`Start it on that host:\n plum node start --name ${name} --url ${url} --port <port> ` +
|
|
382
|
-
`--primary ${API_URL} --token ${token}`
|
|
383
|
-
);
|
|
384
|
-
}
|
|
385
|
-
return;
|
|
386
|
-
}
|
|
387
|
-
|
|
388
|
-
prepareNodeEnv();
|
|
389
|
-
if (findPidOnPort(Number(port))) await killPort(Number(port));
|
|
390
|
-
const entry = startNode({ id, port, token });
|
|
391
|
-
s.start(`Starting "${name}" on port ${port}...`);
|
|
392
|
-
const up = await nodeReachable(`http://localhost:${port}`, token, 20000);
|
|
393
|
-
s.stop(up ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
|
|
394
|
-
|
|
395
|
-
if (up) {
|
|
396
|
-
// Persist the same way `plum node start` does, so `plum node stop`/
|
|
397
|
-
// `restart` and `plum update`'s restart sweep can find this node too.
|
|
398
|
-
saveNodeConfig(process.cwd(), {
|
|
399
|
-
...loadNodeConfig(process.cwd()),
|
|
400
|
-
id,
|
|
401
|
-
name,
|
|
402
|
-
url,
|
|
403
|
-
token,
|
|
404
|
-
primary: API_URL,
|
|
405
|
-
browser: 'chromium',
|
|
406
|
-
port
|
|
407
|
-
});
|
|
408
|
-
globalRegistry.registerInstall('node', process.cwd());
|
|
409
|
-
clack.log.success(pc.green(`Runner "${name}" is ready.`));
|
|
410
|
-
return;
|
|
411
|
-
}
|
|
412
|
-
|
|
413
|
-
stopNode(id, Number(port));
|
|
414
|
-
if (!reused) {
|
|
415
|
-
try {
|
|
416
|
-
await deleteRunner(id);
|
|
417
|
-
} catch {}
|
|
311
|
+
} catch {
|
|
312
|
+
clack.log.warn(pc.yellow('Node start reported a problem — see the output above.'));
|
|
418
313
|
}
|
|
419
|
-
if (entry.logFile) clack.note(entry.logFile, 'Node log');
|
|
420
|
-
clack.log.warn(pc.yellow(`"${name}" was not registered — check ${entry.logFile} and try again.`));
|
|
421
314
|
}
|
|
422
315
|
|
|
423
316
|
async function main() {
|
|
@@ -32,10 +32,16 @@ const getAll = async () => {
|
|
|
32
32
|
|
|
33
33
|
const normaliseUrl = (url) => (url ?? '').replace(/\/+$/, '');
|
|
34
34
|
|
|
35
|
+
// Upsert on name+url. Re-registering the same node (`plum node start` run
|
|
36
|
+
// again, a stop/recreate) must refresh its token in place — a second row or a
|
|
37
|
+
// kept-stale token leaves the primary pinging with the wrong credential and the
|
|
38
|
+
// node showing "unreachable".
|
|
35
39
|
const create = async ({ name, url, token, browser = DEFAULT_BROWSER }) => {
|
|
36
|
-
const
|
|
37
|
-
|
|
38
|
-
|
|
40
|
+
const normalisedUrl = normaliseUrl(url);
|
|
41
|
+
const existing = await prisma.runner.findFirst({ where: { name, url: normalisedUrl } });
|
|
42
|
+
const runner = existing
|
|
43
|
+
? await prisma.runner.update({ where: { id: existing.id }, data: { token, browser } })
|
|
44
|
+
: await prisma.runner.create({ data: { name, url: normalisedUrl, token, browser } });
|
|
39
45
|
return toPublicRunner(runner);
|
|
40
46
|
};
|
|
41
47
|
|
package/bin/plum.js
CHANGED
|
@@ -496,11 +496,11 @@ async function serverUpdate() {
|
|
|
496
496
|
const toVersion = readPlumVersion();
|
|
497
497
|
clack.log.success(`Plum CLI updated: ${fromVersion} → ${toVersion}`);
|
|
498
498
|
|
|
499
|
-
//
|
|
500
|
-
//
|
|
501
|
-
// `plum update` happens to be run from.
|
|
499
|
+
// Servers register their directory here when configured; nodes live in the
|
|
500
|
+
// named store (~/.plum/nodes/). Both are found regardless of cwd.
|
|
502
501
|
const { getInstalls } = globalRegistryLib();
|
|
503
|
-
const {
|
|
502
|
+
const { listNodeNames } = nodeRegisterLib();
|
|
503
|
+
migrateLegacyNodes();
|
|
504
504
|
|
|
505
505
|
let restartedAnything = false;
|
|
506
506
|
|
|
@@ -536,15 +536,10 @@ async function serverUpdate() {
|
|
|
536
536
|
}
|
|
537
537
|
}
|
|
538
538
|
|
|
539
|
-
for (const
|
|
540
|
-
const nodeCfg = loadNodeConfig(dir);
|
|
541
|
-
if (!nodeCfg.id) continue;
|
|
542
|
-
|
|
543
|
-
// This registry spans the whole machine, not just the directory
|
|
544
|
-
// `plum update` was run from.
|
|
539
|
+
for (const nodeName of listNodeNames()) {
|
|
545
540
|
if (interactiveAllowed()) {
|
|
546
541
|
const proceed = await clack.confirm({
|
|
547
|
-
message: `
|
|
542
|
+
message: `Restart node "${nodeName}"?`,
|
|
548
543
|
initialValue: true
|
|
549
544
|
});
|
|
550
545
|
if (clack.isCancel(proceed)) cancelAndExit();
|
|
@@ -552,18 +547,15 @@ async function serverUpdate() {
|
|
|
552
547
|
}
|
|
553
548
|
|
|
554
549
|
// Always attempt the restart rather than gating on the local PID
|
|
555
|
-
// registry: that registry goes stale
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
|
|
559
|
-
// while actually being unreachable. `plum node restart` itself falls
|
|
560
|
-
// back to port-based PID discovery, so it's safe to call unconditionally.
|
|
561
|
-
clack.log.step(`Restarting node runner at ${dir}…`);
|
|
550
|
+
// registry: that registry goes stale and skipping the restart silently
|
|
551
|
+
// leaves the OLD node process running mismatched code — it still answers
|
|
552
|
+
// /api/ping so it looks "online" while actually being unreachable.
|
|
553
|
+
clack.log.step(`Restarting node "${nodeName}"…`);
|
|
562
554
|
try {
|
|
563
|
-
execSync(
|
|
555
|
+
execSync(`plum node restart ${nodeName}`, { stdio: 'inherit' });
|
|
564
556
|
restartedAnything = true;
|
|
565
557
|
} catch (e) {
|
|
566
|
-
clack.log.warn(`Could not restart node
|
|
558
|
+
clack.log.warn(`Could not restart node "${nodeName}": ${e.message}`);
|
|
567
559
|
}
|
|
568
560
|
}
|
|
569
561
|
|
|
@@ -588,40 +580,70 @@ async function serverReconfig() {
|
|
|
588
580
|
* Node flow
|
|
589
581
|
* ------------------------------------------------------ */
|
|
590
582
|
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
const
|
|
583
|
+
// `plum node <sub> <name>` — the first positional after the subcommand.
|
|
584
|
+
function nodeNameArg() {
|
|
585
|
+
const a = process.argv[4];
|
|
586
|
+
return a && !a.startsWith('-') ? a : null;
|
|
587
|
+
}
|
|
588
|
+
|
|
589
|
+
// Resolves which node a command targets: an explicit name, or the only one on
|
|
590
|
+
// this machine. Prints guidance and returns null when it can't decide.
|
|
591
|
+
function resolveNodeName(explicit) {
|
|
592
|
+
if (explicit) return explicit;
|
|
593
|
+
const names = nodeRegisterLib().listNodeNames();
|
|
594
|
+
if (names.length === 1) return names[0];
|
|
595
|
+
if (names.length === 0) {
|
|
596
|
+
clack.log.warn('No nodes configured here yet — run `plum node start <name>`.');
|
|
597
|
+
} else {
|
|
598
|
+
clack.log.warn(`Name which node: ${names.map((n) => pc.cyan(n)).join(' ')}`);
|
|
599
|
+
}
|
|
600
|
+
return null;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// One-time: pull any legacy `.plum-node.json` (older `plum node start` wrote it
|
|
604
|
+
// into whatever directory it ran in) into ~/.plum/nodes/<name>/.
|
|
605
|
+
function migrateLegacyNodes() {
|
|
606
|
+
const { migrateLegacyNodes: run } = nodeRegisterLib();
|
|
607
|
+
const { getInstalls } = globalRegistryLib();
|
|
608
|
+
const imported = run(getInstalls('node'));
|
|
609
|
+
if (imported.length) {
|
|
610
|
+
clack.log.info(`Imported node config: ${imported.join(', ')}`);
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
async function configureNode({ force, name: nameArg }) {
|
|
615
|
+
const { generateToken, detectLanIp, loadNodeByName, saveNodeByName, nodeHome } =
|
|
616
|
+
nodeRegisterLib();
|
|
594
617
|
const args = process.argv.slice(3);
|
|
595
|
-
|
|
618
|
+
|
|
619
|
+
let name = nameArg ?? getFlag(args, '--name') ?? null;
|
|
620
|
+
const interactive =
|
|
621
|
+
force ||
|
|
622
|
+
(interactiveAllowed() &&
|
|
623
|
+
!name &&
|
|
624
|
+
!anyFlags(args, ['--primary', '--url', '--port', '--token', '--browser']));
|
|
625
|
+
|
|
626
|
+
if (!name && interactive) {
|
|
627
|
+
const v = await clack.text({
|
|
628
|
+
message: 'Runner name',
|
|
629
|
+
placeholder: 'node-1',
|
|
630
|
+
defaultValue: 'node-1'
|
|
631
|
+
});
|
|
632
|
+
if (clack.isCancel(v)) cancelAndExit();
|
|
633
|
+
name = v || 'node-1';
|
|
634
|
+
}
|
|
635
|
+
if (!name) name = `node-${generateToken().slice(0, 6)}`;
|
|
636
|
+
|
|
637
|
+
const saved = loadNodeByName(name);
|
|
596
638
|
|
|
597
639
|
let primary = getFlag(args, '--primary') ?? process.env.PRIMARY_URL ?? saved.primary ?? '';
|
|
598
640
|
// Not 3001 — that's the primary's default; a co-located node must not collide.
|
|
599
641
|
let port = getFlag(args, '--port') ?? saved.port ?? '3002';
|
|
600
642
|
let browser = getFlag(args, '--browser') ?? saved.browser ?? 'chromium';
|
|
601
643
|
let token = getFlag(args, '--token') ?? process.env.NODE_TOKEN ?? saved.token ?? generateToken();
|
|
602
|
-
let name = getFlag(args, '--name') ?? saved.name ?? `node-${token.slice(0, 6)}`;
|
|
603
|
-
// A provided --url is advertised verbatim; otherwise fall back to host:port.
|
|
604
644
|
let url = getFlag(args, '--url') ?? saved.url ?? '';
|
|
605
645
|
|
|
606
|
-
const hasFlags = anyFlags(args, [
|
|
607
|
-
'--primary',
|
|
608
|
-
'--url',
|
|
609
|
-
'--port',
|
|
610
|
-
'--token',
|
|
611
|
-
'--name',
|
|
612
|
-
'--browser'
|
|
613
|
-
]);
|
|
614
|
-
const interactive = force || (interactiveAllowed() && !hasFlags);
|
|
615
|
-
|
|
616
646
|
if (interactive) {
|
|
617
|
-
const nameVal = await clack.text({
|
|
618
|
-
message: 'Runner name',
|
|
619
|
-
placeholder: name,
|
|
620
|
-
defaultValue: name
|
|
621
|
-
});
|
|
622
|
-
if (clack.isCancel(nameVal)) cancelAndExit();
|
|
623
|
-
name = nameVal || name;
|
|
624
|
-
|
|
625
647
|
const primaryVal = await clack.text({
|
|
626
648
|
message: 'Your Plum server backend URL',
|
|
627
649
|
placeholder: primary || 'http://localhost:3001',
|
|
@@ -640,7 +662,7 @@ async function configureNode({ force }) {
|
|
|
640
662
|
|
|
641
663
|
const defaultUrl = url || `http://${detectLanIp()}:${port}`;
|
|
642
664
|
const urlVal = await clack.text({
|
|
643
|
-
message: 'The URL your Plum server calls to
|
|
665
|
+
message: 'The URL your Plum server calls to reach this node',
|
|
644
666
|
placeholder: defaultUrl,
|
|
645
667
|
defaultValue: defaultUrl
|
|
646
668
|
});
|
|
@@ -655,7 +677,7 @@ async function configureNode({ force }) {
|
|
|
655
677
|
process.exit(1);
|
|
656
678
|
}
|
|
657
679
|
|
|
658
|
-
|
|
680
|
+
saveNodeByName(name, {
|
|
659
681
|
id: saved.id ?? null,
|
|
660
682
|
name,
|
|
661
683
|
url,
|
|
@@ -665,47 +687,32 @@ async function configureNode({ force }) {
|
|
|
665
687
|
port,
|
|
666
688
|
pid: saved.pid ?? null
|
|
667
689
|
});
|
|
668
|
-
globalRegistryLib().registerInstall('node',
|
|
690
|
+
globalRegistryLib().registerInstall('node', nodeHome(name));
|
|
669
691
|
return { primary, port, browser, token, name, url };
|
|
670
692
|
}
|
|
671
693
|
|
|
672
694
|
async function registerNode({ primary, name, url, token, browser, port }) {
|
|
673
|
-
const { registerWithPrimary,
|
|
695
|
+
const { registerWithPrimary, loadNodeByName, saveNodeByName } = nodeRegisterLib();
|
|
674
696
|
let registeredId = null;
|
|
675
697
|
|
|
676
698
|
if (primary) {
|
|
677
699
|
const s = clack.spinner();
|
|
678
|
-
s.start(`Registering with primary at ${primary}...`);
|
|
700
|
+
s.start(`Registering "${name}" with primary at ${primary}...`);
|
|
679
701
|
try {
|
|
680
702
|
const { id, reused } = await registerWithPrimary({ primary, name, url, token, browser });
|
|
681
703
|
registeredId = id;
|
|
682
|
-
s.stop(
|
|
704
|
+
s.stop(
|
|
705
|
+
pc.green(reused ? `✓ Updated "${name}" on primary` : `✓ Registered "${name}" on primary`)
|
|
706
|
+
);
|
|
683
707
|
} catch (e) {
|
|
684
708
|
s.stop(pc.yellow(`Could not register with primary: ${e.message}`));
|
|
685
|
-
clack.log.warn('Add this runner manually using the details below.');
|
|
686
709
|
}
|
|
687
710
|
} else {
|
|
688
|
-
clack.log.
|
|
711
|
+
clack.log.warn('No --primary given — the node is configured but not registered anywhere.');
|
|
689
712
|
}
|
|
690
713
|
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
registeredId ? `id: ${registeredId}` : 'id: (assigned when added on the server)',
|
|
694
|
-
`name: ${name}`,
|
|
695
|
-
`url: ${url}`,
|
|
696
|
-
`token: ${token}`,
|
|
697
|
-
`browser: ${browser}`
|
|
698
|
-
].join('\n'),
|
|
699
|
-
'Runner details'
|
|
700
|
-
);
|
|
701
|
-
|
|
702
|
-
clack.log.info(
|
|
703
|
-
`The url above must be reachable from the primary. The local port (${port}) is only what this node listens on — forward your proxy/domain to it.`
|
|
704
|
-
);
|
|
705
|
-
|
|
706
|
-
const cwd = process.cwd();
|
|
707
|
-
saveNodeConfig(cwd, {
|
|
708
|
-
...loadNodeConfig(cwd),
|
|
714
|
+
saveNodeByName(name, {
|
|
715
|
+
...loadNodeByName(name),
|
|
709
716
|
id: registeredId,
|
|
710
717
|
name,
|
|
711
718
|
url,
|
|
@@ -717,168 +724,171 @@ async function registerNode({ primary, name, url, token, browser, port }) {
|
|
|
717
724
|
return registeredId;
|
|
718
725
|
}
|
|
719
726
|
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
const { loadNodeConfig } = nodeRegisterLib();
|
|
725
|
-
const { statusOf } = runnerProcessLib();
|
|
726
|
-
const existing = loadNodeConfig(process.cwd());
|
|
727
|
+
// Register a node with the primary and start its process here — this is the one
|
|
728
|
+
// path both `plum node start` and manage-runners' "Add new runner" run.
|
|
729
|
+
async function bringNodeUp(cfg) {
|
|
730
|
+
const { prepareEnv, startNode, findPidOnPort, killPort, nodeReachable } = runnerProcessLib();
|
|
727
731
|
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
const wantName = getFlag(process.argv.slice(3), '--name');
|
|
732
|
-
if (!reconfig && existing.name && wantName && wantName !== existing.name) {
|
|
733
|
-
clack.log.error(
|
|
734
|
-
`This folder is set up for node "${existing.name}". Run each node from its own folder:\n\n` +
|
|
735
|
-
` mkdir -p ~/plum-nodes/${wantName} && cd ~/plum-nodes/${wantName}\n` +
|
|
736
|
-
` plum node start --name ${wantName} …`
|
|
737
|
-
);
|
|
738
|
-
clack.outro(pc.red('Not started — use a fresh folder for this node.'));
|
|
732
|
+
const registeredId = await registerNode(cfg);
|
|
733
|
+
if (!registeredId) {
|
|
734
|
+
clack.outro(pc.red('Node not started.'));
|
|
739
735
|
process.exitCode = 1;
|
|
740
736
|
return;
|
|
741
737
|
}
|
|
742
738
|
|
|
743
|
-
// Re-running `node start` on an already-running node used to spawn a second
|
|
744
|
-
// process on the same port (orphaning the first) and re-register a duplicate
|
|
745
|
-
// runner on the primary. Route to the same menu this command ends on anyway
|
|
746
|
-
// instead of repeating the whole configure/register/spawn dance.
|
|
747
|
-
if (!reconfig && existing.id && statusOf(String(existing.id)) === 'running') {
|
|
748
|
-
clack.log.info(
|
|
749
|
-
`Node "${existing.name ?? existing.id}" is already running from this folder — opening the runner menu instead of starting a new one.`
|
|
750
|
-
);
|
|
751
|
-
await openManageRunnersMenu(existing.primary);
|
|
752
|
-
clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
|
|
753
|
-
return;
|
|
754
|
-
}
|
|
755
|
-
|
|
756
|
-
const cfg = await configureNode({ force: reconfig });
|
|
757
|
-
const registeredId = await registerNode(cfg);
|
|
758
|
-
|
|
759
|
-
const {
|
|
760
|
-
prepareEnv,
|
|
761
|
-
startNode: startNodeProc,
|
|
762
|
-
findPidOnPort,
|
|
763
|
-
killPort,
|
|
764
|
-
nodeReachable
|
|
765
|
-
} = runnerProcessLib();
|
|
766
|
-
|
|
767
739
|
clack.log.step('Preparing environment (deps + browsers)...');
|
|
768
740
|
try {
|
|
769
741
|
prepareEnv();
|
|
770
|
-
clack.log.success('Environment ready.');
|
|
771
742
|
} catch (e) {
|
|
772
743
|
clack.log.error(
|
|
773
|
-
`Environment prep failed: ${e.message}
|
|
744
|
+
`Environment prep failed: ${e.message} — not starting (tests would fail at browser launch).`
|
|
774
745
|
);
|
|
775
746
|
clack.outro(pc.red('Node not started.'));
|
|
776
747
|
process.exitCode = 1;
|
|
777
748
|
return;
|
|
778
749
|
}
|
|
779
750
|
|
|
780
|
-
if (
|
|
781
|
-
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
if (up) {
|
|
793
|
-
clack.log.success(
|
|
794
|
-
pc.green(
|
|
795
|
-
`Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at ${entry.logFile}`
|
|
796
|
-
)
|
|
797
|
-
);
|
|
798
|
-
} else {
|
|
799
|
-
clack.log.error(
|
|
800
|
-
pc.red(
|
|
801
|
-
`Node "${cfg.name}" did not come up on port ${cfg.port}. Check ${entry.logFile} — the port may still be held by another process.`
|
|
802
|
-
)
|
|
803
|
-
);
|
|
804
|
-
process.exitCode = 1;
|
|
805
|
-
}
|
|
806
|
-
} catch (e) {
|
|
807
|
-
clack.log.warn(`Could not start runner process: ${e.message}`);
|
|
808
|
-
}
|
|
751
|
+
if (findPidOnPort(Number(cfg.port))) {
|
|
752
|
+
clack.log.step(`Port ${cfg.port} is in use — freeing it...`);
|
|
753
|
+
await killPort(Number(cfg.port));
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
const entry = startNode({ id: String(registeredId), port: cfg.port, token: cfg.token });
|
|
757
|
+
clack.log.step(`Starting "${cfg.name}" on port ${cfg.port} (pid ${entry.pid})...`);
|
|
758
|
+
const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
|
|
759
|
+
if (up) {
|
|
760
|
+
clack.outro(
|
|
761
|
+
pc.green(`Node "${cfg.name}" running (pid ${entry.pid}) — logs at ${entry.logFile}`)
|
|
762
|
+
);
|
|
809
763
|
} else {
|
|
810
|
-
clack.log.
|
|
764
|
+
clack.log.error(
|
|
765
|
+
pc.red(
|
|
766
|
+
`Node "${cfg.name}" isn't answering on port ${cfg.port}. Check ${entry.logFile}. ` +
|
|
767
|
+
`If ${cfg.url} is a proxy/domain, make sure it forwards here on ${cfg.port}.`
|
|
768
|
+
)
|
|
769
|
+
);
|
|
770
|
+
process.exitCode = 1;
|
|
771
|
+
clack.outro(pc.red('Node started but unverified.'));
|
|
811
772
|
}
|
|
773
|
+
}
|
|
812
774
|
|
|
813
|
-
|
|
814
|
-
clack.
|
|
775
|
+
async function nodeStart({ reconfig, name }) {
|
|
776
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
|
|
777
|
+
migrateLegacyNodes();
|
|
778
|
+
const cfg = await configureNode({ force: reconfig, name });
|
|
779
|
+
await bringNodeUp(cfg);
|
|
815
780
|
}
|
|
816
781
|
|
|
817
|
-
async function nodeRestart() {
|
|
782
|
+
async function nodeRestart({ name: nameArg }) {
|
|
818
783
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
|
|
819
|
-
|
|
784
|
+
migrateLegacyNodes();
|
|
785
|
+
const { loadNodeByName } = nodeRegisterLib();
|
|
820
786
|
const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
|
|
821
|
-
const cfg = loadNodeConfig(process.cwd());
|
|
822
787
|
|
|
788
|
+
const target = resolveNodeName(nameArg);
|
|
789
|
+
if (!target) return clack.outro(pc.dim('Done.'));
|
|
790
|
+
const cfg = loadNodeByName(target);
|
|
823
791
|
if (!cfg.id) {
|
|
824
|
-
clack.
|
|
825
|
-
clack.outro(pc.dim('Done.'));
|
|
792
|
+
clack.outro(pc.yellow(`"${target}" isn't registered — run \`plum node start ${target}\`.`));
|
|
826
793
|
return;
|
|
827
794
|
}
|
|
828
795
|
|
|
829
|
-
|
|
830
|
-
// the local registry entry is missing or stale (e.g. after `plum update`
|
|
831
|
-
// reinstalls in a fresh process) — otherwise a still-running old process is
|
|
832
|
-
// left bound to the port while a new one starts up alongside it.
|
|
833
|
-
const stopped = stopNode(String(cfg.id), Number(cfg.port));
|
|
834
|
-
if (stopped) {
|
|
835
|
-
clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
|
|
836
|
-
} else {
|
|
837
|
-
clack.log.info('Node was not running — starting fresh.');
|
|
838
|
-
}
|
|
839
|
-
|
|
840
|
-
clack.log.step('Refreshing dependencies…');
|
|
796
|
+
stopNode(String(cfg.id), Number(cfg.port));
|
|
841
797
|
try {
|
|
842
798
|
prepareEnv();
|
|
843
799
|
} catch (e) {
|
|
800
|
+
clack.log.error(`Dependency refresh failed: ${e.message}`);
|
|
801
|
+
clack.outro(pc.red('Node not restarted.'));
|
|
802
|
+
process.exitCode = 1;
|
|
803
|
+
return;
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
await killPort(Number(cfg.port));
|
|
807
|
+
const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
|
|
808
|
+
const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
|
|
809
|
+
if (up) {
|
|
810
|
+
clack.outro(pc.green(`"${target}" restarted (pid ${entry.pid}).`));
|
|
811
|
+
} else {
|
|
844
812
|
clack.log.error(
|
|
845
|
-
`
|
|
813
|
+
pc.red(`"${target}" didn't come back on port ${cfg.port} — check ${entry.logFile}.`)
|
|
846
814
|
);
|
|
847
|
-
clack.outro(pc.red('Node not restarted.'));
|
|
848
815
|
process.exitCode = 1;
|
|
816
|
+
clack.outro(pc.red('Restart unverified.'));
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
async function nodeStop({ name: nameArg }) {
|
|
821
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Stop ')));
|
|
822
|
+
migrateLegacyNodes();
|
|
823
|
+
const { loadNodeByName } = nodeRegisterLib();
|
|
824
|
+
const { stopNode, killPort } = runnerProcessLib();
|
|
825
|
+
|
|
826
|
+
const target = resolveNodeName(nameArg);
|
|
827
|
+
if (!target) return clack.outro(pc.dim('Done.'));
|
|
828
|
+
const cfg = loadNodeByName(target);
|
|
829
|
+
const stopped = stopNode(String(cfg.id ?? target), cfg.port ? Number(cfg.port) : null);
|
|
830
|
+
if (cfg.port) await killPort(Number(cfg.port));
|
|
831
|
+
clack.outro(stopped ? pc.green(`Stopped "${target}".`) : pc.dim(`"${target}" wasn't running.`));
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async function nodeList() {
|
|
835
|
+
migrateLegacyNodes();
|
|
836
|
+
const { listNodeNames, loadNodeByName } = nodeRegisterLib();
|
|
837
|
+
const { statusOf } = runnerProcessLib();
|
|
838
|
+
const names = listNodeNames();
|
|
839
|
+
if (names.length === 0) {
|
|
840
|
+
clack.log.info('No nodes on this machine — add one with `plum node start <name>`.');
|
|
849
841
|
return;
|
|
850
842
|
}
|
|
843
|
+
for (const n of names) {
|
|
844
|
+
const c = loadNodeByName(n);
|
|
845
|
+
const running = c.id && statusOf(String(c.id)) === 'running';
|
|
846
|
+
console.log(
|
|
847
|
+
`${running ? pc.green('●') : pc.dim('○')} ${n.padEnd(16)} ${pc.dim((c.url || '') + ' :' + (c.port || '?'))}`
|
|
848
|
+
);
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
851
|
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
|
|
861
|
-
|
|
862
|
-
|
|
863
|
-
|
|
864
|
-
|
|
865
|
-
|
|
852
|
+
async function nodeDelete({ name: nameArg }) {
|
|
853
|
+
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Delete ')));
|
|
854
|
+
migrateLegacyNodes();
|
|
855
|
+
const { loadNodeByName, deleteNodeByName, nodeHome } = nodeRegisterLib();
|
|
856
|
+
const { stopNode, killPort } = runnerProcessLib();
|
|
857
|
+
const { unregisterInstall } = globalRegistryLib();
|
|
858
|
+
|
|
859
|
+
const target = nameArg || resolveNodeName(null);
|
|
860
|
+
if (!target) return clack.outro(pc.dim('Done.'));
|
|
861
|
+
const cfg = loadNodeByName(target);
|
|
862
|
+
|
|
863
|
+
stopNode(String(cfg.id ?? target), cfg.port ? Number(cfg.port) : null);
|
|
864
|
+
if (cfg.port) await killPort(Number(cfg.port));
|
|
865
|
+
|
|
866
|
+
if (cfg.id && cfg.primary) {
|
|
867
|
+
try {
|
|
868
|
+
const res = await fetch(`${cfg.primary.replace(/\/$/, '')}/runners/${cfg.id}`, {
|
|
869
|
+
method: 'DELETE',
|
|
870
|
+
headers: { Authorization: `Bearer ${cfg.token}` },
|
|
871
|
+
signal: AbortSignal.timeout(10000)
|
|
872
|
+
});
|
|
873
|
+
clack.log[res.ok ? 'success' : 'warn'](
|
|
874
|
+
res.ok ? 'Removed from primary.' : `Primary responded HTTP ${res.status}`
|
|
866
875
|
);
|
|
867
|
-
|
|
868
|
-
clack.
|
|
876
|
+
} catch (e) {
|
|
877
|
+
clack.log.warn(`Could not reach primary: ${e.message}`);
|
|
869
878
|
}
|
|
870
|
-
} catch (e) {
|
|
871
|
-
clack.log.warn(`Could not restart node: ${e.message}`);
|
|
872
|
-
clack.outro(pc.red('Node not restarted.'));
|
|
873
879
|
}
|
|
880
|
+
|
|
881
|
+
deleteNodeByName(target);
|
|
882
|
+
unregisterInstall('node', nodeHome(target));
|
|
883
|
+
clack.outro(pc.green(`Deleted "${target}" — process, local config, and primary registration.`));
|
|
874
884
|
}
|
|
875
885
|
|
|
876
|
-
async function nodeReconfig() {
|
|
886
|
+
async function nodeReconfig({ name }) {
|
|
877
887
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Node ')));
|
|
878
|
-
|
|
888
|
+
migrateLegacyNodes();
|
|
889
|
+
const cfg = await configureNode({ force: true, name });
|
|
879
890
|
await registerNode(cfg);
|
|
880
|
-
clack.
|
|
881
|
-
clack.outro(pc.dim('Done.'));
|
|
891
|
+
clack.outro(pc.dim(`Saved. Run \`plum node restart ${cfg.name}\` to apply.`));
|
|
882
892
|
}
|
|
883
893
|
|
|
884
894
|
// stop/restart/delete on the /runners API want a registered runner's token
|
|
@@ -1065,8 +1075,8 @@ switch (command) {
|
|
|
1065
1075
|
'| `plum server reconfig` | Change server URL/ports without starting |',
|
|
1066
1076
|
'| `plum stop` | Stop the server |',
|
|
1067
1077
|
'| `plum create-step` | Interactively generate a new step definition |',
|
|
1068
|
-
'| `plum node start
|
|
1069
|
-
'| `plum node
|
|
1078
|
+
'| `plum node start <name>` | Register a runner node and start it here |',
|
|
1079
|
+
'| `plum node list` | List this machine’s nodes |',
|
|
1070
1080
|
'',
|
|
1071
1081
|
'---',
|
|
1072
1082
|
'',
|
|
@@ -1305,64 +1315,68 @@ switch (command) {
|
|
|
1305
1315
|
break;
|
|
1306
1316
|
|
|
1307
1317
|
case 'node': {
|
|
1318
|
+
const nodeName = nodeNameArg();
|
|
1319
|
+
if (subcommand === '-h' || subcommand === '--help') {
|
|
1320
|
+
console.log(
|
|
1321
|
+
[
|
|
1322
|
+
'',
|
|
1323
|
+
`${pc.bold('Usage:')} plum node <command> [name] [options]`,
|
|
1324
|
+
'',
|
|
1325
|
+
' start [name] register a node with the primary and start it here',
|
|
1326
|
+
' list list this machine’s nodes and their status',
|
|
1327
|
+
' restart [name] stop, refresh deps, restart a node',
|
|
1328
|
+
' stop [name] stop a node',
|
|
1329
|
+
' delete <name> stop it, delete its config, unregister it from the primary',
|
|
1330
|
+
' reconfig [name] re-enter settings and re-register, without starting',
|
|
1331
|
+
'',
|
|
1332
|
+
' Options for start: --primary <url> --url <url> --port <n> --token <s> --browser <chromium|firefox>',
|
|
1333
|
+
''
|
|
1334
|
+
].join('\n')
|
|
1335
|
+
);
|
|
1336
|
+
break;
|
|
1337
|
+
}
|
|
1308
1338
|
if (subcommand === 'stop') {
|
|
1309
|
-
|
|
1310
|
-
const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
1311
|
-
const { stopNode } = runnerProcessLib();
|
|
1312
|
-
const cfg = loadNodeConfig(process.cwd());
|
|
1313
|
-
|
|
1314
|
-
if (cfg.id) {
|
|
1315
|
-
const stopped = stopNode(String(cfg.id), Number(cfg.port));
|
|
1316
|
-
if (stopped) {
|
|
1317
|
-
clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
|
|
1318
|
-
} else if (cfg.pid) {
|
|
1319
|
-
try {
|
|
1320
|
-
process.kill(cfg.pid, 'SIGTERM');
|
|
1321
|
-
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
1322
|
-
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
1323
|
-
} catch {
|
|
1324
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1325
|
-
}
|
|
1326
|
-
} else {
|
|
1327
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1328
|
-
}
|
|
1329
|
-
} else if (cfg.pid) {
|
|
1330
|
-
try {
|
|
1331
|
-
process.kill(cfg.pid, 'SIGTERM');
|
|
1332
|
-
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
1333
|
-
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
1334
|
-
} catch {
|
|
1335
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1336
|
-
}
|
|
1337
|
-
} else {
|
|
1338
|
-
clack.log.info('No node started from this folder.');
|
|
1339
|
-
clack.log.info(`Use ${pc.cyan('plum manage-runners')} to stop running nodes.`);
|
|
1340
|
-
}
|
|
1341
|
-
clack.outro(pc.dim('Done.'));
|
|
1339
|
+
await nodeStop({ name: nodeName });
|
|
1342
1340
|
break;
|
|
1343
1341
|
}
|
|
1344
|
-
|
|
1345
1342
|
if (subcommand === 'restart') {
|
|
1346
|
-
await nodeRestart();
|
|
1343
|
+
await nodeRestart({ name: nodeName });
|
|
1347
1344
|
break;
|
|
1348
1345
|
}
|
|
1349
|
-
|
|
1350
1346
|
if (subcommand === 'reconfig') {
|
|
1351
|
-
await nodeReconfig();
|
|
1347
|
+
await nodeReconfig({ name: nodeName });
|
|
1352
1348
|
break;
|
|
1353
1349
|
}
|
|
1354
|
-
|
|
1355
|
-
|
|
1350
|
+
if (subcommand === 'list' || subcommand === 'ls') {
|
|
1351
|
+
await nodeList();
|
|
1352
|
+
break;
|
|
1353
|
+
}
|
|
1354
|
+
if (subcommand === 'delete' || subcommand === 'rm') {
|
|
1355
|
+
await nodeDelete({ name: nodeName });
|
|
1356
|
+
break;
|
|
1357
|
+
}
|
|
1358
|
+
// `plum node start [name]` or bare `plum node` or `plum node <name>`
|
|
1359
|
+
await nodeStart({
|
|
1360
|
+
reconfig: false,
|
|
1361
|
+
name:
|
|
1362
|
+
subcommand === 'start'
|
|
1363
|
+
? nodeName
|
|
1364
|
+
: subcommand && !subcommand.startsWith('-')
|
|
1365
|
+
? subcommand
|
|
1366
|
+
: null
|
|
1367
|
+
});
|
|
1356
1368
|
break;
|
|
1357
1369
|
}
|
|
1358
1370
|
|
|
1359
1371
|
case 'manage-runners': {
|
|
1360
|
-
const {
|
|
1361
|
-
const
|
|
1372
|
+
const { listNodeNames, loadNodeByName } = nodeRegisterLib();
|
|
1373
|
+
const firstNode = listNodeNames()
|
|
1374
|
+
.map(loadNodeByName)
|
|
1375
|
+
.find((c) => c.primary);
|
|
1362
1376
|
const primaryUrl =
|
|
1363
1377
|
getFlag(process.argv.slice(3), '--primary') ??
|
|
1364
1378
|
process.env.PLUM_API_URL ??
|
|
1365
|
-
|
|
1379
|
+
firstNode?.primary ??
|
|
1366
1380
|
'http://localhost:3001';
|
|
1367
1381
|
await openManageRunnersMenu(primaryUrl);
|
|
1368
1382
|
break;
|
|
@@ -1415,19 +1429,24 @@ switch (command) {
|
|
|
1415
1429
|
console.log(
|
|
1416
1430
|
' update Update Plum and restart whichever is running (server/node)'
|
|
1417
1431
|
);
|
|
1418
|
-
console.log(' node start
|
|
1419
|
-
console.log(' --primary <url> Primary Plum server to
|
|
1432
|
+
console.log(' node start [name] Register a node with the primary and start it here');
|
|
1433
|
+
console.log(' --primary <url> Primary Plum server to register with');
|
|
1420
1434
|
console.log(' --url <url> Address the primary calls back (default: <lan-ip>:<port>;');
|
|
1421
1435
|
console.log(
|
|
1422
1436
|
' pass a domain like https://node1.example behind a TLS proxy)'
|
|
1423
1437
|
);
|
|
1424
|
-
console.log(' --port <n> Local HTTP port the node listens on (default:
|
|
1438
|
+
console.log(' --port <n> Local HTTP port the node listens on (default: 3002)');
|
|
1425
1439
|
console.log(' --token <secret> Auth token (auto-generated + saved if omitted)');
|
|
1426
|
-
console.log(' --name <name> Runner name shown on the primary (default: node-<rand>)');
|
|
1427
1440
|
console.log(' --browser <name> chromium | firefox (default: chromium)');
|
|
1428
|
-
console.log(' node
|
|
1429
|
-
console.log(' node
|
|
1430
|
-
console.log(' node stop
|
|
1441
|
+
console.log(' node list List this machine’s nodes and their status');
|
|
1442
|
+
console.log(' node restart [name] Stop, refresh deps, and restart a node');
|
|
1443
|
+
console.log(' node stop [name] Stop a node');
|
|
1444
|
+
console.log(
|
|
1445
|
+
' node delete <name> Stop it, remove its config, and unregister it from the primary'
|
|
1446
|
+
);
|
|
1447
|
+
console.log(
|
|
1448
|
+
' node reconfig [name] Re-enter a node’s settings and re-register, without starting'
|
|
1449
|
+
);
|
|
1431
1450
|
console.log(' manage-runners Open the runner management menu');
|
|
1432
1451
|
console.log(
|
|
1433
1452
|
' --primary <url> Primary server URL (default: saved config or localhost:3001)'
|