plum-e2e 2.3.0 → 2.4.0

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.
@@ -8,7 +8,8 @@
8
8
  "create-env": "node services/envService.js",
9
9
  "test": "node config/scripts/run-tests.js",
10
10
  "manage-runners": "node scripts/manage-runners.mjs",
11
- "create-test": "node scripts/create-test.js"
11
+ "create-test": "node scripts/create-test.js",
12
+ "mcp": "node mcp/server.js"
12
13
  },
13
14
  "keywords": [],
14
15
  "author": "",
@@ -25,6 +26,7 @@
25
26
  "@aws-sdk/client-s3": "^3.840.0",
26
27
  "@clack/prompts": "^1.5.1",
27
28
  "@cucumber/cucumber": "^11.2.0",
29
+ "@modelcontextprotocol/sdk": "^1.29.0",
28
30
  "@prisma/client": "^6.19.3",
29
31
  "bcryptjs": "^2.4.3",
30
32
  "chai": "^4.3.6",
@@ -0,0 +1,2 @@
1
+ -- AlterTable
2
+ ALTER TABLE "Project" ADD COLUMN "mcpKey" TEXT NOT NULL DEFAULT '';
@@ -90,6 +90,7 @@ model Project {
90
90
  backupS3Prefix String @default("")
91
91
  backupLastRunAt DateTime?
92
92
  backupLastStatus String @default("")
93
+ mcpKey String @default("")
93
94
  }
94
95
 
95
96
  model User {
@@ -103,4 +103,22 @@ router.post('/integrations', async (req, res, next) => {
103
103
  }
104
104
  });
105
105
 
106
+ router.get('/mcp', jwtAuth, async (req, res, next) => {
107
+ try {
108
+ const config = await settingsService.getMcpConfig();
109
+ res.json(config);
110
+ } catch (e) {
111
+ next(e);
112
+ }
113
+ });
114
+
115
+ router.post('/mcp/generate', jwtAuth, async (req, res, next) => {
116
+ try {
117
+ const config = await settingsService.generateMcpKey();
118
+ res.json(config);
119
+ } catch (e) {
120
+ next(e);
121
+ }
122
+ });
123
+
106
124
  module.exports = router;
@@ -0,0 +1,94 @@
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
+
18
+ const path = require('path');
19
+ const { spawn } = require('child_process');
20
+ const { randomUUID } = require('crypto');
21
+ const express = require('express');
22
+ const router = express.Router();
23
+ const { jwtAuth } = require('../middleware/jwtAuth');
24
+ const prisma = require('../services/prisma');
25
+ const { TRIGGER_TYPE } = require('../constants/triggers');
26
+
27
+ const BACKEND_DIR = path.resolve(__dirname, '..');
28
+ const JOB_TTL_MS = 60 * 60 * 1000; // 1 hour
29
+
30
+ // In-memory job store: jobId → { status, exitCode, reportId, startedAt }
31
+ const jobs = new Map();
32
+
33
+ function pruneOldJobs() {
34
+ const cutoff = Date.now() - JOB_TTL_MS;
35
+ for (const [id, job] of jobs) {
36
+ if (job.startedAt < cutoff) jobs.delete(id);
37
+ }
38
+ }
39
+
40
+ router.post('/', jwtAuth, async (req, res, next) => {
41
+ try {
42
+ pruneOldJobs();
43
+
44
+ const { tag = '', browser = 'chromium', workers = 1, baseUrl, testRunId } = req.body;
45
+
46
+ const jobId = randomUUID();
47
+ const startedAt = Date.now();
48
+ jobs.set(jobId, { status: 'running', exitCode: null, reportId: null, startedAt });
49
+
50
+ const env = {
51
+ ...process.env,
52
+ TAG: tag,
53
+ TRIGGER: TRIGGER_TYPE.MCP,
54
+ BROWSER: browser,
55
+ REPORT_RUNNERS: String(workers)
56
+ };
57
+ if (Number(workers) > 1) env.PARALLEL = String(workers);
58
+ if (testRunId) env.TEST_RUN_ID = testRunId;
59
+ if (baseUrl) env.BASE_URL = baseUrl;
60
+
61
+ const proc = spawn('npm', ['run', 'test'], { env, shell: true, cwd: BACKEND_DIR });
62
+
63
+ proc.on('close', async (code) => {
64
+ try {
65
+ // Find the latest report created after this job started
66
+ const report = await prisma.report.findFirst({
67
+ where: { createdAt: { gte: new Date(startedAt) } },
68
+ orderBy: { createdAt: 'desc' },
69
+ select: { id: true, status: true }
70
+ });
71
+ jobs.set(jobId, {
72
+ status: code === 130 ? 'cancelled' : 'done',
73
+ exitCode: code,
74
+ reportId: report?.id ?? null,
75
+ startedAt
76
+ });
77
+ } catch {
78
+ jobs.set(jobId, { status: 'done', exitCode: code, reportId: null, startedAt });
79
+ }
80
+ });
81
+
82
+ res.status(202).json({ jobId, status: 'running' });
83
+ } catch (e) {
84
+ next(e);
85
+ }
86
+ });
87
+
88
+ router.get('/:jobId', jwtAuth, (req, res) => {
89
+ const job = jobs.get(req.params.jobId);
90
+ if (!job) return res.status(404).json({ error: 'Job not found' });
91
+ res.json(job);
92
+ });
93
+
94
+ module.exports = router;
@@ -42,12 +42,27 @@ const {
42
42
  stopNode,
43
43
  findPidOnPort
44
44
  } = runnerProcess;
45
- const { generateToken, registerWithPrimary } = nodeRegister;
45
+ const { generateToken, registerWithPrimary, detectLanIp } = nodeRegister;
46
46
 
47
47
  const API_URL = process.env.PLUM_API_URL || 'http://localhost:3001';
48
48
 
49
49
  const cancelled = (v) => clack.isCancel(v);
50
50
 
51
+ /**
52
+ * When the primary runs in Docker it cannot reach `localhost` on the host —
53
+ * only substitute when the user explicitly enters localhost/127.0.0.1.
54
+ */
55
+ function resolveNodeUrl(url) {
56
+ try {
57
+ const u = new URL(url);
58
+ if (u.hostname === 'localhost' || u.hostname === '127.0.0.1') {
59
+ u.hostname = 'host.docker.internal';
60
+ return u.toString();
61
+ }
62
+ } catch {}
63
+ return url;
64
+ }
65
+
51
66
  async function fetchRunners() {
52
67
  const res = await fetch(`${API_URL}/runners`);
53
68
  if (!res.ok) throw new Error(`HTTP ${res.status}`);
@@ -239,9 +254,15 @@ async function addRunner() {
239
254
  });
240
255
  if (cancelled(token)) return;
241
256
 
242
- // Dev nodes run as a bare process on the host; the dockerized primary reaches
243
- // them via host.docker.internal.
244
- const url = `http://host.docker.internal:${port}`;
257
+ const defaultUrl = `http://${detectLanIp()}:${port}`;
258
+ const urlInput = await clack.text({
259
+ message: 'URL the Plum server uses to reach this node',
260
+ placeholder: defaultUrl,
261
+ defaultValue: defaultUrl
262
+ });
263
+ if (cancelled(urlInput)) return;
264
+
265
+ const url = resolveNodeUrl(urlInput || defaultUrl);
245
266
 
246
267
  const s = clack.spinner();
247
268
  s.start(`Registering "${name}" with the primary...`);
package/backend/server.js CHANGED
@@ -54,6 +54,14 @@ async function start() {
54
54
  if (cronService) await cronService.init();
55
55
  if (backupCronService) await backupCronService.init();
56
56
 
57
+ if (!isNodeMode && !process.env.PLUM_MCP_KEY) {
58
+ try {
59
+ const settingsService = require('./services/settingsService');
60
+ const { mcpKey } = await settingsService.getMcpConfig();
61
+ if (mcpKey) process.env.PLUM_MCP_KEY = mcpKey;
62
+ } catch {}
63
+ }
64
+
57
65
  server.listen(port, async () => {
58
66
  console.log(`Backend running on port ${port}${isNodeMode ? ' (node/runner mode)' : ''}`);
59
67
  if (isNodeMode) {
@@ -15,6 +15,7 @@
15
15
  * along with Plum. If not, see https://www.gnu.org/licenses/.
16
16
  */
17
17
 
18
+ const crypto = require('crypto');
18
19
  const prisma = require('./prisma');
19
20
 
20
21
  const getProject = async () => {
@@ -113,6 +114,22 @@ const updateBackupConfig = async ({
113
114
  });
114
115
  };
115
116
 
117
+ const getMcpConfig = async () => {
118
+ const project = await getProject();
119
+ return { mcpKeySet: project.mcpKey.length > 0, mcpKey: project.mcpKey };
120
+ };
121
+
122
+ const generateMcpKey = async () => {
123
+ const key = crypto.randomBytes(32).toString('hex');
124
+ await prisma.project.upsert({
125
+ where: { id: 1 },
126
+ create: { id: 1, mcpKey: key },
127
+ update: { mcpKey: key }
128
+ });
129
+ process.env.PLUM_MCP_KEY = key;
130
+ return { mcpKey: key };
131
+ };
132
+
116
133
  module.exports = {
117
134
  getProject,
118
135
  updateProject,
@@ -121,5 +138,7 @@ module.exports = {
121
138
  getWebhooks,
122
139
  updateWebhooks,
123
140
  getBackupConfig,
124
- updateBackupConfig
141
+ updateBackupConfig,
142
+ getMcpConfig,
143
+ generateMcpKey
125
144
  };
package/bin/plum.js CHANGED
@@ -186,36 +186,18 @@ async function configureServer({ force }) {
186
186
  const cfg = loadServerConfig(cwd);
187
187
 
188
188
  const overrides = {
189
- baseUrl: getFlag(args, '--base-url'),
190
189
  headless: getFlag(args, '--headless'),
191
190
  backendPort: getFlag(args, '--backend-port'),
192
- frontendPort: getFlag(args, '--frontend-port'),
193
- primaryPublicUrl: getFlag(args, '--primary-url')
191
+ frontendPort: getFlag(args, '--frontend-port')
194
192
  };
195
- if (overrides.baseUrl !== undefined) cfg.baseUrl = overrides.baseUrl;
196
193
  if (overrides.headless !== undefined) cfg.headless = overrides.headless === 'true';
197
194
  if (overrides.backendPort !== undefined) cfg.backendPort = overrides.backendPort;
198
195
  if (overrides.frontendPort !== undefined) cfg.frontendPort = overrides.frontendPort;
199
- if (overrides.primaryPublicUrl !== undefined) cfg.primaryPublicUrl = overrides.primaryPublicUrl;
200
196
 
201
- const hasFlags = anyFlags(args, [
202
- '--base-url',
203
- '--headless',
204
- '--backend-port',
205
- '--frontend-port',
206
- '--primary-url'
207
- ]);
197
+ const hasFlags = anyFlags(args, ['--headless', '--backend-port', '--frontend-port']);
208
198
  const interactive = force || (interactiveAllowed() && !hasFlags);
209
199
 
210
200
  if (interactive) {
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
201
  const headless = await clack.confirm({
220
202
  message: 'Run browsers headless?',
221
203
  initialValue: cfg.headless
@@ -238,14 +220,6 @@ async function configureServer({ force }) {
238
220
  });
239
221
  if (clack.isCancel(frontendPort)) cancelAndExit();
240
222
  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;
249
223
  }
250
224
 
251
225
  saveServerConfig(cwd, cfg);
@@ -277,8 +251,7 @@ async function serverStart() {
277
251
  clack.intro(pc.bgMagenta(pc.white(' 🟣 Plum — Server ')));
278
252
  const cfg = await configureServer({ force: false });
279
253
  applyServerConfig(cfg);
280
- clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
281
- clack.log.info(`Nodes register against: ${pc.dim(cfg.primaryPublicUrl)}`);
254
+ clack.log.info(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
282
255
 
283
256
  execSync('docker compose up --build -d', { cwd: plumRoot, stdio: 'inherit' });
284
257
 
@@ -355,9 +328,7 @@ async function serverReconfig() {
355
328
  const cfg = await configureServer({ force: true });
356
329
  applyServerConfig(cfg);
357
330
  clack.log.success("Saved. Run 'plum server start' to apply.");
358
- clack.outro(
359
- `UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)} · Nodes: ${pc.dim(cfg.primaryPublicUrl)}`
360
- );
331
+ clack.outro(`UI: ${pc.cyan(`http://localhost:${cfg.frontendPort}`)}`);
361
332
  }
362
333
 
363
334
  /* -----------------------------------------------------
@@ -413,22 +384,6 @@ async function configureNode({ force }) {
413
384
  });
414
385
  if (clack.isCancel(urlVal)) cancelAndExit();
415
386
  url = urlVal || defaultUrl;
416
-
417
- const nameVal = await clack.text({
418
- message: 'Runner name',
419
- placeholder: name,
420
- defaultValue: name
421
- });
422
- if (clack.isCancel(nameVal)) cancelAndExit();
423
- name = nameVal || name;
424
-
425
- const tokenVal = await clack.text({
426
- message: 'Auth token (Enter to keep)',
427
- placeholder: token,
428
- defaultValue: token
429
- });
430
- if (clack.isCancel(tokenVal)) cancelAndExit();
431
- token = tokenVal || token;
432
387
  }
433
388
 
434
389
  if (!url) url = `http://${detectLanIp()}:${port}`;
@@ -980,16 +935,27 @@ switch (command) {
980
935
  break;
981
936
  }
982
937
 
938
+ case 'mcp': {
939
+ const mcpScript = path.join(plumRoot, 'backend', 'mcp', 'server.js');
940
+ spawn(process.execPath, [mcpScript], {
941
+ stdio: 'inherit',
942
+ env: {
943
+ ...process.env,
944
+ PLUM_API_URL: process.env.PLUM_API_URL || 'http://localhost:3001',
945
+ PLUM_API_KEY: process.env.PLUM_API_KEY || ''
946
+ }
947
+ });
948
+ break;
949
+ }
950
+
983
951
  default:
984
952
  console.log('--------------------------------------\n');
985
953
  console.log('Usage: plum <command>\n');
986
954
  console.log(' init Set up a new Plum project');
987
955
  console.log(' server start Start the full UI stack (interactive; alias: plum start)');
988
- console.log(' --base-url <url> App URL to test (skips the prompt)');
989
956
  console.log(' --headless <bool> Run browsers headless (true/false)');
990
957
  console.log(' --backend-port <n> Host port for the backend/API (default: 3001)');
991
958
  console.log(' --frontend-port <n> Host port for the UI (default: 5173)');
992
- console.log(' --primary-url <url> Public URL node operators point --primary at');
993
959
  console.log(' server reconfig Re-enter server settings without starting');
994
960
  console.log(' server stop Stop the server (alias: plum stop)');
995
961
  console.log(' node start Start a runner node (interactive), then open runner menu');
@@ -1014,5 +980,6 @@ switch (command) {
1014
980
  console.log(' --browser <name> chromium | firefox (default: chromium)');
1015
981
  console.log(' create-step Interactively scaffold a new step definition');
1016
982
  console.log(' create-test Scaffold a new .feature + Page.ts + Steps.ts');
983
+ console.log(' mcp Start the Plum MCP server (stdio) for Claude integration');
1017
984
  console.log('\n--------------------------------------\n');
1018
985
  }
@@ -16,6 +16,11 @@
16
16
  */
17
17
 
18
18
  import { API_BASE } from '$lib/constants';
19
+ import { auth } from '$lib/stores/auth';
20
+
21
+ function authHeaders() {
22
+ return { Authorization: `Bearer ${auth.getToken()}` };
23
+ }
19
24
 
20
25
  export async function fetchProject() {
21
26
  const res = await fetch(`${API_BASE}/settings/project`);
@@ -102,3 +107,18 @@ export async function saveIntegrations({ discordWebhookUrl, slackWebhookUrl, not
102
107
  });
103
108
  return res.json();
104
109
  }
110
+
111
+ export async function fetchMcpConfig() {
112
+ const res = await fetch(`${API_BASE}/settings/mcp`, { headers: authHeaders() });
113
+ if (!res.ok) return { mcpKeySet: false, mcpKey: '' };
114
+ return res.json();
115
+ }
116
+
117
+ export async function generateMcpKey() {
118
+ const res = await fetch(`${API_BASE}/settings/mcp/generate`, {
119
+ method: 'POST',
120
+ headers: authHeaders()
121
+ });
122
+ if (!res.ok) throw new Error('Failed to generate key');
123
+ return res.json();
124
+ }
@@ -20,7 +20,6 @@
20
20
  import { slide } from 'svelte/transition';
21
21
  import { onMount } from 'svelte';
22
22
  import { auth } from '$lib/stores/auth';
23
- import { goto } from '$app/navigation';
24
23
  import { fetchProject } from '$lib/api/settings';
25
24
 
26
25
  let menuOpen = false;
@@ -0,0 +1,20 @@
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
+
18
+ // Plum is a private authenticated app — SSR adds no value and causes
19
+ // hydration mismatches (auth state differs between server and client).
20
+ export const ssr = false;
@@ -28,30 +28,38 @@
28
28
 
29
29
  const PUBLIC_ROUTES = ['/login', '/setup'];
30
30
 
31
- let ready = true;
31
+ let ready = false;
32
32
 
33
33
  onMount(async () => {
34
34
  const pathname = $page.url.pathname;
35
- if (PUBLIC_ROUTES.includes(pathname)) return;
35
+ if (PUBLIC_ROUTES.includes(pathname)) {
36
+ ready = true;
37
+ return;
38
+ }
36
39
 
37
40
  const token = $auth.token;
38
- if (!token) {
39
- try {
40
- const needsSetup = await checkNeedsSetup();
41
- goto(needsSetup ? '/setup' : '/login');
42
- } catch {
43
- goto('/login');
44
- }
41
+ if (token) {
42
+ ready = true;
43
+ return;
44
+ }
45
+
46
+ try {
47
+ const needsSetup = await checkNeedsSetup();
48
+ goto(needsSetup ? '/setup' : '/login');
49
+ } catch {
50
+ goto('/login');
45
51
  }
46
52
  });
47
53
  </script>
48
54
 
49
- {#if $page.url.pathname === '/login' || $page.url.pathname === '/setup'}
50
- <slot />
51
- {:else}
52
- <Nav />
53
- <PageShell>
55
+ {#if ready}
56
+ {#if $page.url.pathname === '/login' || $page.url.pathname === '/setup'}
54
57
  <slot />
55
- </PageShell>
56
- <RunnerPanel />
58
+ {:else}
59
+ <Nav />
60
+ <PageShell>
61
+ <slot />
62
+ </PageShell>
63
+ <RunnerPanel />
64
+ {/if}
57
65
  {/if}