plum-e2e 2.9.8 → 2.9.9

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,7 +30,8 @@ const {
30
30
  startNode,
31
31
  stopNode,
32
32
  findPidOnPort,
33
- killPort
33
+ killPort,
34
+ nodeReachable
34
35
  } = runnerProcess;
35
36
  const { generateToken, registerWithPrimary, detectLanIp, loadNodeConfig } = nodeRegister;
36
37
 
@@ -38,22 +39,23 @@ const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
38
39
 
39
40
  const cancelled = (v) => clack.isCancel(v);
40
41
 
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() {
42
+ // The mutating /runners routes accept any registered runner's own token in
43
+ // 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.
46
+ function resolveRunnerToken() {
47
+ if (process.env.PLUM_RUNNER_TOKEN) return process.env.PLUM_RUNNER_TOKEN;
45
48
  const cwdToken = loadNodeConfig(process.cwd()).token;
46
- if (cwdToken) return { Authorization: `Bearer ${cwdToken}` };
49
+ if (cwdToken) return cwdToken;
47
50
  for (const dir of globalRegistry.getInstalls('node')) {
48
51
  const token = loadNodeConfig(dir).token;
49
- if (token) return { Authorization: `Bearer ${token}` };
52
+ if (token) return token;
50
53
  }
51
- if (process.env.PLUM_MCP_KEY) return { Authorization: `ApiKey ${process.env.PLUM_MCP_KEY}` };
52
- return {};
54
+ return null;
53
55
  }
54
- const AUTH_HEADER = resolveAuthHeader();
56
+ const RUNNER_TOKEN = resolveRunnerToken();
55
57
  function authHeaders() {
56
- return AUTH_HEADER;
58
+ return RUNNER_TOKEN ? { Authorization: `Bearer ${RUNNER_TOKEN}` } : {};
57
59
  }
58
60
 
59
61
  /**
@@ -301,26 +303,6 @@ async function runAction(r) {
301
303
  }
302
304
  }
303
305
 
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
306
  async function addRunner() {
325
307
  const suggested = `node-${generateToken().slice(0, 6)}`;
326
308
 
@@ -391,11 +373,11 @@ async function addRunner() {
391
373
  prepareNodeEnv();
392
374
  entry = startNode({ id, port, token });
393
375
  s.start(`Waiting for "${name}" to come up on port ${port}...`);
394
- ok = await nodeIsUp(`http://localhost:${port}`, token, 20000);
376
+ ok = await nodeReachable(`http://localhost:${port}`, token, 20000);
395
377
  s.stop(ok ? pc.green(`"${name}" is up (pid ${entry.pid})`) : pc.red(`"${name}" did not start`));
396
378
  } else {
397
379
  s.start(`Checking for a Plum node at ${url}...`);
398
- ok = await nodeIsUp(url, token, 8000);
380
+ ok = await nodeReachable(url, token, 8000);
399
381
  s.stop(ok ? pc.green(`"${name}" is reachable`) : pc.red(`No Plum node answered at ${url}`));
400
382
  }
401
383
 
@@ -423,10 +405,10 @@ async function addRunner() {
423
405
  async function main() {
424
406
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Manage Runners ')));
425
407
 
426
- if (!AUTH_HEADER.Authorization) {
408
+ if (!RUNNER_TOKEN) {
427
409
  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.'
410
+ 'No runner token available — stop / restart / delete will be rejected.\n' +
411
+ 'Run `plum manage-runners` on the primary host, or from a folder where you started a node.'
430
412
  );
431
413
  }
432
414
 
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',
@@ -732,7 +741,13 @@ async function nodeStart({ reconfig }) {
732
741
  const cfg = await configureNode({ force: reconfig });
733
742
  const registeredId = await registerNode(cfg);
734
743
 
735
- const { prepareEnv, startNode: startNodeProc } = runnerProcessLib();
744
+ const {
745
+ prepareEnv,
746
+ startNode: startNodeProc,
747
+ findPidOnPort,
748
+ killPort,
749
+ nodeReachable
750
+ } = runnerProcessLib();
736
751
 
737
752
  clack.log.step('Preparing environment (deps + browsers)...');
738
753
  try {
@@ -749,12 +764,30 @@ async function nodeStart({ reconfig }) {
749
764
 
750
765
  if (registeredId) {
751
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
+ }
752
774
  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
- );
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
+ }
758
791
  } catch (e) {
759
792
  clack.log.warn(`Could not start runner process: ${e.message}`);
760
793
  }
@@ -769,7 +802,7 @@ async function nodeStart({ reconfig }) {
769
802
  async function nodeRestart() {
770
803
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Restart ')));
771
804
  const { loadNodeConfig } = nodeRegisterLib();
772
- const { prepareEnv, stopNode, startNode } = runnerProcessLib();
805
+ const { prepareEnv, stopNode, startNode, killPort, nodeReachable } = runnerProcessLib();
773
806
  const cfg = loadNodeConfig(process.cwd());
774
807
 
775
808
  if (!cfg.id) {
@@ -802,17 +835,27 @@ async function nodeRestart() {
802
835
  }
803
836
 
804
837
  try {
838
+ await killPort(Number(cfg.port));
805
839
  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
- );
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
+ )
851
+ );
852
+ process.exitCode = 1;
853
+ clack.outro(pc.red('Node not restarted.'));
854
+ }
811
855
  } catch (e) {
812
856
  clack.log.warn(`Could not restart node: ${e.message}`);
857
+ clack.outro(pc.red('Node not restarted.'));
813
858
  }
814
-
815
- clack.outro(pc.green('Node restarted.'));
816
859
  }
817
860
 
818
861
  async function nodeReconfig() {
@@ -823,13 +866,41 @@ async function nodeReconfig() {
823
866
  clack.outro(pc.dim('Done.'));
824
867
  }
825
868
 
869
+ // stop/restart/delete on the /runners API want a registered runner's token
870
+ // (runnerOrAdmin). On the primary host those tokens sit in the backend's DB —
871
+ // pull one straight from the running container so the menu can authenticate
872
+ // without a node's .plum-node.json in the current folder. Best-effort: on a
873
+ // node-only box (no server install / no Docker) this no-ops and the menu falls
874
+ // back to a local .plum-node.json.
875
+ function readRunnerTokenFromPrimary() {
876
+ const { getInstalls } = globalRegistryLib();
877
+ const script =
878
+ "require('./services/prisma').runner.findFirst({select:{token:true}})" +
879
+ ".then(r=>{process.stdout.write(r&&r.token||'');process.exit(0)}).catch(()=>process.exit(1))";
880
+ for (const dir of getInstalls('server')) {
881
+ try {
882
+ const token = execSync(`docker compose exec -T backend node -e "${script}"`, {
883
+ cwd: dir,
884
+ stdio: ['ignore', 'pipe', 'ignore'],
885
+ timeout: 15000
886
+ })
887
+ .toString()
888
+ .trim();
889
+ if (token) return token;
890
+ } catch {}
891
+ }
892
+ return null;
893
+ }
894
+
826
895
  async function openManageRunnersMenu(primaryUrl) {
827
896
  const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
828
897
  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
- });
898
+ const env = { ...process.env, PLUM_API_URL: apiUrl };
899
+ if (!env.PLUM_RUNNER_TOKEN) {
900
+ const token = readRunnerTokenFromPrimary();
901
+ if (token) env.PLUM_RUNNER_TOKEN = token;
902
+ }
903
+ const menu = spawn(process.execPath, [manageScript], { stdio: 'inherit', env });
833
904
  await new Promise((resolve) => menu.on('exit', resolve));
834
905
  }
835
906
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "2.9.8",
3
+ "version": "2.9.9",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"