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.
- package/backend/lib/runnerProcess.js +24 -0
- package/backend/scripts/manage-runners.mjs +61 -61
- package/bin/plum.js +105 -19
- package/package.json +1 -1
|
@@ -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 } =
|
|
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
|
|
42
|
-
// an admin session (runnerOrAdmin.js)
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
|
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
|
|
53
|
+
if (token) return token;
|
|
50
54
|
}
|
|
51
|
-
|
|
52
|
-
return {};
|
|
55
|
+
return null;
|
|
53
56
|
}
|
|
54
|
-
const
|
|
57
|
+
const RUNNER_TOKEN = resolveRunnerToken();
|
|
55
58
|
function authHeaders() {
|
|
56
|
-
return
|
|
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
|
-
//
|
|
386
|
-
//
|
|
387
|
-
// the
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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 (!
|
|
426
|
+
if (!RUNNER_TOKEN) {
|
|
427
427
|
clack.log.warn(
|
|
428
|
-
'No runner
|
|
429
|
-
'Run
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|