plum-e2e 1.3.4 → 1.3.6

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/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: 'Your Plum server backend 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 Plum 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: 'The URL your Plum server calls to communicate with this node',
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
  }