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.
@@ -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: '1',
46
+ version: '2',
28
47
  exportedAt: new Date().toISOString(),
29
- cronJobs: cronJobs.map(({ id, createdAt, updatedAt, reports: _, ...r }) => r),
30
- project: project ? { name: project.name, logoUrl: project.logoUrl } : null
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
- const importAll = async ({ cronJobs = [], project = null }, cronService) => {
35
- await prisma.$transaction(async (tx) => {
36
- for (const job of cronJobs) {
37
- await tx.cronJob.upsert({
38
- where: { taskName: job.taskName },
39
- create: {
40
- taskName: job.taskName,
41
- cronExpression: job.cronExpression,
42
- tags: job.tags,
43
- workers: job.workers ?? 1
44
- },
45
- update: { cronExpression: job.cronExpression, tags: job.tags, workers: job.workers ?? 1 }
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
- if (project) {
50
- await tx.project.upsert({
51
- where: { id: 1 },
52
- create: { id: 1, name: project.name ?? '', logoUrl: project.logoUrl ?? '' },
53
- update: { name: project.name ?? '', logoUrl: project.logoUrl ?? '' }
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
- if (cronService) await cronService.reload();
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: ${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`);
@@ -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
+ }
@@ -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}