plum-e2e 2.9.8 → 2.9.12

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.
@@ -201,6 +201,29 @@ function prepareEnv() {
201
201
  });
202
202
  }
203
203
 
204
+ /**
205
+ * Polls a node's own /api/ping (at `baseUrl`, e.g. http://localhost:3002) until
206
+ * it reports mode:'node', or the timeout elapses. A detached spawn returns a
207
+ * pid immediately even when the process goes on to die on EADDRINUSE — callers
208
+ * use this to report honestly whether the node actually came up.
209
+ */
210
+ async function nodeReachable(baseUrl, token, timeoutMs = 15000) {
211
+ const url = `${String(baseUrl).replace(/\/+$/, '')}/api/ping`;
212
+ const headers = token ? { Authorization: `Bearer ${token}` } : {};
213
+ const deadline = Date.now() + timeoutMs;
214
+ while (Date.now() < deadline) {
215
+ try {
216
+ const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) });
217
+ if (res.ok) {
218
+ const body = await res.json().catch(() => ({}));
219
+ if (body.ok && body.mode === 'node') return true;
220
+ }
221
+ } catch {}
222
+ await new Promise((r) => setTimeout(r, 500));
223
+ }
224
+ return false;
225
+ }
226
+
204
227
  /**
205
228
  * Spawns a detached node-mode server for the given runner and records its pid.
206
229
  * Returns the registry entry.
@@ -316,6 +339,7 @@ module.exports = {
316
339
  parsePort,
317
340
  findPidOnPort,
318
341
  killPort,
342
+ nodeReachable,
319
343
  pruneDead,
320
344
  statusOf,
321
345
  ensureBackendDeps,
@@ -30,30 +30,33 @@ const {
30
30
  startNode,
31
31
  stopNode,
32
32
  findPidOnPort,
33
- killPort
33
+ killPort,
34
+ nodeReachable
34
35
  } = runnerProcess;
35
- const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig } = nodeRegister;
36
+ const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig, saveNodeConfig } =
37
+ nodeRegister;
36
38
 
37
39
  const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
38
40
 
39
41
  const cancelled = (v) => clack.isCancel(v);
40
42
 
41
- // The mutating /runners routes take a registered runner's own token in place of
42
- // an admin session (runnerOrAdmin.js), and this manager has no JWT — so find
43
- // any node token on this machine, falling back to PLUM_MCP_KEY.
44
- function resolveAuthHeader() {
43
+ // The mutating /runners routes accept any registered runner's own token in
44
+ // 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.
47
+ function resolveRunnerToken() {
48
+ if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
45
49
  const cwdToken = loadNodeConfig(process.cwd()).token;
46
- if (cwdToken) return { Authorization: `Bearer ${cwdToken}` };
50
+ if (cwdToken) return cwdToken;
47
51
  for (const dir of globalRegistry.getInstalls('node')) {
48
52
  const token = loadNodeConfig(dir).token;
49
- if (token) return { Authorization: `Bearer ${token}` };
53
+ if (token) return token;
50
54
  }
51
- if (process.env.PLUM_MCP_KEY) return { Authorization: `ApiKey ${process.env.PLUM_MCP_KEY}` };
52
- return {};
55
+ return null;
53
56
  }
54
- const AUTH_HEADER = resolveAuthHeader();
57
+ const RUNNER_TOKEN = resolveRunnerToken();
55
58
  function authHeaders() {
56
- return AUTH_HEADER;
59
+ return RUNNER_TOKEN ? { Authorization: `Bearer ${RUNNER_TOKEN}` } : {};
57
60
  }
58
61
 
59
62
  /**
@@ -301,26 +304,6 @@ async function runAction(r) {
301
304
  }
302
305
  }
303
306
 
304
- // Polls a node's own /api/ping until it reports mode:'node' or the timeout
305
- // elapses — used to confirm a runner actually serves before its registration
306
- // is kept (see addRunner).
307
- async function nodeIsUp(baseUrl, token, timeoutMs) {
308
- const url = `${baseUrl.replace(/\/+$/, '')}/api/ping`;
309
- const headers = token ? { Authorization: `Bearer ${token}` } : {};
310
- const deadline = Date.now() + timeoutMs;
311
- while (Date.now() < deadline) {
312
- try {
313
- const res = await fetch(url, { headers, signal: AbortSignal.timeout(3000) });
314
- if (res.ok) {
315
- const body = await res.json().catch(() => ({}));
316
- if (body.ok && body.mode === 'node') return true;
317
- }
318
- } catch {}
319
- await new Promise((r) => setTimeout(r, 750));
320
- }
321
- return false;
322
- }
323
-
324
307
  async function addRunner() {
325
308
  const suggested = `node-${generateToken().slice(0, 6)}`;
326
309
 
@@ -361,7 +344,6 @@ async function addRunner() {
361
344
  if (cancelled(urlInput)) return;
362
345
 
363
346
  const url = resolveNodeUrl(urlInput || defaultUrl);
364
- const local = isLocalUrl(url);
365
347
 
366
348
  const s = clack.spinner();
367
349
  s.start(`Registering "${name}" with the primary...`);
@@ -382,51 +364,69 @@ async function addRunner() {
382
364
  return;
383
365
  }
384
366
 
385
- // Verify the node actually answers before keeping the registration start a
386
- // local one here, expect a remote one to already be up. A failed check rolls
387
- // the registration back so no dead runner is left behind.
388
- let entry = null;
389
- let ok = false;
390
- if (local) {
391
- prepareNodeEnv();
392
- entry = startNode({ id, port, token });
393
- s.start(`Waiting for "${name}" to come up on port ${port}...`);
394
- ok = await nodeIsUp(`http://localhost:${port}`, token, 20000);
395
- s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
396
- } else {
397
- s.start(`Checking for a Plum node at ${url}...`);
398
- ok = await nodeIsUp(url, token, 8000);
399
- s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
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`)
378
+ );
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;
400
386
  }
401
387
 
402
- if (ok) {
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());
403
409
  clack.log.success(pc.green(`Runner "${name}" is ready.`));
404
410
  return;
405
411
  }
406
412
 
407
- if (entry) stopNode(id, Number(port));
413
+ stopNode(id, Number(port));
408
414
  if (!reused) {
409
415
  try {
410
416
  await deleteRunner(id);
411
417
  } catch {}
412
418
  }
413
- if (entry?.logFile) clack.note(entry.logFile, 'Node log');
414
- clack.log.warn(
415
- pc.yellow(
416
- reused
417
- ? `"${name}" stays registered but is unreachable — check the port/URL.`
418
- : `"${name}" was not registered — check the port/URL and try again.`
419
- )
420
- );
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
421
  }
422
422
 
423
423
  async function main() {
424
424
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
425
425
 
426
- if (!AUTH_HEADER.Authorization) {
426
+ if (!RUNNER_TOKEN) {
427
427
  clack.log.warn(
428
- 'No runner credential found on this machine — stop / restart / delete will be rejected.\n' +
429
- 'Run this from a folder where you started a node, or export PLUM_MCP_KEY.'
428
+ 'No runner token available — stop / restart / delete will be rejected.\n' +
429
+ 'Run `plum manage-runners` on the primary host, or from a folder where you started a node.'
430
430
  );
431
431
  }
432
432
 
package/bin/plum.js CHANGED
@@ -595,7 +595,8 @@ async function configureNode({ force }) {
595
595
  const saved = loadNodeConfig(cwd);
596
596
 
597
597
  let primary = getFlag(args, '--primary') ?? process.env.PRIMARY_URL ?? saved.primary ?? '';
598
- let port = getFlag(args, '--port') ?? saved.port ?? '3001';
598
+ // Not 3001 that's the primary's default; a co-located node must not collide.
599
+ let port = getFlag(args, '--port') ?? saved.port ?? '3002';
599
600
  let browser = getFlag(args, '--browser') ?? saved.browser ?? 'chromium';
600
601
  let token = getFlag(args, '--token') ?? process.env.NODE_TOKEN ?? saved.token ?? generateToken();
601
602
  let name = getFlag(args, '--name') ?? saved.name ?? `node-${token.slice(0, 6)}`;
@@ -613,6 +614,14 @@ async function configureNode({ force }) {
613
614
  const interactive = force || (interactiveAllowed() && !hasFlags);
614
615
 
615
616
  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
+
616
625
  const primaryVal = await clack.text({
617
626
  message: 'Your Plum server backend URL',
618
627
  placeholder: primary || 'http://localhost:3001',
@@ -716,6 +725,21 @@ async function nodeStart({ reconfig }) {
716
725
  const { statusOf } = runnerProcessLib();
717
726
  const existing = loadNodeConfig(process.cwd());
718
727
 
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.'));
739
+ process.exitCode = 1;
740
+ return;
741
+ }
742
+
719
743
  // Re-running `node start` on an already-running node used to spawn a second
720
744
  // process on the same port (orphaning the first) and re-register a duplicate
721
745
  // runner on the primary. Route to the same menu this command ends on anyway
@@ -732,7 +756,13 @@ async function nodeStart({ reconfig }) {
732
756
  const cfg = await configureNode({ force: reconfig });
733
757
  const registeredId = await registerNode(cfg);
734
758
 
735
- const { prepareEnv, startNode: startNodeProc } = runnerProcessLib();
759
+ const {
760
+ prepareEnv,
761
+ startNode: startNodeProc,
762
+ findPidOnPort,
763
+ killPort,
764
+ nodeReachable
765
+ } = runnerProcessLib();
736
766
 
737
767
  clack.log.step('Preparing environment (deps + browsers)...');
738
768
  try {
@@ -749,12 +779,30 @@ async function nodeStart({ reconfig }) {
749
779
 
750
780
  if (registeredId) {
751
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
+ }
752
789
  const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
753
- clack.log.success(
754
- pc.green(
755
- `Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at backend/logs/runner-${registeredId}.log`
756
- )
757
- );
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
+ }
758
806
  } catch (e) {
759
807
  clack.log.warn(`Could not start runner process: ${e.message}`);
760
808
  }
@@ -769,7 +817,7 @@ async function nodeStart({ reconfig }) {
769
817
  async function nodeRestart() {
770
818
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
771
819
  const { loadNodeConfig } = nodeRegisterLib();
772
- const { prepareEnv, stopNode, startNode } = runnerProcessLib();
820
+ const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
773
821
  const cfg = loadNodeConfig(process.cwd());
774
822
 
775
823
  if (!cfg.id) {
@@ -802,17 +850,27 @@ async function nodeRestart() {
802
850
  }
803
851
 
804
852
  try {
853
+ await killPort(Number(cfg.port));
805
854
  const entry = startNode({ id: String(cfg.id), port: cfg.port, token: cfg.token });
806
- clack.log.success(
807
- pc.green(
808
- `Node "${cfg.name}" restarted (pid ${entry.pid}) — logs at backend/logs/runner-${cfg.id}.log`
809
- )
810
- );
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
+ )
866
+ );
867
+ process.exitCode = 1;
868
+ clack.outro(pc.red('Node not restarted.'));
869
+ }
811
870
  } catch (e) {
812
871
  clack.log.warn(`Could not restart node: ${e.message}`);
872
+ clack.outro(pc.red('Node not restarted.'));
813
873
  }
814
-
815
- clack.outro(pc.green('Node restarted.'));
816
874
  }
817
875
 
818
876
  async function nodeReconfig() {
@@ -823,13 +881,41 @@ async function nodeReconfig() {
823
881
  clack.outro(pc.dim('Done.'));
824
882
  }
825
883
 
884
+ // stop/restart/delete on the /runners API want a registered runner's token
885
+ // (runnerOrAdmin). On the primary host those tokens sit in the backend's DB —
886
+ // pull one straight from the running container so the menu can authenticate
887
+ // without a node's .plum-node.json in the current folder. Best-effort: on a
888
+ // node-only box (no server install / no Docker) this no-ops and the menu falls
889
+ // back to a local .plum-node.json.
890
+ function readRunnerTokenFromPrimary() {
891
+ const { getInstalls } = globalRegistryLib();
892
+ const script =
893
+ "require('./services/prisma').runner.findFirst({select:{token:true}})" +
894
+ ".then(r=>{process.stdout.write(r&&r.token||'');process.exit(0)}).catch(()=>process.exit(1))";
895
+ for (const dir of getInstalls('server')) {
896
+ try {
897
+ const token = execSync(`docker compose exec -T backend node -e "${script}"`, {
898
+ cwd: dir,
899
+ stdio: ['ignore', 'pipe', 'ignore'],
900
+ timeout: 15000
901
+ })
902
+ .toString()
903
+ .trim();
904
+ if (token) return token;
905
+ } catch {}
906
+ }
907
+ return null;
908
+ }
909
+
826
910
  async function openManageRunnersMenu(primaryUrl) {
827
911
  const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
828
912
  const apiUrl = primaryUrl || 'http://localhost:3001';
829
- const menu = spawn(process.execPath, [manageScript], {
830
- stdio: 'inherit',
831
- env: { ...process.env, PLUM_API_URL: apiUrl }
832
- });
913
+ const env = { ...process.env, PLUM_API_URL: apiUrl };
914
+ if (!env.PLUM_RUNNER_TOKEN) {
915
+ const token = readRunnerTokenFromPrimary();
916
+ if (token) env.PLUM_RUNNER_TOKEN = token;
917
+ }
918
+ const menu = spawn(process.execPath, [manageScript], { stdio: 'inherit', env });
833
919
  await new Promise((resolve) => menu.on('exit', resolve));
834
920
  }
835
921
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.8",
3
+ "version": "2.9.12",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"