plum-e2e 2.3.0 → 2.4.1
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 +15 -15
- 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 +3 -1
- package/backend/prisma/migrations/20260621000002_add_mcp_key/migration.sql +2 -0
- package/backend/prisma/schema.prisma +1 -0
- 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 +8 -0
- package/backend/services/settingsService.js +20 -1
- package/bin/plum.js +18 -51
- package/frontend/src/lib/api/settings.js +20 -0
- package/frontend/src/lib/components/layout/Nav.svelte +0 -1
- package/frontend/src/lib/components/ui/Badge.svelte +6 -1
- package/frontend/src/lib/constants.js +2 -1
- package/frontend/src/lib/utils/format.js +8 -1
- package/frontend/src/routes/+layout.js +20 -0
- package/frontend/src/routes/+layout.svelte +24 -16
- package/frontend/src/routes/settings/+page.svelte +298 -58
- package/package.json +2 -2
|
@@ -29,7 +29,9 @@
|
|
|
29
29
|
fetchBackupConfig,
|
|
30
30
|
saveBackupConfig,
|
|
31
31
|
testBackupS3,
|
|
32
|
-
runBackupNow
|
|
32
|
+
runBackupNow,
|
|
33
|
+
fetchMcpConfig,
|
|
34
|
+
generateMcpKey as generateMcpKeyApi
|
|
33
35
|
} from '$lib/api/settings';
|
|
34
36
|
import {
|
|
35
37
|
fetchRunners,
|
|
@@ -54,7 +56,7 @@
|
|
|
54
56
|
import Toast from '$lib/components/ui/Toast.svelte';
|
|
55
57
|
import ConfirmModal from '$lib/components/ui/ConfirmModal.svelte';
|
|
56
58
|
|
|
57
|
-
/** @type {'project' | 'runners' | 'repository' | 'integrations' | 'account' | 'users' | 'backup'} */
|
|
59
|
+
/** @type {'project' | 'runners' | 'repository' | 'integrations' | 'mcp' | 'account' | 'users' | 'backup'} */
|
|
58
60
|
let section =
|
|
59
61
|
(typeof sessionStorage !== 'undefined' && sessionStorage.getItem('plum:settings:section')) ||
|
|
60
62
|
'project';
|
|
@@ -98,6 +100,13 @@
|
|
|
98
100
|
let integrations = { discordWebhookUrl: '', slackWebhookUrl: '', notifyPublicUrl: '' };
|
|
99
101
|
let integrationsSaving = false;
|
|
100
102
|
|
|
103
|
+
let mcpKey = '';
|
|
104
|
+
let mcpKeySet = false;
|
|
105
|
+
let mcpShowKey = false;
|
|
106
|
+
let mcpGenerating = false;
|
|
107
|
+
let mcpKeyCopied = false;
|
|
108
|
+
let mcpSnippetCopied = false;
|
|
109
|
+
|
|
101
110
|
let backupConfig = {
|
|
102
111
|
backupEnabled: false,
|
|
103
112
|
backupCron: '0 2 * * *',
|
|
@@ -157,6 +166,11 @@
|
|
|
157
166
|
try {
|
|
158
167
|
integrations = await fetchIntegrations();
|
|
159
168
|
} catch {}
|
|
169
|
+
try {
|
|
170
|
+
const mcp = await fetchMcpConfig();
|
|
171
|
+
mcpKeySet = mcp.mcpKeySet;
|
|
172
|
+
mcpKey = mcp.mcpKey;
|
|
173
|
+
} catch {}
|
|
160
174
|
try {
|
|
161
175
|
const bc = await fetchBackupConfig();
|
|
162
176
|
backupS3SecretKeySet = bc.backupS3SecretKeySet;
|
|
@@ -446,6 +460,35 @@
|
|
|
446
460
|
goto('/login');
|
|
447
461
|
}
|
|
448
462
|
|
|
463
|
+
async function handleGenerateMcpKey() {
|
|
464
|
+
mcpGenerating = true;
|
|
465
|
+
try {
|
|
466
|
+
const result = await generateMcpKeyApi();
|
|
467
|
+
mcpKey = result.mcpKey;
|
|
468
|
+
mcpKeySet = true;
|
|
469
|
+
mcpShowKey = true;
|
|
470
|
+
showToast('success', 'MCP key generated.');
|
|
471
|
+
} catch {
|
|
472
|
+
showToast('error', 'Failed to generate MCP key.');
|
|
473
|
+
} finally {
|
|
474
|
+
mcpGenerating = false;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function handleCopyMcpKey() {
|
|
479
|
+
navigator.clipboard.writeText(mcpKey).then(() => {
|
|
480
|
+
mcpKeyCopied = true;
|
|
481
|
+
setTimeout(() => (mcpKeyCopied = false), 1500);
|
|
482
|
+
});
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
function handleCopyMcpSnippet() {
|
|
486
|
+
navigator.clipboard.writeText(mcpConfigSnippet).then(() => {
|
|
487
|
+
mcpSnippetCopied = true;
|
|
488
|
+
setTimeout(() => (mcpSnippetCopied = false), 1500);
|
|
489
|
+
});
|
|
490
|
+
}
|
|
491
|
+
|
|
449
492
|
async function handleSaveIntegrations() {
|
|
450
493
|
integrationsSaving = true;
|
|
451
494
|
try {
|
|
@@ -512,11 +555,35 @@
|
|
|
512
555
|
}
|
|
513
556
|
}
|
|
514
557
|
|
|
558
|
+
$: s3Configured = !!(
|
|
559
|
+
backupConfig.backupS3Bucket &&
|
|
560
|
+
backupConfig.backupS3AccessKey &&
|
|
561
|
+
backupS3SecretKeySet
|
|
562
|
+
);
|
|
563
|
+
|
|
564
|
+
$: mcpConfigSnippet = JSON.stringify(
|
|
565
|
+
{
|
|
566
|
+
mcpServers: {
|
|
567
|
+
plum: {
|
|
568
|
+
command: 'plum',
|
|
569
|
+
args: ['mcp'],
|
|
570
|
+
env: {
|
|
571
|
+
PLUM_API_URL: 'http://localhost:3001',
|
|
572
|
+
PLUM_API_KEY: mcpKey
|
|
573
|
+
}
|
|
574
|
+
}
|
|
575
|
+
}
|
|
576
|
+
},
|
|
577
|
+
null,
|
|
578
|
+
2
|
|
579
|
+
);
|
|
580
|
+
|
|
515
581
|
$: navItems = [
|
|
516
582
|
{ id: 'project', label: 'Project' },
|
|
517
583
|
{ id: 'runners', label: 'Runners' },
|
|
518
584
|
{ id: 'repository', label: 'Repository' },
|
|
519
585
|
{ id: 'integrations', label: 'Integrations' },
|
|
586
|
+
{ id: 'mcp', label: 'MCP' },
|
|
520
587
|
{ id: 'account', label: 'Account' },
|
|
521
588
|
...($auth.user?.role === 'admin' ? [{ id: 'users', label: 'Users' }] : []),
|
|
522
589
|
{ id: 'backup', label: 'Backup' }
|
|
@@ -994,6 +1061,137 @@
|
|
|
994
1061
|
</div>
|
|
995
1062
|
</div>
|
|
996
1063
|
|
|
1064
|
+
<!-- MCP -->
|
|
1065
|
+
{:else if section === 'mcp'}
|
|
1066
|
+
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
1067
|
+
<div class="content-header">
|
|
1068
|
+
<h2>MCP Integration</h2>
|
|
1069
|
+
<p class="content-desc">
|
|
1070
|
+
Generate an API key for any MCP-compatible AI client — Claude, Cursor, Windsurf, and
|
|
1071
|
+
others.
|
|
1072
|
+
</p>
|
|
1073
|
+
</div>
|
|
1074
|
+
|
|
1075
|
+
<div class="card settings-card">
|
|
1076
|
+
<p class="card-title">API Key</p>
|
|
1077
|
+
|
|
1078
|
+
{#if !mcpKeySet}
|
|
1079
|
+
<p class="content-desc">No key generated yet.</p>
|
|
1080
|
+
<div class="card-footer">
|
|
1081
|
+
<Button on:click={handleGenerateMcpKey} disabled={mcpGenerating}>
|
|
1082
|
+
{mcpGenerating ? 'Generating…' : 'Generate Key'}
|
|
1083
|
+
</Button>
|
|
1084
|
+
</div>
|
|
1085
|
+
{:else}
|
|
1086
|
+
<div class="mcp-key-row">
|
|
1087
|
+
<input
|
|
1088
|
+
type={mcpShowKey ? 'text' : 'password'}
|
|
1089
|
+
class="field-input mcp-key-input"
|
|
1090
|
+
value={mcpKey}
|
|
1091
|
+
readonly
|
|
1092
|
+
spellcheck="false"
|
|
1093
|
+
autocomplete="off"
|
|
1094
|
+
/>
|
|
1095
|
+
<button
|
|
1096
|
+
class="icon-btn"
|
|
1097
|
+
title={mcpShowKey ? 'Hide key' : 'Show key'}
|
|
1098
|
+
on:click={() => (mcpShowKey = !mcpShowKey)}
|
|
1099
|
+
>
|
|
1100
|
+
{#if mcpShowKey}
|
|
1101
|
+
<svg
|
|
1102
|
+
width="14"
|
|
1103
|
+
height="14"
|
|
1104
|
+
viewBox="0 0 24 24"
|
|
1105
|
+
fill="none"
|
|
1106
|
+
stroke="currentColor"
|
|
1107
|
+
stroke-width="2"
|
|
1108
|
+
stroke-linecap="round"
|
|
1109
|
+
stroke-linejoin="round"
|
|
1110
|
+
>
|
|
1111
|
+
<path
|
|
1112
|
+
d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94"
|
|
1113
|
+
/><path
|
|
1114
|
+
d="M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19"
|
|
1115
|
+
/><line x1="1" y1="1" x2="23" y2="23" />
|
|
1116
|
+
</svg>
|
|
1117
|
+
{:else}
|
|
1118
|
+
<svg
|
|
1119
|
+
width="14"
|
|
1120
|
+
height="14"
|
|
1121
|
+
viewBox="0 0 24 24"
|
|
1122
|
+
fill="none"
|
|
1123
|
+
stroke="currentColor"
|
|
1124
|
+
stroke-width="2"
|
|
1125
|
+
stroke-linecap="round"
|
|
1126
|
+
stroke-linejoin="round"
|
|
1127
|
+
>
|
|
1128
|
+
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z" /><circle
|
|
1129
|
+
cx="12"
|
|
1130
|
+
cy="12"
|
|
1131
|
+
r="3"
|
|
1132
|
+
/>
|
|
1133
|
+
</svg>
|
|
1134
|
+
{/if}
|
|
1135
|
+
</button>
|
|
1136
|
+
<button class="icon-btn" title="Copy key" on:click={handleCopyMcpKey}>
|
|
1137
|
+
{#if mcpKeyCopied}
|
|
1138
|
+
<svg
|
|
1139
|
+
width="14"
|
|
1140
|
+
height="14"
|
|
1141
|
+
viewBox="0 0 24 24"
|
|
1142
|
+
fill="none"
|
|
1143
|
+
stroke="currentColor"
|
|
1144
|
+
stroke-width="2"
|
|
1145
|
+
stroke-linecap="round"
|
|
1146
|
+
stroke-linejoin="round"
|
|
1147
|
+
>
|
|
1148
|
+
<polyline points="20 6 9 17 4 12" />
|
|
1149
|
+
</svg>
|
|
1150
|
+
{:else}
|
|
1151
|
+
<svg
|
|
1152
|
+
width="14"
|
|
1153
|
+
height="14"
|
|
1154
|
+
viewBox="0 0 24 24"
|
|
1155
|
+
fill="none"
|
|
1156
|
+
stroke="currentColor"
|
|
1157
|
+
stroke-width="2"
|
|
1158
|
+
stroke-linecap="round"
|
|
1159
|
+
stroke-linejoin="round"
|
|
1160
|
+
>
|
|
1161
|
+
<rect x="9" y="9" width="13" height="13" rx="2" /><path
|
|
1162
|
+
d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"
|
|
1163
|
+
/>
|
|
1164
|
+
</svg>
|
|
1165
|
+
{/if}
|
|
1166
|
+
</button>
|
|
1167
|
+
</div>
|
|
1168
|
+
<p class="mcp-regen-note">Regenerating invalidates the existing key immediately.</p>
|
|
1169
|
+
<div class="card-footer">
|
|
1170
|
+
<Button variant="ghost" on:click={handleGenerateMcpKey} disabled={mcpGenerating}>
|
|
1171
|
+
{mcpGenerating ? 'Generating…' : 'Regenerate Key'}
|
|
1172
|
+
</Button>
|
|
1173
|
+
</div>
|
|
1174
|
+
{/if}
|
|
1175
|
+
</div>
|
|
1176
|
+
|
|
1177
|
+
{#if mcpKeySet}
|
|
1178
|
+
<div class="card settings-card">
|
|
1179
|
+
<p class="card-title">Config Snippet</p>
|
|
1180
|
+
<p class="content-desc">
|
|
1181
|
+
Add this to your MCP client's config file (e.g.
|
|
1182
|
+
<code class="code-sample">claude_desktop_config.json</code>, Cursor MCP settings,
|
|
1183
|
+
etc.).
|
|
1184
|
+
</p>
|
|
1185
|
+
<pre class="mcp-snippet">{mcpConfigSnippet}</pre>
|
|
1186
|
+
<div class="card-footer">
|
|
1187
|
+
<Button variant="ghost" on:click={handleCopyMcpSnippet}>
|
|
1188
|
+
{mcpSnippetCopied ? 'Copied!' : 'Copy Config'}
|
|
1189
|
+
</Button>
|
|
1190
|
+
</div>
|
|
1191
|
+
</div>
|
|
1192
|
+
{/if}
|
|
1193
|
+
</div>
|
|
1194
|
+
|
|
997
1195
|
<!-- ACCOUNT -->
|
|
998
1196
|
{:else if section === 'account'}
|
|
999
1197
|
<div class="content-section" transition:fly={{ y: 6, duration: 180 }}>
|
|
@@ -1373,67 +1571,71 @@
|
|
|
1373
1571
|
</div>
|
|
1374
1572
|
|
|
1375
1573
|
<!-- Scheduled backup -->
|
|
1376
|
-
<div class="card settings-card">
|
|
1574
|
+
<div class="card settings-card" class:card-disabled={!s3Configured}>
|
|
1377
1575
|
<p class="card-title">Scheduled Backup</p>
|
|
1378
1576
|
|
|
1379
|
-
|
|
1380
|
-
<
|
|
1381
|
-
|
|
1382
|
-
|
|
1383
|
-
|
|
1384
|
-
|
|
1385
|
-
|
|
1386
|
-
|
|
1387
|
-
|
|
1388
|
-
|
|
1389
|
-
|
|
1390
|
-
|
|
1391
|
-
|
|
1392
|
-
</label>
|
|
1393
|
-
</div>
|
|
1394
|
-
|
|
1395
|
-
<div class="field">
|
|
1396
|
-
<label class="field-label" for="backup-cron">
|
|
1397
|
-
<span>Cron Expression</span>
|
|
1398
|
-
<span class="field-hint">
|
|
1399
|
-
5-field cron — e.g. <code>0 2 * * *</code> = daily at 2 AM.
|
|
1400
|
-
<a href="https://crontab.guru" target="_blank" rel="noopener noreferrer"
|
|
1401
|
-
>Test at crontab.guru ↗</a
|
|
1577
|
+
{#if !s3Configured}
|
|
1578
|
+
<p class="backup-block-desc">Configure S3 storage above to enable scheduled backups.</p>
|
|
1579
|
+
{:else}
|
|
1580
|
+
<div class="field">
|
|
1581
|
+
<label class="field-label backup-toggle-label" for="backup-enabled">
|
|
1582
|
+
<span>Enable scheduled backup</span>
|
|
1583
|
+
<button
|
|
1584
|
+
id="backup-enabled"
|
|
1585
|
+
class="toggle-btn"
|
|
1586
|
+
class:active={backupConfig.backupEnabled}
|
|
1587
|
+
on:click={() => (backupConfig.backupEnabled = !backupConfig.backupEnabled)}
|
|
1588
|
+
role="switch"
|
|
1589
|
+
aria-checked={backupConfig.backupEnabled}
|
|
1402
1590
|
>
|
|
1403
|
-
|
|
1404
|
-
|
|
1405
|
-
|
|
1406
|
-
|
|
1407
|
-
type="text"
|
|
1408
|
-
class="field-input field-input-mono"
|
|
1409
|
-
bind:value={backupConfig.backupCron}
|
|
1410
|
-
placeholder="0 2 * * *"
|
|
1411
|
-
/>
|
|
1412
|
-
</div>
|
|
1591
|
+
<span class="toggle-thumb"></span>
|
|
1592
|
+
</button>
|
|
1593
|
+
</label>
|
|
1594
|
+
</div>
|
|
1413
1595
|
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1418
|
-
|
|
1419
|
-
|
|
1420
|
-
|
|
1421
|
-
|
|
1422
|
-
|
|
1423
|
-
|
|
1424
|
-
|
|
1425
|
-
|
|
1426
|
-
|
|
1427
|
-
|
|
1596
|
+
<div class="field">
|
|
1597
|
+
<label class="field-label" for="backup-cron">
|
|
1598
|
+
<span>Cron Expression</span>
|
|
1599
|
+
<span class="field-hint">
|
|
1600
|
+
5-field cron — e.g. <code>0 2 * * *</code> = daily at 2 AM.
|
|
1601
|
+
<a href="https://crontab.guru" target="_blank" rel="noopener noreferrer"
|
|
1602
|
+
>Test at crontab.guru ↗</a
|
|
1603
|
+
>
|
|
1604
|
+
</span>
|
|
1605
|
+
</label>
|
|
1606
|
+
<input
|
|
1607
|
+
id="backup-cron"
|
|
1608
|
+
type="text"
|
|
1609
|
+
class="field-input field-input-mono"
|
|
1610
|
+
bind:value={backupConfig.backupCron}
|
|
1611
|
+
placeholder="0 2 * * *"
|
|
1612
|
+
/>
|
|
1613
|
+
</div>
|
|
1428
1614
|
|
|
1429
|
-
|
|
1430
|
-
|
|
1431
|
-
|
|
1432
|
-
|
|
1433
|
-
|
|
1434
|
-
|
|
1435
|
-
|
|
1436
|
-
|
|
1615
|
+
{#if backupLastRunAt}
|
|
1616
|
+
<p class="backup-last-run">
|
|
1617
|
+
Last backup: {new Date(backupLastRunAt).toLocaleString()} —
|
|
1618
|
+
{#if backupLastStatus?.startsWith('success:')}
|
|
1619
|
+
<span class="status-success"
|
|
1620
|
+
>uploaded to {backupLastStatus.replace('success:', '')}</span
|
|
1621
|
+
>
|
|
1622
|
+
{:else if backupLastStatus?.startsWith('error:')}
|
|
1623
|
+
<span class="status-error">{backupLastStatus.replace('error:', '')}</span>
|
|
1624
|
+
{:else}
|
|
1625
|
+
<span>{backupLastStatus}</span>
|
|
1626
|
+
{/if}
|
|
1627
|
+
</p>
|
|
1628
|
+
{/if}
|
|
1629
|
+
|
|
1630
|
+
<div class="backup-actions">
|
|
1631
|
+
<Button variant="ghost" on:click={handleRunBackupNow} disabled={backupRunningNow}>
|
|
1632
|
+
{backupRunningNow ? 'Uploading…' : 'Upload to S3 Now'}
|
|
1633
|
+
</Button>
|
|
1634
|
+
<Button on:click={handleSaveBackupConfig} disabled={backupConfigSaving}>
|
|
1635
|
+
{backupConfigSaving ? 'Saving…' : 'Save Schedule'}
|
|
1636
|
+
</Button>
|
|
1637
|
+
</div>
|
|
1638
|
+
{/if}
|
|
1437
1639
|
</div>
|
|
1438
1640
|
</div>
|
|
1439
1641
|
{/if}
|
|
@@ -1764,6 +1966,11 @@
|
|
|
1764
1966
|
gap: 0.5rem;
|
|
1765
1967
|
}
|
|
1766
1968
|
|
|
1969
|
+
.card-disabled {
|
|
1970
|
+
opacity: 0.5;
|
|
1971
|
+
pointer-events: none;
|
|
1972
|
+
}
|
|
1973
|
+
|
|
1767
1974
|
/* ── Backup ── */
|
|
1768
1975
|
.backup-row {
|
|
1769
1976
|
display: flex;
|
|
@@ -2076,6 +2283,39 @@
|
|
|
2076
2283
|
flex-shrink: 0;
|
|
2077
2284
|
}
|
|
2078
2285
|
|
|
2286
|
+
/* ── MCP ── */
|
|
2287
|
+
.mcp-key-row {
|
|
2288
|
+
display: flex;
|
|
2289
|
+
align-items: center;
|
|
2290
|
+
gap: 0.375rem;
|
|
2291
|
+
}
|
|
2292
|
+
|
|
2293
|
+
.mcp-key-input {
|
|
2294
|
+
flex: 1;
|
|
2295
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2296
|
+
font-size: 0.8125rem;
|
|
2297
|
+
min-width: 0;
|
|
2298
|
+
}
|
|
2299
|
+
|
|
2300
|
+
.mcp-regen-note {
|
|
2301
|
+
font-size: 0.78rem;
|
|
2302
|
+
color: var(--text-muted);
|
|
2303
|
+
}
|
|
2304
|
+
|
|
2305
|
+
.mcp-snippet {
|
|
2306
|
+
font-family: 'JetBrains Mono', monospace;
|
|
2307
|
+
font-size: 0.78rem;
|
|
2308
|
+
background: var(--bg-subtle);
|
|
2309
|
+
border: 1px solid var(--border);
|
|
2310
|
+
border-radius: var(--radius-sm);
|
|
2311
|
+
padding: 0.875rem 1rem;
|
|
2312
|
+
white-space: pre;
|
|
2313
|
+
overflow-x: auto;
|
|
2314
|
+
color: var(--text);
|
|
2315
|
+
line-height: 1.6;
|
|
2316
|
+
margin: 0;
|
|
2317
|
+
}
|
|
2318
|
+
|
|
2079
2319
|
/* reuse icon-btn from other pages */
|
|
2080
2320
|
.icon-btn {
|
|
2081
2321
|
display: flex;
|
package/package.json
CHANGED
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "plum-e2e",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.4.1",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/silverlunah/plum.git"
|
|
7
7
|
},
|
|
8
|
-
"description": "A
|
|
8
|
+
"description": "A ready-to-use E2E test automation environment built on Playwright + Cucumber. Write tests in Gherkin, run them from the CLI or UI, view reports, schedule jobs, manage your entire test case repository, and get notified on Discord or Slack — all in one place.",
|
|
9
9
|
"main": "index.js",
|
|
10
10
|
"scripts": {
|
|
11
11
|
"init": "npm install && npm --prefix backend install && npm --prefix backend run init && npm --prefix frontend install && node bin/scaffold-tests.js",
|