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.
@@ -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,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
- 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 } = 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 when run on the server host), or from
45
- // 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.
46
45
  function resolveRunnerToken() {
47
46
  if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
48
- const cwdToken = loadNodeConfig(process.cwd()).token;
49
- if (cwdToken) return cwdToken;
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 options = [];
162
+ const localCfg = loadNodeByName(r.name);
163
+ const isLocalNode = Boolean(localCfg.id) && localCfg.id === r.id;
184
164
 
185
- if (r.managed) {
186
- // Local, and this manager owns its process — control it directly by PID.
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
- // Offline and nothing is listening to control remotely — offer Start
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
- // Prefer the port this runner actually last ran on (remembered in the
223
- // local registry even after being stopped) over parsing the URL — the URL
224
- // may not carry an explicit port at all, and would otherwise silently fall
225
- // back to the primary's own default port.
226
- const remembered = runnerProcess.loadRegistry()[r.id]?.port;
227
- const port = remembered || parsePort(r.url);
228
-
229
- if (action === 'start') {
230
- const s = clack.spinner();
231
- s.start(`Freeing port ${port}...`);
232
- const killed = await killPort(Number(port));
233
- s.stop(killed ? pc.dim(`Freed port ${port}`) : pc.dim(`Port ${port} was already free`));
234
-
235
- prepareNodeEnv();
236
- const entry = startNode({ id: r.id, port, token: r.token });
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 (r.managed) {
240
- const ok = stopNode(r.id);
241
- await killPort(Number(port));
242
- clack.log.success(
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 === 'restart') {
261
- if (r.managed) {
262
- const s = clack.spinner();
263
- s.start(`Restarting "${r.name}"...`);
264
- stopNode(r.id);
265
- await killPort(Number(port));
266
- const entry = startNode({ id: r.id, port, token: r.token });
267
- 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 {}
268
231
  } else {
269
232
  const s = clack.spinner();
270
- s.start(`Restarting "${r.name}"...`);
233
+ s.start(`Deleting "${r.name}"...`);
271
234
  try {
272
- await controlRunner(r.id, 'restart');
273
- s.stop(pc.green(`Restarted "${r.name}"`));
235
+ await deleteRunner(r.id);
236
+ s.stop(pc.green(`Deleted "${r.name}"`));
274
237
  } catch (e) {
275
- s.stop(pc.red(`Could not restart "${r.name}": ${e.message}`));
238
+ s.stop(pc.red(`Could not delete "${r.name}": ${e.message}`));
276
239
  }
277
240
  }
278
241
  } else if (action === 'log') {
279
- const entry = runnerProcess.loadRegistry()[r.id];
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(r.token, 'Auth token');
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 the node listens on',
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
- 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}"`)
308
+ '--browser',
309
+ 'chromium'
361
310
  );
362
- } catch (e) {
363
- s.stop(pc.red(`Could not register "${name}": ${e.message}`));
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 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,153 +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
- // Re-running `node start` on an already-running node used to spawn a second
729
- // process on the same port (orphaning the first) and re-register a duplicate
730
- // runner on the primary. Route to the same menu this command ends on anyway
731
- // instead of repeating the whole configure/register/spawn dance.
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}\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).`
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 (registeredId) {
766
- try {
767
- // A stale process on this port makes the new node die on EADDRINUSE
768
- // after a silent retry loop — clear it first (almost always a
769
- // previous instance of this same node).
770
- if (findPidOnPort(Number(cfg.port))) {
771
- clack.log.step(`Port ${cfg.port} is in use freeing it...`);
772
- await killPort(Number(cfg.port));
773
- }
774
- const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
775
- clack.log.step(`Starting "${cfg.name}" (pid ${entry.pid})...`);
776
- const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
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.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.'));
796
772
  }
773
+ }
797
774
 
798
- await openManageRunnersMenu(cfg.primary);
799
- 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);
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
- const { loadNodeConfig } = nodeRegisterLib();
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.log.warn('No node configured in this folder — run `plum node start` first.');
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
- // Passing the port lets stopNode fall back to port-based PID discovery when
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
- `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}.`)
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
- try {
838
- await killPort(Number(cfg.port));
839
- const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
840
- const up = await nodeReachable(`http://localhost:${cfg.port}`, cfg.token, 15000);
841
- if (up) {
842
- clack.log.success(
843
- pc.green(`Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at ${entry.logFile}`)
844
- );
845
- clack.outro(pc.green('Node restarted.'));
846
- } else {
847
- clack.log.error(
848
- pc.red(
849
- `Node "${cfg.name}" did not come back up on port ${cfg.port}. Check ${entry.logFile}.`
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
- process.exitCode = 1;
853
- clack.outro(pc.red('Node not restarted.'));
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
- const cfg = await configureNode({ force: true });
888
+ migrateLegacyNodes();
889
+ const cfg = await configureNode({ force: true, name });
864
890
  await registerNode(cfg);
865
- clack.log.success("Saved. Run 'plum node start' to launch this node.");
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` | Start a runner node and auto-register it with the server |',
1054
- '| `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 |',
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
- clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum Node Runner ')));
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
- 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
+ });
1341
1368
  break;
1342
1369
  }
1343
1370
 
1344
1371
  case 'manage-runners': {
1345
- const { loadNodeConfig } = nodeRegisterLib();
1346
- const saved = loadNodeConfig(process.cwd());
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
- saved.primary ??
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 Start a runner node (interactive), then open runner menu');
1404
- 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');
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: 3001)');
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 restart Stop, refresh deps, and restart the node runner');
1414
- console.log(' node reconfig Re-enter node settings + re-register, without starting');
1415
- 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
+ );
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)'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.9",
3
+ "version": "2.9.13",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"