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.
package/README.md CHANGED
@@ -161,20 +161,58 @@ wanted, and one new name of the same kind. A button that is gone with nothing
161
161
  in its place, or a rename next to other new buttons, fails as it always did.
162
162
  Checks you added with the toolbar are never redirected.
163
163
 
164
- ## Files it creates in your folder
164
+ ## Dashboard
165
165
 
166
- | File | What it is |
167
- | --- | --- |
168
- | `tests/` | your recordings |
169
- | `kryptheon-baselines.json` | the remembered result for each test |
170
- | `kryptheon-history.jsonl` | one line per run, used for "this was working on …" |
166
+ ```
167
+ npx kryptheon
168
+ ```
169
+
170
+ In a terminal, with no command, this opens a page on your own machine
171
+ (`http://127.0.0.1:4789/`) for this project: what is broken, what needs a
172
+ look, every run, recent changes, and switches for each check. `Run now`
173
+ replays your recordings or reads your code from the page. It is only
174
+ reachable from this machine; press Ctrl+C to stop it. From a script, use
175
+ `npx kryptheon dashboard`.
176
+
177
+ Checks that are not built yet are listed as "not available" - never as
178
+ passed.
179
+
180
+ ## Reading your code
181
+
182
+ ```
183
+ npx kryptheon code
184
+ ```
185
+
186
+ Reads your frontend code for values put into the page as HTML - `innerHTML`,
187
+ `insertAdjacentHTML`, `document.write`, React's `dangerouslySetInnerHTML`,
188
+ Vue's `v-html`. If such a value can hold text someone typed, a `<` in it can
189
+ run script with the signed-in person's session. Nothing is run; every result
190
+ is "verification required", ranked HIGH when the value comes from a server
191
+ response or an error message, MEDIUM when the code alone cannot say. The fix
192
+ prompt for each one names the file, line and value.
193
+
194
+ ## What goes where
195
+
196
+ Only your recordings go in your project, in `tests/`. Everything Kryptheon
197
+ remembers about the project is kept outside it, so it can never end up in your
198
+ repo:
199
+
200
+ ~/.kryptheon/projects/<folder-name>-<id>/
201
+ baselines.json the remembered result for each test
202
+ history.jsonl one line per run, used for "this was working on …"
203
+ test-results/ the screenshot of the last failure
204
+
205
+ Set `KRYPTHEON_HOME` to keep them somewhere else. Each project folder gets its
206
+ own store, so two projects are never mixed. A project folder that is moved or
207
+ renamed starts a new store.
208
+
209
+ Older versions kept `kryptheon-baselines.json`, `kryptheon-history.jsonl` and
210
+ `test-results/` in the project. The first run of this version moves them out,
211
+ once, and says so.
171
212
 
172
213
  A `.env` in the same folder is loaded automatically, and anything you type into a
173
214
  password box is replaced with an environment variable rather than written into
174
- the test.
175
-
176
- Worth adding to `.gitignore`: `.env`, `kryptheon-baselines.json`,
177
- `kryptheon-history.jsonl`, `test-results/`.
215
+ the test. When there is a password, `.env` is added to an existing `.gitignore`.
178
216
 
179
217
  ## Requirements
180
218
 
package/bin/kryptheon.js CHANGED
@@ -5,8 +5,11 @@
5
5
  //
6
6
  // Two roots matter, and they are not the same once this is installed:
7
7
  // PACKAGE_DIR - the tool's own files (fixture, reporter, config, this file)
8
- // USER_DIR - the folder the command was run in, which owns tests/,
9
- // kryptheon-baselines.json, kryptheon-history.jsonl and .env
8
+ // USER_DIR - the folder the command was run in, which owns tests/ and .env
9
+ //
10
+ // Everything Kryptheon remembers about USER_DIR - baselines, history, the last
11
+ // run's screenshots - is kept in its store under ~/.kryptheon, never in the
12
+ // project (kryptheon-store.js).
10
13
 
11
14
  const fs = require('fs');
12
15
  const path = require('path');
@@ -19,6 +22,11 @@ const recordings = require('../kryptheon-recordings.js');
19
22
  const cleanup = require('../kryptheon-cleanup.js');
20
23
  const baselines = require('../kryptheon-baselines.js');
21
24
  const project = require('../kryptheon-project.js');
25
+ const store = require('../kryptheon-store.js');
26
+
27
+ // The first version that keeps its records in the store. An older copy in a
28
+ // project folder still looks for them inside the project.
29
+ const FIRST_STORE_VERSION = '0.1.20';
22
30
 
23
31
  const PACKAGE_DIR = path.join(__dirname, '..');
24
32
  const USER_DIR = process.cwd();
@@ -77,6 +85,10 @@ function usage() {
77
85
  console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
78
86
  console.log(' npx kryptheon remove [name] list the recordings, or remove one you no longer want');
79
87
  console.log(' npx kryptheon setup-ai tell your AI assistant to check its work');
88
+ console.log(' npx kryptheon verify after a change: what changed, what broke, what got fixed');
89
+ console.log(' npx kryptheon recheck [id] prove a fix: run the check again, and say FIXED or not');
90
+ console.log(' npx kryptheon code read your frontend code for text inserted as HTML');
91
+ console.log(' npx kryptheon dashboard open this project\'s dashboard (also: npx kryptheon, in a terminal)');
80
92
  console.log('');
81
93
  console.log(' add --quiet to check for one line when everything passes');
82
94
  console.log(' npx kryptheon --version print the version you have installed');
@@ -864,15 +876,49 @@ function takeOutSecrets(relativeFile) {
864
876
  return writeSpec(relativeFile, result.source) ? result.replacements : [];
865
877
  }
866
878
 
867
- // kryptheon leaves a password file and three files of machine state in the
868
- // project. If there is already a .gitignore, they belong in it.
869
- function keepArtefactsOutOfGit() {
879
+ // The password file is the one thing of kryptheon's in a project that must not
880
+ // be committed. If there is already a .gitignore, it belongs in it - but only
881
+ // once there is a password to protect: a .env here, or a recording that just
882
+ // had one taken out. A landing page with no sign-in has nothing to hide, and
883
+ // an edit to its .gitignore would be one more change of Kryptheon's in the repo.
884
+ function keepArtefactsOutOfGit(passwordTakenOut) {
885
+ if (!passwordTakenOut && !fs.existsSync(path.join(USER_DIR, '.env'))) return;
870
886
  const added = secrets.updateGitignore(USER_DIR);
871
887
  if (!added.length) return;
872
888
  console.log(' Added to .gitignore: ' + added.join(', '));
873
889
  console.log('');
874
890
  }
875
891
 
892
+ // The project's store, opened before anything reads a record, so whatever an
893
+ // older version left in this folder is moved out first - and said once, here,
894
+ // rather than by the fixture or the reporter, which run inside the test runner.
895
+ //
896
+ // Returns null when the store cannot be made. Carrying on would mean every
897
+ // recording is compared against nothing, which reads as "all fine" and is
898
+ // the one answer that must never be given by accident.
899
+ function openStore() {
900
+ let opened;
901
+ try {
902
+ opened = store.open(USER_DIR);
903
+ } catch (err) {
904
+ console.error('');
905
+ console.error(' Kryptheon keeps what it remembers about this project in');
906
+ console.error(' ' + store.dirFor(USER_DIR));
907
+ console.error(' and could not write there: ' + err.message);
908
+ console.error('');
909
+ console.error(' Without it every recording would look new, so nothing was checked.');
910
+ console.error(' Set KRYPTHEON_HOME to a folder you can write to, and run this again.');
911
+ console.error('');
912
+ return null;
913
+ }
914
+ const lines = store.migrationLines(opened);
915
+ if (lines.length) {
916
+ console.log('');
917
+ for (const line of lines) console.log(line);
918
+ }
919
+ return opened;
920
+ }
921
+
876
922
  function reportSecrets(replacements) {
877
923
  if (!replacements || !replacements.length) return;
878
924
  const names = replacements.map((item) => item.envName);
@@ -1170,7 +1216,7 @@ async function finaliseRecording(outFile, context) {
1170
1216
 
1171
1217
  reportTidying(tidied);
1172
1218
  reportSecrets(secrets);
1173
- keepArtefactsOutOfGit();
1219
+ keepArtefactsOutOfGit(!!(secrets && secrets.length));
1174
1220
  await offerToDropLogout(named);
1175
1221
  reportReplayRisks(named);
1176
1222
  reportFragileSelectors(named);
@@ -1321,6 +1367,7 @@ async function remove(name) {
1321
1367
  console.error("");
1322
1368
  return 1;
1323
1369
  }
1370
+ if (!openStore()) return 1;
1324
1371
 
1325
1372
  const gone = [];
1326
1373
  const failed = [];
@@ -1494,6 +1541,9 @@ async function record(url) {
1494
1541
  // gets a folder set up; before the browser, which is the expensive part.
1495
1542
  const setup = await prepareFolder(url);
1496
1543
  if (setup !== null) return setup;
1544
+ // Before an old recording can be replaced, because replacing one also drops
1545
+ // its saved result - which has to be found where it now lives.
1546
+ if (!openStore()) return 1;
1497
1547
 
1498
1548
  if (!ensureBrowser()) return 1;
1499
1549
 
@@ -1705,6 +1755,7 @@ function check(options) {
1705
1755
  for (const line of project.noProjectLines(here)) console.error(line);
1706
1756
  return 1;
1707
1757
  }
1758
+ if (!openStore()) return 1;
1708
1759
 
1709
1760
  // Deliberately not a failure. An assistant told to run this after every
1710
1761
  // change will read a non-zero exit as "something broke" and start fixing
@@ -1937,6 +1988,377 @@ function setupAi() {
1937
1988
  return results.some((r) => r.action === 'failed') ? 1 : 0;
1938
1989
  }
1939
1990
 
1991
+ // --- code -------------------------------------------------------------------
1992
+ //
1993
+ // A read of the project's own frontend code for text inserted as HTML
1994
+ // (kryptheon-code.js). Nothing is run and nothing is sent; every finding is
1995
+ // "verification required", so the exit code is 0 unless the read could not
1996
+ // happen at all - a script must never take an unproven risk for a break.
1997
+
1998
+ // How many fix prompts are printed in full. The rest are saved in the store
1999
+ // and shown by the dashboard; forty prompts in a terminal is a wall nobody reads.
2000
+ const CODE_PROMPTS_SHOWN = 5;
2001
+
2002
+ // The read itself, saved to the store. Shared by the command and the
2003
+ // dashboard's "Run now", so both leave the same record behind.
2004
+ function readCodeAndSave(opened) {
2005
+ const reader = require(path.join(PACKAGE_DIR, 'kryptheon-code.js'));
2006
+ const result = reader.scanProject(USER_DIR, { packageDir: PACKAGE_DIR });
2007
+ if (!result.ran) return result;
2008
+ try {
2009
+ fs.writeFileSync(opened.codeFindings, JSON.stringify({
2010
+ checkedAt: new Date().toISOString(),
2011
+ filesRead: result.filesRead,
2012
+ findings: result.findings,
2013
+ unreadable: result.unreadable,
2014
+ }, null, 2) + String.fromCharCode(10), 'utf8');
2015
+ } catch (err) {
2016
+ result.saveError = err.message;
2017
+ }
2018
+ return result;
2019
+ }
2020
+
2021
+ function codeRead() {
2022
+ const here = project.inspectProject(USER_DIR);
2023
+ if (!here.ok) {
2024
+ for (const line of project.noProjectLines(here)) console.error(line);
2025
+ return 1;
2026
+ }
2027
+ const opened = openStore();
2028
+ if (!opened) return 1;
2029
+
2030
+ const result = readCodeAndSave(opened);
2031
+ if (!result.ran) {
2032
+ console.error('');
2033
+ console.error(' I could not read your code this time: ' + result.why + '.');
2034
+ console.error(' Nothing was checked - this is not a pass.');
2035
+ console.error('');
2036
+ return 2;
2037
+ }
2038
+ if (result.saveError) console.error(' (could not save this read: ' + result.saveError + ')');
2039
+
2040
+ const high = result.findings.filter((f) => f.severity === 'HIGH');
2041
+ const medium = result.findings.filter((f) => f.severity !== 'HIGH');
2042
+ console.log('');
2043
+ console.log(' I read ' + result.filesRead + ' file' + (result.filesRead === 1 ? '' : 's') + ' of your app\'s code.');
2044
+ if (!result.findings.length) {
2045
+ console.log(' I found no place where text that could come from outside the page is');
2046
+ console.log(' inserted as HTML. That is what this read looks for, and only that.');
2047
+ } else {
2048
+ console.log(' ' + result.findings.length + ' place' + (result.findings.length === 1 ? '' : 's') +
2049
+ ' where text may be inserted as HTML (' + high.length + ' HIGH, ' + medium.length + ' MEDIUM).');
2050
+ console.log(' I did not run anything, so each one needs a look before it is called a bug.');
2051
+ for (const f of high.concat(medium)) {
2052
+ console.log('');
2053
+ console.log(' ' + f.severity + ' ' + f.file + ':' + f.line + ' (verification required)');
2054
+ console.log(' ' + f.headline);
2055
+ console.log(' ' + f.sink + ': ' + f.expression);
2056
+ }
2057
+ const prompted = high.slice(0, CODE_PROMPTS_SHOWN);
2058
+ if (prompted.length) {
2059
+ console.log('');
2060
+ console.log(' ' + '-'.repeat(68));
2061
+ console.log(' For the HIGH ones, paste this into your AI tool - one at a time:');
2062
+ for (const f of prompted) {
2063
+ console.log('');
2064
+ f.fixPrompt.split(String.fromCharCode(10)).forEach((l) => console.log(' ' + l));
2065
+ }
2066
+ }
2067
+ if (result.findings.length > prompted.length) {
2068
+ console.log('');
2069
+ console.log(' The fix for every one is saved in:');
2070
+ console.log(' ' + opened.codeFindings);
2071
+ }
2072
+ }
2073
+ if (result.unreadable.length) {
2074
+ console.log('');
2075
+ console.log(' What I could not read (unknown, not clear):');
2076
+ for (const u of result.unreadable) console.log(' ' + u.file + ' - ' + u.why);
2077
+ }
2078
+ console.log('');
2079
+ return 0;
2080
+ }
2081
+
2082
+ // --- verify -----------------------------------------------------------------
2083
+ //
2084
+ // The command for the end of an AI task: what changed since Kryptheon last
2085
+ // looked, which checks that calls for, run the ones that can run here, and
2086
+ // say what broke and what got fixed. "The AI said done" is not the answer;
2087
+ // this is. The looking itself is kryptheon-look.js, shared with the dashboard.
2088
+
2089
+ function verify() {
2090
+ const here = project.inspectProject(USER_DIR);
2091
+ if (!here.ok) {
2092
+ for (const line of project.noProjectLines(here)) console.error(line);
2093
+ return 1;
2094
+ }
2095
+ const opened = openStore();
2096
+ if (!opened) return 1;
2097
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2098
+
2099
+ const result = looker.look(USER_DIR, {
2100
+ beforeRun: (diff, now) => {
2101
+ console.log('');
2102
+ if (diff.first) {
2103
+ console.log(' First look at this project: ' + Object.keys(now.files).length + ' files noted.');
2104
+ console.log(' Next time, I will say exactly what changed since now.');
2105
+ } else if (!diff.files.length) {
2106
+ console.log(' Nothing changed since the last look.');
2107
+ } else {
2108
+ console.log(' WHAT CHANGED ' + diff.files.length + ' file' + (diff.files.length === 1 ? '' : 's') + ', in: ' + diff.parts.join(', '));
2109
+ for (const part of diff.parts) {
2110
+ const inPart = diff.files.filter((f) => f.part === part);
2111
+ console.log('');
2112
+ console.log(' ' + part);
2113
+ for (const f of inPart.slice(0, 15)) console.log(' ' + f.state.padEnd(9) + f.path);
2114
+ if (inPart.length > 15) console.log(' ... and ' + (inPart.length - 15) + ' more');
2115
+ if (part === 'dependencies') {
2116
+ for (const d of diff.dependencies) {
2117
+ console.log(' package ' + d.name + ': ' + (d.change === 'added' ? 'added ' + d.to : d.change === 'removed' ? 'removed (was ' + d.from + ')' : d.from + ' -> ' + d.to));
2118
+ }
2119
+ }
2120
+ }
2121
+ }
2122
+ },
2123
+ frontend: () => readCodeAndSave(opened),
2124
+ regression: () => {
2125
+ console.log('');
2126
+ console.log(' Replaying your recordings...');
2127
+ const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2128
+ cwd: USER_DIR,
2129
+ stdio: 'inherit',
2130
+ env: process.env,
2131
+ });
2132
+ return r.status;
2133
+ },
2134
+ }, { by: 'verify' });
2135
+
2136
+ const { diff, plan, fresh, fixed, removed, openConfirmed, after } = result;
2137
+ console.log('');
2138
+ if (plan.length) {
2139
+ console.log(' CHECKS');
2140
+ for (const step of plan) console.log(' ' + step.id.padEnd(12) + step.state);
2141
+ } else if (!diff.first && diff.files.length) {
2142
+ console.log(' CHECKS none apply to this change (' + diff.parts.join(', ') + ').');
2143
+ }
2144
+
2145
+ console.log('');
2146
+ if (diff.first) {
2147
+ console.log(' STARTING POINT ' + after.findings.length + ' finding' + (after.findings.length === 1 ? '' : 's') +
2148
+ ' already here (see npx kryptheon dashboard). Only what changes from now on will be called new.');
2149
+ } else {
2150
+ if (fresh.length) {
2151
+ console.log(' WHAT BROKE ' + fresh.length + ' new');
2152
+ for (const f of fresh) {
2153
+ console.log(' ' + f.severity + ' ' + f.status + ' ' + f.where);
2154
+ console.log(' ' + f.headline);
2155
+ }
2156
+ } else {
2157
+ console.log(' WHAT BROKE nothing new that the checks above could see.');
2158
+ }
2159
+ if (fixed.length) {
2160
+ console.log('');
2161
+ console.log(' WHAT GOT FIXED ' + fixed.length);
2162
+ for (const f of fixed) console.log(' ' + f.where + ' ' + f.headline);
2163
+ }
2164
+ if (removed.length) {
2165
+ console.log('');
2166
+ console.log(' GONE WITH ITS FILE ' + removed.length + ' (the file was deleted - not the same as fixed)');
2167
+ for (const f of removed) console.log(' ' + f.where + ' ' + f.headline);
2168
+ }
2169
+ }
2170
+ if (openConfirmed.length) {
2171
+ console.log('');
2172
+ console.log(' Still broken: ' + openConfirmed.length + ' confirmed problem' + (openConfirmed.length === 1 ? '' : 's') + '. The fix prompts are in the dashboard.');
2173
+ }
2174
+ console.log('');
2175
+ if (result.saveError) console.error(' (could not save this look: ' + result.saveError + ')');
2176
+
2177
+ // Non-zero while anything confirmed is broken - new or not - so an AI tool
2178
+ // reading only the exit code is never told its work left things fine when
2179
+ // they are not. Findings that only need a look do not count.
2180
+ return openConfirmed.length || result.regressionExit === 1 ? 1 : 0;
2181
+ }
2182
+
2183
+ // --- recheck ----------------------------------------------------------------
2184
+ //
2185
+ // The end of the fix loop: prove a fix. The finding's own check runs again,
2186
+ // and so does everything the changes since call for, so "fixed" never means
2187
+ // "the code changed", and a fix that broke something else says so.
2188
+
2189
+ function replayForLoop() {
2190
+ console.log('');
2191
+ console.log(' Replaying your recordings...');
2192
+ const r = spawnSync(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2193
+ cwd: USER_DIR,
2194
+ stdio: 'inherit',
2195
+ env: process.env,
2196
+ });
2197
+ return r.status;
2198
+ }
2199
+
2200
+ const VERDICT_WORDS = {
2201
+ fixed: 'FIXED',
2202
+ 'still open': 'STILL OPEN',
2203
+ 'gone with its file': 'GONE WITH ITS FILE (not the same as fixed)',
2204
+ 'could not confirm': 'COULD NOT CONFIRM',
2205
+ };
2206
+
2207
+ function recheckCommand(id) {
2208
+ const here = project.inspectProject(USER_DIR);
2209
+ if (!here.ok) {
2210
+ for (const line of project.noProjectLines(here)) console.error(line);
2211
+ return 1;
2212
+ }
2213
+ const opened = openStore();
2214
+ if (!opened) return 1;
2215
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2216
+ const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
2217
+
2218
+ if (!id) {
2219
+ const open = dash.buildState(USER_DIR).findings;
2220
+ console.log('');
2221
+ if (!open.length) {
2222
+ console.log(' Nothing is open. There is nothing to re-check.');
2223
+ } else {
2224
+ console.log(' Open findings - re-check one after fixing it: npx kryptheon recheck <id>');
2225
+ console.log('');
2226
+ for (const f of open) console.log(' ' + f.id + ' ' + f.severity.padEnd(8) + f.status.padEnd(23) + f.where + ' ' + f.headline);
2227
+ }
2228
+ console.log('');
2229
+ return 0;
2230
+ }
2231
+
2232
+ const r = looker.recheck(USER_DIR, { frontend: () => readCodeAndSave(opened), regression: replayForLoop }, String(id).trim());
2233
+ console.log('');
2234
+ if (!r.found) {
2235
+ console.error(' There is no open finding with the id ' + id + '.');
2236
+ console.error(' npx kryptheon recheck, on its own, lists the ones that are open.');
2237
+ console.error('');
2238
+ return 1;
2239
+ }
2240
+ console.log(' RE-CHECK ' + r.id + ' ' + r.where);
2241
+ console.log(' ' + r.headline);
2242
+ console.log('');
2243
+ console.log(' VERDICT ' + VERDICT_WORDS[r.verdict] + (r.why ? ' - ' + r.why : ''));
2244
+ if (r.newProblems.length) {
2245
+ console.log('');
2246
+ console.log(' NEW PROBLEMS ' + r.newProblems.length + ' appeared with this change:');
2247
+ for (const p of r.newProblems) console.log(' ' + p.id + ' ' + p.severity + ' ' + p.status + ' ' + p.where + ' ' + p.headline);
2248
+ } else if (r.verdict === 'fixed') {
2249
+ console.log('');
2250
+ console.log(' Nothing new broke that the checks could see.');
2251
+ }
2252
+ console.log('');
2253
+ if (r.verdict === 'could not confirm') return 2;
2254
+ return r.verdict === 'fixed' && !r.newProblems.length ? 0 : 1;
2255
+ }
2256
+
2257
+ // --- dashboard --------------------------------------------------------------
2258
+ //
2259
+ // One page on this machine for everything Kryptheon knows about this project
2260
+ // (kryptheon-dashboard.js). Runs until Ctrl+C.
2261
+
2262
+ const DASHBOARD_PORT = 4789;
2263
+
2264
+ function runFromDashboard(id, opened) {
2265
+ if (id === 'frontend') {
2266
+ const result = readCodeAndSave(opened);
2267
+ return result.ran ? { ok: true } : { ok: false, why: result.why };
2268
+ }
2269
+ if (id === 'regression') {
2270
+ // The same command a person types, as a separate process, so the page
2271
+ // stays answerable while a browser replays the flows.
2272
+ return new Promise((resolve) => {
2273
+ const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'check', '--quiet'], {
2274
+ cwd: USER_DIR,
2275
+ stdio: 'ignore',
2276
+ env: process.env,
2277
+ });
2278
+ child.on('error', (err) => resolve({ ok: false, why: err.message }));
2279
+ // A run that found broken flows is a run that finished; its results are
2280
+ // in the history the page reads next.
2281
+ child.on('close', (code) => resolve(code === 0 || code === 1 ? { ok: true, exit: code } : { ok: false, why: 'the run stopped with exit ' + code }));
2282
+ });
2283
+ }
2284
+ return { ok: false, why: '"' + id + '" cannot be run from the dashboard' };
2285
+ }
2286
+
2287
+ async function dashboardCommand() {
2288
+ const here = project.inspectProject(USER_DIR);
2289
+ if (!here.ok) {
2290
+ for (const line of project.noProjectLines(here)) console.error(line);
2291
+ return 1;
2292
+ }
2293
+ const opened = openStore();
2294
+ if (!opened) return 1;
2295
+ const dash = require(path.join(PACKAGE_DIR, 'kryptheon-dashboard.js'));
2296
+ const app = dash.createServer(USER_DIR, {
2297
+ run: (id) => runFromDashboard(id, opened),
2298
+ // The same `recheck` a person types, in its own process, so the page stays
2299
+ // answerable while a flow is replayed. Its verdict is read from the store.
2300
+ recheck: (id) => new Promise((resolve) => {
2301
+ // Only a verdict written by this re-check counts: an older one for the
2302
+ // same finding, read after a child that failed early, would be a stale
2303
+ // answer shown as a fresh one.
2304
+ const startedAt = new Date().toISOString();
2305
+ const child = spawn(process.execPath, [path.join(PACKAGE_DIR, 'bin', 'kryptheon.js'), 'recheck', id], {
2306
+ cwd: USER_DIR,
2307
+ stdio: 'ignore',
2308
+ env: process.env,
2309
+ });
2310
+ child.on('error', (err) => resolve({ ok: false, why: err.message }));
2311
+ child.on('close', () => {
2312
+ let last = null;
2313
+ try {
2314
+ const lines = fs.readFileSync(opened.fixes, 'utf8').split(String.fromCharCode(10)).filter(Boolean);
2315
+ for (let i = lines.length - 1; i >= 0 && !last; i--) {
2316
+ const a = JSON.parse(lines[i]);
2317
+ if (a.id === id && a.at >= startedAt) last = a;
2318
+ }
2319
+ } catch (err) {
2320
+ last = null;
2321
+ }
2322
+ resolve(last || { ok: false, why: 'the re-check did not record a verdict' });
2323
+ });
2324
+ }),
2325
+ });
2326
+ let port;
2327
+ try {
2328
+ port = await app.listen(DASHBOARD_PORT);
2329
+ } catch (err) {
2330
+ // Taken - by another project's dashboard, most likely. Any free port will do.
2331
+ port = await app.listen(0);
2332
+ }
2333
+ console.log('');
2334
+ console.log(' Kryptheon dashboard for ' + path.basename(USER_DIR) + ':');
2335
+ console.log('');
2336
+ console.log(' http://127.0.0.1:' + port + '/');
2337
+ console.log('');
2338
+ console.log(' Open that address in your browser. It is only reachable from this');
2339
+ console.log(' machine. Press Ctrl+C here to stop it.');
2340
+ console.log('');
2341
+
2342
+ // While the page is open, Kryptheon notices changes by itself: once a change
2343
+ // has settled, it looks, runs the fast checks, and the page shows what
2344
+ // changed and what came of it. Recordings are replayed only from the page's
2345
+ // Run now - a replay while the app is down would call every flow broken.
2346
+ const looker = require(path.join(PACKAGE_DIR, 'kryptheon-look.js'));
2347
+ looker.startWatch(USER_DIR, {
2348
+ frontend: () => readCodeAndSave(opened),
2349
+ regression: () => null,
2350
+ }, {
2351
+ onLook: (r) => {
2352
+ if (r.diff.first) return;
2353
+ const when = new Date().toLocaleTimeString();
2354
+ console.log(' ' + when + ' ' + r.diff.files.length + ' file' + (r.diff.files.length === 1 ? '' : 's') + ' changed (' +
2355
+ r.diff.parts.join(', ') + ') - ' + r.fresh.length + ' new, ' + r.fixed.length + ' fixed. See the page.');
2356
+ },
2357
+ onError: (err) => console.error(' (could not look at a change: ' + err.message + ')'),
2358
+ });
2359
+ return new Promise(() => {});
2360
+ }
2361
+
1940
2362
  // --- accept -----------------------------------------------------------------
1941
2363
 
1942
2364
  function baselineApi() {
@@ -1983,6 +2405,9 @@ function listBaselines(api) {
1983
2405
  }
1984
2406
 
1985
2407
  function accept(name) {
2408
+ // Only where there are recordings: anywhere else there is nothing to accept,
2409
+ // and no reason to make a store for a folder that is not a project.
2410
+ if (fs.existsSync(TESTS_DIR) && !openStore()) return 1;
1986
2411
  const api = baselineApi();
1987
2412
  if (!api) return 1;
1988
2413
  if (!name) return listBaselines(api);
@@ -2121,6 +2546,26 @@ function handToFolderCopy() {
2121
2546
  }
2122
2547
  }
2123
2548
 
2549
+ // A copy from before the store looks for its records inside the project. If
2550
+ // they have already moved out, it finds none, takes every recording as new,
2551
+ // and learns whatever the app does today as correct - a broken page would
2552
+ // pass. So it is not run; the one fix is to bring it up to date.
2553
+ if (project.olderVersion(theirVersion, FIRST_STORE_VERSION)) {
2554
+ const kept = store.pathsFor(USER_DIR);
2555
+ if (fs.existsSync(kept.baselines) || fs.existsSync(kept.history)) {
2556
+ console.error('');
2557
+ console.error(' This folder has kryptheon ' + theirVersion + ', which looks for its saved results');
2558
+ console.error(' inside the project. They have moved out, to');
2559
+ console.error(' ' + kept.dir);
2560
+ console.error(' so ' + theirVersion + ' would see none, treat every recording as new, and could');
2561
+ console.error(' miss a real change. Nothing was checked. Update this folder\'s copy:');
2562
+ console.error(' npm i -D kryptheon@latest');
2563
+ console.error(' then run this again.');
2564
+ console.error('');
2565
+ return 1;
2566
+ }
2567
+ }
2568
+
2124
2569
  if (theirVersion !== mine) {
2125
2570
  // Not an error: the folder's copy is the one its recordings are written
2126
2571
  // against. Said, so an older one is never mistaken for this one.
@@ -2173,12 +2618,27 @@ async function main() {
2173
2618
  case 'accept':
2174
2619
  return finishWith(accept(rest.join(' ').trim()));
2175
2620
  break;
2621
+ case 'code':
2622
+ return finishWith(codeRead());
2623
+ case 'verify':
2624
+ return finishWith(verify());
2625
+ case 'recheck':
2626
+ return finishWith(recheckCommand(rest[0]));
2176
2627
  case '-v':
2177
2628
  case '--version':
2178
2629
  case 'version':
2179
2630
  console.log(packageVersion());
2180
2631
  return finishWith(0);
2632
+ case 'dashboard':
2633
+ return finishWith(await dashboardCommand());
2181
2634
  case undefined:
2635
+ // One command starts Kryptheon - in a terminal a person is looking at.
2636
+ // Run by a script or an AI tool there is nobody to open a page for, and
2637
+ // a server that never exits would hang it, so it prints the usage as it
2638
+ // always did.
2639
+ if (process.stdin.isTTY && process.stdout.isTTY) return finishWith(await dashboardCommand());
2640
+ usage();
2641
+ return finishWith(0);
2182
2642
  case '-h':
2183
2643
  case '--help':
2184
2644
  case 'help':