plum-e2e 2.2.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.
- package/README.md +61 -520
- package/backend/app.js +1 -0
- package/backend/constants/triggers.js +3 -1
- package/backend/lib/serverConfig.js +23 -14
- package/backend/mcp/server.js +385 -0
- package/backend/middleware/jwtAuth.js +18 -0
- package/backend/package-lock.json +1432 -28
- package/backend/package.json +4 -1
- package/backend/prisma/migrations/20260621000001_add_backup_config/migration.sql +11 -0
- package/backend/prisma/migrations/20260621000002_add_mcp_key/migration.sql +2 -0
- package/backend/prisma/schema.prisma +21 -10
- package/backend/routes/backup.routes.js +70 -5
- package/backend/routes/settings.routes.js +18 -0
- package/backend/routes/trigger.routes.js +94 -0
- package/backend/scripts/manage-runners.mjs +25 -4
- package/backend/server.js +11 -0
- package/backend/services/backupCronService.js +82 -0
- package/backend/services/backupService.js +254 -27
- package/backend/services/settingsService.js +65 -1
- package/bin/plum.js +18 -51
- package/frontend/src/lib/api/settings.js +61 -0
- package/frontend/src/lib/components/layout/Nav.svelte +0 -1
- package/frontend/src/routes/+layout.js +20 -0
- package/frontend/src/routes/+layout.svelte +24 -16
- package/frontend/src/routes/settings/+page.svelte +622 -9
- package/package.json +2 -2
|
@@ -17,45 +17,272 @@
|
|
|
17
17
|
|
|
18
18
|
const prisma = require('./prisma');
|
|
19
19
|
|
|
20
|
+
// ---------------------------------------------------------------------------
|
|
21
|
+
// Export — all data except reports (reports are too large; use pg_dump instead)
|
|
22
|
+
// ---------------------------------------------------------------------------
|
|
23
|
+
|
|
20
24
|
const exportAll = async () => {
|
|
21
|
-
const [cronJobs, project] = await Promise.all([
|
|
25
|
+
const [cronJobs, project, testSuites, testRuns, users, runners] = await Promise.all([
|
|
22
26
|
prisma.cronJob.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
23
|
-
prisma.project.findUnique({ where: { id: 1 } })
|
|
27
|
+
prisma.project.findUnique({ where: { id: 1 } }),
|
|
28
|
+
prisma.testSuite.findMany({
|
|
29
|
+
orderBy: { createdAt: 'asc' },
|
|
30
|
+
include: {
|
|
31
|
+
cases: {
|
|
32
|
+
include: { steps: { orderBy: { order: 'asc' } } },
|
|
33
|
+
orderBy: { createdAt: 'asc' }
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
}),
|
|
37
|
+
prisma.testRun.findMany({
|
|
38
|
+
orderBy: { createdAt: 'asc' },
|
|
39
|
+
include: { entries: { orderBy: { order: 'asc' } } }
|
|
40
|
+
}),
|
|
41
|
+
prisma.user.findMany({ orderBy: { createdAt: 'asc' } }),
|
|
42
|
+
prisma.runner.findMany({ orderBy: { createdAt: 'asc' } })
|
|
24
43
|
]);
|
|
25
44
|
|
|
26
45
|
return {
|
|
27
|
-
version: '
|
|
46
|
+
version: '2',
|
|
28
47
|
exportedAt: new Date().toISOString(),
|
|
29
|
-
|
|
30
|
-
|
|
48
|
+
disclaimer:
|
|
49
|
+
'Reports are not included in this backup. Use pg_dump on the PostgreSQL volume to back up report history.',
|
|
50
|
+
cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, runnerId: __, ...r }) => r),
|
|
51
|
+
project: project
|
|
52
|
+
? {
|
|
53
|
+
name: project.name,
|
|
54
|
+
logoUrl: project.logoUrl,
|
|
55
|
+
testCasePrefix: project.testCasePrefix,
|
|
56
|
+
testSuitePrefix: project.testSuitePrefix,
|
|
57
|
+
discordWebhookUrl: project.discordWebhookUrl,
|
|
58
|
+
slackWebhookUrl: project.slackWebhookUrl,
|
|
59
|
+
notifyPublicUrl: project.notifyPublicUrl
|
|
60
|
+
}
|
|
61
|
+
: null,
|
|
62
|
+
users: users.map(({ updatedAt: _, ...u }) => u),
|
|
63
|
+
runners: runners.map(({ createdAt: _, cronJobs: __, reports: ___, ...r }) => r),
|
|
64
|
+
testSuites: testSuites.map(({ cases, ...suite }) => ({
|
|
65
|
+
...suite,
|
|
66
|
+
cases: cases.map(({ steps, runEntries: _, history: __, ...tc }) => ({
|
|
67
|
+
...tc,
|
|
68
|
+
steps: steps.map(({ createdAt: ___, ...step }) => step)
|
|
69
|
+
}))
|
|
70
|
+
})),
|
|
71
|
+
testRuns: testRuns.map(({ entries, history: _, ...run }) => ({
|
|
72
|
+
...run,
|
|
73
|
+
entries: entries.map(({ executedAt, ...entry }) => ({ ...entry, executedAt }))
|
|
74
|
+
}))
|
|
31
75
|
};
|
|
32
76
|
};
|
|
33
77
|
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
78
|
+
// ---------------------------------------------------------------------------
|
|
79
|
+
// Import — upsert all exported data, preserve IDs for relational integrity
|
|
80
|
+
// ---------------------------------------------------------------------------
|
|
81
|
+
|
|
82
|
+
const importAll = async (
|
|
83
|
+
{ cronJobs = [], project = null, users = [], runners = [], testSuites = [], testRuns = [] },
|
|
84
|
+
cronService
|
|
85
|
+
) => {
|
|
86
|
+
await prisma.$transaction(
|
|
87
|
+
async (tx) => {
|
|
88
|
+
// 1. Users (needed before suites/runs reference createdById)
|
|
89
|
+
for (const user of users) {
|
|
90
|
+
await tx.user.upsert({
|
|
91
|
+
where: { email: user.email },
|
|
92
|
+
create: user,
|
|
93
|
+
update: { name: user.name, role: user.role }
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
// 2. Runners
|
|
98
|
+
for (const runner of runners) {
|
|
99
|
+
await tx.runner.upsert({
|
|
100
|
+
where: { id: runner.id },
|
|
101
|
+
create: runner,
|
|
102
|
+
update: {
|
|
103
|
+
name: runner.name,
|
|
104
|
+
url: runner.url,
|
|
105
|
+
token: runner.token,
|
|
106
|
+
browser: runner.browser
|
|
107
|
+
}
|
|
108
|
+
});
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// 3. CronJobs
|
|
112
|
+
for (const job of cronJobs) {
|
|
113
|
+
await tx.cronJob.upsert({
|
|
114
|
+
where: { taskName: job.taskName },
|
|
115
|
+
create: {
|
|
116
|
+
taskName: job.taskName,
|
|
117
|
+
cronExpression: job.cronExpression,
|
|
118
|
+
tags: job.tags,
|
|
119
|
+
workers: job.workers ?? 1,
|
|
120
|
+
browser: job.browser ?? 'chromium',
|
|
121
|
+
enabled: job.enabled ?? true,
|
|
122
|
+
runnerIds: job.runnerIds ?? 'built-in',
|
|
123
|
+
notifyDiscord: job.notifyDiscord ?? false,
|
|
124
|
+
notifySlack: job.notifySlack ?? false
|
|
125
|
+
},
|
|
126
|
+
update: {
|
|
127
|
+
cronExpression: job.cronExpression,
|
|
128
|
+
tags: job.tags,
|
|
129
|
+
workers: job.workers ?? 1,
|
|
130
|
+
browser: job.browser ?? 'chromium',
|
|
131
|
+
runnerIds: job.runnerIds ?? 'built-in',
|
|
132
|
+
notifyDiscord: job.notifyDiscord ?? false,
|
|
133
|
+
notifySlack: job.notifySlack ?? false
|
|
134
|
+
}
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// 4. Project settings
|
|
139
|
+
if (project) {
|
|
140
|
+
await tx.project.upsert({
|
|
141
|
+
where: { id: 1 },
|
|
142
|
+
create: { id: 1, ...project },
|
|
143
|
+
update: project
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// 5. Test suites + cases + steps
|
|
148
|
+
for (const suite of testSuites) {
|
|
149
|
+
const { cases = [], ...suiteData } = suite;
|
|
150
|
+
await tx.testSuite.upsert({
|
|
151
|
+
where: { displayId: suiteData.displayId },
|
|
152
|
+
create: suiteData,
|
|
153
|
+
update: {
|
|
154
|
+
name: suiteData.name,
|
|
155
|
+
description: suiteData.description,
|
|
156
|
+
priority: suiteData.priority
|
|
157
|
+
}
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
for (const tc of cases) {
|
|
161
|
+
const { steps = [], ...caseData } = tc;
|
|
162
|
+
await tx.testCase.upsert({
|
|
163
|
+
where: { displayId: caseData.displayId },
|
|
164
|
+
create: caseData,
|
|
165
|
+
update: {
|
|
166
|
+
title: caseData.title,
|
|
167
|
+
description: caseData.description,
|
|
168
|
+
priority: caseData.priority,
|
|
169
|
+
isAutomated: caseData.isAutomated
|
|
170
|
+
}
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
// Replace all steps for this case (order may have changed)
|
|
174
|
+
await tx.testStep.deleteMany({ where: { caseId: caseData.id } });
|
|
175
|
+
for (const step of steps) {
|
|
176
|
+
await tx.testStep.create({ data: step });
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
// 6. Test runs + entries
|
|
182
|
+
for (const run of testRuns) {
|
|
183
|
+
const { entries = [], ...runData } = run;
|
|
184
|
+
await tx.testRun.upsert({
|
|
185
|
+
where: { id: runData.id },
|
|
186
|
+
create: runData,
|
|
187
|
+
update: { title: runData.title, status: runData.status }
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
for (const entry of entries) {
|
|
191
|
+
await tx.testRunEntry.upsert({
|
|
192
|
+
where: { id: entry.id },
|
|
193
|
+
create: entry,
|
|
194
|
+
update: { status: entry.status, notes: entry.notes, order: entry.order }
|
|
195
|
+
});
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
},
|
|
199
|
+
{ timeout: 30000 }
|
|
200
|
+
);
|
|
201
|
+
|
|
202
|
+
if (cronService) await cronService.reload();
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// ---------------------------------------------------------------------------
|
|
206
|
+
// S3 upload — S3-compatible object storage (AWS, R2, B2, MinIO)
|
|
207
|
+
// ---------------------------------------------------------------------------
|
|
208
|
+
|
|
209
|
+
const uploadToS3 = async (jsonData, config) => {
|
|
210
|
+
const { S3Client, PutObjectCommand } = await import('@aws-sdk/client-s3');
|
|
211
|
+
|
|
212
|
+
const {
|
|
213
|
+
backupS3Endpoint,
|
|
214
|
+
backupS3Region,
|
|
215
|
+
backupS3Bucket,
|
|
216
|
+
backupS3AccessKey,
|
|
217
|
+
backupS3SecretKey,
|
|
218
|
+
backupS3Prefix
|
|
219
|
+
} = config;
|
|
220
|
+
|
|
221
|
+
const clientConfig = {
|
|
222
|
+
region: backupS3Region || 'auto',
|
|
223
|
+
credentials: {
|
|
224
|
+
accessKeyId: backupS3AccessKey,
|
|
225
|
+
secretAccessKey: backupS3SecretKey
|
|
47
226
|
}
|
|
227
|
+
};
|
|
228
|
+
if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
|
|
229
|
+
|
|
230
|
+
const client = new S3Client(clientConfig);
|
|
231
|
+
|
|
232
|
+
const date = new Date().toISOString().slice(0, 10);
|
|
233
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
234
|
+
const key = `${prefix}plum-backup-${date}.json`;
|
|
235
|
+
|
|
236
|
+
await client.send(
|
|
237
|
+
new PutObjectCommand({
|
|
238
|
+
Bucket: backupS3Bucket,
|
|
239
|
+
Key: key,
|
|
240
|
+
Body: JSON.stringify(jsonData, null, 2),
|
|
241
|
+
ContentType: 'application/json'
|
|
242
|
+
})
|
|
243
|
+
);
|
|
244
|
+
|
|
245
|
+
return key;
|
|
246
|
+
};
|
|
247
|
+
|
|
248
|
+
const testS3Connection = async (config) => {
|
|
249
|
+
const { S3Client, PutObjectCommand, DeleteObjectCommand } = await import('@aws-sdk/client-s3');
|
|
48
250
|
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
251
|
+
const {
|
|
252
|
+
backupS3Endpoint,
|
|
253
|
+
backupS3Region,
|
|
254
|
+
backupS3Bucket,
|
|
255
|
+
backupS3AccessKey,
|
|
256
|
+
backupS3SecretKey,
|
|
257
|
+
backupS3Prefix
|
|
258
|
+
} = config;
|
|
259
|
+
|
|
260
|
+
const clientConfig = {
|
|
261
|
+
region: backupS3Region || 'auto',
|
|
262
|
+
credentials: {
|
|
263
|
+
accessKeyId: backupS3AccessKey,
|
|
264
|
+
secretAccessKey: backupS3SecretKey
|
|
55
265
|
}
|
|
56
|
-
}
|
|
266
|
+
};
|
|
267
|
+
if (backupS3Endpoint) clientConfig.endpoint = backupS3Endpoint;
|
|
57
268
|
|
|
58
|
-
|
|
269
|
+
const client = new S3Client(clientConfig);
|
|
270
|
+
const prefix = backupS3Prefix ? backupS3Prefix.replace(/\/?$/, '/') : '';
|
|
271
|
+
const key = `${prefix}.plum-connection-test`;
|
|
272
|
+
|
|
273
|
+
await client.send(
|
|
274
|
+
new PutObjectCommand({
|
|
275
|
+
Bucket: backupS3Bucket,
|
|
276
|
+
Key: key,
|
|
277
|
+
Body: 'ok',
|
|
278
|
+
ContentType: 'text/plain'
|
|
279
|
+
})
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
// Clean up the test file
|
|
283
|
+
try {
|
|
284
|
+
await client.send(new DeleteObjectCommand({ Bucket: backupS3Bucket, Key: key }));
|
|
285
|
+
} catch {}
|
|
59
286
|
};
|
|
60
287
|
|
|
61
|
-
module.exports = { exportAll, importAll };
|
|
288
|
+
module.exports = { exportAll, importAll, uploadToS3, testS3Connection };
|
|
@@ -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 () => {
|
|
@@ -70,11 +71,74 @@ const updateWebhooks = async ({ discordWebhookUrl, slackWebhookUrl, notifyPublic
|
|
|
70
71
|
});
|
|
71
72
|
};
|
|
72
73
|
|
|
74
|
+
const getBackupConfig = async () => {
|
|
75
|
+
const project = await getProject();
|
|
76
|
+
return {
|
|
77
|
+
backupEnabled: project.backupEnabled,
|
|
78
|
+
backupCron: project.backupCron,
|
|
79
|
+
backupS3Endpoint: project.backupS3Endpoint,
|
|
80
|
+
backupS3Region: project.backupS3Region,
|
|
81
|
+
backupS3Bucket: project.backupS3Bucket,
|
|
82
|
+
backupS3AccessKey: project.backupS3AccessKey,
|
|
83
|
+
backupS3SecretKeySet: project.backupS3SecretKey.length > 0,
|
|
84
|
+
backupS3Prefix: project.backupS3Prefix,
|
|
85
|
+
backupLastRunAt: project.backupLastRunAt,
|
|
86
|
+
backupLastStatus: project.backupLastStatus
|
|
87
|
+
};
|
|
88
|
+
};
|
|
89
|
+
|
|
90
|
+
const updateBackupConfig = async ({
|
|
91
|
+
backupEnabled,
|
|
92
|
+
backupCron,
|
|
93
|
+
backupS3Endpoint,
|
|
94
|
+
backupS3Region,
|
|
95
|
+
backupS3Bucket,
|
|
96
|
+
backupS3AccessKey,
|
|
97
|
+
backupS3SecretKey,
|
|
98
|
+
backupS3Prefix
|
|
99
|
+
}) => {
|
|
100
|
+
const update = {
|
|
101
|
+
...(backupEnabled !== undefined && { backupEnabled }),
|
|
102
|
+
...(backupCron !== undefined && { backupCron }),
|
|
103
|
+
...(backupS3Endpoint !== undefined && { backupS3Endpoint }),
|
|
104
|
+
...(backupS3Region !== undefined && { backupS3Region }),
|
|
105
|
+
...(backupS3Bucket !== undefined && { backupS3Bucket }),
|
|
106
|
+
...(backupS3AccessKey !== undefined && { backupS3AccessKey }),
|
|
107
|
+
...(backupS3SecretKey && { backupS3SecretKey }),
|
|
108
|
+
...(backupS3Prefix !== undefined && { backupS3Prefix })
|
|
109
|
+
};
|
|
110
|
+
return prisma.project.upsert({
|
|
111
|
+
where: { id: 1 },
|
|
112
|
+
create: { id: 1, ...update },
|
|
113
|
+
update
|
|
114
|
+
});
|
|
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
|
+
|
|
73
133
|
module.exports = {
|
|
74
134
|
getProject,
|
|
75
135
|
updateProject,
|
|
76
136
|
getTestPrefixes,
|
|
77
137
|
updateTestPrefixes,
|
|
78
138
|
getWebhooks,
|
|
79
|
-
updateWebhooks
|
|
139
|
+
updateWebhooks,
|
|
140
|
+
getBackupConfig,
|
|
141
|
+
updateBackupConfig,
|
|
142
|
+
getMcpConfig,
|
|
143
|
+
generateMcpKey
|
|
80
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:
|
|
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`);
|
|
@@ -47,6 +52,47 @@ export async function importBackup(data) {
|
|
|
47
52
|
return res.json();
|
|
48
53
|
}
|
|
49
54
|
|
|
55
|
+
export async function fetchBackupConfig() {
|
|
56
|
+
const res = await fetch(`${API_BASE}/backup/config`);
|
|
57
|
+
if (!res.ok)
|
|
58
|
+
return {
|
|
59
|
+
backupEnabled: false,
|
|
60
|
+
backupCron: '0 2 * * *',
|
|
61
|
+
backupS3Endpoint: '',
|
|
62
|
+
backupS3Region: '',
|
|
63
|
+
backupS3Bucket: '',
|
|
64
|
+
backupS3AccessKey: '',
|
|
65
|
+
backupS3SecretKeySet: false,
|
|
66
|
+
backupS3Prefix: '',
|
|
67
|
+
backupLastRunAt: null,
|
|
68
|
+
backupLastStatus: ''
|
|
69
|
+
};
|
|
70
|
+
return res.json();
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export async function saveBackupConfig(config) {
|
|
74
|
+
const res = await fetch(`${API_BASE}/backup/config`, {
|
|
75
|
+
method: 'POST',
|
|
76
|
+
headers: { 'Content-Type': 'application/json' },
|
|
77
|
+
body: JSON.stringify(config)
|
|
78
|
+
});
|
|
79
|
+
return res.json();
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export async function testBackupS3(config) {
|
|
83
|
+
const res = await fetch(`${API_BASE}/backup/test-s3`, {
|
|
84
|
+
method: 'POST',
|
|
85
|
+
headers: { 'Content-Type': 'application/json' },
|
|
86
|
+
body: JSON.stringify(config)
|
|
87
|
+
});
|
|
88
|
+
return res.json();
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export async function runBackupNow() {
|
|
92
|
+
const res = await fetch(`${API_BASE}/backup/run-now`, { method: 'POST' });
|
|
93
|
+
return res.json();
|
|
94
|
+
}
|
|
95
|
+
|
|
50
96
|
export async function fetchIntegrations() {
|
|
51
97
|
const res = await fetch(`${API_BASE}/settings/integrations`);
|
|
52
98
|
if (!res.ok) return { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
@@ -61,3 +107,18 @@ export async function saveIntegrations({ discordWebhookUrl, slackWebhookUrl, not
|
|
|
61
107
|
});
|
|
62
108
|
return res.json();
|
|
63
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
|
+
}
|
|
@@ -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 =
|
|
31
|
+
let ready = false;
|
|
32
32
|
|
|
33
33
|
onMount(async () => {
|
|
34
34
|
const pathname = $page.url.pathname;
|
|
35
|
-
if (PUBLIC_ROUTES.includes(pathname))
|
|
35
|
+
if (PUBLIC_ROUTES.includes(pathname)) {
|
|
36
|
+
ready = true;
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
36
39
|
|
|
37
40
|
const token = $auth.token;
|
|
38
|
-
if (
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
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
|
|
50
|
-
|
|
51
|
-
{:else}
|
|
52
|
-
<Nav />
|
|
53
|
-
<PageShell>
|
|
55
|
+
{#if ready}
|
|
56
|
+
{#if $page.url.pathname === '/login' || $page.url.pathname === '/setup'}
|
|
54
57
|
<slot />
|
|
55
|
-
|
|
56
|
-
|
|
58
|
+
{:else}
|
|
59
|
+
<Nav />
|
|
60
|
+
<PageShell>
|
|
61
|
+
<slot />
|
|
62
|
+
</PageShell>
|
|
63
|
+
<RunnerPanel />
|
|
64
|
+
{/if}
|
|
57
65
|
{/if}
|