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,259 @@
1
+ // One look at a project: what changed since the last look, which checks that
2
+ // calls for, run the ones that can run, and what is new, fixed or merely
3
+ // deleted. Shared by `npx kryptheon verify` and the dashboard's own watch, so
4
+ // the command and the page can never tell two different stories.
5
+ //
6
+ // Printing is not done here; the caller decides how to say it.
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const store = require('./kryptheon-store.js');
11
+ const changes = require('./kryptheon-changes.js');
12
+ const dash = require('./kryptheon-dashboard.js');
13
+
14
+ function readJson(file) {
15
+ try {
16
+ return JSON.parse(fs.readFileSync(file, 'utf8'));
17
+ } catch (err) {
18
+ return null;
19
+ }
20
+ }
21
+
22
+ /**
23
+ * Looks once. `runners` does the work that needs the caller's tools:
24
+ * runners.frontend() -> { ran, why } (the code read)
25
+ * runners.regression() -> exit code, or null (replaying the recordings)
26
+ * `options.replay` false keeps the recordings from being replayed - the
27
+ * dashboard's automatic look does that by default, because a replay while the
28
+ * app is not running reports every flow broken, and that would be a lie.
29
+ */
30
+ function look(root, runners, options) {
31
+ const opts = options || {};
32
+ const paths = store.pathsFor(root);
33
+ const previous = readJson(paths.snapshot);
34
+ const now = changes.takeSnapshot(root, previous);
35
+ const diff = changes.compare(previous, now);
36
+ const before = dash.buildState(root);
37
+ const enabled = dash.readConfig(paths).enabled;
38
+ const available = new Set(dash.CHECKS.filter((c) => c.available).map((c) => c.id));
39
+
40
+ // On a first look, every check that can run, so the next look has
41
+ // something to compare against. `force` adds checks that must run whatever
42
+ // changed - a re-check has to run the check that found the problem, even
43
+ // when the fix was made somewhere that check would not otherwise cover.
44
+ const wanted = (diff.first ? ['frontend', 'regression', 'data'] : diff.checks).slice();
45
+ for (const id of opts.force || []) if (!wanted.includes(id)) wanted.push(id);
46
+ const plan = wanted.map((id) => {
47
+ if (!available.has(id)) return { id: id, state: 'not available yet' };
48
+ if (!enabled[id]) return { id: id, state: 'switched off in the dashboard' };
49
+ if (id === 'data') return { id: id, state: 'needs your database - run npx kryptheon-night' };
50
+ if (id === 'regression' && !before.recordings) return { id: id, state: 'no recordings yet - npx kryptheon record <url>' };
51
+ if (id === 'regression' && opts.replay === false) return { id: id, state: 'not replayed automatically - press Run now when your app is running' };
52
+ return { id: id, state: 'run', run: true };
53
+ });
54
+
55
+ // What changed is said before anything runs: a replay can take a minute,
56
+ // and the person should not sit through it without knowing why.
57
+ if (typeof runners.beforeRun === 'function') runners.beforeRun(diff, now);
58
+
59
+ let regressionExit = null;
60
+ for (const step of plan.filter((s) => s.run)) {
61
+ if (step.id === 'frontend') {
62
+ const r = runners.frontend();
63
+ step.state = r.ran ? 'ran' : 'could not run: ' + r.why;
64
+ } else if (step.id === 'regression') {
65
+ regressionExit = runners.regression();
66
+ step.state = regressionExit === 0 ? 'ran - every recording held'
67
+ : regressionExit === 1 ? 'ran - something broke'
68
+ : 'could not run (exit ' + regressionExit + ')';
69
+ }
70
+ }
71
+
72
+ const after = dash.buildState(root);
73
+ const fresh = changes.newFindings(before.findings, after.findings);
74
+ const gone = changes.goneFindings(before.findings, after.findings);
75
+ // A finding whose file was deleted is gone, not fixed - the same rule as a
76
+ // dropped table in the database check. Kept apart, so deleting the code a
77
+ // finding points at is never mistaken for making it safe.
78
+ const fileOf = (f) => path.join(root, String(f.where || '').replace(/:\d+$/, ''));
79
+ const removed = gone.filter((f) => f.check !== 'data' && !fs.existsSync(fileOf(f)));
80
+ const fixed = gone.filter((f) => removed.indexOf(f) === -1);
81
+ const openConfirmed = after.findings.filter((f) => f.status === 'confirmed');
82
+
83
+ const event = {
84
+ at: now.takenAt,
85
+ by: opts.by || 'verify',
86
+ first: diff.first,
87
+ files: diff.files,
88
+ parts: diff.parts,
89
+ dependencies: diff.dependencies,
90
+ checks: plan.map((s) => ({ id: s.id, state: s.state })),
91
+ newFindings: fresh.length,
92
+ fixed: fixed.length,
93
+ goneWithFile: removed.length,
94
+ openConfirmed: openConfirmed.length,
95
+ };
96
+ let saveError = null;
97
+ try {
98
+ fs.mkdirSync(paths.dir, { recursive: true });
99
+ fs.writeFileSync(paths.snapshot, JSON.stringify(now) + '\n', 'utf8');
100
+ fs.appendFileSync(paths.changes, JSON.stringify(event) + '\n', 'utf8');
101
+ } catch (err) {
102
+ saveError = err.message;
103
+ }
104
+
105
+ return {
106
+ diff: diff,
107
+ snapshot: now,
108
+ plan: plan,
109
+ fresh: fresh,
110
+ fixed: fixed,
111
+ removed: removed,
112
+ openConfirmed: openConfirmed,
113
+ after: after,
114
+ regressionExit: regressionExit,
115
+ event: event,
116
+ saveError: saveError,
117
+ };
118
+ }
119
+
120
+ /**
121
+ * Whether anything changed since the last look, without looking. Cheap enough
122
+ * to poll: unchanged files keep their hash without being read. The signature
123
+ * carries each changed file's content hash, so a file still being written
124
+ * keeps changing it - which is what tells a pause from the end of a task.
125
+ */
126
+ function pending(root) {
127
+ const paths = store.pathsFor(root);
128
+ const previous = readJson(paths.snapshot);
129
+ if (!previous) return { changed: false, first: true };
130
+ const now = changes.takeSnapshot(root, previous);
131
+ const diff = changes.compare(previous, now);
132
+ const signature = JSON.stringify(diff.files.map((f) => [f.path, f.state, now.files[f.path] ? now.files[f.path].hash : null]));
133
+ return { changed: diff.files.length > 0, files: diff.files.length, signature: signature };
134
+ }
135
+
136
+ /**
137
+ * Looks by itself whenever the project changes, and waits for the change to
138
+ * settle first. An AI tool writes a dozen files over half a minute; looking
139
+ * after the first one would report a half-finished task as broken. So a look
140
+ * happens once the changed files have stayed the same for `quietMs`.
141
+ *
142
+ * Recorded flows are not replayed here unless `replay` is true: an automatic
143
+ * replay while the app is not running reports every flow broken.
144
+ */
145
+ function startWatch(root, runners, options) {
146
+ const opts = options || {};
147
+ const intervalMs = opts.intervalMs || 2000;
148
+ const quietMs = opts.quietMs || 4000;
149
+ let seen = null;
150
+ let since = 0;
151
+ let busy = false;
152
+ const doLook = () => {
153
+ busy = true;
154
+ try {
155
+ const result = look(root, runners, { by: 'watch', replay: opts.replay === true });
156
+ if (typeof opts.onLook === 'function') opts.onLook(result);
157
+ } catch (err) {
158
+ if (typeof opts.onError === 'function') opts.onError(err);
159
+ } finally {
160
+ busy = false;
161
+ }
162
+ };
163
+ const tick = () => {
164
+ if (busy) return;
165
+ const p = pending(root);
166
+ // Nothing to compare against yet: this first look is the starting point.
167
+ if (p.first) return doLook();
168
+ if (!p.changed) {
169
+ seen = null;
170
+ return;
171
+ }
172
+ if (p.signature !== seen) {
173
+ seen = p.signature;
174
+ since = Date.now();
175
+ return;
176
+ }
177
+ if (Date.now() - since >= quietMs) doLook();
178
+ };
179
+ const timer = setInterval(tick, intervalMs);
180
+ if (timer.unref && opts.unref) timer.unref();
181
+ // Now, not one interval from now: a change made in the first seconds after
182
+ // the page opened would otherwise land in the starting point and never be
183
+ // seen as a change at all.
184
+ tick();
185
+ return {
186
+ tick: tick,
187
+ stop() {
188
+ clearInterval(timer);
189
+ },
190
+ };
191
+ }
192
+
193
+ // What a broken flow says when the app itself did not answer - which is not
194
+ // evidence either way about the fix.
195
+ const APP_DID_NOT_ANSWER = /could not be opened|took too long to load|net::ERR|ECONNREFUSED/i;
196
+
197
+ /**
198
+ * The second half of the loop: someone says a finding is fixed; prove it.
199
+ *
200
+ * The check that found it is run again - always, whatever else changed - and
201
+ * so is every check the changes since the last look call for, so a fix that
202
+ * breaks something else is caught in the same step. The verdict:
203
+ *
204
+ * fixed the check ran, and the finding is not there
205
+ * still open the check ran, and the finding is still there
206
+ * gone with its file the file it pointed at was deleted - not the same as fixed
207
+ * could not confirm the check did not run, or the app did not answer
208
+ *
209
+ * A fix is never assumed from a changed file. Every attempt is kept.
210
+ */
211
+ function recheck(root, runners, id, options) {
212
+ const paths = store.pathsFor(root);
213
+ const target = dash.buildState(root).findings.find((f) => f.id === id);
214
+ if (!target) return { found: false };
215
+
216
+ const result = look(root, runners, { by: 'recheck', force: [target.check], replay: true });
217
+ const own = result.plan.find((s) => s.id === target.check);
218
+ const still = result.after.findings.find((f) => f.id === id);
219
+ let verdict;
220
+ let why = '';
221
+ if (!own || !/^ran/.test(own.state)) {
222
+ verdict = 'could not confirm';
223
+ why = own ? own.state : 'the check that found it did not run';
224
+ } else if (still && target.check === 'regression' && APP_DID_NOT_ANSWER.test(still.detail || '')) {
225
+ verdict = 'could not confirm';
226
+ why = 'your app did not answer while the flow was replayed - start it and re-check';
227
+ } else if (still) {
228
+ verdict = 'still open';
229
+ why = still.detail || '';
230
+ } else if (result.removed.some((f) => f.id === id)) {
231
+ verdict = 'gone with its file';
232
+ why = 'the file it pointed at was deleted, which is not the same as fixing it';
233
+ } else {
234
+ verdict = 'fixed';
235
+ }
236
+ // Problems that appeared with this fix. The finding itself is never one of
237
+ // them: it was there before, so it cannot be new.
238
+ const newProblems = result.fresh.map((f) => ({
239
+ id: f.id, check: f.check, severity: f.severity, status: f.status, where: f.where, headline: f.headline,
240
+ }));
241
+ const attempt = {
242
+ id: id,
243
+ at: new Date().toISOString(),
244
+ check: target.check,
245
+ where: target.where,
246
+ headline: target.headline,
247
+ verdict: verdict,
248
+ why: why,
249
+ newProblems: newProblems,
250
+ };
251
+ try {
252
+ fs.appendFileSync(paths.fixes, JSON.stringify(attempt) + '\n', 'utf8');
253
+ } catch (err) {
254
+ attempt.saveError = err.message;
255
+ }
256
+ return Object.assign({ found: true, target: target, look: result }, attempt);
257
+ }
258
+
259
+ module.exports = { look: look, pending: pending, startWatch: startWatch, recheck: recheck };
@@ -1,20 +1,29 @@
1
1
  // Plain-language Playwright reporter for non-technical readers.
2
2
  // Prints a short line per passing test and a readable block per failure,
3
- // and appends one JSON line per run to kryptheon-history.jsonl.
3
+ // and appends one JSON line per run to the project's history.jsonl.
4
4
 
5
5
  const fs = require('fs');
6
+ const os = require('os');
6
7
  const path = require('path');
7
8
  const recordings = require('./kryptheon-recordings.js');
8
9
  const heal = require('./kryptheon-heal.js');
10
+ const store = require('./kryptheon-store.js');
9
11
 
10
12
  // An assistant runs this after every change, so it needs a mode that says
11
13
  // nothing at all when the news is good.
12
14
  const QUIET = !!process.env.KRYPTHEON_QUIET;
13
15
 
14
- // The history belongs to whoever is running the tests, so it lives in their
15
- // folder - not inside the installed package.
16
+ // The history belongs to whoever is running the tests, so it is kept in their
17
+ // project's store - not inside the installed package, and not in the project.
16
18
  const USER_DIR = process.cwd();
17
- const HISTORY_FILE = path.join(USER_DIR, 'kryptheon-history.jsonl');
19
+ const HISTORY_FILE = store.pathsFor(USER_DIR).history;
20
+
21
+ // Said with ~ for the home folder: the full path is long, and the person only
22
+ // needs to know it is outside their project and where to look.
23
+ function shortHome(file) {
24
+ const home = os.homedir();
25
+ return file.toLowerCase().startsWith(home.toLowerCase() + path.sep) ? '~' + file.slice(home.length) : file;
26
+ }
18
27
 
19
28
  const MONTHS = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sept', 'Oct', 'Nov', 'Dec'];
20
29
 
@@ -962,10 +971,13 @@ class KryptheonReporter {
962
971
  };
963
972
 
964
973
  // Append only - existing lines are never rewritten.
974
+ let saved = false;
965
975
  try {
976
+ fs.mkdirSync(path.dirname(HISTORY_FILE), { recursive: true });
966
977
  fs.appendFileSync(HISTORY_FILE, JSON.stringify(entry) + '\n', 'utf8');
978
+ saved = true;
967
979
  } catch (err) {
968
- process.stdout.write('\n(could not write ' + path.basename(HISTORY_FILE) + ': ' + err.message + ')\n');
980
+ process.stdout.write('\n(could not write ' + shortHome(HISTORY_FILE) + ': ' + err.message + ')\n');
969
981
  }
970
982
 
971
983
  // "Broken" is kept for recordings that used to work. The ones that never
@@ -1011,7 +1023,7 @@ class KryptheonReporter {
1011
1023
 
1012
1024
  process.stdout.write(
1013
1025
  '\n' + recordings.summaryLines(counts).join('\n') + '\n' +
1014
- 'History saved to ' + path.basename(HISTORY_FILE) + '\n\n'
1026
+ (saved ? 'History saved outside your project, in ' + shortHome(HISTORY_FILE) + '\n' : '') + '\n'
1015
1027
  );
1016
1028
 
1017
1029
  // Once per run, not once per test: it is one fact about the app, and
@@ -139,9 +139,11 @@ function writeEnvExample(dir, names) {
139
139
  }
140
140
  }
141
141
 
142
- // Everything kryptheon leaves in a project that should not be committed: the
143
- // real password, and three files that are machine state rather than source.
144
- const GITIGNORE_ENTRIES = ['.env', 'kryptheon-baselines.json', 'kryptheon-history.jsonl', 'test-results/'];
142
+ // What must never be committed from a project: the real password. The three
143
+ // files of machine state that used to be listed here - baselines, history and
144
+ // test-results/ - are no longer written into the project at all; they live in
145
+ // its store (kryptheon-store.js), so there is nothing of Kryptheon's to ignore.
146
+ const GITIGNORE_ENTRIES = ['.env'];
145
147
 
146
148
  /**
147
149
  * Adds the missing entries to an existing .gitignore. Returns what it added.
@@ -0,0 +1,332 @@
1
+ // Where Kryptheon keeps what it remembers about a project.
2
+ //
3
+ // It used to be the project itself: kryptheon-baselines.json,
4
+ // kryptheon-history.jsonl, test-results/ and .kryptheon-last.json all appeared
5
+ // next to the person's own code. A plain landing page picked up six or seven
6
+ // files that way, and an AI assistant that commits "everything that changed"
7
+ // commits them too - the repo grows because Kryptheon is watching it.
8
+ //
9
+ // So every record now lives in one folder per project, outside it:
10
+ //
11
+ // ~/.kryptheon/projects/<folder-name>-<hash>/ (KRYPTHEON_HOME moves ~/.kryptheon)
12
+ // project.json which folder this store belongs to
13
+ // baselines.json was kryptheon-baselines.json
14
+ // history.jsonl was kryptheon-history.jsonl
15
+ // test-results/ was test-results/ (Playwright's screenshots of a failure)
16
+ // night-last.json was .kryptheon-last.json (kryptheon-night)
17
+ // code-findings.json the last frontend code read (kryptheon code)
18
+ // config.json which checks are switched on (the dashboard)
19
+ // snapshot.json a hash of each project file, to tell what changed
20
+ // changes.jsonl one line per change Kryptheon noticed, and what it ran
21
+ // fixes.jsonl every re-check of a finding someone tried to fix, and its verdict
22
+ //
23
+ // Only tests/ - the recordings the person made - stays in the project.
24
+ //
25
+ // The hash is of the project's full path, so two folders that happen to share
26
+ // a name are never mixed. kryptheon-night carries a copy of this file and must
27
+ // land in the same folder for the same project; both packages check the ids
28
+ // below against the same fixed examples, so the two copies cannot drift apart.
29
+ //
30
+ // Records an older version left in a project are moved here the first time a
31
+ // project is opened, and only moved: nothing is thrown away, and when both
32
+ // places already hold a file the project's copy is kept in parked/ rather than
33
+ // overwriting what is here.
34
+
35
+ const fs = require('fs');
36
+ const os = require('os');
37
+ const path = require('path');
38
+ const crypto = require('crypto');
39
+
40
+ const HOME_ENV = 'KRYPTHEON_HOME';
41
+
42
+ const FILES = {
43
+ project: 'project.json',
44
+ baselines: 'baselines.json',
45
+ history: 'history.jsonl',
46
+ testResults: 'test-results',
47
+ nightLast: 'night-last.json',
48
+ codeFindings: 'code-findings.json',
49
+ config: 'config.json',
50
+ snapshot: 'snapshot.json',
51
+ changes: 'changes.jsonl',
52
+ fixes: 'fixes.jsonl',
53
+ };
54
+
55
+ // What older versions wrote into the project, and where each one goes now.
56
+ // append - history: lines the store does not have yet are added to it
57
+ // park - both exist: the project's copy goes to parked/, the store's stays
58
+ const LEGACY = [
59
+ { from: 'kryptheon-baselines.json', to: FILES.baselines, onBoth: 'park' },
60
+ { from: 'kryptheon-history.jsonl', to: FILES.history, onBoth: 'append' },
61
+ { from: '.kryptheon-last.json', to: FILES.nightLast, onBoth: 'park' },
62
+ { from: 'test-results', to: FILES.testResults, onBoth: 'park', dir: true },
63
+ ];
64
+
65
+ // A project with a Playwright config of its own owns its test-results/ -
66
+ // Playwright's default output folder. Moving that would take the person's own
67
+ // screenshots away, so it is left exactly where it is.
68
+ const OWN_PLAYWRIGHT = /^playwright\.config\.(js|cjs|mjs|ts|cts|mts)$/;
69
+
70
+ /** ~/.kryptheon, or wherever KRYPTHEON_HOME points. */
71
+ function home(env) {
72
+ const source = env || process.env;
73
+ const set = source[HOME_ENV];
74
+ if (set && String(set).trim()) return path.resolve(String(set).trim());
75
+ return path.join(os.homedir(), '.kryptheon');
76
+ }
77
+
78
+ /** The project folder as one exact path, whatever form it was given in. */
79
+ function canonicalRoot(dir) {
80
+ const resolved = path.resolve(dir || process.cwd());
81
+ try {
82
+ return fs.realpathSync.native(resolved);
83
+ } catch (err) {
84
+ return resolved;
85
+ }
86
+ }
87
+
88
+ /**
89
+ * <folder-name>-<12 hex>. Pure: the same path always gives the same id.
90
+ *
91
+ * Windows paths are compared without regard to case, because C:\Users\a and
92
+ * c:\users\a are the same folder there and must be the same project.
93
+ */
94
+ function projectId(root, platform) {
95
+ const plat = platform || process.platform;
96
+ const P = plat === 'win32' ? path.win32 : path.posix;
97
+ // resolve() also drops a trailing separator and turns / into \ on Windows.
98
+ let key = P.resolve(String(root));
99
+ if (plat === 'win32') key = key.toLowerCase();
100
+ const hash = crypto.createHash('sha256').update(key, 'utf8').digest('hex').slice(0, 12);
101
+ const base = P.basename(key) || 'project';
102
+ const name = base.toLowerCase().replace(/[^a-z0-9._-]+/g, '-').replace(/^[-.]+|-+$/g, '').slice(0, 40) || 'project';
103
+ return name + '-' + hash;
104
+ }
105
+
106
+ /**
107
+ * The store folder for a project. Only reads: nothing is created or moved, so
108
+ * the fixture and the reporter can ask for it just by being loaded.
109
+ */
110
+ function dirFor(dir, env) {
111
+ const root = canonicalRoot(dir);
112
+ const short = path.join(home(env), 'projects', projectId(root));
113
+ const owner = readProject(path.join(short, FILES.project));
114
+ if (!owner || !owner.root || sameRoot(owner.root, root)) return short;
115
+ // Two different folders with the same short hash. Astronomically rare, but
116
+ // the one thing a store must never do is hold two projects, so the second
117
+ // one gets the full hash instead.
118
+ const full = crypto.createHash('sha256').update(root, 'utf8').digest('hex');
119
+ return short.replace(/-[0-9a-f]{12}$/, '-' + full);
120
+ }
121
+
122
+ /** Every file in a project's store, by name. Only reads, like dirFor. */
123
+ function pathsFor(dir, env) {
124
+ return pathsIn(dirFor(dir, env));
125
+ }
126
+
127
+ function pathsIn(dir) {
128
+ return {
129
+ dir: dir,
130
+ project: path.join(dir, FILES.project),
131
+ baselines: path.join(dir, FILES.baselines),
132
+ history: path.join(dir, FILES.history),
133
+ testResults: path.join(dir, FILES.testResults),
134
+ nightLast: path.join(dir, FILES.nightLast),
135
+ codeFindings: path.join(dir, FILES.codeFindings),
136
+ config: path.join(dir, FILES.config),
137
+ snapshot: path.join(dir, FILES.snapshot),
138
+ changes: path.join(dir, FILES.changes),
139
+ fixes: path.join(dir, FILES.fixes),
140
+ };
141
+ }
142
+
143
+ function exists(file) {
144
+ try {
145
+ fs.lstatSync(file);
146
+ return true;
147
+ } catch (err) {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ function hasOwnPlaywright(root) {
153
+ try {
154
+ return fs.readdirSync(root).some((name) => OWN_PLAYWRIGHT.test(name));
155
+ } catch (err) {
156
+ return false;
157
+ }
158
+ }
159
+
160
+ // rename is the whole move when both sides are on one drive. Across drives it
161
+ // fails with EXDEV, and then it is copy, check the copy, and only then remove.
162
+ function move(from, to, isDir) {
163
+ try {
164
+ fs.renameSync(from, to);
165
+ return;
166
+ } catch (err) {
167
+ // Anything else - a file held open by an editor, say - is reported and the
168
+ // record stays where it is, whole.
169
+ if (err.code !== 'EXDEV') throw err;
170
+ }
171
+ if (isDir) {
172
+ fs.cpSync(from, to, { recursive: true, errorOnExist: true, force: false });
173
+ fs.rmSync(from, { recursive: true, force: true });
174
+ return;
175
+ }
176
+ const tmp = to + '.moving';
177
+ fs.copyFileSync(from, tmp);
178
+ if (fs.statSync(tmp).size !== fs.statSync(from).size) {
179
+ fs.rmSync(tmp, { force: true });
180
+ throw new Error('the copy of ' + from + ' came out a different size');
181
+ }
182
+ fs.renameSync(tmp, to);
183
+ fs.rmSync(from, { force: true });
184
+ }
185
+
186
+ // Lines of the project's history the store does not have yet, in their order.
187
+ // Checked line by line so moving twice - say the project file could not be
188
+ // removed the first time - never writes a run down twice.
189
+ function appendHistory(from, to) {
190
+ const have = new Set(fs.readFileSync(to, 'utf8').split('\n').filter((l) => l.trim()));
191
+ const extra = fs.readFileSync(from, 'utf8').split('\n').filter((l) => l.trim() && !have.has(l));
192
+ if (extra.length) {
193
+ const current = fs.readFileSync(to, 'utf8');
194
+ const lead = current.length && !current.endsWith('\n') ? '\n' : '';
195
+ fs.appendFileSync(to, lead + extra.join('\n') + '\n', 'utf8');
196
+ }
197
+ fs.rmSync(from, { force: true });
198
+ return extra.length;
199
+ }
200
+
201
+ function stamp(now) {
202
+ return (now || new Date()).toISOString().replace(/[:.]/g, '-');
203
+ }
204
+
205
+ /**
206
+ * Moves what an older version left in the project into the store.
207
+ * Returns { moved: [names], parked: [{ name, to }], appended: [{ name, lines }],
208
+ * kept: [{ name, why }], failed: [{ name, why }] }.
209
+ */
210
+ function migrate(root, dir, options) {
211
+ const out = { moved: [], parked: [], appended: [], kept: [], failed: [] };
212
+ const ownPlaywright = hasOwnPlaywright(root);
213
+ for (const item of LEGACY) {
214
+ const from = path.join(root, item.from);
215
+ if (!exists(from)) continue;
216
+ const label = item.from + (item.dir ? '/' : '');
217
+ if (item.dir && ownPlaywright) {
218
+ out.kept.push({ name: label, why: 'this project has its own Playwright config, so it is theirs' });
219
+ continue;
220
+ }
221
+ const to = path.join(dir, item.to);
222
+ try {
223
+ if (!exists(to)) {
224
+ move(from, to, item.dir);
225
+ out.moved.push(label);
226
+ } else if (item.onBoth === 'append') {
227
+ out.appended.push({ name: label, lines: appendHistory(from, to) });
228
+ } else {
229
+ const parkedDir = path.join(dir, 'parked');
230
+ fs.mkdirSync(parkedDir, { recursive: true });
231
+ const parked = path.join(parkedDir, stamp(options && options.now) + '-' + item.from.replace(/^\./, ''));
232
+ move(from, parked, item.dir);
233
+ out.parked.push({ name: label, to: parked });
234
+ }
235
+ } catch (err) {
236
+ out.failed.push({ name: label, why: err.message });
237
+ }
238
+ }
239
+ return out;
240
+ }
241
+
242
+ /**
243
+ * The store for the project in `dir` (default: the current folder), created if
244
+ * it is not there yet, with anything an older version left in the project moved
245
+ * into it. Safe to call again: once moved, there is nothing left to move.
246
+ *
247
+ * Throws when the store cannot be created. A record that silently cannot be
248
+ * written makes every run look like the first one - which reads as "nothing to
249
+ * compare against, all fine" - so that is never swallowed here.
250
+ */
251
+ function open(dir, options) {
252
+ const env = (options && options.env) || process.env;
253
+ const root = canonicalRoot(dir);
254
+ const storeDir = dirFor(root, env);
255
+ fs.mkdirSync(storeDir, { recursive: true });
256
+ const paths = pathsIn(storeDir);
257
+ if (!exists(paths.project)) {
258
+ fs.writeFileSync(paths.project, JSON.stringify({
259
+ root: root,
260
+ name: path.basename(root),
261
+ id: path.basename(storeDir),
262
+ createdAt: new Date().toISOString(),
263
+ }, null, 2) + '\n', 'utf8');
264
+ }
265
+ return Object.assign({ root: root, id: path.basename(storeDir) }, paths, {
266
+ migration: migrate(root, storeDir, options),
267
+ });
268
+ }
269
+
270
+ function sameRoot(a, b, platform) {
271
+ const plat = platform || process.platform;
272
+ const norm = (p) => {
273
+ const r = path.resolve(String(p)).replace(/[\\/]+$/, '');
274
+ return plat === 'win32' ? r.split('/').join('\\').toLowerCase() : r;
275
+ };
276
+ return norm(a) === norm(b);
277
+ }
278
+
279
+ function readProject(file) {
280
+ try {
281
+ let raw = fs.readFileSync(file, 'utf8');
282
+ if (raw.charCodeAt(0) === 0xfeff) raw = raw.slice(1);
283
+ const parsed = JSON.parse(raw);
284
+ return parsed && typeof parsed === 'object' ? parsed : null;
285
+ } catch (err) {
286
+ return null;
287
+ }
288
+ }
289
+
290
+ /** What to tell the person about a migration, or nothing when nothing moved. */
291
+ function migrationLines(store) {
292
+ const m = store && store.migration;
293
+ if (!m) return [];
294
+ const lines = [];
295
+ const movedNames = m.moved.concat(m.appended.map((a) => a.name)).concat(m.parked.map((p) => p.name));
296
+ if (movedNames.length) {
297
+ lines.push(' Kryptheon now keeps its records outside your project, so they can never');
298
+ lines.push(' end up in your repo. Moved out of this folder:');
299
+ for (const name of movedNames) lines.push(' ' + name);
300
+ lines.push(' They are now in:');
301
+ lines.push(' ' + store.dir);
302
+ for (const p of m.parked) {
303
+ lines.push(' ' + p.name + ' was already there too, so this folder\'s copy is kept aside in:');
304
+ lines.push(' ' + p.to);
305
+ }
306
+ lines.push('');
307
+ }
308
+ for (const k of m.kept) {
309
+ lines.push(' Left ' + k.name + ' where it is: ' + k.why + '.');
310
+ lines.push('');
311
+ }
312
+ for (const f of m.failed) {
313
+ lines.push(' Could not move ' + f.name + ' out of this folder: ' + f.why);
314
+ lines.push(' It is still here. Close anything that has it open and run this again.');
315
+ lines.push('');
316
+ }
317
+ return lines;
318
+ }
319
+
320
+ module.exports = {
321
+ HOME_ENV: HOME_ENV,
322
+ FILES: FILES,
323
+ LEGACY: LEGACY,
324
+ home: home,
325
+ canonicalRoot: canonicalRoot,
326
+ projectId: projectId,
327
+ dirFor: dirFor,
328
+ pathsFor: pathsFor,
329
+ open: open,
330
+ migrate: migrate,
331
+ migrationLines: migrationLines,
332
+ };