kryptheon 0.1.12 → 0.1.14

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
@@ -6,6 +6,27 @@ plain English what broke.
6
6
  Runs entirely on your machine. No account, no API key, nothing leaves your
7
7
  computer. It is a wrapper around [Playwright](https://playwright.dev).
8
8
 
9
+ ## Only have a web address?
10
+
11
+ Built your app in Lovable, Bolt or v0, and there is no project folder on your
12
+ computer? Open a terminal and run:
13
+
14
+ ```
15
+ npx kryptheon record https://your-app.lovable.app
16
+ ```
17
+
18
+ Before it opens anything, it looks at the folder you are in. If that folder is
19
+ not set up for recordings it says so and asks first, then adds a `package.json`
20
+ and kryptheon itself (about 20MB) — nothing else. A new terminal opens in your
21
+ home folder, so from there it keeps your recordings in a `kryptheon-tests`
22
+ folder of their own and tells you where that is.
23
+
24
+ After every change to your app, from that same folder:
25
+
26
+ ```
27
+ npx kryptheon check
28
+ ```
29
+
9
30
  ## Install
10
31
 
11
32
  ```
package/bin/kryptheon.js CHANGED
@@ -68,14 +68,14 @@ function usage() {
68
68
  console.log('');
69
69
  console.log(' kryptheon - record and check your app');
70
70
  console.log('');
71
- console.log(' kryptheon record <url> open your app and record what you do as a test');
72
- console.log(' kryptheon check run every recorded test and report in plain language');
73
- console.log(' kryptheon accept <name> agree that one test\'s new result is the correct one');
74
- console.log(' kryptheon remove [name] list the recordings, or remove one you no longer want');
75
- console.log(' kryptheon setup-ai tell your AI assistant to check its work');
71
+ console.log(' npx kryptheon record <url> open your app and record what you do as a test');
72
+ console.log(' npx kryptheon check run every recorded test and report in plain language');
73
+ console.log(' npx kryptheon accept <name> agree that one test\'s new result is the correct one');
74
+ console.log(' npx kryptheon remove [name] list the recordings, or remove one you no longer want');
75
+ console.log(' npx kryptheon setup-ai tell your AI assistant to check its work');
76
76
  console.log('');
77
77
  console.log(' add --quiet to check for one line when everything passes');
78
- console.log(' kryptheon --version print the version you have installed');
78
+ console.log(' npx kryptheon --version print the version you have installed');
79
79
  console.log('');
80
80
  }
81
81
 
@@ -603,7 +603,7 @@ function windowUndetectedLines(seconds) {
603
603
  ' so this is most likely the check being wrong rather than the browser.',
604
604
  '',
605
605
  ' If the window is open in front of you, run it again with the check off:',
606
- ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
606
+ ' KRYPTHEON_FORCE_RECORD=1 npx kryptheon record <url>',
607
607
  '',
608
608
  ' If there is no window on screen, the browser may have opened somewhere',
609
609
  ' with no desktop to draw on - a remote session, a container, or an AI',
@@ -626,7 +626,7 @@ function probeUnclearLines(answered, unanswered) {
626
626
  ' machine under load, or security software holding up the check itself.',
627
627
  '',
628
628
  ' If the window is open in front of you, run it again with the check off:',
629
- ' KRYPTHEON_FORCE_RECORD=1 kryptheon record <url>',
629
+ ' KRYPTHEON_FORCE_RECORD=1 npx kryptheon record <url>',
630
630
  '',
631
631
  ' Otherwise just run the same command again.',
632
632
  '',
@@ -852,7 +852,7 @@ function reportSecrets(replacements) {
852
852
  console.log('');
853
853
  // Said plainly, because the alternative is signing in with a blank password
854
854
  // and spending an afternoon looking at the wrong thing.
855
- console.log(' Until that file exists, "kryptheon check" will skip this recording');
855
+ console.log(' Until that file exists, "npx kryptheon check" will skip this recording');
856
856
  console.log(' rather than sign in with a blank password.');
857
857
  console.log('');
858
858
  console.log(' Keep .env out of version control - it holds the real value.');
@@ -1116,7 +1116,9 @@ async function finaliseRecording(outFile, context) {
1116
1116
  const named = await nameRecording(outFile);
1117
1117
  console.log('');
1118
1118
  console.log(' Saved to ' + named);
1119
- console.log(' Run it any time with: kryptheon check');
1119
+ // npx, not the bare name: somebody who started with `npx kryptheon record`
1120
+ // has no `kryptheon` command, and would be told it does not exist.
1121
+ console.log(' Run it any time with: npx kryptheon check');
1120
1122
  console.log('');
1121
1123
 
1122
1124
  reportTidying(tidied);
@@ -1251,7 +1253,7 @@ async function remove(name) {
1251
1253
  } else {
1252
1254
  listRecordings(names);
1253
1255
  if (!process.stdin.isTTY) {
1254
- console.log(" To remove one, run: kryptheon remove <name>");
1256
+ console.log(" To remove one, run: npx kryptheon remove <name>");
1255
1257
  console.log("");
1256
1258
  return 0;
1257
1259
  }
@@ -1268,7 +1270,7 @@ async function remove(name) {
1268
1270
  if (choice.action === "unclear") {
1269
1271
  console.error("");
1270
1272
  console.error(" " + choice.why + ": " + JSON.stringify(choice.said));
1271
- console.error(" Run \"kryptheon remove\" on its own to see the list.");
1273
+ console.error(" Run \"npx kryptheon remove\" on its own to see the list.");
1272
1274
  console.error("");
1273
1275
  return 1;
1274
1276
  }
@@ -1300,13 +1302,129 @@ async function remove(name) {
1300
1302
  return failed.length ? 1 : 0;
1301
1303
  }
1302
1304
 
1305
+ // --- setting the folder up --------------------------------------------------
1306
+ //
1307
+ // See the setup section of kryptheon-project.js for why this exists. That file
1308
+ // decides; this one asks, writes and installs.
1309
+
1310
+ function writeOwnPackageJson(dir) {
1311
+ // 'wx': never over the top of one that appeared since the folder was looked
1312
+ // at. Somebody's package.json is not ours to replace.
1313
+ try {
1314
+ fs.writeFileSync(path.join(dir, 'package.json'), JSON.stringify({ private: true }, null, 2) + '\n', { flag: 'wx' });
1315
+ return true;
1316
+ } catch (err) {
1317
+ return err.code === 'EEXIST';
1318
+ }
1319
+ }
1320
+
1321
+ function installKryptheonIn(dir) {
1322
+ console.log(' Installing kryptheon here - this usually takes under a minute.');
1323
+ const args = ['install', '--save-dev', 'kryptheon', '--no-audit', '--no-fund', '--loglevel=error'];
1324
+ // npm is npm.cmd on Windows, and Node will not start a .cmd directly
1325
+ // (spawnSync EINVAL), so there it goes through cmd.exe. Every argument is a
1326
+ // fixed string; nothing anybody typed reaches this command line.
1327
+ const result = process.platform === 'win32'
1328
+ ? spawnSync(process.env.ComSpec || 'cmd.exe', ['/d', '/s', '/c', 'npm'].concat(args), { cwd: dir, encoding: 'utf8' })
1329
+ : spawnSync('npm', args, { cwd: dir, encoding: 'utf8' });
1330
+ // Judged by what is on disk afterwards, not by npm's exit code alone.
1331
+ const ok = !result.error && result.status === 0 && project.kryptheonInstalledIn(dir);
1332
+ if (ok) {
1333
+ console.log(' Installed.');
1334
+ console.log('');
1335
+ }
1336
+ return ok;
1337
+ }
1338
+
1339
+ // A folder `record` is about to use, made ready: its own package.json, its own
1340
+ // copy of kryptheon. Returns false, having said why, if either did not happen.
1341
+ function makeReady(dir) {
1342
+ if (!project.hasPackageJson(dir) && !writeOwnPackageJson(dir)) {
1343
+ console.error('');
1344
+ console.error(' Could not create package.json in: ' + dir);
1345
+ console.error(' This folder may be read-only. Make a new folder of your own and');
1346
+ console.error(' run the same command from there.');
1347
+ console.error('');
1348
+ return false;
1349
+ }
1350
+ if (!project.kryptheonInstalledIn(dir) && !installKryptheonIn(dir)) {
1351
+ for (const line of project.installFailedLines()) console.error(line);
1352
+ return false;
1353
+ }
1354
+ return true;
1355
+ }
1356
+
1357
+ // Run again from inside the new folder rather than moving this process there:
1358
+ // every path in this file is worked out once, from the folder the command
1359
+ // started in.
1360
+ async function recordFromHome(plan, url) {
1361
+ try {
1362
+ fs.mkdirSync(plan.into, { recursive: true });
1363
+ } catch (err) {
1364
+ console.error('');
1365
+ console.error(' Could not make the folder: ' + plan.into);
1366
+ console.error(' Make a new folder of your own, open a terminal in it, and run the');
1367
+ console.error(' same command again.');
1368
+ console.error('');
1369
+ return 1;
1370
+ }
1371
+ if (!makeReady(plan.into)) return 1;
1372
+
1373
+ const child = spawn(process.execPath, [__filename, 'record', url], { cwd: plan.into, stdio: 'inherit' });
1374
+ // Ctrl-C reaches the child too, and the child is the one that saves what was
1375
+ // recorded. This process waits for it rather than exiting under it.
1376
+ const wait = () => {};
1377
+ process.on('SIGINT', wait);
1378
+ process.on('SIGTERM', wait);
1379
+ const code = await new Promise((resolve) => {
1380
+ child.on('error', () => resolve(1));
1381
+ child.on('close', (status) => resolve(status === null ? 1 : status));
1382
+ });
1383
+ process.removeListener('SIGINT', wait);
1384
+ process.removeListener('SIGTERM', wait);
1385
+
1386
+ if (code === 0) for (const line of project.homeDoneLines(plan.into)) console.log(line);
1387
+ return code;
1388
+ }
1389
+
1390
+ // Before the browser: it is a 200MB download on a new machine, and a
1391
+ // recording that cannot be checked afterwards is somebody's five minutes
1392
+ // thrown away. Returns null to carry on recording, or an exit code to stop.
1393
+ async function prepareFolder(url) {
1394
+ const plan = project.setupPlan(USER_DIR);
1395
+ if (plan.action === 'ready') return null;
1396
+
1397
+ const refusing = plan.action === 'refuse';
1398
+ for (const line of project.setupLines(plan, url)) (refusing ? console.error : console.log)(line);
1399
+ if (refusing) return 1;
1400
+
1401
+ // Asked where there is a keyboard. Where there is not, the question's own
1402
+ // default is taken rather than refusing: isTTY is false in plenty of
1403
+ // perfectly good terminals (see noWindowLines), and a command that refuses
1404
+ // there can never work for the person who ran it. The one question whose
1405
+ // default is no - a second project inside somebody's first - stays no.
1406
+ const question = project.setupQuestion(plan);
1407
+ const yes = process.stdin.isTTY
1408
+ ? project.answeredYes(await askLine(question.text), question.yesByDefault)
1409
+ : question.yesByDefault;
1410
+ if (!yes) {
1411
+ console.log('');
1412
+ console.log(' Nothing was changed, and nothing was recorded.');
1413
+ console.log('');
1414
+ return 1;
1415
+ }
1416
+
1417
+ if (plan.action === 'home') return await recordFromHome(plan, url);
1418
+ return makeReady(USER_DIR) ? null : 1;
1419
+ }
1420
+
1303
1421
  async function record(url) {
1304
1422
  if (!url) {
1305
1423
  console.error('');
1306
1424
  console.error(' Which address should I open?');
1307
1425
  console.error('');
1308
1426
  console.error(' Add the web address of your app, for example:');
1309
- console.error(' kryptheon record https://www.example.com');
1427
+ console.error(' npx kryptheon record https://www.example.com');
1310
1428
  console.error('');
1311
1429
  return 1;
1312
1430
  }
@@ -1320,6 +1438,11 @@ async function record(url) {
1320
1438
  return 1;
1321
1439
  }
1322
1440
 
1441
+ // After the address is known to answer, so a typo is not the thing that
1442
+ // gets a folder set up; before the browser, which is the expensive part.
1443
+ const setup = await prepareFolder(url);
1444
+ if (setup !== null) return setup;
1445
+
1323
1446
  if (!ensureBrowser()) return 1;
1324
1447
 
1325
1448
  const existingBefore = listSpecFiles();
@@ -1429,20 +1552,10 @@ async function record(url) {
1429
1552
  // Recordings import the fixture by package name, which only resolves if
1430
1553
  // kryptheon is in the user's own node_modules. Running through a bare `npx`
1431
1554
  // puts the package somewhere the tests cannot see.
1432
- // Deliberately a filesystem walk, not require.resolve: this file lives inside
1433
- // the kryptheon package, and a package with a name and an "exports" map can
1434
- // always resolve itself by name, so require.resolve would answer "yes" even
1435
- // when the user's tests have no way to find it.
1555
+ // One walk, shared with record's setup, so that record and check can never
1556
+ // disagree about whether a folder is ready.
1436
1557
  function fixtureResolvesForUser() {
1437
- let dir = USER_DIR;
1438
- for (;;) {
1439
- if (fs.existsSync(path.join(dir, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
1440
- return true;
1441
- }
1442
- const parent = path.dirname(dir);
1443
- if (parent === dir) return false;
1444
- dir = parent;
1445
- }
1558
+ return project.kryptheonReachableFrom(USER_DIR);
1446
1559
  }
1447
1560
 
1448
1561
  // Only a problem if a recording actually asks for it: specs written against a
@@ -1553,7 +1666,7 @@ function check(options) {
1553
1666
  console.log('');
1554
1667
  console.log(' npm i -D kryptheon');
1555
1668
  console.log('');
1556
- console.log(' Then run "kryptheon check" again.');
1669
+ console.log(' Then run "npx kryptheon check" again.');
1557
1670
  console.log('');
1558
1671
  return 1;
1559
1672
  }
@@ -1588,7 +1701,7 @@ function check(options) {
1588
1701
  console.log(' Skipped ' + path.join('tests', name));
1589
1702
  console.log(' This is not a real recording - it only opens a page and stops,');
1590
1703
  console.log(' so it cannot tell you whether anything works.');
1591
- console.log(' Record it again: kryptheon record <url>');
1704
+ console.log(' Record it again: npx kryptheon record <url>');
1592
1705
  }
1593
1706
 
1594
1707
  if (!triage.runnable.length) {
@@ -1600,7 +1713,7 @@ function check(options) {
1600
1713
  console.log(' Create the .env file above and run this again.');
1601
1714
  } else {
1602
1715
  console.log(' None of the files in tests/ is a usable recording.');
1603
- console.log(' Record one with: kryptheon record <url>');
1716
+ console.log(' Record one with: npx kryptheon record <url>');
1604
1717
  }
1605
1718
  console.log('');
1606
1719
  return 1;
@@ -1793,7 +1906,7 @@ function listBaselines(api) {
1793
1906
  console.log('');
1794
1907
  if (!keys.length) {
1795
1908
  console.log(' No tests have a saved result yet.');
1796
- console.log(' Run "kryptheon check" once and they will be saved automatically.');
1909
+ console.log(' Run "npx kryptheon check" once and they will be saved automatically.');
1797
1910
  console.log('');
1798
1911
  return 0;
1799
1912
  }
@@ -1807,7 +1920,7 @@ function listBaselines(api) {
1807
1920
  }
1808
1921
  console.log('');
1809
1922
  console.log(' To agree that a new result is correct:');
1810
- console.log(' kryptheon accept "<name>"');
1923
+ console.log(' npx kryptheon accept "<name>"');
1811
1924
  console.log('');
1812
1925
  return 0;
1813
1926
  }
@@ -1826,7 +1939,7 @@ function accept(name) {
1826
1939
  if (!matches.length) {
1827
1940
  console.error('');
1828
1941
  console.error(' No saved result for a test called "' + name + '".');
1829
- console.error(' Run "kryptheon accept" on its own to see the names.');
1942
+ console.error(' Run "npx kryptheon accept" on its own to see the names.');
1830
1943
  console.error('');
1831
1944
  return 1;
1832
1945
  }
@@ -1956,6 +2069,7 @@ module.exports = {
1956
2069
  applyRules: applyRules,
1957
2070
  rulesBlock: rulesBlock,
1958
2071
  record: record,
2072
+ prepareFolder: prepareFolder,
1959
2073
  finaliseRecording: finaliseRecording,
1960
2074
  takeOutSecrets: takeOutSecrets,
1961
2075
  reportSecrets: reportSecrets,
@@ -23,6 +23,12 @@ const path = require('path');
23
23
  // stumble into.
24
24
  const ESCAPE_HATCH = 'KRYPTHEON_ALLOW_NO_PACKAGE_JSON';
25
25
 
26
+ // Where `record` keeps recordings for somebody who ran it in their home folder.
27
+ // A new terminal opens there, so for anyone who has only a web address - an
28
+ // app built in Lovable, no source folder anywhere - it is where the command
29
+ // gets typed.
30
+ const HOME_RECORDINGS = 'kryptheon-tests';
31
+
26
32
  function hasPackageJson(dir) {
27
33
  try {
28
34
  return fs.statSync(path.join(dir, 'package.json')).isFile();
@@ -107,6 +113,13 @@ function inspectProject(dir, env) {
107
113
  const personal = isHomeOrSystem(dir);
108
114
  const base = { dir: dir, packaged: packaged, personal: personal, allowed: allowed };
109
115
 
116
+ // Somebody whose recordings `record` put in a folder of their own, standing
117
+ // in their home folder again because that is where a new terminal opens.
118
+ // Pointed at by name, rather than told to go and find a project they never
119
+ // had.
120
+ const kept = tidy(dir) === tidy(os.homedir()) ? path.join(path.resolve(dir), HOME_RECORDINGS) : null;
121
+ if (kept && hasPackageJson(kept)) base.recordingsAt = kept;
122
+
110
123
  if (allowed) return Object.assign({ ok: true, reason: null }, base);
111
124
 
112
125
  // A home folder is refused even when it has a package.json, and this is not
@@ -131,6 +144,19 @@ function inspectProject(dir, env) {
131
144
  function noProjectLines(inspection) {
132
145
  const lines = [''];
133
146
 
147
+ if (inspection.recordingsAt) {
148
+ lines.push(' This is your home folder, not a project.');
149
+ lines.push('');
150
+ lines.push(' Your recordings are in: ' + inspection.recordingsAt);
151
+ lines.push('');
152
+ lines.push(' To check them:');
153
+ lines.push('');
154
+ lines.push(' cd "' + inspection.recordingsAt + '"');
155
+ lines.push(' npx kryptheon check');
156
+ lines.push('');
157
+ return lines;
158
+ }
159
+
134
160
  if (inspection.personal) {
135
161
  lines.push(' This is your home or a system folder, not a project.');
136
162
  lines.push('');
@@ -161,8 +187,18 @@ function noProjectLines(inspection) {
161
187
  lines.push(' Change into your project folder and run this again:');
162
188
  lines.push('');
163
189
  lines.push(' cd path\\to\\your\\project');
164
- lines.push(' kryptheon check');
190
+ lines.push(' npx kryptheon check');
165
191
  lines.push('');
192
+ // The person with a web address and no folder at all - an app built in
193
+ // Lovable - has no project to change into, and would be stuck on the line
194
+ // above. Not offered in a system folder: record refuses to set one up.
195
+ if (!inspection.personal || tidy(inspection.dir) === tidy(os.homedir())) {
196
+ lines.push(' No project folder, only a web address? Record from here and it');
197
+ lines.push(' will be set up for you:');
198
+ lines.push('');
199
+ lines.push(' npx kryptheon record https://your-app.example.com');
200
+ lines.push('');
201
+ }
166
202
  lines.push(' If you really do keep recordings outside a package, set');
167
203
  lines.push(' ' + ESCAPE_HATCH + '=1 and this will run anyway.');
168
204
  lines.push('');
@@ -179,7 +215,7 @@ function noRecordingsLines() {
179
215
  '',
180
216
  ' Record a flow through your app first:',
181
217
  '',
182
- ' kryptheon record http://localhost:3000',
218
+ ' npx kryptheon record http://localhost:3000',
183
219
  '',
184
220
  ' That opens a browser window, so it has to be run by a person in a',
185
221
  ' normal terminal - it cannot be done from an AI assistant.',
@@ -187,8 +223,296 @@ function noRecordingsLines() {
187
223
  ];
188
224
  }
189
225
 
226
+ // --- Setting a folder up, before recording into it ---------------------------
227
+ //
228
+ // Somebody who built their app in Lovable has an address and nothing else: no
229
+ // source folder, no package.json, nothing to change into. Their recordings
230
+ // still need somewhere to live, `check` refuses a folder with no package.json,
231
+ // and a recording cannot load the fixture unless kryptheon is installed where
232
+ // the recording is.
233
+ //
234
+ // `record` used to find all of that out afterwards. Measured in an empty
235
+ // folder: it started the browser, would have let the person spend five minutes
236
+ // using their app and saved the recording - and then `check`, in that same
237
+ // folder, said "There is no project here". So record now works out what is
238
+ // missing before the browser, which is a 200MB download on a new machine, and
239
+ // offers to add it.
240
+
241
+ /**
242
+ * Whether kryptheon can be loaded by a recording saved in this folder.
243
+ *
244
+ * A filesystem walk, not require.resolve: this file lives inside the kryptheon
245
+ * package, and a package with a name and an "exports" map can always resolve
246
+ * itself by name, so require.resolve would answer "yes" even when the user's
247
+ * recordings have no way to find it.
248
+ */
249
+ function kryptheonReachableFrom(dir) {
250
+ let current = path.resolve(String(dir || ''));
251
+ if (insideKryptheonItself(current)) return true;
252
+ for (;;) {
253
+ if (fs.existsSync(path.join(current, 'node_modules', 'kryptheon', 'kryptheon-fixture.js'))) {
254
+ return true;
255
+ }
256
+ const parent = path.dirname(current);
257
+ if (parent === current) return false;
258
+ current = parent;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Inside the kryptheon package's own folder - this repository.
264
+ *
265
+ * Node lets a package load itself by its own name, so a recording made here
266
+ * finds the fixture with no node_modules/kryptheon anywhere. Without this,
267
+ * record in this repository offered to npm install kryptheon into kryptheon.
268
+ * Only the nearest package.json counts: that is the package a file belongs to.
269
+ */
270
+ function insideKryptheonItself(dir) {
271
+ let current = dir;
272
+ for (;;) {
273
+ if (hasPackageJson(current)) {
274
+ // The name alone is not enough: somebody's own app can be called that.
275
+ // The fixture beside it is what makes it this package.
276
+ try {
277
+ return JSON.parse(fs.readFileSync(path.join(current, 'package.json'), 'utf8')).name === 'kryptheon' &&
278
+ fs.existsSync(path.join(current, 'kryptheon-fixture.js'));
279
+ } catch (err) {
280
+ return false;
281
+ }
282
+ }
283
+ const parent = path.dirname(current);
284
+ if (parent === current) return false;
285
+ current = parent;
286
+ }
287
+ }
288
+
289
+ /**
290
+ * Installed in this folder itself, not borrowed from one above it.
291
+ *
292
+ * A folder record sets up gets its own copy even when one is reachable from
293
+ * further up. The copy further up is, on the one machine where this was looked
294
+ * at, a stray install in a home folder - and recordings that lean on that stop
295
+ * working the day somebody tidies it away.
296
+ */
297
+ function kryptheonInstalledIn(dir) {
298
+ return fs.existsSync(path.join(path.resolve(String(dir || '')), 'node_modules', 'kryptheon', 'kryptheon-fixture.js'));
299
+ }
300
+
301
+ /**
302
+ * The nearest folder above this one that is a project.
303
+ *
304
+ * Stops at a home or system folder rather than walking through it: the one
305
+ * package.json most likely to be found up there is the stray one a mistaken
306
+ * install left in a home folder, and that is nobody's project.
307
+ */
308
+ function projectAbove(dir) {
309
+ let current = path.resolve(String(dir || ''));
310
+ for (;;) {
311
+ const parent = path.dirname(current);
312
+ if (parent === current) return null;
313
+ if (isHomeOrSystem(parent)) return null;
314
+ if (hasPackageJson(parent)) return parent;
315
+ current = parent;
316
+ }
317
+ }
318
+
319
+ /** A folder record set up on an earlier run - its package.json asks for kryptheon. */
320
+ function setUpForKryptheon(dir) {
321
+ try {
322
+ const json = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
323
+ return !!((json.devDependencies && json.devDependencies.kryptheon) ||
324
+ (json.dependencies && json.dependencies.kryptheon));
325
+ } catch (err) {
326
+ return false;
327
+ }
328
+ }
329
+
330
+ function isEmptyFolder(dir) {
331
+ try {
332
+ return fs.statSync(dir).isDirectory() && fs.readdirSync(dir).length === 0;
333
+ } catch (err) {
334
+ return false;
335
+ }
336
+ }
337
+
338
+ /**
339
+ * What this folder needs before a recording made in it can be checked. Only
340
+ * looks; never writes.
341
+ *
342
+ * ready nothing
343
+ * create no package.json and no project above: make one, then install
344
+ * install a project without kryptheon: install it
345
+ * above no package.json, but a project sits above this folder
346
+ * home a home folder: record in a kryptheon-tests folder inside it
347
+ * refuse a system folder, or a home whose kryptheon-tests is taken
348
+ */
349
+ function setupPlan(dir, env) {
350
+ const environment = env || process.env;
351
+ const here = path.resolve(String(dir || ''));
352
+
353
+ // Somebody who has said they keep recordings outside a package is left
354
+ // exactly as they were before any of this existed.
355
+ if (String(environment[ESCAPE_HATCH] || '') !== '') return { action: 'ready', dir: here };
356
+
357
+ if (tidy(here) === tidy(os.homedir())) {
358
+ const into = path.join(here, HOME_RECORDINGS);
359
+ // Used only if it is plainly ours or plainly nothing. A folder of that name
360
+ // holding something else is somebody's, and recording into it would put a
361
+ // package.json and node_modules in the middle of it.
362
+ if (!fs.existsSync(into) || isEmptyFolder(into) || setUpForKryptheon(into)) {
363
+ return { action: 'home', dir: here, into: into };
364
+ }
365
+ return { action: 'refuse', reason: 'taken', dir: here, into: into };
366
+ }
367
+ if (isHomeOrSystem(here)) return { action: 'refuse', reason: 'system', dir: here };
368
+
369
+ if (!hasPackageJson(here)) {
370
+ const above = projectAbove(here);
371
+ if (above) return { action: 'above', dir: here, project: above };
372
+ return { action: 'create', dir: here };
373
+ }
374
+ if (!kryptheonReachableFrom(here)) return { action: 'install', dir: here };
375
+ return { action: 'ready', dir: here };
376
+ }
377
+
378
+ /** What is about to happen to this folder, said before it happens. */
379
+ function setupLines(plan, url) {
380
+ const address = url || 'https://your-app.example.com';
381
+ const lines = [''];
382
+
383
+ if (plan.action === 'create') {
384
+ lines.push(' This folder is not set up for recordings yet.');
385
+ lines.push('');
386
+ lines.push(' You are in: ' + plan.dir);
387
+ lines.push('');
388
+ lines.push(' Your recordings are saved here, and kryptheon has to be installed');
389
+ lines.push(' beside them or they cannot be checked later. So I will add:');
390
+ lines.push('');
391
+ lines.push(' package.json a small file that marks this folder as a project');
392
+ lines.push(' node_modules kryptheon itself, about 20MB');
393
+ lines.push('');
394
+ lines.push(' Nothing already in this folder is touched.');
395
+ } else if (plan.action === 'install') {
396
+ lines.push(' kryptheon is not installed in this project yet.');
397
+ lines.push('');
398
+ lines.push(' You are in: ' + plan.dir);
399
+ lines.push('');
400
+ lines.push(' Recordings load it by name, so without it here they cannot be');
401
+ lines.push(' checked later. I will run:');
402
+ lines.push('');
403
+ lines.push(' npm install --save-dev kryptheon');
404
+ lines.push('');
405
+ lines.push(' which adds one line to package.json and about 20MB to node_modules.');
406
+ } else if (plan.action === 'above') {
407
+ lines.push(' This folder is inside a project:');
408
+ lines.push('');
409
+ lines.push(' ' + plan.project);
410
+ lines.push('');
411
+ lines.push(' Recordings belong beside the app they test, so the usual thing is');
412
+ lines.push(' to run this from there:');
413
+ lines.push('');
414
+ lines.push(' cd "' + plan.project + '"');
415
+ lines.push(' npx kryptheon record ' + address);
416
+ lines.push('');
417
+ lines.push(' If that is not your project - a package.json can be left behind by');
418
+ lines.push(' an install run in the wrong place - I can set this folder up on its');
419
+ lines.push(' own instead.');
420
+ } else if (plan.action === 'home') {
421
+ lines.push(' You are in your home folder.');
422
+ lines.push('');
423
+ lines.push(' Recordings are kept in a folder of their own rather than loose in');
424
+ lines.push(' here, so I will ' + (fs.existsSync(plan.into) ? 'use this one' : 'make one') + ' and work in that:');
425
+ lines.push('');
426
+ lines.push(' ' + plan.into);
427
+ } else if (plan.action === 'refuse' && plan.reason === 'taken') {
428
+ lines.push(' You are in your home folder. Recordings are kept in a folder of');
429
+ lines.push(' their own, and I would normally use:');
430
+ lines.push('');
431
+ lines.push(' ' + plan.into);
432
+ lines.push('');
433
+ lines.push(' but something else is already there, and it is not one I made.');
434
+ lines.push(' Nothing was changed.');
435
+ lines.push('');
436
+ lines.push(' Make a new folder of your own, open a terminal in it, and run the');
437
+ lines.push(' same command again.');
438
+ } else if (plan.action === 'refuse') {
439
+ lines.push(' This is a system folder, not somewhere to keep recordings.');
440
+ lines.push('');
441
+ lines.push(' You are in: ' + plan.dir);
442
+ lines.push('');
443
+ lines.push(' Nothing was changed. Make a new folder of your own - on the Desktop');
444
+ lines.push(' is fine - open a terminal in it, and run the same command again.');
445
+ if (process.platform === 'win32') {
446
+ lines.push(' (In File Explorer: right-click inside the folder, Open in Terminal.)');
447
+ }
448
+ }
449
+ lines.push('');
450
+ return lines;
451
+ }
452
+
453
+ /**
454
+ * The question, and which way Enter answers it.
455
+ *
456
+ * Yes by default where saying yes is what the person came for. No by default
457
+ * for a folder inside another project, where yes puts a second project inside
458
+ * somebody's first.
459
+ */
460
+ function setupQuestion(plan) {
461
+ if (plan.action === 'above') return { text: ' Set this folder up on its own instead? [y/N] ', yesByDefault: false };
462
+ if (plan.action === 'install') return { text: ' Install it? [Y/n] ', yesByDefault: true };
463
+ if (plan.action === 'home') return { text: ' Go ahead? [Y/n] ', yesByDefault: true };
464
+ return { text: ' Set this folder up? [Y/n] ', yesByDefault: true };
465
+ }
466
+
467
+ /** An answer typed at the question, read the way the question offered it. */
468
+ function answeredYes(answer, yesByDefault) {
469
+ const typed = String(answer || '').trim();
470
+ if (!typed) return !!yesByDefault;
471
+ return /^y/i.test(typed);
472
+ }
473
+
474
+ /** After recording from a home folder: where the recordings went, and how to get back to them. */
475
+ function homeDoneLines(into) {
476
+ return [
477
+ '',
478
+ ' Your recordings are in: ' + into,
479
+ '',
480
+ ' To check them after a change, open a terminal and run:',
481
+ '',
482
+ ' cd "' + into + '"',
483
+ ' npx kryptheon check',
484
+ '',
485
+ ];
486
+ }
487
+
488
+ /** The install did not finish. Nothing needs undoing: running again picks up where this stopped. */
489
+ function installFailedLines() {
490
+ return [
491
+ '',
492
+ ' kryptheon could not be installed in this folder.',
493
+ '',
494
+ ' Installing needs an internet connection. Check it, then run the same',
495
+ ' command again - nothing needs undoing first.',
496
+ '',
497
+ ' Or install it yourself, then record:',
498
+ '',
499
+ ' npm install --save-dev kryptheon',
500
+ '',
501
+ ];
502
+ }
503
+
190
504
  module.exports = {
191
505
  ESCAPE_HATCH: ESCAPE_HATCH,
506
+ HOME_RECORDINGS: HOME_RECORDINGS,
507
+ kryptheonReachableFrom: kryptheonReachableFrom,
508
+ kryptheonInstalledIn: kryptheonInstalledIn,
509
+ projectAbove: projectAbove,
510
+ setupPlan: setupPlan,
511
+ setupLines: setupLines,
512
+ setupQuestion: setupQuestion,
513
+ answeredYes: answeredYes,
514
+ homeDoneLines: homeDoneLines,
515
+ installFailedLines: installFailedLines,
192
516
  hasPackageJson: hasPackageJson,
193
517
  systemFolders: systemFolders,
194
518
  tidy: tidy,
@@ -292,7 +292,7 @@ function translate(message) {
292
292
  hideSource: true,
293
293
  // %TEST% is filled in with the test's name when the block is printed,
294
294
  // so the command resets only this one test.
295
- advice: 'if this change is intended, run: kryptheon accept "%TEST%"',
295
+ advice: 'if this change is intended, run: npx kryptheon accept "%TEST%"',
296
296
  };
297
297
  }
298
298
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "kryptheon",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "description": "Record what you do in your app and get plain-language reports when it breaks.",
5
5
  "license": "MIT",
6
6
  "type": "commonjs",
@@ -38,7 +38,7 @@
38
38
  "@playwright/test": "^1.62.1"
39
39
  },
40
40
  "scripts": {
41
- "check": "node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
41
+ "check": "node kryptheon-reporter.check.js && node kryptheon-signature.check.js && node kryptheon-signature.stream.check.js && node kryptheon-signature.baseline.check.js && node kryptheon-signature.settle.check.js && node kryptheon-signature.number.check.js && node kryptheon-env.check.js && node kryptheon-watchdog.check.js && node kryptheon-record.check.js && node kryptheon-selectors.check.js && node kryptheon-recordings.check.js && node kryptheon-cleanup.check.js && node kryptheon-orphan.check.js && node kryptheon-project.check.js && node kryptheon-setup.check.js && node kryptheon-load.check.js && node kryptheon-package.check.js",
42
42
  "check:reporter": "node kryptheon-reporter.check.js",
43
43
  "check:signature": "node kryptheon-signature.check.js",
44
44
  "check:stream": "node kryptheon-signature.stream.check.js",
@@ -53,6 +53,7 @@
53
53
  "check:cleanup": "node kryptheon-cleanup.check.js",
54
54
  "check:orphan": "node kryptheon-orphan.check.js",
55
55
  "check:project": "node kryptheon-project.check.js",
56
+ "check:setup": "node kryptheon-setup.check.js",
56
57
  "check:load": "node kryptheon-load.check.js",
57
58
  "check:package": "node kryptheon-package.check.js",
58
59
  "prepublishOnly": "node kryptheon-package.check.js"