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.
@@ -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
- * Registers the node with the primary, reusing an existing runner whose name+url
54
- * match instead of creating a duplicate.
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 existing = null;
63
- const listRes = await fetch(`${base}/runners`, { signal: AbortSignal.timeout(10000) });
64
- if (!listRes.ok) throw new Error(`primary returned HTTP ${listRes.status} listing runners`);
65
- const { runners = [] } = await listRes.json();
66
- existing = runners.find((r) => r.name === name && r.url === url) ?? null;
67
- if (existing) return { id: existing.id, reused: true };
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: false };
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)
@@ -0,0 +1,6 @@
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 9601 (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
- import globalRegistry from '../lib/globalRegistry.js';
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 when run on the server host), or from
46
- // a node's own .plum-node.json — this folder, then any other node install.
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 cwdToken = loadNodeConfig(process.cwd()).token;
50
- if (cwdToken) return cwdToken;
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 options = [];
162
+ const localCfg = loadNodeByName(r.name);
163
+ const isLocalNode = Boolean(localCfg.id) && localCfg.id === r.id;
185
164
 
186
- if (r.managed) {
187
- // Local, and this manager owns its process — control it directly by PID.
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
- // Offline and nothing is listening to control remotely — offer Start
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
- // Prefer the port this runner actually last ran on (remembered in the
224
- // local registry even after being stopped) over parsing the URL — the URL
225
- // may not carry an explicit port at all, and would otherwise silently fall
226
- // back to the primary's own default port.
227
- const remembered = runnerProcess.loadRegistry()[r.id]?.port;
228
- const port = remembered || parsePort(r.url);
229
-
230
- if (action === 'start') {
231
- const s = clack.spinner();
232
- s.start(`Freeing port ${port}...`);
233
- const killed = await killPort(Number(port));
234
- s.stop(killed ? pc.dim(`Freed port ${port}`) : pc.dim(`Port ${port} was already free`));
235
-
236
- prepareNodeEnv();
237
- const entry = startNode({ id: r.id, port, token: r.token });
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 (r.managed) {
241
- const ok = stopNode(r.id);
242
- await killPort(Number(port));
243
- clack.log.success(
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 === 'restart') {
262
- if (r.managed) {
263
- const s = clack.spinner();
264
- s.start(`Restarting "${r.name}"...`);
265
- stopNode(r.id);
266
- await killPort(Number(port));
267
- const entry = startNode({ id: r.id, port, token: r.token });
268
- s.stop(pc.green(`Restarted "${r.name}" (pid ${entry.pid})`));
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(`Restarting "${r.name}"...`);
233
+ s.start(`Deleting "${r.name}"...`);
272
234
  try {
273
- await controlRunner(r.id, 'restart');
274
- s.stop(pc.green(`Restarted "${r.name}"`));
235
+ await deleteRunner(r.id);
236
+ s.stop(pc.green(`Deleted "${r.name}"`));
275
237
  } catch (e) {
276
- s.stop(pc.red(`Could not restart "${r.name}": ${e.message}`));
238
+ s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
277
239
  }
278
240
  }
279
241
  } else if (action === 'log') {
280
- const entry = runnerProcess.loadRegistry()[r.id];
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(r.token, 'Auth token');
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 the node listens on',
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
- const s = clack.spinner();
349
- s.start(`Registering "${name}" with the primary...`);
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
- ({ id, reused } = await registerWithPrimary({
353
- primary: API_URL,
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: 'chromium'
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
- if (!up) {
380
- clack.log.info(
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 runner = await prisma.runner.create({
37
- data: { name, url: normaliseUrl(url), token, browser }
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
- // Every install registers its directory here when configured (see
500
- // configureServer/configureNode), so this finds them regardless of the cwd
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 { loadNodeConfig } = nodeRegisterLib();
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 dir of getInstalls('node')) {
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: `Found a registered node at ${dir} — restart it?`,
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 (manager restarts, pre-existing
556
- // installs from before this tracking existed, etc.), and skipping the
557
- // restart in those cases silently leaves the OLD node process running
558
- // mismatched code — it still answers /api/ping so it looks "online"
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('plum node restart', { stdio: 'inherit', cwd: dir });
555
+ execSync(`plum node restart ${nodeName}`, { stdio: 'inherit' });
564
556
  restartedAnything = true;
565
557
  } catch (e) {
566
- clack.log.warn(`Could not restart node at ${dir}: ${e.message}`);
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
- async function configureNode({ force }) {
592
- const { generateToken, detectLanIp, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
593
- const cwd = process.cwd();
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
- const saved = loadNodeConfig(cwd);
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 communicate with this node',
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
- saveNodeConfig(cwd, {
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', cwd);
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, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
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(pc.green(reused ? '✓ Reusing existing runner on primary' : '✓ Registered on primary'));
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.info('No primary setadd this runner manually on your Plum server.');
711
+ clack.log.warn('No --primary giventhe node is configured but not registered anywhere.');
689
712
  }
690
713
 
691
- clack.note(
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
- async function nodeStart({ reconfig }) {
721
- const backendDir = path.join(plumRoot, 'backend');
722
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum Node Runner ')));
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
- // One folder holds one node's config (.plum-node.json). Starting a
729
- // different-named node here would overwrite it — orphaning the previous
730
- // node's still-running process and losing the config needed to stop it.
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}\nNot starting the node it would come up "running" but fail every dispatched test at the browser-launch step.`
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 (registeredId) {
781
- try {
782
- // A stale process on this port makes the new node die on EADDRINUSE
783
- // after a silent retry loop — clear it first (almost always a
784
- // previous instance of this same node).
785
- if (findPidOnPort(Number(cfg.port))) {
786
- clack.log.step(`Port ${cfg.port} is in use freeing it...`);
787
- await killPort(Number(cfg.port));
788
- }
789
- const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
790
- clack.log.step(`Starting "${cfg.name}" (pid ${entry.pid})...`);
791
- const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
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.info('Runner not registered on primary — use the menu below to add and start it.');
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
- await openManageRunnersMenu(cfg.primary);
814
- clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
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
- const { loadNodeConfig } = nodeRegisterLib();
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.log.warn('No node configured in this folder — run `plum node start` first.');
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
- // Passing the port lets stopNode fall back to port-based PID discovery when
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
- `Dependency refresh failed: ${e.message}\nNot restarting the node — it would come up "running" but fail every dispatched test at the browser-launch step. The node stays stopped until this is fixed and \`plum node restart\` is run again.`
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
- try {
853
- await killPort(Number(cfg.port));
854
- const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
855
- const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
856
- if (up) {
857
- clack.log.success(
858
- pc.green(`Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at ${entry.logFile}`)
859
- );
860
- clack.outro(pc.green('Node restarted.'));
861
- } else {
862
- clack.log.error(
863
- pc.red(
864
- `Node "${cfg.name}" did not come back up on port ${cfg.port}. Check ${entry.logFile}.`
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
- process.exitCode = 1;
868
- clack.outro(pc.red('Node not restarted.'));
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
- const cfg = await configureNode({ force: true });
888
+ migrateLegacyNodes();
889
+ const cfg = await configureNode({ force: true, name });
879
890
  await registerNode(cfg);
880
- clack.log.success("Saved. Run 'plum node start' to launch this node.");
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` | Start a runner node and auto-register it with the server |',
1069
- '| `plum node stop` | Stop the runner node started from this folder |',
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
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum Node Runner ')));
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
- await nodeStart({ reconfig: false });
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 { loadNodeConfig } = nodeRegisterLib();
1361
- const saved = loadNodeConfig(process.cwd());
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
- saved.primary ??
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 Start a runner node (interactive), then open runner menu');
1419
- console.log(' --primary <url> Primary Plum server to auto-register with');
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: 3001)');
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 restart Stop, refresh deps, and restart the node runner');
1429
- console.log(' node reconfig Re-enter node settings + re-register, without starting');
1430
- console.log(' node stop Stop the runner node started from this folder');
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)'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.12",
3
+ "version": "2.9.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"