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.
- package/backend/lib/runnerProcess.js +24 -0
- package/backend/scripts/manage-runners.mjs +18 -36
- package/bin/plum.js +90 -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,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
|
|
42
|
-
// an admin session (runnerOrAdmin.js)
|
|
43
|
-
//
|
|
44
|
-
|
|
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
|
|
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
|
|
52
|
+
if (token) return token;
|
|
50
53
|
}
|
|
51
|
-
|
|
52
|
-
return {};
|
|
54
|
+
return null;
|
|
53
55
|
}
|
|
54
|
-
const
|
|
56
|
+
const RUNNER_TOKEN = resolveRunnerToken();
|
|
55
57
|
function authHeaders() {
|
|
56
|
-
return
|
|
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
|
|
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
|
|
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 (!
|
|
408
|
+
if (!RUNNER_TOKEN) {
|
|
427
409
|
clack.log.warn(
|
|
428
|
-
'No runner
|
|
429
|
-
'Run
|
|
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
|
-
|
|
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 {
|
|
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.
|
|
754
|
-
|
|
755
|
-
|
|
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
|
-
|
|
807
|
-
|
|
808
|
-
|
|
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
|
|
830
|
-
|
|
831
|
-
|
|
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
|
|