plum-e2e 2.9.9 → 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 -163
- package/backend/services/runnerService.js +9 -3
- package/bin/plum.js +259 -225
- 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,40 +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 } = nodeRegister;
|
|
25
|
+
|
|
26
|
+
const { isLocalUrl, parsePort, pruneDead, statusOf, findPidOnPort } = runnerProcess;
|
|
27
|
+
const { generateToken, detectLanIp, loadNodeByName } = nodeRegister;
|
|
37
28
|
|
|
38
29
|
const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
|
|
39
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
|
+
|
|
40
39
|
const cancelled = (v) => clack.isCancel(v);
|
|
41
40
|
|
|
42
41
|
// The mutating /runners routes accept any registered runner's own token in
|
|
43
42
|
// place of an admin session (runnerOrAdmin.js). Use one: handed in by `plum`
|
|
44
|
-
// (which reads it from the primary's DB
|
|
45
|
-
//
|
|
43
|
+
// (which reads it from the primary's DB on the server host), or any node's own
|
|
44
|
+
// stored token.
|
|
46
45
|
function resolveRunnerToken() {
|
|
47
46
|
if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
|
|
48
|
-
const
|
|
49
|
-
|
|
50
|
-
for (const dir of globalRegistry.getInstalls('node')) {
|
|
51
|
-
const token = loadNodeConfig(dir).token;
|
|
47
|
+
for (const name of nodeRegister.listNodeNames()) {
|
|
48
|
+
const token = loadNodeByName(name).token;
|
|
52
49
|
if (token) return token;
|
|
53
50
|
}
|
|
54
51
|
return null;
|
|
@@ -161,55 +158,27 @@ function statusBadge(r) {
|
|
|
161
158
|
return `${dot} ${detail}`;
|
|
162
159
|
}
|
|
163
160
|
|
|
164
|
-
/**
|
|
165
|
-
* Installs backend deps + the Playwright browser so a freshly started node can
|
|
166
|
-
* actually launch a browser. Runs with inherited stdio (outside any spinner) so
|
|
167
|
-
* npm/playwright progress is visible. A failure is surfaced but non-fatal — the
|
|
168
|
-
* operator can retry or fix it manually.
|
|
169
|
-
*/
|
|
170
|
-
function prepareNodeEnv() {
|
|
171
|
-
clack.log.step('Preparing node environment (deps + browsers)...');
|
|
172
|
-
try {
|
|
173
|
-
prepareEnv();
|
|
174
|
-
clack.log.success(pc.green('Environment ready.'));
|
|
175
|
-
return true;
|
|
176
|
-
} catch (e) {
|
|
177
|
-
clack.log.warn(pc.yellow(`Environment prep failed: ${e.message}`));
|
|
178
|
-
return false;
|
|
179
|
-
}
|
|
180
|
-
}
|
|
181
|
-
|
|
182
161
|
async function runAction(r) {
|
|
183
|
-
const
|
|
162
|
+
const localCfg = loadNodeByName(r.name);
|
|
163
|
+
const isLocalNode = Boolean(localCfg.id) && localCfg.id === r.id;
|
|
184
164
|
|
|
185
|
-
|
|
186
|
-
|
|
165
|
+
const options = [];
|
|
166
|
+
if (isLocalNode) {
|
|
187
167
|
options.push(
|
|
168
|
+
{ value: 'restart', label: pc.yellow(r.online ? 'Restart' : 'Start') },
|
|
188
169
|
{ value: 'stop', label: pc.red('Stop') },
|
|
189
|
-
{ value: 'restart', label: pc.yellow('Restart') },
|
|
190
170
|
{ value: 'log', label: 'Show log path' },
|
|
191
171
|
{ value: 'ping', label: 'Ping' }
|
|
192
172
|
);
|
|
193
173
|
} else if (r.online) {
|
|
194
|
-
// Remote, or local but started outside this manager (no PID to own) —
|
|
195
|
-
// either way the runner's own /api/shutdown|restart endpoints are
|
|
196
|
-
// reachable over the network via the primary's control routes.
|
|
197
174
|
options.push(
|
|
198
175
|
{ value: 'stop', label: pc.red('Stop') },
|
|
199
176
|
{ value: 'restart', label: pc.yellow('Restart') },
|
|
200
177
|
{ value: 'ping', label: 'Ping' }
|
|
201
178
|
);
|
|
202
179
|
} else {
|
|
203
|
-
|
|
204
|
-
// unconditionally rather than gating on address-based "is this local"
|
|
205
|
-
// detection. That heuristic breaks the moment this machine's address
|
|
206
|
-
// differs from what was registered (DHCP renewal, VPN, multiple NICs),
|
|
207
|
-
// permanently trapping an offline runner with no way back. Starting
|
|
208
|
-
// here is always safe to attempt: it spawns a managed process this menu
|
|
209
|
-
// can immediately Stop again if the address guess was wrong.
|
|
210
|
-
options.push({ value: 'start', label: pc.green('Start') }, { value: 'ping', label: 'Ping' });
|
|
180
|
+
options.push({ value: 'ping', label: 'Ping' });
|
|
211
181
|
}
|
|
212
|
-
|
|
213
182
|
options.push(
|
|
214
183
|
{ value: 'token', label: 'Show token' },
|
|
215
184
|
{ value: 'delete', label: pc.red('Delete') },
|
|
@@ -219,29 +188,26 @@ async function runAction(r) {
|
|
|
219
188
|
const action = await clack.select({ message: `${r.name} — ${r.url}`, options });
|
|
220
189
|
if (cancelled(action) || action === 'back') return;
|
|
221
190
|
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
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
|
+
}
|
|
238
206
|
} else if (action === 'stop') {
|
|
239
|
-
if (
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
ok ? pc.green(`Stopped "${r.name}"`) : pc.dim(`"${r.name}" was not running`)
|
|
244
|
-
);
|
|
207
|
+
if (isLocalNode) {
|
|
208
|
+
try {
|
|
209
|
+
plumNode('stop', r.name);
|
|
210
|
+
} catch {}
|
|
245
211
|
} else {
|
|
246
212
|
const s = clack.spinner();
|
|
247
213
|
s.start(`Stopping "${r.name}"...`);
|
|
@@ -250,56 +216,37 @@ async function runAction(r) {
|
|
|
250
216
|
s.stop(pc.green(`Stopped "${r.name}"`));
|
|
251
217
|
} catch (e) {
|
|
252
218
|
s.stop(pc.red(`Could not stop "${r.name}": ${e.message}`));
|
|
253
|
-
} finally {
|
|
254
|
-
// Belt-and-suspenders: if this runner happens to be local (or
|
|
255
|
-
// the network shutdown call above silently failed), make sure
|
|
256
|
-
// nothing is left bound to its port.
|
|
257
|
-
await killPort(Number(port));
|
|
258
219
|
}
|
|
259
220
|
}
|
|
260
|
-
} else if (action === '
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
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 {}
|
|
268
231
|
} else {
|
|
269
232
|
const s = clack.spinner();
|
|
270
|
-
s.start(`
|
|
233
|
+
s.start(`Deleting "${r.name}"...`);
|
|
271
234
|
try {
|
|
272
|
-
await
|
|
273
|
-
s.stop(pc.green(`
|
|
235
|
+
await deleteRunner(r.id);
|
|
236
|
+
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
274
237
|
} catch (e) {
|
|
275
|
-
s.stop(pc.red(`Could not
|
|
238
|
+
s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
|
|
276
239
|
}
|
|
277
240
|
}
|
|
278
241
|
} else if (action === 'log') {
|
|
279
|
-
|
|
280
|
-
clack.note(entry?.logFile ?? '(no log file)', 'Log file');
|
|
242
|
+
clack.note(runnerProcess.loadRegistry()[r.id]?.logFile ?? '(no log file)', 'Log file');
|
|
281
243
|
} else if (action === 'token') {
|
|
282
|
-
clack.note(
|
|
244
|
+
clack.note(localCfg.token || '(stored on the node’s own machine)', 'Auth token');
|
|
283
245
|
} else if (action === 'ping') {
|
|
284
246
|
const s = clack.spinner();
|
|
285
247
|
s.start(`Pinging "${r.name}"...`);
|
|
286
248
|
const online = await pingRunner(r.id);
|
|
287
249
|
s.stop(online ? pc.green(`"${r.name}" is reachable`) : pc.red(`"${r.name}" is unreachable`));
|
|
288
|
-
} else if (action === 'delete') {
|
|
289
|
-
const confirmed = await clack.confirm({
|
|
290
|
-
message: `Delete runner "${r.name}"? This removes it from the server.`,
|
|
291
|
-
initialValue: false
|
|
292
|
-
});
|
|
293
|
-
if (cancelled(confirmed) || !confirmed) return;
|
|
294
|
-
const s = clack.spinner();
|
|
295
|
-
s.start(`Deleting "${r.name}"...`);
|
|
296
|
-
if (r.local) stopNode(r.id, Number(parsePort(r.url)));
|
|
297
|
-
try {
|
|
298
|
-
await deleteRunner(r.id);
|
|
299
|
-
s.stop(pc.green(`Deleted "${r.name}"`));
|
|
300
|
-
} catch (e) {
|
|
301
|
-
s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
|
|
302
|
-
}
|
|
303
250
|
}
|
|
304
251
|
}
|
|
305
252
|
|
|
@@ -316,7 +263,7 @@ async function addRunner() {
|
|
|
316
263
|
let port;
|
|
317
264
|
for (;;) {
|
|
318
265
|
port = await clack.text({
|
|
319
|
-
message: 'Local port
|
|
266
|
+
message: 'Local port this node listens on',
|
|
320
267
|
placeholder: '3002',
|
|
321
268
|
defaultValue: '3002'
|
|
322
269
|
});
|
|
@@ -343,63 +290,27 @@ async function addRunner() {
|
|
|
343
290
|
if (cancelled(urlInput)) return;
|
|
344
291
|
|
|
345
292
|
const url = resolveNodeUrl(urlInput || defaultUrl);
|
|
346
|
-
const local = isLocalUrl(url);
|
|
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}"`)
|
|
308
|
+
'--browser',
|
|
309
|
+
'chromium'
|
|
361
310
|
);
|
|
362
|
-
} catch
|
|
363
|
-
|
|
364
|
-
return;
|
|
365
|
-
}
|
|
366
|
-
|
|
367
|
-
// Verify the node actually answers before keeping the registration — start a
|
|
368
|
-
// local one here, expect a remote one to already be up. A failed check rolls
|
|
369
|
-
// the registration back so no dead runner is left behind.
|
|
370
|
-
let entry = null;
|
|
371
|
-
let ok = false;
|
|
372
|
-
if (local) {
|
|
373
|
-
prepareNodeEnv();
|
|
374
|
-
entry = startNode({ id, port, token });
|
|
375
|
-
s.start(`Waiting for "${name}" to come up on port ${port}...`);
|
|
376
|
-
ok = await nodeReachable(`http://localhost:${port}`, token, 20000);
|
|
377
|
-
s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
|
|
378
|
-
} else {
|
|
379
|
-
s.start(`Checking for a Plum node at ${url}...`);
|
|
380
|
-
ok = await nodeReachable(url, token, 8000);
|
|
381
|
-
s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
|
|
382
|
-
}
|
|
383
|
-
|
|
384
|
-
if (ok) {
|
|
385
|
-
clack.log.success(pc.green(`Runner "${name}" is ready.`));
|
|
386
|
-
return;
|
|
387
|
-
}
|
|
388
|
-
|
|
389
|
-
if (entry) stopNode(id, Number(port));
|
|
390
|
-
if (!reused) {
|
|
391
|
-
try {
|
|
392
|
-
await deleteRunner(id);
|
|
393
|
-
} catch {}
|
|
311
|
+
} catch {
|
|
312
|
+
clack.log.warn(pc.yellow('Node start reported a problem — see the output above.'));
|
|
394
313
|
}
|
|
395
|
-
if (entry?.logFile) clack.note(entry.logFile, 'Node log');
|
|
396
|
-
clack.log.warn(
|
|
397
|
-
pc.yellow(
|
|
398
|
-
reused
|
|
399
|
-
? `"${name}" stays registered but is unreachable — check the port/URL.`
|
|
400
|
-
: `"${name}" was not registered — check the port/URL and try again.`
|
|
401
|
-
)
|
|
402
|
-
);
|
|
403
314
|
}
|
|
404
315
|
|
|
405
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,153 +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
|
-
|
|
732
|
-
if (!reconfig && existing.id && statusOf(String(existing.id)) === 'running') {
|
|
733
|
-
clack.log.info(
|
|
734
|
-
`Node "${existing.name ?? existing.id}" is already running from this folder — opening the runner menu instead of starting a new one.`
|
|
735
|
-
);
|
|
736
|
-
await openManageRunnersMenu(existing.primary);
|
|
737
|
-
clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
|
|
732
|
+
const registeredId = await registerNode(cfg);
|
|
733
|
+
if (!registeredId) {
|
|
734
|
+
clack.outro(pc.red('Node not started.'));
|
|
735
|
+
process.exitCode = 1;
|
|
738
736
|
return;
|
|
739
737
|
}
|
|
740
738
|
|
|
741
|
-
const cfg = await configureNode({ force: reconfig });
|
|
742
|
-
const registeredId = await registerNode(cfg);
|
|
743
|
-
|
|
744
|
-
const {
|
|
745
|
-
prepareEnv,
|
|
746
|
-
startNode: startNodeProc,
|
|
747
|
-
findPidOnPort,
|
|
748
|
-
killPort,
|
|
749
|
-
nodeReachable
|
|
750
|
-
} = runnerProcessLib();
|
|
751
|
-
|
|
752
739
|
clack.log.step('Preparing environment (deps + browsers)...');
|
|
753
740
|
try {
|
|
754
741
|
prepareEnv();
|
|
755
|
-
clack.log.success('Environment ready.');
|
|
756
742
|
} catch (e) {
|
|
757
743
|
clack.log.error(
|
|
758
|
-
`Environment prep failed: ${e.message}
|
|
744
|
+
`Environment prep failed: ${e.message} — not starting (tests would fail at browser launch).`
|
|
759
745
|
);
|
|
760
746
|
clack.outro(pc.red('Node not started.'));
|
|
761
747
|
process.exitCode = 1;
|
|
762
748
|
return;
|
|
763
749
|
}
|
|
764
750
|
|
|
765
|
-
if (
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
if (up) {
|
|
778
|
-
clack.log.success(
|
|
779
|
-
pc.green(
|
|
780
|
-
`Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at ${entry.logFile}`
|
|
781
|
-
)
|
|
782
|
-
);
|
|
783
|
-
} else {
|
|
784
|
-
clack.log.error(
|
|
785
|
-
pc.red(
|
|
786
|
-
`Node "${cfg.name}" did not come up on port ${cfg.port}. Check ${entry.logFile} — the port may still be held by another process.`
|
|
787
|
-
)
|
|
788
|
-
);
|
|
789
|
-
process.exitCode = 1;
|
|
790
|
-
}
|
|
791
|
-
} catch (e) {
|
|
792
|
-
clack.log.warn(`Could not start runner process: ${e.message}`);
|
|
793
|
-
}
|
|
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
|
+
);
|
|
794
763
|
} else {
|
|
795
|
-
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.'));
|
|
796
772
|
}
|
|
773
|
+
}
|
|
797
774
|
|
|
798
|
-
|
|
799
|
-
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);
|
|
800
780
|
}
|
|
801
781
|
|
|
802
|
-
async function nodeRestart() {
|
|
782
|
+
async function nodeRestart({ name: nameArg }) {
|
|
803
783
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
|
|
804
|
-
|
|
784
|
+
migrateLegacyNodes();
|
|
785
|
+
const { loadNodeByName } = nodeRegisterLib();
|
|
805
786
|
const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
|
|
806
|
-
const cfg = loadNodeConfig(process.cwd());
|
|
807
787
|
|
|
788
|
+
const target = resolveNodeName(nameArg);
|
|
789
|
+
if (!target) return clack.outro(pc.dim('Done.'));
|
|
790
|
+
const cfg = loadNodeByName(target);
|
|
808
791
|
if (!cfg.id) {
|
|
809
|
-
clack.
|
|
810
|
-
clack.outro(pc.dim('Done.'));
|
|
792
|
+
clack.outro(pc.yellow(`"${target}" isn't registered — run \`plum node start ${target}\`.`));
|
|
811
793
|
return;
|
|
812
794
|
}
|
|
813
795
|
|
|
814
|
-
|
|
815
|
-
// the local registry entry is missing or stale (e.g. after `plum update`
|
|
816
|
-
// reinstalls in a fresh process) — otherwise a still-running old process is
|
|
817
|
-
// left bound to the port while a new one starts up alongside it.
|
|
818
|
-
const stopped = stopNode(String(cfg.id), Number(cfg.port));
|
|
819
|
-
if (stopped) {
|
|
820
|
-
clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
|
|
821
|
-
} else {
|
|
822
|
-
clack.log.info('Node was not running — starting fresh.');
|
|
823
|
-
}
|
|
824
|
-
|
|
825
|
-
clack.log.step('Refreshing dependencies…');
|
|
796
|
+
stopNode(String(cfg.id), Number(cfg.port));
|
|
826
797
|
try {
|
|
827
798
|
prepareEnv();
|
|
828
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 {
|
|
829
812
|
clack.log.error(
|
|
830
|
-
`
|
|
813
|
+
pc.red(`"${target}" didn't come back on port ${cfg.port} — check ${entry.logFile}.`)
|
|
831
814
|
);
|
|
832
|
-
clack.outro(pc.red('Node not restarted.'));
|
|
833
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>`.');
|
|
834
841
|
return;
|
|
835
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
|
+
}
|
|
836
851
|
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
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}`
|
|
851
875
|
);
|
|
852
|
-
|
|
853
|
-
clack.
|
|
876
|
+
} catch (e) {
|
|
877
|
+
clack.log.warn(`Could not reach primary: ${e.message}`);
|
|
854
878
|
}
|
|
855
|
-
} catch (e) {
|
|
856
|
-
clack.log.warn(`Could not restart node: ${e.message}`);
|
|
857
|
-
clack.outro(pc.red('Node not restarted.'));
|
|
858
879
|
}
|
|
880
|
+
|
|
881
|
+
deleteNodeByName(target);
|
|
882
|
+
unregisterInstall('node', nodeHome(target));
|
|
883
|
+
clack.outro(pc.green(`Deleted "${target}" — process, local config, and primary registration.`));
|
|
859
884
|
}
|
|
860
885
|
|
|
861
|
-
async function nodeReconfig() {
|
|
886
|
+
async function nodeReconfig({ name }) {
|
|
862
887
|
clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Node ')));
|
|
863
|
-
|
|
888
|
+
migrateLegacyNodes();
|
|
889
|
+
const cfg = await configureNode({ force: true, name });
|
|
864
890
|
await registerNode(cfg);
|
|
865
|
-
clack.
|
|
866
|
-
clack.outro(pc.dim('Done.'));
|
|
891
|
+
clack.outro(pc.dim(`Saved. Run \`plum node restart ${cfg.name}\` to apply.`));
|
|
867
892
|
}
|
|
868
893
|
|
|
869
894
|
// stop/restart/delete on the /runners API want a registered runner's token
|
|
@@ -1050,8 +1075,8 @@ switch (command) {
|
|
|
1050
1075
|
'| `plum server reconfig` | Change server URL/ports without starting |',
|
|
1051
1076
|
'| `plum stop` | Stop the server |',
|
|
1052
1077
|
'| `plum create-step` | Interactively generate a new step definition |',
|
|
1053
|
-
'| `plum node start
|
|
1054
|
-
'| `plum node
|
|
1078
|
+
'| `plum node start <name>` | Register a runner node and start it here |',
|
|
1079
|
+
'| `plum node list` | List this machine’s nodes |',
|
|
1055
1080
|
'',
|
|
1056
1081
|
'---',
|
|
1057
1082
|
'',
|
|
@@ -1290,64 +1315,68 @@ switch (command) {
|
|
|
1290
1315
|
break;
|
|
1291
1316
|
|
|
1292
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
|
+
}
|
|
1293
1338
|
if (subcommand === 'stop') {
|
|
1294
|
-
|
|
1295
|
-
const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
|
|
1296
|
-
const { stopNode } = runnerProcessLib();
|
|
1297
|
-
const cfg = loadNodeConfig(process.cwd());
|
|
1298
|
-
|
|
1299
|
-
if (cfg.id) {
|
|
1300
|
-
const stopped = stopNode(String(cfg.id), Number(cfg.port));
|
|
1301
|
-
if (stopped) {
|
|
1302
|
-
clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
|
|
1303
|
-
} else if (cfg.pid) {
|
|
1304
|
-
try {
|
|
1305
|
-
process.kill(cfg.pid, 'SIGTERM');
|
|
1306
|
-
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
1307
|
-
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
1308
|
-
} catch {
|
|
1309
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1310
|
-
}
|
|
1311
|
-
} else {
|
|
1312
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1313
|
-
}
|
|
1314
|
-
} else if (cfg.pid) {
|
|
1315
|
-
try {
|
|
1316
|
-
process.kill(cfg.pid, 'SIGTERM');
|
|
1317
|
-
clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
|
|
1318
|
-
saveNodeConfig(process.cwd(), { ...cfg, pid: null });
|
|
1319
|
-
} catch {
|
|
1320
|
-
clack.log.info('No running process found — it may already be stopped.');
|
|
1321
|
-
}
|
|
1322
|
-
} else {
|
|
1323
|
-
clack.log.info('No node started from this folder.');
|
|
1324
|
-
clack.log.info(`Use ${pc.cyan('plum manage-runners')} to stop running nodes.`);
|
|
1325
|
-
}
|
|
1326
|
-
clack.outro(pc.dim('Done.'));
|
|
1339
|
+
await nodeStop({ name: nodeName });
|
|
1327
1340
|
break;
|
|
1328
1341
|
}
|
|
1329
|
-
|
|
1330
1342
|
if (subcommand === 'restart') {
|
|
1331
|
-
await nodeRestart();
|
|
1343
|
+
await nodeRestart({ name: nodeName });
|
|
1332
1344
|
break;
|
|
1333
1345
|
}
|
|
1334
|
-
|
|
1335
1346
|
if (subcommand === 'reconfig') {
|
|
1336
|
-
await nodeReconfig();
|
|
1347
|
+
await nodeReconfig({ name: nodeName });
|
|
1337
1348
|
break;
|
|
1338
1349
|
}
|
|
1339
|
-
|
|
1340
|
-
|
|
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
|
+
});
|
|
1341
1368
|
break;
|
|
1342
1369
|
}
|
|
1343
1370
|
|
|
1344
1371
|
case 'manage-runners': {
|
|
1345
|
-
const {
|
|
1346
|
-
const
|
|
1372
|
+
const { listNodeNames, loadNodeByName } = nodeRegisterLib();
|
|
1373
|
+
const firstNode = listNodeNames()
|
|
1374
|
+
.map(loadNodeByName)
|
|
1375
|
+
.find((c) => c.primary);
|
|
1347
1376
|
const primaryUrl =
|
|
1348
1377
|
getFlag(process.argv.slice(3), '--primary') ??
|
|
1349
1378
|
process.env.PLUM_API_URL ??
|
|
1350
|
-
|
|
1379
|
+
firstNode?.primary ??
|
|
1351
1380
|
'http://localhost:3001';
|
|
1352
1381
|
await openManageRunnersMenu(primaryUrl);
|
|
1353
1382
|
break;
|
|
@@ -1400,19 +1429,24 @@ switch (command) {
|
|
|
1400
1429
|
console.log(
|
|
1401
1430
|
' update Update Plum and restart whichever is running (server/node)'
|
|
1402
1431
|
);
|
|
1403
|
-
console.log(' node start
|
|
1404
|
-
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');
|
|
1405
1434
|
console.log(' --url <url> Address the primary calls back (default: <lan-ip>:<port>;');
|
|
1406
1435
|
console.log(
|
|
1407
1436
|
' pass a domain like https://node1.example behind a TLS proxy)'
|
|
1408
1437
|
);
|
|
1409
|
-
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)');
|
|
1410
1439
|
console.log(' --token <secret> Auth token (auto-generated + saved if omitted)');
|
|
1411
|
-
console.log(' --name <name> Runner name shown on the primary (default: node-<rand>)');
|
|
1412
1440
|
console.log(' --browser <name> chromium | firefox (default: chromium)');
|
|
1413
|
-
console.log(' node
|
|
1414
|
-
console.log(' node
|
|
1415
|
-
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
|
+
);
|
|
1416
1450
|
console.log(' manage-runners Open the runner management menu');
|
|
1417
1451
|
console.log(
|
|
1418
1452
|
' --primary <url> Primary server URL (default: saved config or localhost:3001)'
|