plum-e2e 1.3.4 → 1.3.5

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/README.md CHANGED
@@ -282,11 +282,11 @@ plum run-test --parallel 4 @suite-login # run a suite in parallel
282
282
  plum run-test --browser firefox # run in a specific browser
283
283
  ```
284
284
 
285
- | Flag | Description |
286
- | ------------------ | -------------------------------------------- |
287
- | `@tag` | Run only tests matching the tag |
288
- | `--parallel <n>` | Run across `n` parallel workers |
289
- | `--browser <name>` | `chromium` (default), `firefox`, or `webkit` |
285
+ | Flag | Description |
286
+ | ------------------ | --------------------------------- |
287
+ | `@tag` | Run only tests matching the tag |
288
+ | `--parallel <n>` | Run across `n` parallel workers |
289
+ | `--browser <name>` | `chromium` (default) or `firefox` |
290
290
 
291
291
  > `plum run-test` syncs your tests, installs dependencies, and runs Cucumber. No Docker needed.
292
292
 
@@ -296,6 +296,8 @@ plum run-test --browser firefox # run in a specific browser
296
296
 
297
297
  Runners are additional machines that execute tests in parallel alongside the primary server, letting you distribute a large suite across many nodes. A node runs as a plain Node process — **no Docker required**.
298
298
 
299
+ Each node automatically installs **Chromium and Firefox** — no browser selection needed. When triggering a test run from the UI, you choose which browser to use at run time.
300
+
299
301
  ### Start a runner node
300
302
 
301
303
  On the machine that will act as a runner, navigate to your Plum project and run:
@@ -304,7 +306,7 @@ On the machine that will act as a runner, navigate to your Plum project and run:
304
306
  plum node start
305
307
  ```
306
308
 
307
- Plum asks a few questions and then **registers the node with your server automatically** you don't need to add anything in the UI by hand:
309
+ Plum asks a few questions, **registers the node with your server automatically**, starts it in the background, then opens the runner management menu:
308
310
 
309
311
  | Prompt | Default | What it sets |
310
312
  | ------------------ | ------------------------- | -------------------------------------------------------------------- |
@@ -312,10 +314,11 @@ Plum asks a few questions and then **registers the node with your server automat
312
314
  | **Local port** | `3001` | The port this node process listens on |
313
315
  | **Advertised URL** | `http://<your-ip>:<port>` | The address the **server** uses to reach this node |
314
316
  | **Runner name** | `node-<random>` | The name shown in the UI |
315
- | **Browser** | `chromium` | Default browser for this node |
316
317
  | **Auth token** | auto-generated | Shared secret; press Enter to keep the generated one |
317
318
 
318
- When it registers, Plum prints a details card with the assigned **id**, **token**, and **url**, then boots the node (Ctrl+C to stop). Settings are saved to `.plum-node.json`, so re-running reuses them and never creates a duplicate.
319
+ The node starts as a **background daemon** your terminal is free immediately. Logs go to `backend/logs/runner-<id>.log`. Settings are saved to `.plum-node.json`, so re-running reuses them and never creates a duplicate.
320
+
321
+ After setup, the **runner management menu** opens automatically. From there you can start, stop, restart, and ping any runner registered on the primary. Exit the menu any time — the node keeps running.
319
322
 
320
323
  **Skip the prompts** with flags (handy for CI or scripted nodes):
321
324
 
@@ -330,7 +333,6 @@ plum node start --primary http://192.168.1.5:3001 --port 3001 --name ci-node-1
330
333
  | `--port <n>` | Local HTTP port the node listens on (default `3001`). |
331
334
  | `--token <secret>` | Auth token. Auto-generated and saved if omitted. |
332
335
  | `--name <name>` | Runner name shown in the UI. |
333
- | `--browser <name>` | `chromium` (default), `firefox`, or `webkit`. |
334
336
 
335
337
  #### Nodes behind a domain or reverse proxy
336
338
 
@@ -342,6 +344,20 @@ plum node start --primary https://plum.example.com --url https://node1.example.c
342
344
 
343
345
  The server reaches the node at `https://node1.example.com`; the proxy forwards to the node on port `3001`. The advertised `--url` must be reachable from the server.
344
346
 
347
+ ### Manage runners
348
+
349
+ ```bash
350
+ plum manage-runners
351
+ ```
352
+
353
+ Opens the interactive runner management menu at any time (reads the primary URL from your saved node config). You can also pass a different server:
354
+
355
+ ```bash
356
+ plum manage-runners --primary http://192.168.1.5:3001
357
+ ```
358
+
359
+ > In development, the equivalent command is `npm run manage-runners` from the `backend/` directory.
360
+
345
361
  ### Change settings later
346
362
 
347
363
  ```bash
@@ -364,26 +380,27 @@ If you run `plum node start` without a reachable `--primary`, Plum prints the no
364
380
  plum node stop
365
381
  ```
366
382
 
367
- Stops the node started from the current folder.
383
+ Stops the node started from the current folder. You can also stop individual runners from the `plum manage-runners` menu.
368
384
 
369
385
  ---
370
386
 
371
387
  ## Command Reference
372
388
 
373
- | Command | Description |
374
- | ----------------------------- | ----------------------------------------------------------------------- |
375
- | `plum init` | Initialize a new project in the current folder |
376
- | `plum server start` | Start the full UI stack via Docker, interactively (alias: `plum start`) |
377
- | `plum server reconfig` | Re-enter server settings (URL, ports) without starting |
378
- | `plum server stop` | Stop the server and preserve data (alias: `plum stop`) |
379
- | `plum run-test` | Run all tests locally without Docker |
380
- | `plum run-test @tag` | Run tests matching a tag |
381
- | `plum run-test --parallel N` | Run tests across N parallel workers |
382
- | `plum run-test --browser <b>` | Run in a specific browser (chromium/firefox/webkit) |
383
- | `plum create-step` | Interactively scaffold a new step definition |
384
- | `plum node start` | Start a runner node (interactive) and auto-register it with the server |
385
- | `plum node reconfig` | Re-enter node settings + re-register, without starting |
386
- | `plum node stop` | Stop the runner node started from this folder |
389
+ | Command | Description |
390
+ | ----------------------------- | ------------------------------------------------------------------------------ |
391
+ | `plum init` | Initialize a new project in the current folder |
392
+ | `plum server start` | Start the full UI stack via Docker, interactively (alias: `plum start`) |
393
+ | `plum server reconfig` | Re-enter server settings (URL, ports) without starting |
394
+ | `plum server stop` | Stop the server and preserve data (alias: `plum stop`) |
395
+ | `plum run-test` | Run all tests locally without Docker |
396
+ | `plum run-test @tag` | Run tests matching a tag |
397
+ | `plum run-test --parallel N` | Run tests across N parallel workers |
398
+ | `plum run-test --browser <b>` | Run in a specific browser (`chromium` or `firefox`) |
399
+ | `plum create-step` | Interactively scaffold a new step definition |
400
+ | `plum node start` | Configure, register, and start a runner node; opens the runner management menu |
401
+ | `plum node reconfig` | Re-enter node settings + re-register, without starting |
402
+ | `plum node stop` | Stop the runner node started from this folder |
403
+ | `plum manage-runners` | Open the interactive runner management menu |
387
404
 
388
405
  ---
389
406
 
@@ -1,19 +1,20 @@
1
1
  /*
2
- This file is part of Plum.
2
+ * This file is part of Plum.
3
+ *
4
+ * Plum is free software: you can redistribute it and/or modify
5
+ * it under the terms of the GNU General Public License as published by
6
+ * the Free Software Foundation, either version 3 of the License, or
7
+ * (at your option) any later version.
8
+ *
9
+ * Plum is distributed in the hope that it will be useful,
10
+ * but WITHOUT ANY WARRANTY; without even the implied warranty of
11
+ * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
+ * GNU General Public License for more details.
13
+ *
14
+ * You should have received a copy of the GNU General Public License
15
+ * along with Plum. If not, see https://www.gnu.org/licenses/.
16
+ */
3
17
 
4
- Plum is free software: you can redistribute it and/or modify
5
- it under the terms of the GNU General Public License as published by
6
- the Free Software Foundation, either version 3 of the License, or
7
- (at your option) any later version.
8
-
9
- Plum is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with Plum. If not, see https://www.gnu.org/licenses/.
16
- */
17
18
  import * as clack from '@clack/prompts';
18
19
  import pc from 'picocolors';
19
20
  import fs from 'fs';
@@ -109,13 +109,12 @@ function statusOf(id, registry = loadRegistry()) {
109
109
  * an interactive prompt running in the same terminal (the caller's menu would
110
110
  * wedge after the install finishes).
111
111
  */
112
- function prepareEnv(browser) {
112
+ function prepareEnv() {
113
113
  const stdio = ['ignore', 'inherit', 'inherit'];
114
114
  if (!fs.existsSync(path.join(BACKEND_DIR, 'node_modules'))) {
115
115
  execSync('npm install', { cwd: BACKEND_DIR, stdio, shell: true });
116
116
  }
117
- const target = browser && browser !== 'all' ? ` ${browser}` : '';
118
- execSync(`npx playwright install${target}`, { cwd: BACKEND_DIR, stdio, shell: true });
117
+ execSync('npx playwright install chromium firefox', { cwd: BACKEND_DIR, stdio, shell: true });
119
118
  }
120
119
 
121
120
  /**
@@ -103,10 +103,10 @@ function statusBadge(r) {
103
103
  * npm/playwright progress is visible. A failure is surfaced but non-fatal — the
104
104
  * operator can retry or fix it manually.
105
105
  */
106
- function prepareNodeEnv(browser) {
107
- clack.log.step(`Preparing node environment (deps + ${browser ?? 'browser'})...`);
106
+ function prepareNodeEnv() {
107
+ clack.log.step('Preparing node environment (deps + browsers)...');
108
108
  try {
109
- prepareEnv(browser);
109
+ prepareEnv();
110
110
  clack.log.success(pc.green('Environment ready.'));
111
111
  return true;
112
112
  } catch (e) {
@@ -152,7 +152,7 @@ async function runAction(r) {
152
152
  const port = parsePort(r.url);
153
153
 
154
154
  if (action === 'start') {
155
- prepareNodeEnv(r.browser);
155
+ prepareNodeEnv();
156
156
  const entry = startNode({ id: r.id, port, token: r.token });
157
157
  clack.log.success(pc.green(`Started "${r.name}" on port ${port} (pid ${entry.pid})`));
158
158
  } else if (action === 'stop') {
@@ -204,17 +204,6 @@ async function addRunner() {
204
204
  });
205
205
  if (cancelled(port)) return;
206
206
 
207
- const browser = await clack.select({
208
- message: 'Default browser',
209
- options: [
210
- { value: 'chromium', label: 'Chrome' },
211
- { value: 'firefox', label: 'Firefox' },
212
- { value: 'webkit', label: 'WebKit' }
213
- ],
214
- initialValue: 'chromium'
215
- });
216
- if (cancelled(browser)) return;
217
-
218
207
  const defToken = process.env.NODE_TOKEN || generateToken();
219
208
  const token = await clack.text({ message: 'Auth token', placeholder: defToken, defaultValue: defToken });
220
209
  if (cancelled(token)) return;
@@ -227,7 +216,7 @@ async function addRunner() {
227
216
  s.start(`Registering "${name}" with the primary...`);
228
217
  let id;
229
218
  try {
230
- const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser });
219
+ const res = await registerWithPrimary({ primary: API_URL, name, url, token, browser: 'chromium' });
231
220
  id = res.id;
232
221
  s.stop(res.reused ? pc.green(`Reusing existing runner "${name}"`) : pc.green(`Registered "${name}" (id ${id})`));
233
222
  } catch (e) {
@@ -235,7 +224,7 @@ async function addRunner() {
235
224
  return;
236
225
  }
237
226
 
238
- prepareNodeEnv(browser);
227
+ prepareNodeEnv();
239
228
 
240
229
  const startNow = await clack.confirm({ message: 'Start this runner now?' });
241
230
  if (!cancelled(startNow) && startNow) {
@@ -88,9 +88,20 @@ function runSingleBuiltIn({ taskName, tags, workers, browser }) {
88
88
  async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
89
89
  const allIds = getTestIdsForTag(tags);
90
90
  const chunks = chunkTests(allIds, runnerIds.length);
91
- const laneInfos = await resolveLaneInfos(runnerIds);
92
91
 
93
- const collectedReports = new Array(runnerIds.length).fill(null);
92
+ // Surplus runners beyond the number of non-empty chunks would fall back to
93
+ // running the full tag expression, producing duplicate scenarios in the report.
94
+ const activeRunnerIds = runnerIds.slice(0, chunks.length);
95
+
96
+ if (activeRunnerIds.length === 0) {
97
+ console.log(`Task "${taskName}" — no tests found, skipping.`);
98
+ if (_io) _io.emit('cron-done', { taskName, code: 0 });
99
+ return;
100
+ }
101
+
102
+ const laneInfos = await resolveLaneInfos(activeRunnerIds);
103
+
104
+ const collectedReports = new Array(activeRunnerIds.length).fill(null);
94
105
  let doneCount = 0;
95
106
  let overallCode = 0;
96
107
 
@@ -99,7 +110,7 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
99
110
  collectedReports[idx] = reportContent;
100
111
  doneCount++;
101
112
 
102
- if (doneCount === runnerIds.length) {
113
+ if (doneCount === activeRunnerIds.length) {
103
114
  console.log(`Task "${taskName}" — all runners done (exit ${overallCode})`);
104
115
  if (_io) _io.emit('cron-done', { taskName, code: overallCode });
105
116
 
@@ -116,9 +127,9 @@ async function runDistributed({ taskName, tags, workers, browser, runnerIds }) {
116
127
  }
117
128
  }
118
129
 
119
- for (let i = 0; i < runnerIds.length; i++) {
130
+ for (let i = 0; i < activeRunnerIds.length; i++) {
120
131
  const lane = laneInfos[i];
121
- const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tags;
132
+ const chunkTag = buildTagExpression(chunks[i]);
122
133
 
123
134
  if (lane.id === BUILT_IN_RUNNER_ID) {
124
135
  const env = {
@@ -118,8 +118,26 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
118
118
  const allIds = getTestIdsForTag(tag);
119
119
  const chunks = chunkTests(allIds, runnerIds.length);
120
120
 
121
+ // Surplus runners beyond the number of non-empty chunks would fall back to
122
+ // running the full tag expression, producing duplicate scenarios in the report.
123
+ const activeRunnerIds = runnerIds.slice(0, chunks.length);
124
+
125
+ if (activeRunnerIds.length === 0) {
126
+ socket.emit('log', '\nNo tests found matching the selected tag.\n');
127
+ socket.emit('done', 0);
128
+ return;
129
+ }
130
+
131
+ if (activeRunnerIds.length < runnerIds.length) {
132
+ const skipped = runnerIds.length - activeRunnerIds.length;
133
+ socket.emit(
134
+ 'log',
135
+ `[INFO] ${skipped} runner(s) not used — fewer tests than runners selected.\n`
136
+ );
137
+ }
138
+
121
139
  const laneInfos = await Promise.all(
122
- runnerIds.map(async (id) => {
140
+ activeRunnerIds.map(async (id) => {
123
141
  if (id === BUILT_IN_RUNNER_ID) return { id, name: 'Built-in', dbId: null };
124
142
  const r = await runnerService.getById(id);
125
143
  return { id, name: r?.name ?? id, dbId: r?.id ?? null };
@@ -128,10 +146,10 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
128
146
 
129
147
  socket.emit(
130
148
  'runner-lanes-init',
131
- laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount: (chunks[i] || []).length }))
149
+ laneInfos.map((l, i) => ({ id: l.id, name: l.name, testCount: chunks[i].length }))
132
150
  );
133
151
 
134
- const total = runnerIds.length;
152
+ const total = activeRunnerIds.length;
135
153
  const collectedReports = new Array(total).fill(null);
136
154
  let doneCount = 0;
137
155
  let overallCode = 0;
@@ -168,9 +186,9 @@ async function runDistributed(io, socket, activeProcs, tag, workers, browser, ru
168
186
  }
169
187
  }
170
188
 
171
- for (let i = 0; i < runnerIds.length; i++) {
189
+ for (let i = 0; i < activeRunnerIds.length; i++) {
172
190
  const lane = laneInfos[i];
173
- const chunkTag = chunks[i]?.length > 0 ? buildTagExpression(chunks[i]) : tag;
191
+ const chunkTag = buildTagExpression(chunks[i]);
174
192
 
175
193
  if (lane.id === BUILT_IN_RUNNER_ID) {
176
194
  const env = {
package/bin/plum.js CHANGED
@@ -19,10 +19,11 @@
19
19
  import { execSync, spawn } from 'child_process';
20
20
  import fs from 'fs';
21
21
  import path from 'path';
22
- import readline from 'readline';
23
22
  import { fileURLToPath } from 'url';
24
23
  import { createRequire } from 'module';
25
24
  import fse from 'fs-extra';
25
+ import * as clack from '@clack/prompts';
26
+ import pc from 'picocolors';
26
27
 
27
28
  const __filename = fileURLToPath(import.meta.url);
28
29
  const __dirname = path.dirname(__filename);
@@ -42,28 +43,25 @@ const backendEnvPath = path.join(plumRoot, 'backend', '.env');
42
43
  function createEnvFile() {
43
44
  const envFilePath = path.join(process.cwd(), '.env');
44
45
 
45
- // Check if .env file already exists
46
46
  if (fs.existsSync(envFilePath)) {
47
47
  copyEnvFile();
48
- console.log('⚠️ .env file already exists. Syncing .env file...\n');
49
- return; // Exit if file exists
48
+ clack.log.warn('.env already exists synced to backend.');
49
+ return;
50
50
  }
51
51
 
52
- // Default content for .env file
53
52
  const envContent = `BASE_URL=https://www.saucedemo.com/v1/
54
53
  IS_HEADLESS=false
55
54
  `;
56
55
 
57
- // Write the content to the .env file
58
56
  fs.writeFileSync(envFilePath, envContent, 'utf8');
59
- console.log('.env file created with default values.\n');
57
+ clack.log.success('.env created with default values.');
60
58
  }
61
59
 
62
60
  // Scaffold plum.plugins.json if it doesn't exist yet
63
61
  function scaffoldPluginsFile() {
64
62
  const pluginsPath = path.join(process.cwd(), 'plum.plugins.json');
65
63
  if (fs.existsSync(pluginsPath)) {
66
- console.log('⚠️ plum.plugins.json already exists. Skipping.\n');
64
+ clack.log.warn('plum.plugins.json already exists — skipping.');
67
65
  return;
68
66
  }
69
67
  const content = {
@@ -73,7 +71,7 @@ function scaffoldPluginsFile() {
73
71
  dependencies: {}
74
72
  };
75
73
  fs.writeFileSync(pluginsPath, JSON.stringify(content, null, 2) + '\n', 'utf8');
76
- console.log('plum.plugins.json created. Add npm packages here to extend your tests.\n');
74
+ clack.log.success('plum.plugins.json created.');
77
75
  }
78
76
 
79
77
  // Install user plugins listed in plum.plugins.json into the backend
@@ -108,19 +106,19 @@ function ensureGitignore() {
108
106
 
109
107
  if (!fs.existsSync(gitignorePath)) {
110
108
  fs.writeFileSync(gitignorePath, plumBlock.trimStart(), 'utf8');
111
- console.log('.gitignore created with Plum entries.\n');
109
+ clack.log.success('.gitignore created with Plum entries.');
112
110
  return;
113
111
  }
114
112
 
115
113
  const existing = fs.readFileSync(gitignorePath, 'utf8');
116
114
  const missing = plumEntries.filter((e) => !existing.includes(e));
117
115
  if (missing.length === 0) {
118
- console.log('⚠️ .gitignore already contains Plum entries. Skipping.\n');
116
+ clack.log.warn('.gitignore already contains Plum entries — skipping.');
119
117
  return;
120
118
  }
121
119
 
122
120
  fs.appendFileSync(gitignorePath, `\n# Plum (auto-generated)\n${missing.join('\n')}\n`);
123
- console.log('.gitignore updated with Plum entries.\n');
121
+ clack.log.success('.gitignore updated with Plum entries.');
124
122
  }
125
123
 
126
124
  // Function to copy .env file from root to backend
@@ -128,18 +126,18 @@ function copyEnvFile() {
128
126
  try {
129
127
  if (fs.existsSync(rootEnvPath)) {
130
128
  fse.copySync(rootEnvPath, backendEnvPath);
131
- console.log('📦 .env file copied to the backend folder.\n');
132
129
  } else {
133
- console.log('⚠️ .env file not found in the root directory.\n');
130
+ clack.log.warn('.env not found in project root — skipping backend sync.');
134
131
  }
135
132
  } catch (err) {
136
- console.error('Error copying .env file:', err);
133
+ clack.log.error(`Error copying .env: ${err.message}`);
137
134
  }
138
135
  }
139
136
 
140
137
  const backendLib = path.join(plumRoot, 'backend', 'lib');
141
138
  const serverConfigLib = () => require(path.join(backendLib, 'serverConfig.js'));
142
139
  const nodeRegisterLib = () => require(path.join(backendLib, 'nodeRegister.js'));
140
+ const runnerProcessLib = () => require(path.join(backendLib, 'runnerProcess.js'));
143
141
 
144
142
  /* -----------------------------------------------------
145
143
  * Interactive prompts
@@ -152,64 +150,12 @@ const getFlag = (args, name) => {
152
150
  };
153
151
  const anyFlags = (args, names) => names.some((n) => args.includes(n));
154
152
 
155
- /**
156
- * A line-buffered prompter that works for both an interactive TTY and piped
157
- * stdin. Non-TTY input emits every line then closes immediately, so we queue
158
- * lines as they arrive and hand them out one question at a time.
159
- */
160
- function createPrompter() {
161
- const rl = readline.createInterface({ input: process.stdin });
162
- const queue = [];
163
- let waiting = null;
164
- let closed = false;
165
- rl.on('line', (line) => {
166
- if (waiting) {
167
- const r = waiting;
168
- waiting = null;
169
- r(line);
170
- } else queue.push(line);
171
- });
172
- rl.on('close', () => {
173
- closed = true;
174
- if (waiting) {
175
- const r = waiting;
176
- waiting = null;
177
- r(null);
178
- }
179
- });
180
- const nextLine = () =>
181
- new Promise((resolve) => {
182
- if (queue.length) resolve(queue.shift());
183
- else if (closed) resolve(null);
184
- else waiting = resolve;
185
- });
186
-
187
- const ask = async (q, def) => {
188
- process.stdout.write(` ${q}${def ? ` (${def})` : ''}: `);
189
- const line = await nextLine();
190
- const a = (line ?? '').trim();
191
- return a || def || '';
192
- };
193
- const askYesNo = async (q, def) => {
194
- process.stdout.write(` ${q} (${def ? 'Y/n' : 'y/N'}): `);
195
- const a = (((await nextLine()) ?? '') + '').trim().toLowerCase();
196
- if (!a) return def;
197
- return a === 'y' || a === 'yes';
198
- };
199
- const askChoice = async (q, opts, def) => {
200
- console.log(` ${q}`);
201
- opts.forEach((o, i) => console.log(` ${i + 1}) ${o}${o === def ? ' (default)' : ''}`));
202
- process.stdout.write(' > ');
203
- const a = (((await nextLine()) ?? '') + '').trim();
204
- if (!a) return def;
205
- const n = Number(a);
206
- if (Number.isInteger(n) && n >= 1 && n <= opts.length) return opts[n - 1];
207
- return opts.includes(a) ? a : def;
208
- };
209
- return { ask, askYesNo, askChoice, close: () => rl.close() };
153
+ function cancelAndExit() {
154
+ clack.cancel('Cancelled.');
155
+ process.exit(0);
210
156
  }
211
157
 
212
- const VALID_BROWSERS = ['chromium', 'firefox', 'webkit'];
158
+ const VALID_BROWSERS = ['chromium', 'firefox'];
213
159
 
214
160
  /* -----------------------------------------------------
215
161
  * Server flow
@@ -226,10 +172,10 @@ function mergeUserPlugins() {
226
172
  if (Object.keys(pluginDeps).length > 0) {
227
173
  backendPkg.dependencies = { ...backendPkg.dependencies, ...pluginDeps };
228
174
  fs.writeFileSync(backendPkgPath, JSON.stringify(backendPkg, null, '\t') + '\n', 'utf8');
229
- console.log(`📦 Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}\n`);
175
+ clack.log.info(`Merged plugins into backend: ${Object.keys(pluginDeps).join(', ')}`);
230
176
  }
231
177
  } catch {
232
- console.log('⚠️ Could not read plum.plugins.json. Skipping plugin merge.\n');
178
+ clack.log.warn('Could not read plum.plugins.json. Skipping plugin merge.');
233
179
  }
234
180
  }
235
181
 
@@ -262,19 +208,44 @@ async function configureServer({ force }) {
262
208
  const interactive = force || (interactiveAllowed() && !hasFlags);
263
209
 
264
210
  if (interactive) {
265
- const p = createPrompter();
266
- try {
267
- cfg.baseUrl = await p.ask('App URL to test (BASE_URL)', cfg.baseUrl);
268
- cfg.headless = await p.askYesNo('Run browsers headless?', cfg.headless);
269
- cfg.backendPort = await p.ask('Backend port', cfg.backendPort);
270
- cfg.frontendPort = await p.ask('Frontend (UI) port', cfg.frontendPort);
271
- cfg.primaryPublicUrl = await p.ask(
272
- 'Primary public URL (share with node operators)',
273
- cfg.primaryPublicUrl
274
- );
275
- } finally {
276
- p.close();
277
- }
211
+ const baseUrl = await clack.text({
212
+ message: 'App URL to test (BASE_URL)',
213
+ placeholder: cfg.baseUrl,
214
+ defaultValue: cfg.baseUrl
215
+ });
216
+ if (clack.isCancel(baseUrl)) cancelAndExit();
217
+ cfg.baseUrl = baseUrl || cfg.baseUrl;
218
+
219
+ const headless = await clack.confirm({
220
+ message: 'Run browsers headless?',
221
+ initialValue: cfg.headless
222
+ });
223
+ if (clack.isCancel(headless)) cancelAndExit();
224
+ cfg.headless = headless;
225
+
226
+ const backendPort = await clack.text({
227
+ message: 'Backend port',
228
+ placeholder: String(cfg.backendPort),
229
+ defaultValue: String(cfg.backendPort)
230
+ });
231
+ if (clack.isCancel(backendPort)) cancelAndExit();
232
+ cfg.backendPort = backendPort || cfg.backendPort;
233
+
234
+ const frontendPort = await clack.text({
235
+ message: 'Frontend (UI) port',
236
+ placeholder: String(cfg.frontendPort),
237
+ defaultValue: String(cfg.frontendPort)
238
+ });
239
+ if (clack.isCancel(frontendPort)) cancelAndExit();
240
+ cfg.frontendPort = frontendPort || cfg.frontendPort;
241
+
242
+ const primaryPublicUrl = await clack.text({
243
+ message: 'Primary public URL (share with node operators)',
244
+ placeholder: cfg.primaryPublicUrl,
245
+ defaultValue: cfg.primaryPublicUrl
246
+ });
247
+ if (clack.isCancel(primaryPublicUrl)) cancelAndExit();
248
+ cfg.primaryPublicUrl = primaryPublicUrl || cfg.primaryPublicUrl;
278
249
  }
279
250
 
280
251
  saveServerConfig(cwd, cfg);
@@ -299,29 +270,27 @@ function applyServerConfig(cfg) {
299
270
  }),
300
271
  'utf8'
301
272
  );
302
- console.log('docker-compose.override.yml written');
273
+ clack.log.success('docker-compose.override.yml written');
303
274
  }
304
275
 
305
276
  async function serverStart() {
306
- console.log('--------------------------------------\n');
307
- console.log('🚀 Starting Plum...\n');
277
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
308
278
  const cfg = await configureServer({ force: false });
309
279
  applyServerConfig(cfg);
310
- console.log(`\n🟣 UI: http://localhost:${cfg.frontendPort}`);
311
- console.log(` Nodes register against: ${cfg.primaryPublicUrl}\n`);
280
+ clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
281
+ clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
312
282
  execSync('docker compose up --build', { cwd: plumRoot, stdio: 'inherit' });
313
- console.log('--------------------------------------\n');
283
+ clack.outro(pc.dim('Plum server stopped.'));
314
284
  }
315
285
 
316
286
  async function serverReconfig() {
317
- console.log('--------------------------------------\n');
318
- console.log('⚙️ Reconfiguring Plum server...\n');
287
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Server ')));
319
288
  const cfg = await configureServer({ force: true });
320
289
  applyServerConfig(cfg);
321
- console.log('\n✅ Saved. Run `plum server start` to apply.');
322
- console.log(` UI will be: http://localhost:${cfg.frontendPort}`);
323
- console.log(` Nodes register against: ${cfg.primaryPublicUrl}`);
324
- console.log('--------------------------------------\n');
290
+ clack.log.success("Saved. Run 'plum server start' to apply.");
291
+ clack.outro(
292
+ `UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)} · Nodes: ${pc.dim(cfg.primaryPublicUrl)}`
293
+ );
325
294
  }
326
295
 
327
296
  /* -----------------------------------------------------
@@ -353,27 +322,52 @@ async function configureNode({ force }) {
353
322
  const interactive = force || (interactiveAllowed() && !hasFlags);
354
323
 
355
324
  if (interactive) {
356
- const p = createPrompter();
357
- try {
358
- primary = await p.ask('Primary server URL', primary);
359
- port = await p.ask('Local port this node listens on', port);
360
- url = await p.ask(
361
- 'URL the primary calls back (advertised)',
362
- url || `http://${detectLanIp()}:${port}`
363
- );
364
- name = await p.ask('Runner name', name);
365
- browser = await p.askChoice('Default browser', VALID_BROWSERS, browser);
366
- const newTok = await p.ask('Auth token (Enter to keep)', token);
367
- token = newTok || token;
368
- } finally {
369
- p.close();
370
- }
325
+ const primaryVal = await clack.text({
326
+ message: 'Primary server URL',
327
+ placeholder: primary || 'http://localhost:3001',
328
+ defaultValue: primary
329
+ });
330
+ if (clack.isCancel(primaryVal)) cancelAndExit();
331
+ primary = primaryVal || primary;
332
+
333
+ const portVal = await clack.text({
334
+ message: 'Local port this node listens on',
335
+ placeholder: port,
336
+ defaultValue: port
337
+ });
338
+ if (clack.isCancel(portVal)) cancelAndExit();
339
+ port = portVal || port;
340
+
341
+ const defaultUrl = url || `http://${detectLanIp()}:${port}`;
342
+ const urlVal = await clack.text({
343
+ message: 'URL the primary calls back (advertised)',
344
+ placeholder: defaultUrl,
345
+ defaultValue: defaultUrl
346
+ });
347
+ if (clack.isCancel(urlVal)) cancelAndExit();
348
+ url = urlVal || defaultUrl;
349
+
350
+ const nameVal = await clack.text({
351
+ message: 'Runner name',
352
+ placeholder: name,
353
+ defaultValue: name
354
+ });
355
+ if (clack.isCancel(nameVal)) cancelAndExit();
356
+ name = nameVal || name;
357
+
358
+ const tokenVal = await clack.text({
359
+ message: 'Auth token (Enter to keep)',
360
+ placeholder: token,
361
+ defaultValue: token
362
+ });
363
+ if (clack.isCancel(tokenVal)) cancelAndExit();
364
+ token = tokenVal || token;
371
365
  }
372
366
 
373
367
  if (!url) url = `http://${detectLanIp()}:${port}`;
374
368
 
375
369
  if (!VALID_BROWSERS.includes(browser)) {
376
- console.error(`✗ Invalid browser "${browser}". Choose one of: ${VALID_BROWSERS.join(', ')}`);
370
+ clack.log.error(`Invalid browser "${browser}". Choose one of: ${VALID_BROWSERS.join(', ')}`);
377
371
  process.exit(1);
378
372
  }
379
373
 
@@ -393,36 +387,36 @@ async function configureNode({ force }) {
393
387
  async function registerNode({ primary, name, url, token, browser, port }) {
394
388
  const { registerWithPrimary, loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
395
389
  let registeredId = null;
390
+
396
391
  if (primary) {
397
- console.log(`🔗 Registering with primary at ${primary}...`);
392
+ const s = clack.spinner();
393
+ s.start(`Registering with primary at ${primary}...`);
398
394
  try {
399
395
  const { id, reused } = await registerWithPrimary({ primary, name, url, token, browser });
400
396
  registeredId = id;
401
- console.log(reused ? '✓ Reusing existing runner on primary\n' : '✓ Registered on primary\n');
397
+ s.stop(pc.green(reused ? '✓ Reusing existing runner on primary' : '✓ Registered on primary'));
402
398
  } catch (e) {
403
- console.log(`⚠️ Could not register with primary: ${e.message}`);
404
- console.log(' Add it manually using the details below.\n');
399
+ s.stop(pc.yellow(`Could not register with primary: ${e.message}`));
400
+ clack.log.warn('Add this runner manually using the details below.');
405
401
  }
406
402
  } else {
407
- console.log('ℹ️ No primary set — add this runner manually on your Plum server.\n');
403
+ clack.log.info('No primary set — add this runner manually on your Plum server.');
408
404
  }
409
405
 
410
- const card = [
411
- ' ┌─ Runner details ───────────────────────────',
412
- registeredId
413
- ? ` │ id: ${registeredId}`
414
- : ' │ id: (assigned when added on the server)',
415
- ` │ name: ${name}`,
416
- ` │ url: ${url}`,
417
- ` │ token: ${token}`,
418
- ` │ browser: ${browser}`,
419
- ' └────────────────────────────────────────────'
420
- ].join('\n');
421
- console.log(card + '\n');
422
- console.log(
423
- `ℹ️ The url above must be reachable from the primary. The local port (${port}) is only`
406
+ clack.note(
407
+ [
408
+ registeredId ? `id: ${registeredId}` : 'id: (assigned when added on the server)',
409
+ `name: ${name}`,
410
+ `url: ${url}`,
411
+ `token: ${token}`,
412
+ `browser: ${browser}`
413
+ ].join('\n'),
414
+ 'Runner details'
415
+ );
416
+
417
+ clack.log.info(
418
+ `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.`
424
419
  );
425
- console.log(' what this node listens on — forward your proxy/domain to it.\n');
426
420
 
427
421
  const cwd = process.cwd();
428
422
  saveNodeConfig(cwd, {
@@ -440,45 +434,56 @@ async function registerNode({ primary, name, url, token, browser, port }) {
440
434
 
441
435
  async function nodeStart({ reconfig }) {
442
436
  const backendDir = path.join(plumRoot, 'backend');
443
- console.log('--------------------------------------\n');
444
- console.log('🚀 Setting up Plum node (runner mode)...\n');
437
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
445
438
 
446
439
  const cfg = await configureNode({ force: reconfig });
447
- await registerNode(cfg);
448
-
449
- // backend/node_modules is not published — install deps before launching.
450
- console.log('Running `npm install`...');
451
- execSync('npm install', { cwd: backendDir, stdio: 'inherit', shell: true });
452
- console.log('Running `npx playwright install`...');
453
- execSync('npx playwright install', { cwd: backendDir, stdio: 'inherit', shell: true });
440
+ const registeredId = await registerNode(cfg);
454
441
 
455
- console.log('\n--------------------------------------');
456
- console.log(`🟣 Node "${cfg.name}" running on port ${cfg.port} (Ctrl+C to stop)\n`);
442
+ const { prepareEnv, startNode: startNodeProc } = runnerProcessLib();
457
443
 
458
- const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
459
- const serverPath = path.join(backendDir, 'server.js');
460
- const child = spawn(process.execPath, [serverPath], {
461
- cwd: backendDir,
462
- env: { ...process.env, PLUM_MODE: 'node', NODE_TOKEN: cfg.token, PORT: String(cfg.port) },
463
- stdio: 'inherit'
464
- });
444
+ clack.log.step('Preparing environment (deps + browsers)...');
445
+ try {
446
+ prepareEnv();
447
+ clack.log.success('Environment ready.');
448
+ } catch (e) {
449
+ clack.log.warn(`Environment prep failed: ${e.message}`);
450
+ }
465
451
 
466
- saveNodeConfig(process.cwd(), { ...loadNodeConfig(process.cwd()), pid: child.pid });
452
+ if (registeredId) {
453
+ try {
454
+ const entry = startNodeProc({ id: String(registeredId), port: cfg.port, token: cfg.token });
455
+ clack.log.success(
456
+ pc.green(
457
+ `Node "${cfg.name}" running in background (pid ${entry.pid}) — logs at backend/logs/runner-${registeredId}.log`
458
+ )
459
+ );
460
+ } catch (e) {
461
+ clack.log.warn(`Could not start runner process: ${e.message}`);
462
+ }
463
+ } else {
464
+ clack.log.info('Runner not registered on primary — use the menu below to add and start it.');
465
+ }
467
466
 
468
- child.on('exit', (code) => {
469
- const c = loadNodeConfig(process.cwd());
470
- saveNodeConfig(process.cwd(), { ...c, pid: null });
471
- process.exit(code ?? 0);
472
- });
467
+ await openManageRunnersMenu(cfg.primary);
468
+ clack.outro(`Manage runners anytime: ${pc.cyan('plum manage-runners')}`);
473
469
  }
474
470
 
475
471
  async function nodeReconfig() {
476
- console.log('--------------------------------------\n');
477
- console.log('⚙️ Reconfiguring Plum node...\n');
472
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Reconfigure Node ')));
478
473
  const cfg = await configureNode({ force: true });
479
474
  await registerNode(cfg);
480
- console.log('✅ Saved. Run `plum node start` to launch this node.');
481
- console.log('--------------------------------------\n');
475
+ clack.log.success("Saved. Run 'plum node start' to launch this node.");
476
+ clack.outro(pc.dim('Done.'));
477
+ }
478
+
479
+ async function openManageRunnersMenu(primaryUrl) {
480
+ const manageScript = path.join(plumRoot, 'backend', 'scripts', 'manage-runners.mjs');
481
+ const apiUrl = primaryUrl || 'http://localhost:3001';
482
+ const menu = spawn(process.execPath, [manageScript], {
483
+ stdio: 'inherit',
484
+ env: { ...process.env, PLUM_API_URL: apiUrl }
485
+ });
486
+ await new Promise((resolve) => menu.on('exit', resolve));
482
487
  }
483
488
 
484
489
  /* -----------------------------------------------------
@@ -488,28 +493,22 @@ async function nodeReconfig() {
488
493
  * "plum <command>" to run the desired command.
489
494
  * ------------------------------------------------------ */
490
495
  switch (command) {
491
- case 'init':
492
- console.log('--------------------------------------\n');
493
- console.log('🟣 Preparing Plum...\n');
496
+ case 'init': {
497
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Init ')));
494
498
 
499
+ // Test scaffold
495
500
  if (fs.existsSync(userTestsPath)) {
496
- console.log('⚠️ A `tests/` folder already exists.\n');
501
+ clack.log.warn('`tests/` already exists — skipping scaffold.');
497
502
  } else {
498
- console.log('📦 Creating test scaffold...\n');
499
503
  fse.copySync(scaffoldTestsPath, userTestsPath);
500
- console.log('`tests/` initialized with example files.\n');
504
+ clack.log.success('`tests/` created with example files.');
501
505
  }
502
506
 
503
- // Create .env file with default values
504
507
  createEnvFile();
505
-
506
- // Create or update .gitignore with Plum-generated paths
507
508
  ensureGitignore();
508
-
509
- // Scaffold plum.plugins.json for user-managed dependencies
510
509
  scaffoldPluginsFile();
511
510
 
512
- // Always create .vscode/settings.json for Cucumber extension config
511
+ // .vscode/settings.json
513
512
  {
514
513
  const vscodeSettingsPath = path.join(process.cwd(), '.vscode', 'settings.json');
515
514
  if (!fs.existsSync(vscodeSettingsPath)) {
@@ -526,30 +525,30 @@ switch (command) {
526
525
  ) + '\n',
527
526
  'utf8'
528
527
  );
529
- console.log('.vscode/settings.json created for Cucumber extension.\n');
528
+ clack.log.success('.vscode/settings.json created for Cucumber extension.');
530
529
  } else {
531
- console.log('⚠️ .vscode/settings.json already exists. Skipping.\n');
530
+ clack.log.warn('.vscode/settings.json already exists — skipping.');
532
531
  }
533
532
 
534
- // Install extension via CLI only when the code command is available
533
+ // VS Code Cucumber extension
535
534
  try {
536
535
  execSync('code --version', { stdio: 'ignore' });
537
536
  try {
538
- execSync('code --install-extension cucumberopen.cucumber-official', { stdio: 'inherit' });
539
- console.log('✅ Cucumber VS Code extension installed.\n');
537
+ execSync('code --install-extension cucumberopen.cucumber-official', {
538
+ stdio: 'ignore'
539
+ });
540
+ clack.log.success('Cucumber VS Code extension installed.');
540
541
  } catch {
541
- console.log(
542
- '⚠️ Could not install VS Code extension automatically. Install manually: cucumberopen.cucumber-official\n'
542
+ clack.log.warn(
543
+ 'Could not install VS Code extension automatically.\n Install manually: cucumberopen.cucumber-official'
543
544
  );
544
545
  }
545
546
  } catch {
546
- console.log(
547
- 'ℹ️ Install the Cucumber VS Code extension manually: cucumberopen.cucumber-official\n'
548
- );
547
+ clack.log.info('Install the Cucumber extension manually: cucumberopen.cucumber-official');
549
548
  }
550
549
  }
551
550
 
552
- // Scaffold tsconfig.json so VS Code resolves Plum's types without a local node_modules
551
+ // tsconfig.json
553
552
  {
554
553
  const tsconfigPath = path.join(process.cwd(), 'tsconfig.json');
555
554
  if (!fs.existsSync(tsconfigPath)) {
@@ -576,13 +575,13 @@ switch (command) {
576
575
  include: ['tests/**/*.ts']
577
576
  };
578
577
  fs.writeFileSync(tsconfigPath, JSON.stringify(tsconfig, null, 2) + '\n', 'utf8');
579
- console.log('tsconfig.json created for IDE type resolution.\n');
578
+ clack.log.success('tsconfig.json created for IDE type resolution.');
580
579
  } else {
581
- console.log('⚠️ tsconfig.json already exists. Skipping.\n');
580
+ clack.log.warn('tsconfig.json already exists — skipping.');
582
581
  }
583
582
  }
584
583
 
585
- // Create README.md in user's project if one doesn't exist
584
+ // README.md
586
585
  {
587
586
  const userReadmePath = path.join(process.cwd(), 'README.md');
588
587
  if (!fs.existsSync(userReadmePath)) {
@@ -619,7 +618,7 @@ switch (command) {
619
618
  '| `plum run-test` | Run all tests locally |',
620
619
  '| `plum run-test @tag` | Run tests matching a tag |',
621
620
  '| `plum run-test --parallel N` | Run tests across N parallel workers |',
622
- '| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox/webkit) |',
621
+ '| `plum run-test --browser firefox` | Run in a specific browser (chromium/firefox) |',
623
622
  '| `plum start` | Start the full UI via Docker (interactive setup) |',
624
623
  '| `plum server reconfig` | Change server URL/ports without starting |',
625
624
  '| `plum stop` | Stop the server |',
@@ -678,25 +677,37 @@ switch (command) {
678
677
  '- [Plum documentation](https://github.com/silverlunah/plum) — Full README and reference'
679
678
  ].join('\n');
680
679
  fs.writeFileSync(userReadmePath, readmeContent + '\n', 'utf8');
681
- console.log('README.md created with quick-start guide.\n');
680
+ clack.log.success('README.md created.');
682
681
  } else {
683
- console.log('⚠️ README.md already exists. Skipping.\n');
682
+ clack.log.warn('README.md already exists — skipping.');
684
683
  }
685
684
  }
686
685
 
687
- // Initialize project
688
- console.log('--------------------------------------\n');
689
- console.log('🚀 Initializing Plum...');
690
- execSync('npm run init', {
691
- cwd: plumRoot,
692
- stdio: 'inherit'
693
- });
694
-
695
- console.log(
696
- '🟣 Plum is now ready!\n\n Scaffold test cases are in `tests/`.\n Add extra npm packages to `plum.plugins.json`.\n\n - Run tests locally:\n `plum run-test` or `plum run-test @tag`\n\n - Start the full UI (requires Docker):\n `plum start`\n\n - Generate a step:\n `plum create-step`'
686
+ // Install dependencies
687
+ clack.log.step('Installing dependencies (npm run init)...');
688
+ execSync('npm run init', { cwd: plumRoot, stdio: 'inherit' });
689
+
690
+ clack.note(
691
+ [
692
+ `Tests scaffold ${pc.dim('→')} ${pc.cyan('tests/')}`,
693
+ `Extra packages ${pc.dim('→')} ${pc.cyan('plum.plugins.json')}`,
694
+ `App URL config ${pc.dim('→')} ${pc.cyan('.env')}`,
695
+ '',
696
+ `${pc.bold('Run tests locally')}`,
697
+ ` ${pc.cyan('plum run-test')} run all tests`,
698
+ ` ${pc.cyan('plum run-test @tag')} run by tag`,
699
+ '',
700
+ `${pc.bold('Start the full UI')} ${pc.dim('(requires Docker)')}`,
701
+ ` ${pc.cyan('plum server start')}`,
702
+ '',
703
+ `${pc.bold('Generate a step definition')}`,
704
+ ` ${pc.cyan('plum create-step')}`
705
+ ].join('\n'),
706
+ 'Next steps'
697
707
  );
698
- console.log('--------------------------------------\n');
708
+ clack.outro(pc.magenta('Plum is ready.'));
699
709
  break;
710
+ }
700
711
 
701
712
  case 'server':
702
713
  if (subcommand === 'stop') {
@@ -734,10 +745,9 @@ switch (command) {
734
745
  const userTestsPath = path.resolve(process.cwd(), 'tests');
735
746
  const backendTestsPath = path.join(plumRoot, 'backend', 'tests');
736
747
 
737
- const validBrowsers = ['chromium', 'firefox', 'webkit'];
738
- if (browserArg && !validBrowsers.includes(browserArg)) {
748
+ if (browserArg && !VALID_BROWSERS.includes(browserArg)) {
739
749
  console.error(
740
- `✗ Invalid browser "${browserArg}". Choose one of: ${validBrowsers.join(', ')}`
750
+ `✗ Invalid browser "${browserArg}". Choose one of: ${VALID_BROWSERS.join(', ')}`
741
751
  );
742
752
  process.exit(1);
743
753
  }
@@ -762,9 +772,9 @@ switch (command) {
762
772
  // Install user-defined plugins from plum.plugins.json
763
773
  installPlugins();
764
774
 
765
- console.log('Running `npx playwright install`...');
775
+ console.log('Running `npx playwright install chromium firefox`...');
766
776
 
767
- execSync('npx playwright install', {
777
+ execSync('npx playwright install chromium firefox', {
768
778
  cwd: path.join(plumRoot, 'backend'),
769
779
  stdio: 'inherit'
770
780
  });
@@ -805,23 +815,39 @@ switch (command) {
805
815
 
806
816
  case 'node': {
807
817
  if (subcommand === 'stop') {
818
+ clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Node Runner ')));
808
819
  const { loadNodeConfig, saveNodeConfig } = nodeRegisterLib();
809
- console.log('--------------------------------------\n');
810
- console.log('🛑 Stopping Plum node...');
820
+ const { stopNode } = runnerProcessLib();
811
821
  const cfg = loadNodeConfig(process.cwd());
812
- if (cfg.pid) {
822
+
823
+ if (cfg.id) {
824
+ const stopped = stopNode(String(cfg.id));
825
+ if (stopped) {
826
+ clack.log.success(`Stopped runner "${cfg.name ?? cfg.id}".`);
827
+ } else if (cfg.pid) {
828
+ try {
829
+ process.kill(cfg.pid, 'SIGTERM');
830
+ clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
831
+ saveNodeConfig(process.cwd(), { ...cfg, pid: null });
832
+ } catch {
833
+ clack.log.info('No running process found — it may already be stopped.');
834
+ }
835
+ } else {
836
+ clack.log.info('No running process found — it may already be stopped.');
837
+ }
838
+ } else if (cfg.pid) {
813
839
  try {
814
840
  process.kill(cfg.pid, 'SIGTERM');
815
- console.log(`✅ Stopped node process (pid ${cfg.pid}).\n`);
841
+ clack.log.success(`Stopped node process (pid ${cfg.pid}).`);
842
+ saveNodeConfig(process.cwd(), { ...cfg, pid: null });
816
843
  } catch {
817
- console.log('ℹ️ No running node process found (it may already be stopped).\n');
844
+ clack.log.info('No running process found it may already be stopped.');
818
845
  }
819
- saveNodeConfig(process.cwd(), { ...cfg, pid: null });
820
846
  } else {
821
- console.log('ℹ️ No node started from this folder. If it runs in the foreground,');
822
- console.log(' press Ctrl+C in its terminal to stop it.\n');
847
+ clack.log.info('No node started from this folder.');
848
+ clack.log.info(`Use ${pc.cyan('plum manage-runners')} to stop running nodes.`);
823
849
  }
824
- console.log('--------------------------------------\n');
850
+ clack.outro(pc.dim('Done.'));
825
851
  break;
826
852
  }
827
853
 
@@ -834,6 +860,18 @@ switch (command) {
834
860
  break;
835
861
  }
836
862
 
863
+ case 'manage-runners': {
864
+ const { loadNodeConfig } = nodeRegisterLib();
865
+ const saved = loadNodeConfig(process.cwd());
866
+ const primaryUrl =
867
+ getFlag(process.argv.slice(3), '--primary') ??
868
+ process.env.PLUM_API_URL ??
869
+ saved.primary ??
870
+ 'http://localhost:3001';
871
+ await openManageRunnersMenu(primaryUrl);
872
+ break;
873
+ }
874
+
837
875
  case 'create-step': {
838
876
  const createStepScript = path.join(plumRoot, 'backend', 'config', 'scripts', 'create-step.mjs');
839
877
  execSync(`node "${createStepScript}"`, {
@@ -859,8 +897,8 @@ switch (command) {
859
897
  console.log(' --primary-url <url> Public URL node operators point --primary at');
860
898
  console.log(' server reconfig Re-enter server settings without starting');
861
899
  console.log(' server stop Stop the server (alias: plum stop)');
862
- console.log(' node start Start a runner node (interactive) and register it');
863
- console.log(' --primary <url> Primary Plum server to auto-register with (prints the id)');
900
+ console.log(' node start Start a runner node (interactive), then open runner menu');
901
+ console.log(' --primary <url> Primary Plum server to auto-register with');
864
902
  console.log(' --url <url> Address the primary calls back (default: <lan-ip>:<port>;');
865
903
  console.log(
866
904
  ' pass a domain like https://node1.example behind a TLS proxy)'
@@ -868,13 +906,17 @@ switch (command) {
868
906
  console.log(' --port <n> Local HTTP port the node listens on (default: 3001)');
869
907
  console.log(' --token <secret> Auth token (auto-generated + saved if omitted)');
870
908
  console.log(' --name <name> Runner name shown on the primary (default: node-<rand>)');
871
- console.log(' --browser <name> chromium | firefox | webkit (default: chromium)');
909
+ console.log(' --browser <name> chromium | firefox (default: chromium)');
872
910
  console.log(' node reconfig Re-enter node settings + re-register, without starting');
873
911
  console.log(' node stop Stop the runner node started from this folder');
912
+ console.log(' manage-runners Open the runner management menu');
913
+ console.log(
914
+ ' --primary <url> Primary server URL (default: saved config or localhost:3001)'
915
+ );
874
916
  console.log(' run-test Run tests locally without Docker');
875
917
  console.log(' @tag Run only tests matching a tag');
876
918
  console.log(' --parallel <n> Run across n parallel workers');
877
- console.log(' --browser <name> chromium | firefox | webkit (default: chromium)');
919
+ console.log(' --browser <name> chromium | firefox (default: chromium)');
878
920
  console.log(' create-step Interactively scaffold a new step definition');
879
921
  console.log('\n--------------------------------------\n');
880
922
  }
@@ -1,19 +1,3 @@
1
- /*
2
- This file is part of Plum.
3
-
4
- Plum is free software: you can redistribute it and/or modify
5
- it under the terms of the GNU General Public License as published by
6
- the Free Software Foundation, either version 3 of the License, or
7
- (at your option) any later version.
8
-
9
- Plum is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with Plum. If not, see https://www.gnu.org/licenses/.
16
- */
17
1
  /*
18
2
  * This file is part of Plum.
19
3
  *
@@ -19,8 +19,7 @@ export const API_BASE = import.meta.env.VITE_API_URL ?? 'http://localhost:3001';
19
19
 
20
20
  export const BROWSERS = [
21
21
  { id: 'chromium', label: 'Chrome' },
22
- { id: 'firefox', label: 'Firefox' },
23
- { id: 'webkit', label: 'WebKit' }
22
+ { id: 'firefox', label: 'Firefox' }
24
23
  ];
25
24
 
26
25
  export const TRIGGER_TYPES = Object.freeze({
@@ -15,23 +15,6 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
- /*
19
- This file is part of Plum.
20
-
21
- Plum is free software: you can redistribute it and/or modify
22
- it under the terms of the GNU General Public License as published by
23
- the Free Software Foundation, either version 3 of the License, or
24
- (at your option) any later version.
25
-
26
- Plum is distributed in the hope that it will be useful,
27
- but WITHOUT ANY WARRANTY; without even the implied warranty of
28
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
29
- GNU General Public License for more details.
30
-
31
- You should have received a copy of the GNU General Public License
32
- along with Plum. If not, see https://www.gnu.org/licenses/.
33
- */
34
-
35
18
  import { writable } from 'svelte/store';
36
19
  import { browser } from '$app/environment';
37
20
 
@@ -1,19 +1,3 @@
1
- /*
2
- This file is part of Plum.
3
-
4
- Plum is free software: you can redistribute it and/or modify
5
- it under the terms of the GNU General Public License as published by
6
- the Free Software Foundation, either version 3 of the License, or
7
- (at your option) any later version.
8
-
9
- Plum is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with Plum. If not, see https://www.gnu.org/licenses/.
16
- */
17
1
  /*
18
2
  * This file is part of Plum.
19
3
  *
@@ -1,19 +1,3 @@
1
- /*
2
- This file is part of Plum.
3
-
4
- Plum is free software: you can redistribute it and/or modify
5
- it under the terms of the GNU General Public License as published by
6
- the Free Software Foundation, either version 3 of the License, or
7
- (at your option) any later version.
8
-
9
- Plum is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with Plum. If not, see https://www.gnu.org/licenses/.
16
- */
17
1
  /*
18
2
  * This file is part of Plum.
19
3
  *
@@ -1,19 +1,3 @@
1
- /*
2
- This file is part of Plum.
3
-
4
- Plum is free software: you can redistribute it and/or modify
5
- it under the terms of the GNU General Public License as published by
6
- the Free Software Foundation, either version 3 of the License, or
7
- (at your option) any later version.
8
-
9
- Plum is distributed in the hope that it will be useful,
10
- but WITHOUT ANY WARRANTY; without even the implied warranty of
11
- MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
12
- GNU General Public License for more details.
13
-
14
- You should have received a copy of the GNU General Public License
15
- along with Plum. If not, see https://www.gnu.org/licenses/.
16
- */
17
1
  /*
18
2
  * This file is part of Plum.
19
3
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "plum-e2e",
3
- "version": "1.3.4",
3
+ "version": "1.3.5",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/silverlunah/plum.git"
@@ -29,10 +29,12 @@
29
29
  "prettier-plugin-svelte": "^3.3.3"
30
30
  },
31
31
  "dependencies": {
32
- "fs-extra": "^11.3.0"
32
+ "@clack/prompts": "^1.5.1",
33
+ "fs-extra": "^11.3.0",
34
+ "picocolors": "^1.1.1"
33
35
  },
34
36
  "type": "module",
35
37
  "lint-staged": {
36
- "*.{js,ts,svelte,json,md,css,html}": "prettier --write"
38
+ "*.{js,ts,mjs,svelte,json,md,css,html}": "prettier --write"
37
39
  }
38
40
  }