kryptheon 0.1.19 → 0.1.21

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.
@@ -0,0 +1,532 @@
1
+ // The local dashboard: one page on this machine that shows everything
2
+ // Kryptheon knows about one project, and switches its checks on and off.
3
+ //
4
+ // npx kryptheon (in a terminal) or npx kryptheon dashboard
5
+ //
6
+ // Everything here reads the project's store (kryptheon-store.js); nothing is
7
+ // kept anywhere else, and nothing leaves the machine. The server listens on
8
+ // 127.0.0.1 only.
9
+ //
10
+ // Two rules the page is built around:
11
+ //
12
+ // - A check that does not exist yet is shown as "not available". Never as
13
+ // passed, never as green, never counted. An empty row that looks like a
14
+ // clean one is the most expensive lie this product could tell.
15
+ // - Every value shown comes from a file that may hold text from the app being
16
+ // checked - a failing page's heading, a line of the app's own code. The page
17
+ // writes all of it with textContent, so the dashboard cannot become the XSS
18
+ // it is there to find. Its own code is read by kryptheon-code.js in its check.
19
+
20
+ const fs = require('fs');
21
+ const path = require('path');
22
+ const http = require('http');
23
+ const crypto = require('crypto');
24
+ const { spawnSync } = require('child_process');
25
+ const store = require('./kryptheon-store.js');
26
+ const changes = require('./kryptheon-changes.js');
27
+
28
+ /**
29
+ * Every check the dashboard knows the name of, in the order the person asked
30
+ * for them. `available` is true only for what is actually built. What runs it
31
+ * and where its results come from is said here, once.
32
+ */
33
+ const CHECKS = [
34
+ { id: 'regression', label: 'Regression (recorded flows)', available: true,
35
+ what: 'Replays every recording in tests/ and compares each page with the last time it worked.' },
36
+ { id: 'frontend', label: 'Frontend: text inserted as HTML', available: true,
37
+ what: 'Reads your frontend code for values put into the page with innerHTML and similar (npx kryptheon code).' },
38
+ { id: 'data', label: 'Database: access rules, duplicates, orphans', available: true,
39
+ what: 'Attacks a copy of your Postgres/Supabase database (npx kryptheon-night). Needs a connection string, so it is run from the terminal and read here.' },
40
+ { id: 'build', label: 'Build', available: false },
41
+ { id: 'backend', label: 'Backend', available: false },
42
+ { id: 'api', label: 'API', available: false },
43
+ { id: 'integration', label: 'Integration', available: false },
44
+ { id: 'dependency', label: 'Dependencies', available: false },
45
+ { id: 'runtime', label: 'Runtime', available: false },
46
+ { id: 'performance', label: 'Performance', available: false },
47
+ { id: 'edge', label: 'Input and edge cases', available: false },
48
+ { id: 'reliability', label: 'Reliability', available: false },
49
+ { id: 'synthetic', label: 'Synthetic test users', available: false },
50
+ { id: 'eligibility', label: 'Age and eligibility rules', available: false },
51
+ ];
52
+
53
+ const CHECK_IDS = new Set(CHECKS.map((c) => c.id));
54
+
55
+ function readJson(file) {
56
+ try {
57
+ let raw = fs.readFileSync(file, 'utf8');
58
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
59
+ return JSON.parse(raw);
60
+ } catch (err) {
61
+ return null;
62
+ }
63
+ }
64
+
65
+ function readHistory(file, keep) {
66
+ let raw;
67
+ try {
68
+ raw = fs.readFileSync(file, 'utf8');
69
+ } catch (err) {
70
+ return [];
71
+ }
72
+ const runs = [];
73
+ for (const line of raw.split('\n')) {
74
+ if (!line.trim()) continue;
75
+ try {
76
+ runs.push(JSON.parse(line));
77
+ } catch (err) {
78
+ /* a damaged line is skipped, not fatal */
79
+ }
80
+ }
81
+ return runs.slice(-keep);
82
+ }
83
+
84
+ /** Which checks are switched on. Available checks default to on; others can never be. */
85
+ function readConfig(paths) {
86
+ const saved = readJson(paths.config) || {};
87
+ const enabled = {};
88
+ for (const c of CHECKS) {
89
+ enabled[c.id] = c.available ? (saved.enabled && typeof saved.enabled[c.id] === 'boolean' ? saved.enabled[c.id] : true) : false;
90
+ }
91
+ return { enabled: enabled };
92
+ }
93
+
94
+ /**
95
+ * Switches one check on or off, and keeps it. Refuses what does not exist and
96
+ * what is not built: a "not available" check switched on would read as a
97
+ * promise that something is being checked.
98
+ */
99
+ function setCheck(paths, id, on) {
100
+ if (!CHECK_IDS.has(id)) return { ok: false, why: 'there is no check called "' + id + '"' };
101
+ const check = CHECKS.find((c) => c.id === id);
102
+ if (!check.available) return { ok: false, why: check.label + ' is not available yet, so it cannot be switched on' };
103
+ if (typeof on !== 'boolean') return { ok: false, why: 'on must be true or false' };
104
+ const config = readConfig(paths);
105
+ config.enabled[id] = on;
106
+ fs.mkdirSync(path.dirname(paths.config), { recursive: true });
107
+ fs.writeFileSync(paths.config, JSON.stringify(config, null, 2) + '\n', 'utf8');
108
+ return { ok: true, config: config };
109
+ }
110
+
111
+ /** What git says changed, read only. Null when this is not a git repository. */
112
+ function recentChanges(root) {
113
+ const git = (args) => {
114
+ const r = spawnSync('git', args, { cwd: root, encoding: 'utf8', timeout: 5000 });
115
+ return r.status === 0 ? String(r.stdout || '') : null;
116
+ };
117
+ const inside = git(['rev-parse', '--is-inside-work-tree']);
118
+ if (!inside || inside.trim() !== 'true') return null;
119
+ const log = git(['log', '-5', '--pretty=format:%h%x09%cI%x09%s']) || '';
120
+ const status = git(['status', '--porcelain']) || '';
121
+ return {
122
+ commits: log.split('\n').filter(Boolean).map((l) => {
123
+ const [hash, when, subject] = l.split('\t');
124
+ return { hash: hash, when: when, subject: subject };
125
+ }),
126
+ uncommitted: status.split('\n').filter(Boolean).map((l) => ({ state: l.slice(0, 2).trim(), file: l.slice(3) })),
127
+ };
128
+ }
129
+
130
+ /** When this flow last passed, from the run history, or null. */
131
+ function lastPassOf(history, title) {
132
+ for (let i = history.length - 1; i >= 0; i--) {
133
+ const t = (history[i].tests || []).find((x) => x.title === title);
134
+ if (t && t.status === 'passed') return history[i].runAt;
135
+ }
136
+ return null;
137
+ }
138
+
139
+ /**
140
+ * The fix prompt for a broken recorded flow, from what the run kept: what was
141
+ * seen, what was expected, where, the evidence, and how to see it again. Only
142
+ * what the run recorded is said; nothing is guessed.
143
+ */
144
+ function flowPrompt(t, lastPass) {
145
+ const f = t.failure || {};
146
+ const o = f.observations || {};
147
+ const file = String(f.file || t.specFile || 'tests').split('\\').join('/');
148
+ const extra = String(f.rawMessage || '').split(/\r?\n/).slice(1)
149
+ .map((l) => l.trim()).filter((l) => l && !/^\s*at\s/.test(l) && l.length < 200).slice(0, 6);
150
+ const lines = [
151
+ 'My app has a broken flow. Please fix the app - and nothing else.',
152
+ '',
153
+ 'Kryptheon replayed the recorded flow "' + t.title + '" (' + file + (f.line ? ', line ' + f.line : '') + ') and it failed.',
154
+ '',
155
+ 'Observed: ' + (f.plainLanguage || 'the replay failed'),
156
+ ].concat(extra.map((l) => ' ' + l), [
157
+ 'Expected: the same result as ' + (lastPass ? 'the last time it passed (' + lastPass + ')' : 'when it was recorded') + '.',
158
+ ]);
159
+ if (o.url) lines.push('Where: the browser was on ' + o.url + (o.pageShowed ? ', and the page showed ' + o.pageShowed : '') + '.');
160
+ if (f.locator) lines.push('Step that failed: ' + f.locator);
161
+ if (o.failedRequests && o.failedRequests.length) lines.push('Failed requests: ' + o.failedRequests.slice(0, 5).map((r) => (r.method || 'GET') + ' ' + (r.path || r.url) + ' -> ' + r.status).join('; '));
162
+ if (o.consoleErrors && o.consoleErrors.length) lines.push('Console errors: ' + o.consoleErrors.slice(0, 3).join(' | '));
163
+ if (f.screenshot) lines.push('Evidence: a screenshot of the failure at ' + f.screenshot);
164
+ lines.push('');
165
+ lines.push('To see it yourself: open ' + (o.url || 'the app') + ' and repeat the steps in ' + file + ', or run npx kryptheon check');
166
+ lines.push('');
167
+ lines.push('Fix the app so this flow works again. Do not edit the recording to make it pass. If the new behaviour ' +
168
+ 'is intended, say so instead of changing code - it can be accepted with npx kryptheon accept "' + t.title + '"');
169
+ return lines.join('\n');
170
+ }
171
+
172
+ function countRecordings(root) {
173
+ try {
174
+ return fs.readdirSync(path.join(root, 'tests')).filter((n) => /\.(spec|test)\.[cm]?[jt]s$/.test(n)).length;
175
+ } catch (err) {
176
+ return 0;
177
+ }
178
+ }
179
+
180
+ /**
181
+ * The order someone new should go in, one step at a time. A step is "done"
182
+ * only when it has actually run and left a record - never because nothing was
183
+ * found - and a switched-off check says "off", so an empty list is never read
184
+ * as a clean one.
185
+ */
186
+ function startSteps(checks, seen) {
187
+ const on = (id) => (checks.find((c) => c.id === id) || {}).enabled;
188
+ return [
189
+ {
190
+ id: 'frontend',
191
+ title: 'Read your frontend code',
192
+ why: 'Finds places where text could be put into your page as HTML. Needs nothing from you.',
193
+ state: !on('frontend') ? 'off' : seen.codeAt ? 'done' : 'todo',
194
+ at: seen.codeAt,
195
+ action: on('frontend') ? { kind: 'run', check: 'frontend', label: seen.codeAt ? 'Read it again' : 'Read my code' } : null,
196
+ },
197
+ {
198
+ id: 'record',
199
+ title: 'Record your main flows',
200
+ why: 'Sign up, log in, pay - do each once while Kryptheon watches. It then knows what "working" looks like.',
201
+ state: seen.recordings ? 'done' : 'todo',
202
+ at: null,
203
+ count: seen.recordings,
204
+ action: { kind: 'command', command: 'npx kryptheon record <your app address>' },
205
+ },
206
+ {
207
+ id: 'regression',
208
+ title: 'Replay them',
209
+ why: 'Runs every recording against your app and compares each page with the last time it worked. Start your app first.',
210
+ state: !on('regression') ? 'off' : !seen.recordings ? 'waiting' : seen.lastRunAt ? 'done' : 'todo',
211
+ at: seen.lastRunAt,
212
+ action: on('regression') && seen.recordings ? { kind: 'run', check: 'regression', label: seen.lastRunAt ? 'Replay again' : 'Replay now' } : null,
213
+ },
214
+ {
215
+ id: 'data',
216
+ title: 'Check your database',
217
+ why: 'Attacks a copy of your Postgres or Supabase database and reports what got in. Needs your connection string, so it runs in your terminal.',
218
+ state: !on('data') ? 'off' : seen.nightAt ? 'done' : 'todo',
219
+ at: seen.nightAt,
220
+ action: on('data') ? { kind: 'command', command: 'npx kryptheon-night' } : null,
221
+ },
222
+ ];
223
+ }
224
+
225
+ /**
226
+ * The one thing to do now. Open confirmed problems come before everything; then
227
+ * the first step not taken; then what only needs a look; then nothing.
228
+ */
229
+ function nextAction(findings, steps) {
230
+ const confirmed = findings.filter((f) => f.status === 'confirmed').length;
231
+ const toVerify = findings.length - confirmed;
232
+ if (confirmed) {
233
+ return { kind: 'fix', view: 'findings', count: confirmed,
234
+ title: confirmed === 1 ? 'Fix 1 confirmed problem' : 'Fix ' + confirmed + ' confirmed problems' };
235
+ }
236
+ const step = steps.find((s) => s.state === 'todo');
237
+ if (step) return { kind: 'step', step: step.id, title: step.title };
238
+ if (toVerify) {
239
+ return { kind: 'verify', view: 'findings', count: toVerify,
240
+ title: toVerify === 1 ? 'Look at 1 thing to verify' : 'Look at ' + toVerify + ' things to verify' };
241
+ }
242
+ return { kind: 'quiet', title: 'Nothing open' };
243
+ }
244
+
245
+ /**
246
+ * Everything the page shows, from the store alone. Findings from every check
247
+ * are put into one list, each carrying the same fields - severity, status,
248
+ * evidence, confidence, where, what, and the fix - so the page can show them
249
+ * the same way whichever check found them.
250
+ */
251
+ function buildState(root, env) {
252
+ const paths = store.pathsFor(root, env);
253
+ const config = readConfig(paths);
254
+ const history = readHistory(paths.history, 20);
255
+ const baselines = readJson(paths.baselines) || {};
256
+ const code = readJson(paths.codeFindings);
257
+ const night = readJson(paths.nightLast);
258
+
259
+ const findings = [];
260
+
261
+ // Recorded flows: the last run's broken tests are the findings. They were
262
+ // replayed in a real browser, so they are confirmed at runtime.
263
+ const lastRun = history.length ? history[history.length - 1] : null;
264
+ if (lastRun && config.enabled.regression) {
265
+ for (const t of lastRun.tests || []) {
266
+ if (t.status !== 'failed' && t.status !== 'timedOut') continue;
267
+ findings.push({
268
+ check: 'regression',
269
+ severity: 'HIGH',
270
+ status: 'confirmed',
271
+ evidence: 'runtime confirmed',
272
+ confidence: 'high',
273
+ where: String((t.failure && t.failure.file) || t.specFile || 'tests').split('\\').join('/') +
274
+ (t.failure && t.failure.line ? ':' + t.failure.line : ''),
275
+ headline: 'The recorded flow "' + t.title + '" broke.',
276
+ detail: (t.failure && t.failure.plainLanguage) || '',
277
+ fixPrompt: flowPrompt(t, lastPassOf(history, t.title)),
278
+ when: lastRun.runAt,
279
+ });
280
+ }
281
+ }
282
+
283
+ if (code && config.enabled.frontend) {
284
+ for (const f of code.findings || []) {
285
+ findings.push({
286
+ check: 'frontend',
287
+ severity: f.severity,
288
+ status: f.status,
289
+ evidence: f.evidence,
290
+ confidence: f.confidence,
291
+ where: f.file + ':' + f.line,
292
+ headline: f.headline,
293
+ detail: f.sink + ': ' + f.expression,
294
+ fixPrompt: f.fixPrompt,
295
+ when: code.checkedAt,
296
+ });
297
+ }
298
+ }
299
+
300
+ let nightWhen = null;
301
+ try {
302
+ nightWhen = fs.statSync(paths.nightLast).mtime.toISOString();
303
+ } catch (err) {
304
+ /* never run */
305
+ }
306
+ if (night && config.enabled.data) {
307
+ for (const f of night.findings || []) {
308
+ findings.push({
309
+ check: 'data',
310
+ severity: f.severity,
311
+ status: f.status || 'confirmed',
312
+ evidence: (f.status || 'confirmed') === 'confirmed' ? 'runtime confirmed' : 'code analysis',
313
+ confidence: (f.status || 'confirmed') === 'confirmed' ? 'high' : 'medium',
314
+ where: f.table + (f.column ? '.' + f.column : ''),
315
+ headline: f.headline,
316
+ detail: f.body || '',
317
+ fixPrompt: f.fixPrompt || '',
318
+ when: nightWhen,
319
+ });
320
+ }
321
+ }
322
+
323
+ const rank = { 'HIGH CRITICAL': 0, CRITICAL: 0, HIGH: 1, MEDIUM: 2, LOW: 3 };
324
+ findings.sort((a, b) => (rank[a.severity] ?? 9) - (rank[b.severity] ?? 9) ||
325
+ (a.status === 'confirmed' ? 0 : 1) - (b.status === 'confirmed' ? 0 : 1));
326
+
327
+ // Every finding gets a short id and the last word of its fix prompt: how to
328
+ // prove the fix. Added here, where the loop lives, so every check's prompt
329
+ // ends the same way and the detection code itself stays untouched.
330
+ const attempts = readHistory(paths.fixes, 500);
331
+ for (const f of findings) {
332
+ f.id = changes.findingId(f);
333
+ f.attempts = attempts.filter((a) => a.id === f.id).map((a) => ({ at: a.at, verdict: a.verdict, why: a.why || '', newProblems: (a.newProblems || []).length }));
334
+ if (f.fixPrompt) {
335
+ f.fixPrompt += '\n\nWhen you have fixed it, prove it: run npx kryptheon recheck ' + f.id +
336
+ ' (or press Re-check on this finding in the Kryptheon dashboard). It is fixed only when that says FIXED.';
337
+ }
338
+ }
339
+ // Fixes that were proven, newest first, while their finding is still gone.
340
+ const open = new Set(findings.map((f) => f.id));
341
+ const proven = attempts.filter((a) => a.verdict === 'fixed' && !open.has(a.id)).reverse().slice(0, 10)
342
+ .map((a) => ({ id: a.id, at: a.at, where: a.where, headline: a.headline, newProblems: (a.newProblems || []).length }));
343
+
344
+ const recordings = countRecordings(root);
345
+ const checks = CHECKS.map((c) => {
346
+ let state;
347
+ if (!c.available) state = 'not available';
348
+ else if (!config.enabled[c.id]) state = 'switched off';
349
+ else if (c.id === 'regression') state = !recordings ? 'no recordings yet' : !lastRun ? 'never run' : lastRun.failed ? 'problems found' : 'last run held';
350
+ else if (c.id === 'frontend') state = !code ? 'never run' : (code.findings || []).length ? 'things to check' : 'nothing found';
351
+ else if (c.id === 'data') state = !night ? 'never run here' : (night.findings || []).length ? 'problems found' : 'last run held';
352
+ return {
353
+ id: c.id, label: c.label, available: c.available, enabled: config.enabled[c.id], state: state, what: c.what || '',
354
+ // Runnable from the page: what needs nothing the page cannot supply. The
355
+ // database check needs a connection string, which stays in the terminal.
356
+ runnable: c.available && (c.id === 'frontend' || (c.id === 'regression' && recordings > 0)),
357
+ };
358
+ });
359
+
360
+ const steps = startSteps(checks, {
361
+ codeAt: code ? code.checkedAt : null,
362
+ recordings: recordings,
363
+ lastRunAt: lastRun ? lastRun.runAt : null,
364
+ nightAt: night ? nightWhen : null,
365
+ });
366
+
367
+ // Every look Kryptheon took - by `verify`, or by the dashboard's own watch -
368
+ // newest first: what changed, what was run, and what came of it.
369
+ const looks = readHistory(paths.changes, 15).reverse().map((e) => ({
370
+ at: e.at,
371
+ by: e.by || 'verify',
372
+ first: !!e.first,
373
+ files: (e.files || []).slice(0, 30),
374
+ fileCount: (e.files || []).length,
375
+ parts: e.parts || [],
376
+ dependencies: e.dependencies || [],
377
+ checks: e.checks || [],
378
+ newFindings: e.newFindings || 0,
379
+ fixed: e.fixed || 0,
380
+ goneWithFile: e.goneWithFile || 0,
381
+ }));
382
+
383
+ return {
384
+ project: { name: path.basename(root), root: root, storeDir: paths.dir },
385
+ looks: looks,
386
+ proven: proven,
387
+ checks: checks,
388
+ findings: findings,
389
+ runs: history.slice().reverse().map((r) => ({ runAt: r.runAt, status: r.status, passed: r.passed, failed: r.failed, durationMs: r.durationMs })),
390
+ recordings: recordings,
391
+ steps: steps,
392
+ next: nextAction(findings, steps),
393
+ baselines: Object.keys(baselines).length,
394
+ changes: recentChanges(root),
395
+ // Part 3 is where a version gets verified. Until then this says so,
396
+ // rather than leaving a blank that could be read either way.
397
+ verification: { state: 'not verified', why: 'Kryptheon Verified is not built yet. No version of this project has been verified.' },
398
+ };
399
+ }
400
+
401
+ /**
402
+ * The server. Local only, and closed to other web pages:
403
+ * - it listens on 127.0.0.1, never on the network;
404
+ * - it answers only when the Host header is this address, so a page on some
405
+ * other site cannot reach it through DNS rebinding;
406
+ * - anything that changes something needs the token that is written into the
407
+ * page this server hands out, which another site cannot read.
408
+ */
409
+ function createServer(root, options) {
410
+ const opts = options || {};
411
+ const env = opts.env || process.env;
412
+ const token = crypto.randomBytes(24).toString('hex');
413
+ const page = fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard.html'), 'utf8');
414
+ let port = 0;
415
+ let running = false;
416
+
417
+ const allowedHosts = () => new Set(['127.0.0.1:' + port, 'localhost:' + port]);
418
+ const send = (res, code, type, body) => {
419
+ res.writeHead(code, {
420
+ 'content-type': type,
421
+ 'cache-control': 'no-store',
422
+ 'x-content-type-options': 'nosniff',
423
+ 'referrer-policy': 'no-referrer',
424
+ // The page loads nothing from anywhere else, and says so.
425
+ 'content-security-policy': "default-src 'none'; script-src 'self'; style-src 'self' 'unsafe-inline'; connect-src 'self'; img-src 'self' data:; base-uri 'none'; form-action 'none'; frame-ancestors 'none'",
426
+ });
427
+ res.end(body);
428
+ };
429
+ const json = (res, code, value) => send(res, code, 'application/json; charset=utf-8', JSON.stringify(value));
430
+
431
+ const server = http.createServer((req, res) => {
432
+ if (!allowedHosts().has(String(req.headers.host || ''))) return send(res, 403, 'text/plain', 'Forbidden');
433
+ const url = new URL(req.url, 'http://127.0.0.1');
434
+
435
+ if (req.method === 'GET' && url.pathname === '/') {
436
+ return send(res, 200, 'text/html; charset=utf-8', page.replace('%%TOKEN%%', token));
437
+ }
438
+ if (req.method === 'GET' && url.pathname === '/dashboard.js') {
439
+ return send(res, 200, 'text/javascript; charset=utf-8', fs.readFileSync(path.join(__dirname, 'kryptheon-dashboard-page.js'), 'utf8'));
440
+ }
441
+ if (req.method === 'GET' && url.pathname === '/logo.png') {
442
+ // A missing logo is a missing picture, never a stopped dashboard.
443
+ let logo;
444
+ try {
445
+ logo = fs.readFileSync(opts.logoFile || path.join(__dirname, 'kryptheon-logo.png'));
446
+ } catch (err) {
447
+ return send(res, 404, 'text/plain', 'Not found');
448
+ }
449
+ return send(res, 200, 'image/png', logo);
450
+ }
451
+ if (req.method === 'GET' && url.pathname === '/api/state') {
452
+ return json(res, 200, buildState(root, env));
453
+ }
454
+ if (req.method === 'POST') {
455
+ if (req.headers['x-kryptheon-token'] !== token) return json(res, 403, { ok: false, why: 'missing or wrong token' });
456
+ let body = '';
457
+ req.on('data', (chunk) => {
458
+ body += chunk;
459
+ if (body.length > 10000) req.destroy();
460
+ });
461
+ req.on('end', () => {
462
+ let input;
463
+ try {
464
+ input = JSON.parse(body || '{}');
465
+ } catch (err) {
466
+ return json(res, 400, { ok: false, why: 'not JSON' });
467
+ }
468
+ if (url.pathname === '/api/check') {
469
+ const r = setCheck(store.pathsFor(root, env), String(input.id || ''), input.on);
470
+ return json(res, r.ok ? 200 : 400, r);
471
+ }
472
+ if (url.pathname === '/api/recheck' && typeof opts.recheck === 'function') {
473
+ const id = String(input.id || '');
474
+ if (!/^[0-9a-f]{8}$/.test(id)) return json(res, 400, { ok: false, why: 'not a finding id' });
475
+ if (!buildState(root, env).findings.some((f) => f.id === id)) return json(res, 404, { ok: false, why: 'no open finding with that id' });
476
+ if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
477
+ running = true;
478
+ return Promise.resolve(opts.recheck(id)).finally(() => { running = false; }).then(
479
+ (r) => json(res, r && r.verdict ? 200 : 500, Object.assign({ ok: !!(r && r.verdict) }, r || {})),
480
+ (err) => json(res, 500, { ok: false, why: err.message }),
481
+ );
482
+ }
483
+ if (url.pathname === '/api/run' && typeof opts.run === 'function') {
484
+ const id = String(input.id || '');
485
+ const check = buildState(root, env).checks.find((c) => c.id === id);
486
+ if (!check || !check.runnable || !check.enabled) {
487
+ return json(res, 400, { ok: false, why: (check ? check.label : '"' + id + '"') + ' cannot be run from here' });
488
+ }
489
+ // One run at a time: two browsers replaying the same flows against
490
+ // one app would interfere, and the results would describe neither.
491
+ if (running) return json(res, 409, { ok: false, why: 'a run is already going' });
492
+ running = true;
493
+ return Promise.resolve(opts.run(id)).finally(() => { running = false; }).then(
494
+ (r) => json(res, r && r.ok ? 200 : 400, r || { ok: false }),
495
+ (err) => json(res, 500, { ok: false, why: err.message }),
496
+ );
497
+ }
498
+ return json(res, 404, { ok: false, why: 'no such action' });
499
+ });
500
+ return;
501
+ }
502
+ return send(res, 404, 'text/plain', 'Not found');
503
+ });
504
+
505
+ return {
506
+ server: server,
507
+ token: token,
508
+ listen(wanted) {
509
+ return new Promise((resolve, reject) => {
510
+ server.once('error', reject);
511
+ server.listen(wanted || 0, '127.0.0.1', () => {
512
+ port = server.address().port;
513
+ resolve(port);
514
+ });
515
+ });
516
+ },
517
+ close() {
518
+ return new Promise((resolve) => server.close(() => resolve()));
519
+ },
520
+ };
521
+ }
522
+
523
+ module.exports = {
524
+ CHECKS: CHECKS,
525
+ buildState: buildState,
526
+ flowPrompt: flowPrompt,
527
+ startSteps: startSteps,
528
+ nextAction: nextAction,
529
+ readConfig: readConfig,
530
+ setCheck: setCheck,
531
+ createServer: createServer,
532
+ };
@@ -26,10 +26,13 @@ const heal = require('./kryptheon-heal.js');
26
26
 
27
27
  const MAX_ITEMS = 5; // keep the failure block readable
28
28
 
29
- // Baselines describe the user's app, so they live in the folder the command
30
- // was run from - never inside the installed package.
29
+ const store = require('./kryptheon-store.js');
30
+
31
+ // Baselines describe the user's app, so they belong to the folder the command
32
+ // was run from - but they are kept in that folder's store, outside it, so the
33
+ // project's repo never grows because Kryptheon is watching it.
31
34
  const USER_DIR = process.cwd();
32
- const BASELINE_FILE = path.join(USER_DIR, 'kryptheon-baselines.json');
35
+ const BASELINE_FILE = store.pathsFor(USER_DIR).baselines;
33
36
 
34
37
  function oneLine(text) {
35
38
  return String(text).replace(/\s+/g, ' ').trim();
@@ -78,6 +81,7 @@ function saveBaselines(file, all) {
78
81
  try {
79
82
  // Explicit, and without a byte order mark: this file is read back by the
80
83
  // same code and by people, and a mismatch would look like the app changed.
84
+ fs.mkdirSync(path.dirname(file), { recursive: true });
81
85
  fs.writeFileSync(file, JSON.stringify(all, null, 2) + '\n', { encoding: 'utf8' });
82
86
  return true;
83
87
  } catch (e) {
Binary file