staysfixed 0.6.2 → 0.7.0
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/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
package/src/v2/init.js
CHANGED
|
@@ -384,14 +384,66 @@ function productNeeds(product, project) {
|
|
|
384
384
|
topic: 'app',
|
|
385
385
|
});
|
|
386
386
|
}
|
|
387
|
+
// Whether this app has a device identity to keep apart is a question the source already
|
|
388
|
+
// answers, so it is answered here rather than asked.
|
|
389
|
+
//
|
|
390
|
+
// WHY THIS ONE MATTERS MORE THAN IT LOOKS. It was written because of a real bug: two
|
|
391
|
+
// copies of a desktop app claiming one identity displaced each other on a relay's single
|
|
392
|
+
// slot, over and over, and it read exactly like a broken product. Asking for it is right
|
|
393
|
+
// — when there is something to ask for. On Terminal Deck there is not: every one of the
|
|
394
|
+
// settings its main process reads was listed and not one of them carries a device or
|
|
395
|
+
// machine id, because the identity is generated into the settings folder and the adapter
|
|
396
|
+
// already gives every run a settings folder of its own. Asking anyway put a line on a
|
|
397
|
+
// set-up list that nobody could ever tick off, and a list with an impossible item on it
|
|
398
|
+
// is a list people stop reading.
|
|
399
|
+
const identity = identityVariables(project.envNames);
|
|
400
|
+
if (identity.length > 0) {
|
|
401
|
+
needs.push({
|
|
402
|
+
what: `whether ${identity.length === 1 ? `${identity[0]} is` : `${identity.slice(0, 3).join(', ')} are`} how this app says who it is`,
|
|
403
|
+
why: 'If the app registers itself somewhere with a device id, two runs claiming the same id would fight over the same slot — and that fight looks exactly like a bug in the product.',
|
|
404
|
+
unlocks: 'running the old build and the new one safely, one after the other',
|
|
405
|
+
fix: `${identity.length === 1 ? 'This name was' : 'These names were'} read out of your own source. If ${identity.length === 1 ? 'it carries' : 'one of them carries'} a device or machine id, put {"identityEnv": {"${identity[0]}": "{identity}"}} under "electron" in the settings and each run gets its own. If not, leave it out and nothing is lost.`,
|
|
406
|
+
who: 'the agent',
|
|
407
|
+
product: product.name,
|
|
408
|
+
topic: 'identity',
|
|
409
|
+
});
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
// An iPhone app with no built bundle. Without this the readiness verdict said the iPhone
|
|
414
|
+
// app "can be checked here now" on a fresh clone that contains no built app at all — a
|
|
415
|
+
// ready state that nothing could act on, printed beside four honest ones.
|
|
416
|
+
if (product.kind === 'ios' && !product.built.found) {
|
|
417
|
+
const generated = product.evidence.some((clue) => /project\.ya?ml$/.test(clue.where));
|
|
418
|
+
const scheme = typeof product.suggest?.scheme === 'string' ? String(product.suggest.scheme) : null;
|
|
387
419
|
needs.push({
|
|
388
|
-
what: 'the
|
|
389
|
-
why: '
|
|
390
|
-
unlocks: '
|
|
391
|
-
fix:
|
|
420
|
+
what: 'the app built for the simulator',
|
|
421
|
+
why: 'An iPhone app is checked by installing a built bundle on a simulator. There is no built bundle here yet, and a repository usually does not commit one.',
|
|
422
|
+
unlocks: 'opening the app on a simulator and reading what the screen says every control is and does',
|
|
423
|
+
fix: scheme
|
|
424
|
+
? `cd ${product.where} && ${generated ? 'xcodegen generate && ' : ''}xcodebuild -scheme ${scheme} -sdk iphonesimulator -configuration Debug -derivedDataPath build build (then set ios.app in the settings to the .app it wrote)`
|
|
425
|
+
: `cd ${product.where} && ${generated ? 'xcodegen generate && ' : ''}xcodebuild -list (that names the schemes; build one for the simulator, then set ios.app in the settings to the .app it wrote)`,
|
|
392
426
|
who: 'the agent',
|
|
393
427
|
product: product.name,
|
|
394
|
-
topic: '
|
|
428
|
+
topic: 'app',
|
|
429
|
+
});
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
// A command-line program that has to be built before it can be run. This is what a product
|
|
433
|
+
// nothing in package.json names looks like on a fresh clone: the source is there, the
|
|
434
|
+
// program is real, and the file that would be run does not exist yet.
|
|
435
|
+
if (product.kind === 'cli' && !product.built.found) {
|
|
436
|
+
const build = typeof suggest.buildWith === 'string' ? String(suggest.buildWith) : null;
|
|
437
|
+
needs.push({
|
|
438
|
+
what: `${product.name}, built`,
|
|
439
|
+
why: 'It is built into a folder that is not committed, so on a fresh copy of this repository there is nothing to run yet. Nothing in package.json names it either, which is why it is easy to miss entirely.',
|
|
440
|
+
unlocks: 'every word of its help, what it exits with, and every file it touches',
|
|
441
|
+
fix: build
|
|
442
|
+
? `${build}, then \`staysfixed init --force\` — the commands are filled in exactly from what the build wrote, and nothing has been edited by hand yet.`
|
|
443
|
+
: `Nothing in package.json says how to build it. Name the command that builds it and the command that runs the result under "process" in the settings.`,
|
|
444
|
+
who: build ? 'the agent' : 'a person',
|
|
445
|
+
product: product.name,
|
|
446
|
+
topic: 'app',
|
|
395
447
|
});
|
|
396
448
|
}
|
|
397
449
|
|
|
@@ -410,7 +462,14 @@ function productNeeds(product, project) {
|
|
|
410
462
|
topic: 'start',
|
|
411
463
|
});
|
|
412
464
|
}
|
|
413
|
-
|
|
465
|
+
// Addresses with a changing part in them, whether they came from folder names or from a
|
|
466
|
+
// router. Both are the same problem and both go in one item, because being asked for the
|
|
467
|
+
// same thing twice under two headings is how somebody stops reading a list.
|
|
468
|
+
const fromRouter = /** @type {{url: string, names: string[]}[]} */ (suggest.screensNeedingValues ?? []);
|
|
469
|
+
const needing = [
|
|
470
|
+
...project.pages.filter((p) => p.needs.length > 0),
|
|
471
|
+
...fromRouter.map((one) => ({ url: one.url, file: 'the router', needs: one.names })),
|
|
472
|
+
];
|
|
414
473
|
if (needing.length > 0) {
|
|
415
474
|
const names = [...new Set(needing.flatMap((p) => p.needs))];
|
|
416
475
|
needs.push({
|
|
@@ -427,25 +486,35 @@ function productNeeds(product, project) {
|
|
|
427
486
|
|
|
428
487
|
if (product.kind === 'server') {
|
|
429
488
|
if (!suggest.start) {
|
|
489
|
+
// The product's own blocker where it has one: a server found by reading the code often
|
|
490
|
+
// has the answer written down beside it, in a deploy script or a container file. That
|
|
491
|
+
// is work for the agent — it can read the script — rather than a question for a person
|
|
492
|
+
// who would have to go and read the same script themselves.
|
|
493
|
+
const written = product.blockers.find((line) => /start/i.test(line) && /\.(sh|bash|ya?ml|mjs|js|ts)\b|Dockerfile/.test(line)) ?? null;
|
|
430
494
|
needs.push({
|
|
431
495
|
what: 'the command that starts the server',
|
|
432
496
|
why: 'The routes can be listed by reading the code, but none of them can be asked anything until something is listening.',
|
|
433
497
|
unlocks: `walking ${project.doors.route > 0 ? `all ${project.doors.route} routes` : 'every route'} and seeing what each one quietly does while answering`,
|
|
434
|
-
fix: 'Put {"start": "..."} under "http" in the settings, and have it listen on the PORT it is given.',
|
|
435
|
-
who: project.scripts.start ? 'the agent' : 'a person',
|
|
498
|
+
fix: written ?? 'Put {"start": "..."} under "http" in the settings, and have it listen on the PORT it is given.',
|
|
499
|
+
who: project.scripts.start || written ? 'the agent' : 'a person',
|
|
436
500
|
product: product.name,
|
|
437
501
|
topic: 'start',
|
|
438
502
|
});
|
|
439
503
|
}
|
|
440
|
-
needs
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
504
|
+
// A server with nothing to store needs no way of putting anything back, and asking for
|
|
505
|
+
// one is the same fault as asking for a device id that does not exist: an item on a
|
|
506
|
+
// set-up list that can never be ticked off, sitting beside items that can.
|
|
507
|
+
if (suggest.stateless !== true) {
|
|
508
|
+
needs.push({
|
|
509
|
+
what: 'a way to put the data back how it was',
|
|
510
|
+
why: 'Both builds have to see the same rows. Without that, the second run sees whatever the first one wrote, and every difference after the first write means nothing.',
|
|
511
|
+
unlocks: 'comparing two builds fairly instead of comparing two different sets of data',
|
|
512
|
+
fix: 'Put {"restore": "..."} under "http" in the settings: a command that resets the database or the data folder to a known state. It must not be one that destroys data it cannot rebuild — a command that looks destructive is refused rather than run.',
|
|
513
|
+
who: 'a person',
|
|
514
|
+
product: product.name,
|
|
515
|
+
topic: 'data',
|
|
516
|
+
});
|
|
517
|
+
}
|
|
449
518
|
const withParts = project.routes.filter((route) => /:[A-Za-z_$]|\[[^\]]+\]|\{[^}]+\}/.test(route.name));
|
|
450
519
|
if (withParts.length > 0) {
|
|
451
520
|
const names = [...new Set(withParts.flatMap((route) => [...route.name.matchAll(/:([A-Za-z_$][\w$]*)|\[\.{0,3}([^\]]+)\]|\{([^}]+)\}/g)].map((hit) => hit[1] ?? hit[2] ?? hit[3])))];
|
|
@@ -483,6 +552,21 @@ function productNeeds(product, project) {
|
|
|
483
552
|
return needs;
|
|
484
553
|
}
|
|
485
554
|
|
|
555
|
+
/**
|
|
556
|
+
* Settings this app reads that could be how it says who it is.
|
|
557
|
+
*
|
|
558
|
+
* A guess about meaning, but never a guess about existence: every name here was read out of
|
|
559
|
+
* the project's own source, so the worst case is a question about a real variable rather than
|
|
560
|
+
* a request for one that was never there.
|
|
561
|
+
*
|
|
562
|
+
* @param {string[]} envNames
|
|
563
|
+
* @returns {string[]}
|
|
564
|
+
*/
|
|
565
|
+
export function identityVariables(envNames) {
|
|
566
|
+
const looksLikeAnIdentity = /(DEVICE|MACHINE|INSTANCE|INSTALL|CLIENT|NODE|HOST|AGENT|PEER|REPLICA)[_-]?(ID|UUID|GUID|KEY|NAME|SLOT)\b/i;
|
|
567
|
+
return envNames.filter((name) => looksLikeAnIdentity.test(name));
|
|
568
|
+
}
|
|
569
|
+
|
|
486
570
|
/**
|
|
487
571
|
* Routes whose NAME says they do something that cannot be taken back.
|
|
488
572
|
*
|
|
@@ -519,7 +603,14 @@ function machineNeeds(surface, product, covered) {
|
|
|
519
603
|
product: product.name,
|
|
520
604
|
}];
|
|
521
605
|
}
|
|
606
|
+
/** @type {Set<string|undefined>} */
|
|
522
607
|
const already = new Set(covered.map((need) => need.topic).filter(Boolean));
|
|
608
|
+
// A server with nothing to store has no data to put back, and doctor cannot know that: it
|
|
609
|
+
// answers "what is missing on this machine", so it asks for a way to restore a database on
|
|
610
|
+
// behalf of every server it sees. Telling somebody to install Docker for a switchboard that
|
|
611
|
+
// keeps nothing is the same fault as asking for a device id that does not exist — an item on
|
|
612
|
+
// a set-up list that can never be ticked off, sitting beside items that can.
|
|
613
|
+
if (product.suggest?.stateless === true) already.add('data');
|
|
523
614
|
return surface.needs
|
|
524
615
|
// Doctor answers "what is missing on this machine right now", and right now is before
|
|
525
616
|
// this command has written anything. A need whose whole fix is "run staysfixed init",
|
|
@@ -593,6 +684,21 @@ function sortNeeds(readiness, project, machine) {
|
|
|
593
684
|
who: 'the agent',
|
|
594
685
|
});
|
|
595
686
|
}
|
|
687
|
+
// Room to work. Three of the adapters copy the whole project into a scratch folder before
|
|
688
|
+
// running anything — which is right, a check must never write into somebody's working copy
|
|
689
|
+
// — and a repository carrying gigabytes of build output cannot be copied twice on a laptop
|
|
690
|
+
// that is nearly full. It is not a failure anybody could diagnose from the error: the copy
|
|
691
|
+
// simply stops. The whole answer is one command, and it is the agent's to run.
|
|
692
|
+
if (project.bulk.tooBig) {
|
|
693
|
+
all.push({
|
|
694
|
+
what: 'somewhere with room to copy this project into',
|
|
695
|
+
why: `${project.bulk.why} A check copies the project so a run can write anywhere it likes without touching your working copy, and there is not room here for the two copies a comparison needs.`,
|
|
696
|
+
unlocks: 'every check that runs a command or boots a server or a website',
|
|
697
|
+
fix: `git worktree add ../${path.basename(project.root)}-check HEAD (then copy the settings across and run the check in there — a worktree holds the tracked files and none of the build output)`,
|
|
698
|
+
who: 'the agent',
|
|
699
|
+
topic: 'room',
|
|
700
|
+
});
|
|
701
|
+
}
|
|
596
702
|
if (!project.isGitRepo) {
|
|
597
703
|
all.push({
|
|
598
704
|
what: 'this folder being a git repository',
|
|
@@ -676,7 +782,7 @@ export function proposeJourneys(project) {
|
|
|
676
782
|
out.push({
|
|
677
783
|
name: String(command.name),
|
|
678
784
|
what: `run \`${String(command.run)}\` and compare what it printed, what it exited with and every file it touched`,
|
|
679
|
-
from: 'package.json'
|
|
785
|
+
from: product.where === '.' ? 'package.json' : `the program built in ${product.where}/`,
|
|
680
786
|
surface: 'cli',
|
|
681
787
|
automatic: false,
|
|
682
788
|
ready: true,
|
|
@@ -720,12 +826,22 @@ export function proposeJourneys(project) {
|
|
|
720
826
|
}
|
|
721
827
|
if (Array.isArray(suggest.screens) && suggest.screens.length > 0) {
|
|
722
828
|
const many = suggest.screens.length;
|
|
829
|
+
const router = product.router?.kind ?? 'files';
|
|
830
|
+
// Where each screen came from decides what to call it, and one of these is the whole
|
|
831
|
+
// point: screens reached by clicking are NOT addresses, and a line that calls them
|
|
832
|
+
// pages would be describing something the run does not do.
|
|
833
|
+
const from = router === 'tabs' ? 'the strip of tabs in the source'
|
|
834
|
+
: router === 'hash' ? 'the router in the source'
|
|
835
|
+
: router === 'declared' ? 'the router in the source'
|
|
836
|
+
: 'the folder itself';
|
|
723
837
|
out.push({
|
|
724
|
-
name: many === 1 ? 'the
|
|
725
|
-
what:
|
|
726
|
-
|
|
838
|
+
name: many === 1 ? 'the screen this app has' : 'every screen this app has',
|
|
839
|
+
what: router === 'tabs'
|
|
840
|
+
? `open the app and reach each of its ${many} screens the way a person does — by clicking the control that names it — reading what the screen says every control is and does. The address never changes, so opening one is not an option.`
|
|
841
|
+
: many === 1 ? 'open the single page in this folder' : `open each of the ${many} addresses this app answers on and read what the screen says every control is and does`,
|
|
842
|
+
from,
|
|
727
843
|
surface: 'web',
|
|
728
|
-
automatic:
|
|
844
|
+
automatic: router !== 'files',
|
|
729
845
|
howMany: many,
|
|
730
846
|
ready: Boolean(suggest.start),
|
|
731
847
|
});
|
|
@@ -733,7 +849,7 @@ export function proposeJourneys(project) {
|
|
|
733
849
|
}
|
|
734
850
|
if (product.kind === 'android' && product.adapter === 'android') {
|
|
735
851
|
out.push({
|
|
736
|
-
name:
|
|
852
|
+
name: `open ${product.name}`,
|
|
737
853
|
what: 'install the app on an emulator of its own, open it, read what the screen says every control is and does, then take it off again',
|
|
738
854
|
from: 'the app itself',
|
|
739
855
|
surface: 'android',
|
|
@@ -753,7 +869,7 @@ export function proposeJourneys(project) {
|
|
|
753
869
|
}
|
|
754
870
|
if (product.kind === 'electron') {
|
|
755
871
|
out.push({
|
|
756
|
-
name:
|
|
872
|
+
name: `open ${product.name}`,
|
|
757
873
|
what: `open the app and read everything it shows and all ${project.doors.ipc} channels it registers`,
|
|
758
874
|
from: 'the source',
|
|
759
875
|
surface: 'electron',
|
|
@@ -830,11 +946,15 @@ function formatOf(file) {
|
|
|
830
946
|
*/
|
|
831
947
|
export function configText(project) {
|
|
832
948
|
const has = (/** @type {string} */ kind) => project.products.find((p) => p.kind === kind) ?? null;
|
|
949
|
+
// Several of a kind is normal, and it is the case that goes wrong quietly. One repository
|
|
950
|
+
// here makes two command-line programs; writing the first one's commands and stopping would
|
|
951
|
+
// have left the second unchecked with nothing anywhere saying so.
|
|
952
|
+
const all = (/** @type {string} */ kind) => project.products.filter((p) => p.kind === kind);
|
|
833
953
|
const electron = has('electron');
|
|
834
954
|
const web = has('web');
|
|
835
955
|
const server = has('server');
|
|
836
|
-
const cli = has('cli');
|
|
837
956
|
const library = has('library');
|
|
957
|
+
const ios = has('ios');
|
|
838
958
|
|
|
839
959
|
/** @type {string[]} */
|
|
840
960
|
const out = [];
|
|
@@ -856,6 +976,27 @@ export function configText(project) {
|
|
|
856
976
|
w(' *');
|
|
857
977
|
w(' * EVERY OPTION THAT MATTERS IS IN THIS FILE. The ones that do not apply to this project are');
|
|
858
978
|
w(' * commented out rather than left out, so nothing is hidden from you. Delete freely.');
|
|
979
|
+
if (project.products.length > 1) {
|
|
980
|
+
w(' *');
|
|
981
|
+
w(' * EACH ONE, AND WHAT SAID SO:');
|
|
982
|
+
for (const product of project.products) {
|
|
983
|
+
w(` * ${padTo(product.name, 34)} ${product.why}`);
|
|
984
|
+
}
|
|
985
|
+
}
|
|
986
|
+
if (project.bulk.tooBig || project.bulk.capped) {
|
|
987
|
+
w(' *');
|
|
988
|
+
w(' * BEFORE THE FIRST RUN, one fact about this folder. Commands, servers and websites are');
|
|
989
|
+
w(' * checked in a scratch COPY of the project, so a run can write anywhere it likes without');
|
|
990
|
+
w(' * touching your working copy.');
|
|
991
|
+
for (const line of wrapProse(project.bulk.why, 92)) w(` * ${line}`);
|
|
992
|
+
if (project.bulk.tooBig) {
|
|
993
|
+
w(' * So run the check somewhere with room, which is one command:');
|
|
994
|
+
w(' *');
|
|
995
|
+
w(` * git worktree add ../${path.basename(project.root)}-check HEAD`);
|
|
996
|
+
w(` * cp ${path.basename(project.root)}/staysfixed.config.* ../${path.basename(project.root)}-check/`);
|
|
997
|
+
w(` * cd ../${path.basename(project.root)}-check && npx staysfixed check`);
|
|
998
|
+
}
|
|
999
|
+
}
|
|
859
1000
|
w(' */');
|
|
860
1001
|
w('');
|
|
861
1002
|
w('export default {');
|
|
@@ -874,9 +1015,16 @@ export function configText(project) {
|
|
|
874
1015
|
w(' // This is the only channel that sees a door nobody has ever opened.');
|
|
875
1016
|
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
876
1017
|
w(' source: {');
|
|
877
|
-
w(' // Folders to read. Left out, it reads the usual ones
|
|
878
|
-
w(' // pages, api, electron, main, packages
|
|
879
|
-
w(
|
|
1018
|
+
w(' // Folders to read. Left out, it reads the usual ones — src, lib, app, bin, server,');
|
|
1019
|
+
w(' // pages, api, electron, main, packages — which is right for a repository that makes');
|
|
1020
|
+
w(' // one thing, and misses whole products in a repository that makes several. These are');
|
|
1021
|
+
w(' // the folders the products above actually live in, plus any folder of source that no');
|
|
1022
|
+
w(' // product claimed, because an unclaimed folder is exactly where a silent gap lives.');
|
|
1023
|
+
if (project.sourceFolders.length > 0) {
|
|
1024
|
+
w(` folders: [${project.sourceFolders.map((f) => JSON.stringify(f)).join(', ')}],`);
|
|
1025
|
+
} else {
|
|
1026
|
+
w(" // folders: ['src', 'lib'],");
|
|
1027
|
+
}
|
|
880
1028
|
if (project.doors.read) {
|
|
881
1029
|
w(` // Last read: ${project.doors.route} routes, ${project.doors.ipc} private channels, ${project.doors.export} exported names, ${project.doors.command} commands, ${project.doors.env} settings it reads.`);
|
|
882
1030
|
}
|
|
@@ -890,23 +1038,43 @@ export function configText(project) {
|
|
|
890
1038
|
w(' // connection recorded and then refused.');
|
|
891
1039
|
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
892
1040
|
w(' process: {');
|
|
893
|
-
w(' // Commands worth running.
|
|
894
|
-
w(' //
|
|
895
|
-
|
|
1041
|
+
w(' // Commands worth running. Only ever `--help`, and that is deliberate: a command listed');
|
|
1042
|
+
w(' // in a manifest could deploy, could publish, could wipe a database, and running one');
|
|
1043
|
+
w(' // because it was there would be this tool causing the very kind of damage it exists to');
|
|
1044
|
+
w(' // catch. Asking a program to describe itself is the one thing every command-line tool');
|
|
1045
|
+
w(' // does safely — and its help text is a precise description of everything it offers, so');
|
|
1046
|
+
w(' // a command that quietly disappears is caught by comparing it.');
|
|
1047
|
+
const cliProducts = all('cli');
|
|
1048
|
+
/** @type {any[]} */
|
|
1049
|
+
const commands = [];
|
|
1050
|
+
for (const one of cliProducts) for (const command of /** @type {any[]} */ (one.suggest?.commands ?? [])) commands.push(command);
|
|
1051
|
+
const unbuilt = cliProducts.filter((one) => !one.built.found);
|
|
896
1052
|
if (commands.length > 0) {
|
|
897
1053
|
w(' commands: [');
|
|
898
1054
|
for (const command of commands) {
|
|
899
1055
|
w(` { name: ${JSON.stringify(String(command.name))}, run: ${JSON.stringify(String(command.run))}, describe: ${JSON.stringify(String(command.describe ?? ''))} },`);
|
|
900
1056
|
}
|
|
901
1057
|
w(' ],');
|
|
902
|
-
w(' //
|
|
903
|
-
w(' //
|
|
904
|
-
w(' // never allowed to ask.');
|
|
1058
|
+
w(' // Add any other command whose output you would notice changing. Each entry also takes:');
|
|
1059
|
+
w(' // cwd, stdin, env, timeoutMs, and irreversible: true for a command that would spend');
|
|
1060
|
+
w(' // money or send a message — that one is watched asking and never allowed to ask.');
|
|
905
1061
|
} else {
|
|
906
1062
|
w(" // commands: [{ name: 'help', run: 'node bin/cli.js --help', describe: 'print the help' }],");
|
|
907
1063
|
w(' commands: [],');
|
|
908
1064
|
}
|
|
1065
|
+
for (const one of unbuilt) {
|
|
1066
|
+
const build = typeof one.suggest?.buildWith === 'string' ? String(one.suggest.buildWith) : null;
|
|
1067
|
+
w('');
|
|
1068
|
+
w(` // ${one.where}/ holds a real command-line program that nothing in package.json names, so it`);
|
|
1069
|
+
w(' // was found by reading the code rather than the manifest — and it has not been built here');
|
|
1070
|
+
w(` // yet${one.suggest?.outDir ? `, so ${String(one.suggest.outDir)}/ is empty` : ''}. There is nothing to run until it is:`);
|
|
1071
|
+
w(` // ${build ?? 'build it the way this project builds it'}`);
|
|
1072
|
+
w(' // staysfixed init --force');
|
|
1073
|
+
w(' // The second line fills these commands in exactly from what the build wrote. Nothing');
|
|
1074
|
+
w(' // in this file has been edited by hand yet, so nothing is lost by rewriting it.');
|
|
1075
|
+
}
|
|
909
1076
|
const imports = /** @type {any[]} */ (library?.suggest?.imports ?? []);
|
|
1077
|
+
w('');
|
|
910
1078
|
w(' // Modules to import and compare the exports of.');
|
|
911
1079
|
if (imports.length > 0) {
|
|
912
1080
|
w(' imports: [');
|
|
@@ -926,13 +1094,33 @@ export function configText(project) {
|
|
|
926
1094
|
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
927
1095
|
w(server ? ' http: {' : ' // http: {');
|
|
928
1096
|
const httpOn = server ? ' ' : ' // ';
|
|
1097
|
+
if (server && server.where !== '.') {
|
|
1098
|
+
w(`${httpOn}// This is ${server.name}. ${server.why}`);
|
|
1099
|
+
}
|
|
929
1100
|
w(`${httpOn}// The command that starts it. It must listen on the PORT it is given.`);
|
|
930
1101
|
const httpStart = server?.suggest?.start;
|
|
931
|
-
|
|
932
|
-
|
|
933
|
-
|
|
934
|
-
|
|
935
|
-
|
|
1102
|
+
if (httpStart) {
|
|
1103
|
+
w(`${httpOn}start: ${JSON.stringify(String(httpStart))},`);
|
|
1104
|
+
} else {
|
|
1105
|
+
// The one thing that has to be filled in, and where the answer already is. A server found
|
|
1106
|
+
// by reading the code usually has a deploy script or a container file beside it that says
|
|
1107
|
+
// exactly how it is built and run — naming that file is the difference between work an
|
|
1108
|
+
// agent can finish on its own and a question somebody has to go and answer.
|
|
1109
|
+
for (const line of server?.blockers ?? []) for (const wrapped of wrapProse(line, 76)) w(`${httpOn}// ${wrapped}`);
|
|
1110
|
+
w(`${httpOn}// start: 'npm start',`);
|
|
1111
|
+
}
|
|
1112
|
+
if (server && server.suggest?.stateless === true) {
|
|
1113
|
+
w(`${httpOn}// NO "restore", and that is an answer rather than something left out. A restore command`);
|
|
1114
|
+
w(`${httpOn}// puts the data back so both builds see the same rows — and nothing this server installs`);
|
|
1115
|
+
w(`${httpOn}// stores anything, and there is no database beside it, so there is nothing to put back.`);
|
|
1116
|
+
w(`${httpOn}// If it does keep something in a way nothing here recognised, add it:`);
|
|
1117
|
+
w(`${httpOn}// restore: 'npm run db:reset',`);
|
|
1118
|
+
} else {
|
|
1119
|
+
w(`${httpOn}// A command that puts the data back how it was, so both builds see the same rows.`);
|
|
1120
|
+
w(`${httpOn}// Without it the second run sees whatever the first one wrote. A command that looks`);
|
|
1121
|
+
w(`${httpOn}// like it destroys data it cannot rebuild is refused rather than run.`);
|
|
1122
|
+
w(`${httpOn}// restore: 'npm run db:reset',`);
|
|
1123
|
+
}
|
|
936
1124
|
w(`${httpOn}// One real value per changing part of a route address. A route with a part nobody`);
|
|
937
1125
|
w(`${httpOn}// has given a value for is reported as never looked at, never quietly skipped.`);
|
|
938
1126
|
w(`${httpOn}// samples: { id: '1', slug: 'a-real-one' },`);
|
|
@@ -968,6 +1156,12 @@ export function configText(project) {
|
|
|
968
1156
|
const webStart = web?.suggest?.start;
|
|
969
1157
|
const flatSite = !webStart && Array.isArray(web?.suggest?.screens) && web.suggest.screens.length > 0;
|
|
970
1158
|
if (webStart) {
|
|
1159
|
+
// Why this command and not the obvious one. A development server never exits, serves
|
|
1160
|
+
// unbundled source, and wires a live-reload connection into every page — a second thing
|
|
1161
|
+
// moving under the comparison for reasons that have nothing to do with the change. So
|
|
1162
|
+
// wherever there is a way to build and then serve the build, that is what is written, and
|
|
1163
|
+
// the reason is written beside it.
|
|
1164
|
+
if (web?.startNote) for (const line of wrapProse(web.startNote, 76)) w(`${webOn}// ${line}`);
|
|
971
1165
|
w(`${webOn}start: ${JSON.stringify(String(webStart))},`);
|
|
972
1166
|
} else if (flatSite) {
|
|
973
1167
|
w(`${webOn}// This is a site made of files rather than a program, so anything that serves this`);
|
|
@@ -981,12 +1175,26 @@ export function configText(project) {
|
|
|
981
1175
|
w(`${webOn}// url: 'http://localhost:3000',`);
|
|
982
1176
|
const screens = /** @type {any[]} */ (web?.suggest?.screens ?? []);
|
|
983
1177
|
if (screens.length > 0) {
|
|
984
|
-
|
|
1178
|
+
// WHERE THESE CAME FROM, and it is the most important comment in this file. Reading the
|
|
1179
|
+
// folder names finds one screen in a single-page app and reports it as the whole product.
|
|
1180
|
+
// So the router is read instead; and where there is no router at all, the screens are read
|
|
1181
|
+
// off the strip of tabs that switches between them and reached by CLICKING, because a
|
|
1182
|
+
// made-up address would land on the same screen every time and report it as checked.
|
|
1183
|
+
if (web?.router?.why) for (const line of wrapProse(web.router.why, 76)) w(`${webOn}// ${line}`);
|
|
985
1184
|
w(`${webOn}screens: [`);
|
|
986
1185
|
for (const screen of screens.slice(0, 40)) {
|
|
987
|
-
|
|
1186
|
+
const steps = Array.isArray(screen.steps) && screen.steps.length > 0
|
|
1187
|
+
? `, steps: [${screen.steps.map((/** @type {Record<string, string>} */ step) => `{ ${Object.entries(step).map(([key, value]) => `${key}: ${JSON.stringify(String(value))}`).join(', ')} }`).join(', ')}]`
|
|
1188
|
+
: '';
|
|
1189
|
+
const describe = typeof screen.describe === 'string' ? `, describe: ${JSON.stringify(String(screen.describe))}` : '';
|
|
1190
|
+
w(`${webOn} { name: ${JSON.stringify(String(screen.name))}, url: ${JSON.stringify(String(screen.url))}${steps}${describe} },`);
|
|
988
1191
|
}
|
|
989
1192
|
w(`${webOn}],`);
|
|
1193
|
+
if (screens.some((screen) => Array.isArray(screen.steps) && screen.steps.length > 0)) {
|
|
1194
|
+
w(`${webOn}// A click that finds nothing is reported as a screen that was NOT looked at, never as`);
|
|
1195
|
+
w(`${webOn}// a screen that was fine. If one of these names is not what the control actually says,`);
|
|
1196
|
+
w(`${webOn}// the run says so by name and the fix is one word here.`);
|
|
1197
|
+
}
|
|
990
1198
|
} else if (project.pages.length > 0) {
|
|
991
1199
|
w(`${webOn}// ${project.pages.length} page address${project.pages.length === 1 ? '' : 'es'} are read out of your folder names automatically — nothing to list here.`);
|
|
992
1200
|
w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
|
|
@@ -994,8 +1202,19 @@ export function configText(project) {
|
|
|
994
1202
|
} else {
|
|
995
1203
|
w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
|
|
996
1204
|
}
|
|
997
|
-
|
|
998
|
-
|
|
1205
|
+
const waiting = /** @type {{url: string, names: string[]}[]} */ (web?.suggest?.screensNeedingValues ?? []);
|
|
1206
|
+
if (waiting.length > 0) {
|
|
1207
|
+
const names = [...new Set(waiting.flatMap((one) => one.names))];
|
|
1208
|
+
w(`${webOn}// ${waiting.length === 1 ? 'One more address is' : `${waiting.length} more addresses are`} declared and NOT in the list above, because ${waiting.length === 1 ? 'it has' : 'they have'} a part that`);
|
|
1209
|
+
w(`${webOn}// changes — an id, a slug — and only somebody who knows the data knows a value that really`);
|
|
1210
|
+
w(`${webOn}// exists. ${waiting.slice(0, 6).map((one) => one.url).join(', ')}${waiting.length > 6 ? ' and others' : ''}.`);
|
|
1211
|
+
w(`${webOn}// Fill one value in per name and they start being opened. Until then they are reported as`);
|
|
1212
|
+
w(`${webOn}// never looked at, which is the point of naming them here rather than dropping them.`);
|
|
1213
|
+
w(`${webOn}// samples: { ${names.slice(0, 4).map((name) => `${name}: 'a-real-one'`).join(', ')} },`);
|
|
1214
|
+
} else {
|
|
1215
|
+
w(`${webOn}// One real value per changing part of a page address.`);
|
|
1216
|
+
w(`${webOn}// samples: { slug: 'a-real-one' },`);
|
|
1217
|
+
}
|
|
999
1218
|
w(`${webOn}// Also: viewport { width, height, deviceScaleFactor }, colorScheme, timezone, locale,`);
|
|
1000
1219
|
w(`${webOn}// allowHosts (addresses the page is allowed to reach), refuse, allowWrites,`);
|
|
1001
1220
|
w(`${webOn}// timeoutMs, settleTimeoutMs, startTimeoutMs, env, nodeEnv, restore, everyStep.`);
|
|
@@ -1014,16 +1233,54 @@ export function configText(project) {
|
|
|
1014
1233
|
w(`${elOn}// The built app. On a Mac that is the .app; on Windows the .exe.`);
|
|
1015
1234
|
const binary = electron?.suggest?.binary;
|
|
1016
1235
|
w(binary ? `${elOn}binary: ${JSON.stringify(String(binary))},` : `${elOn}// binary: 'release/mac-arm64/Your App.app',`);
|
|
1017
|
-
|
|
1018
|
-
|
|
1019
|
-
|
|
1020
|
-
|
|
1021
|
-
|
|
1022
|
-
|
|
1023
|
-
|
|
1236
|
+
const appId = electron?.suggest?.appId;
|
|
1237
|
+
if (appId) {
|
|
1238
|
+
w(`${elOn}// The application id, read out of your own packaging config. It is how the run tells the`);
|
|
1239
|
+
w(`${elOn}// window it opened from a window of the same app that was already on your screen.`);
|
|
1240
|
+
w(`${elOn}appId: ${JSON.stringify(String(appId))},`);
|
|
1241
|
+
}
|
|
1242
|
+
// The identity question, answered rather than asked. See identityVariables() for why this
|
|
1243
|
+
// is the shape it is: the whole point of a set-up list is that every line on it can be
|
|
1244
|
+
// ticked off, and a line asking for a variable that does not exist can never be.
|
|
1245
|
+
const identity = electron ? identityVariables(project.envNames) : [];
|
|
1246
|
+
if (identity.length > 0) {
|
|
1247
|
+
w(`${elOn}// This app reads ${identity.length === 1 ? 'a setting' : 'settings'} that could be how it says who it is: ${identity.slice(0, 4).join(', ')}.`);
|
|
1248
|
+
w(`${elOn}// If one of them carries a device or machine id, name it here and every run gets its own,`);
|
|
1249
|
+
w(`${elOn}// so two runs never claim one slot and fight over it — which looks exactly like a bug.`);
|
|
1250
|
+
w(`${elOn}// identityEnv: { ${identity[0]}: '{identity}' },`);
|
|
1251
|
+
} else if (electron && project.doors.read) {
|
|
1252
|
+
w(`${elOn}// NO "identityEnv", and this is an answer rather than something left out. If an app tells`);
|
|
1253
|
+
w(`${elOn}// a server who it is with a device id from its environment, two runs would claim one slot`);
|
|
1254
|
+
w(`${elOn}// and fight over it — that exact bug has happened to a real product. Every setting this app`);
|
|
1255
|
+
w(`${elOn}// reads out of its environment was listed by name, and not one of them carries a device or`);
|
|
1256
|
+
w(`${elOn}// machine id, so there is nothing to pass through. Every run already gets a settings folder`);
|
|
1257
|
+
w(`${elOn}// of its own, which is where an identity generated at first start would live.`);
|
|
1258
|
+
} else {
|
|
1259
|
+
w(`${elOn}// If your app tells a server who it is, name the setting that carries the id and it`);
|
|
1260
|
+
w(`${elOn}// is given a different one per run. Without this, two runs can claim the same slot`);
|
|
1261
|
+
w(`${elOn}// and fight over it — which looks exactly like a bug in your product.`);
|
|
1262
|
+
w(`${elOn}// identityEnv: { YOUR_APP_DEVICE_ID: '{identity}' },`);
|
|
1263
|
+
}
|
|
1264
|
+
const askable = electron ? channelsSafeToAsk(project.channels) : { safe: [], skipped: 0 };
|
|
1265
|
+
w(`${elOn}// Private channels asked to answer. Each becomes a journey of its own, so a channel that`);
|
|
1266
|
+
w(`${elOn}// stops ANSWERING is caught and not only one that stops existing.`);
|
|
1267
|
+
if (askable.safe.length > 0) {
|
|
1268
|
+
w(`${elOn}// These were picked out of the ${project.doors.ipc} channels in your source by NAME — every one of them`);
|
|
1269
|
+
w(`${elOn}// asks for something rather than doing something: get, list, status, read, about. The other`);
|
|
1270
|
+
w(`${elOn}// ${askable.skipped} were left out because their names say they write, or that they carry a secret. That is a`);
|
|
1271
|
+
w(`${elOn}// reading of a name and not a promise: if one of these turns out to change something,`);
|
|
1272
|
+
w(`${elOn}// delete the line. Nothing is ever asked of a channel that is not written here.`);
|
|
1273
|
+
w(`${elOn}exercise: [`);
|
|
1274
|
+
for (const line of chunk(askable.safe, 4)) w(`${elOn} ${line.map((name) => JSON.stringify(name)).join(', ')},`);
|
|
1275
|
+
w(`${elOn}],`);
|
|
1276
|
+
} else {
|
|
1277
|
+
w(`${elOn}// A channel is only ever asked when it is named here, because knocking on an unknown door`);
|
|
1278
|
+
w(`${elOn}// could do anything.`);
|
|
1279
|
+
w(`${elOn}// exercise: ['settings:read', 'sessions:list'],`);
|
|
1280
|
+
}
|
|
1024
1281
|
w(`${elOn}// Walks through the window itself.`);
|
|
1025
1282
|
w(`${elOn}// journeys: [{ name: 'opening a session', steps: [{ click: 'New session' }] }],`);
|
|
1026
|
-
w(`${elOn}// Also: appId, args, env, windowMatch, startTimeoutMs, settleTries, settleGapMs.`);
|
|
1283
|
+
w(`${elOn}// Also: ${appId ? '' : 'appId, '}args, env, windowMatch, startTimeoutMs, settleTries, settleGapMs.`);
|
|
1027
1284
|
w(electron ? ' },' : ' // },');
|
|
1028
1285
|
w('');
|
|
1029
1286
|
|
|
@@ -1072,10 +1329,45 @@ export function configText(project) {
|
|
|
1072
1329
|
w(`${winOn}// Also: args, cwd, journeys.`);
|
|
1073
1330
|
w(windowsHere ? ' },' : ' // },');
|
|
1074
1331
|
w('');
|
|
1075
|
-
|
|
1076
|
-
|
|
1077
|
-
|
|
1078
|
-
|
|
1332
|
+
|
|
1333
|
+
// ── ios ───────────────────────────────────────────────────────────────────
|
|
1334
|
+
const iosHere = ios?.adapter === 'ios';
|
|
1335
|
+
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
1336
|
+
w(' // iPhone and iPad apps. Installed on a simulator, opened, and read — the same');
|
|
1337
|
+
w(' // roles, names and states a person hears read out to them. One build at a time.');
|
|
1338
|
+
w(' // Two builds on a real phone in your hand can never be compared side by side,');
|
|
1339
|
+
w(' // on any machine, ever — that is a fact about phones, not about this tool.');
|
|
1340
|
+
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
1341
|
+
w(iosHere ? ' ios: {' : ' // ios: {');
|
|
1342
|
+
const iosOn = iosHere ? ' ' : ' // ';
|
|
1343
|
+
const iosApp = ios?.suggest?.app;
|
|
1344
|
+
w(`${iosOn}// The built app bundle for the simulator. Left out, it looks where builds land.`);
|
|
1345
|
+
w(iosApp ? `${iosOn}app: ${JSON.stringify(String(iosApp))},` : `${iosOn}// app: 'build/Debug-iphonesimulator/YourApp.app',`);
|
|
1346
|
+
w(`${iosOn}// Which simulator to use, and which system to run it on. Left out, it takes a sensible`);
|
|
1347
|
+
w(`${iosOn}// one and says which.`);
|
|
1348
|
+
w(`${iosOn}// deviceType: 'iPhone 17', runtime: 'iOS 26.4',`);
|
|
1349
|
+
w(`${iosOn}// Walks through the app. Left out, it opens the app and reads the first screen.`);
|
|
1350
|
+
w(`${iosOn}// journeys: [{ name: 'signing in', steps: [{ tap: 'Sign in' }] }],`);
|
|
1351
|
+
w(`${iosOn}// Addresses to open the app with, for a screen that is reached by a link.`);
|
|
1352
|
+
w(`${iosOn}// openUrls: ['yourapp://sessions'],`);
|
|
1353
|
+
w(`${iosOn}// Also: device, appearance, reset, logProcess.`);
|
|
1354
|
+
w(iosHere ? ' },' : ' // },');
|
|
1355
|
+
w('');
|
|
1356
|
+
|
|
1357
|
+
// Anything this repository makes that this copy of the tool has nothing to drive. Read from
|
|
1358
|
+
// what is actually loaded, never stated. A sentence hard-coded here would go on telling
|
|
1359
|
+
// somebody their iPhone app cannot be checked on the day the adapter that checks it landed —
|
|
1360
|
+
// and it would contradict the readiness this same command printed two lines earlier.
|
|
1361
|
+
const undrivable = project.products.filter((product) => product.adapter === null);
|
|
1362
|
+
if (undrivable.length > 0) {
|
|
1363
|
+
const one = undrivable.length === 1;
|
|
1364
|
+
w(` // ${one ? 'One thing this repository makes has no section here' : `${undrivable.length} things this repository makes have no section here`}, and that is not an oversight.`);
|
|
1365
|
+
w(` // This copy of the tool has nothing in it that can drive ${one ? 'it' : 'them'}, so a setting would do`);
|
|
1366
|
+
w(` // nothing at all. ${one ? 'It is' : 'They are'} not being checked, \`staysfixed init\` says so every time it`);
|
|
1367
|
+
w(` // runs, and every clean result stays silent about ${one ? 'it' : 'them'}:`);
|
|
1368
|
+
for (const product of undrivable) {
|
|
1369
|
+
w(` // ${padTo(product.name, 30)} ${PRODUCT_KINDS[product.kind].what}`);
|
|
1370
|
+
}
|
|
1079
1371
|
w('');
|
|
1080
1372
|
}
|
|
1081
1373
|
|
|
@@ -1142,8 +1434,18 @@ function nextCommands(readiness, project) {
|
|
|
1142
1434
|
/** @type {{command: string, what: string}[]} */
|
|
1143
1435
|
const next = [];
|
|
1144
1436
|
next.push({ command: 'staysfixed doctor --json', what: 'What this machine can and cannot drive, as one object. The first call an agent should make.' });
|
|
1145
|
-
|
|
1146
|
-
|
|
1437
|
+
// The first run is worth taking as soon as ANYTHING here can be reached, not only once
|
|
1438
|
+
// everything can. Holding it back until every product is ready meant a project waiting on
|
|
1439
|
+
// one sample value was never told to start, and a first run that records three products out
|
|
1440
|
+
// of four is three products more than nothing — the reply says which ones it left out.
|
|
1441
|
+
const reachable = readiness.filter((r) => r.state !== 'not possible here');
|
|
1442
|
+
if (reachable.length > 0) {
|
|
1443
|
+
next.push({
|
|
1444
|
+
command: 'staysfixed check --paired',
|
|
1445
|
+
what: reachable.some((r) => r.state === 'ready')
|
|
1446
|
+
? 'The first real run. It records what working looks like, so later runs have something to compare against.'
|
|
1447
|
+
: 'The first real run. Nothing here is fully set up yet, so it records what it can reach and says plainly what it left out — which is more useful than waiting.',
|
|
1448
|
+
});
|
|
1147
1449
|
}
|
|
1148
1450
|
if (project.tests.files > 0) {
|
|
1149
1451
|
next.push({ command: 'staysfixed check --journeys suite', what: `Walk the ${project.tests.files} test${project.tests.files === 1 ? '' : 's'} this project already has, under instrumentation.` });
|
|
@@ -1392,3 +1694,114 @@ export async function run(ctx) {
|
|
|
1392
1694
|
// the folder inside a project is called — both owned by src/core/paths.js, both re-stated
|
|
1393
1695
|
// here because init is the one command whose whole job is those two facts.
|
|
1394
1696
|
export { CONFIG_NAMES, DEFAULT_DIR };
|
|
1697
|
+
|
|
1698
|
+
/**
|
|
1699
|
+
* Which private channels are safe to knock on, read off their names.
|
|
1700
|
+
*
|
|
1701
|
+
* THE RULE THIS BENDS, AND WHY IT IS STILL THE RIGHT CALL. A channel between a desktop app's
|
|
1702
|
+
* two halves is only ever asked to answer when it is named in the settings, because knocking
|
|
1703
|
+
* on an unknown door could do anything. That rule stays. What changes is who does the naming:
|
|
1704
|
+
* leaving the list empty means somebody reads four hundred and fifty registrations by hand
|
|
1705
|
+
* before a single one of them is watched, and until they do, a channel that stops answering is
|
|
1706
|
+
* invisible. Nobody does that, so nothing gets watched.
|
|
1707
|
+
*
|
|
1708
|
+
* So the list is filled in from names that ASK for something rather than do something, and the
|
|
1709
|
+
* settings say plainly that it is a reading of a name rather than a promise about behaviour.
|
|
1710
|
+
* Three lines hold it:
|
|
1711
|
+
*
|
|
1712
|
+
* - only channels that hand a value back — a listener that answers nothing has nothing to
|
|
1713
|
+
* compare, and asking it is all risk and no reading;
|
|
1714
|
+
* - only names whose last word is one of a short list of asking words;
|
|
1715
|
+
* - and never a name with a secret in it. `browser-password:get` reads as an asking word and
|
|
1716
|
+
* would put somebody's password into a stored observation, which is the one mistake here
|
|
1717
|
+
* that cannot be taken back by deleting a line.
|
|
1718
|
+
*
|
|
1719
|
+
* @param {ProjectShape['channels']} channels
|
|
1720
|
+
* @returns {{safe: string[], skipped: number}}
|
|
1721
|
+
*/
|
|
1722
|
+
export function channelsSafeToAsk(channels) {
|
|
1723
|
+
const asks = /(^|[:.\-/])(get|list|read|status|state|about|info|paths?|version|capabilities|count|summary|describe|available)$/i;
|
|
1724
|
+
// Anything whose name says it DOES something, wherever in the name it appears — not only at
|
|
1725
|
+
// the end. `settings:open-path` ends in a word that reads like asking and opens a window in
|
|
1726
|
+
// front of somebody; `chrome-import:open-privacy-settings` ends in "settings" and opens a
|
|
1727
|
+
// browser page. Both got through a rule that only looked at the last word, and both are the
|
|
1728
|
+
// kind of mistake that has to be impossible rather than unlikely.
|
|
1729
|
+
const acts = /(^|[:.\-/])(open|set|write|save|delete|remove|clear|reset|start|stop|launch|install|uninstall|send|import|export|sync|run|kill|restart|approve|revoke|pair|unpair|update|create|add|apply|move|rename|copy|quit|close|sign|login|logout|connect|disconnect|enable|disable|toggle|upload|download|share|unshare|grant|deny|prompt|ask|select|choose|pick|reveal|focus|show|hide)([:.\-/]|$)/i;
|
|
1730
|
+
// And anything that could hand back something private. A stored observation is written to
|
|
1731
|
+
// disk and read by an agent; a password or an ssh key in one is the single mistake here that
|
|
1732
|
+
// cannot be undone by deleting a line afterwards. Erring wide costs a channel going
|
|
1733
|
+
// unwatched, which is visible in the coverage ledger. Erring narrow costs a secret.
|
|
1734
|
+
const secret = /(password|secret|token|credential|api-?key|\bkeys?\b|keychain|passphrase|cookie|auth|login|account|identity|session-?id|private)/i;
|
|
1735
|
+
/** @type {string[]} */
|
|
1736
|
+
const safe = [];
|
|
1737
|
+
let skipped = 0;
|
|
1738
|
+
for (const channel of channels) {
|
|
1739
|
+
if (!channel.answers) {
|
|
1740
|
+
skipped += 1;
|
|
1741
|
+
continue;
|
|
1742
|
+
}
|
|
1743
|
+
if (secret.test(channel.name) || acts.test(channel.name) || !asks.test(channel.name)) {
|
|
1744
|
+
skipped += 1;
|
|
1745
|
+
continue;
|
|
1746
|
+
}
|
|
1747
|
+
safe.push(channel.name);
|
|
1748
|
+
}
|
|
1749
|
+
safe.sort();
|
|
1750
|
+
// A ceiling, because a settings file with four hundred lines of one array in it is a file
|
|
1751
|
+
// nobody scrolls past, and the coverage ledger names what was left out either way.
|
|
1752
|
+
const most = 60;
|
|
1753
|
+
if (safe.length > most) skipped += safe.length - most;
|
|
1754
|
+
return { safe: safe.slice(0, most), skipped };
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
/**
|
|
1758
|
+
* A name padded out so a column of them lines up. Purely so a person can read the list.
|
|
1759
|
+
*
|
|
1760
|
+
* @param {string} text
|
|
1761
|
+
* @param {number} width
|
|
1762
|
+
* @returns {string}
|
|
1763
|
+
*/
|
|
1764
|
+
function padTo(text, width) {
|
|
1765
|
+
return text.length >= width ? text : text + ' '.repeat(width - text.length);
|
|
1766
|
+
}
|
|
1767
|
+
|
|
1768
|
+
/**
|
|
1769
|
+
* A sentence broken into lines that fit inside a comment.
|
|
1770
|
+
*
|
|
1771
|
+
* @param {string} text
|
|
1772
|
+
* @param {number} width
|
|
1773
|
+
* @returns {string[]}
|
|
1774
|
+
*/
|
|
1775
|
+
function wrapProse(text, width) {
|
|
1776
|
+
/** @type {string[]} */
|
|
1777
|
+
const lines = [];
|
|
1778
|
+
/** @type {string[]} */
|
|
1779
|
+
let current = [];
|
|
1780
|
+
let length = 0;
|
|
1781
|
+
for (const word of String(text).split(/\s+/).filter(Boolean)) {
|
|
1782
|
+
if (length > 0 && length + 1 + word.length > width) {
|
|
1783
|
+
lines.push(current.join(' '));
|
|
1784
|
+
current = [];
|
|
1785
|
+
length = 0;
|
|
1786
|
+
}
|
|
1787
|
+
current.push(word);
|
|
1788
|
+
length += (length > 0 ? 1 : 0) + word.length;
|
|
1789
|
+
}
|
|
1790
|
+
if (current.length > 0) lines.push(current.join(' '));
|
|
1791
|
+
return lines;
|
|
1792
|
+
}
|
|
1793
|
+
|
|
1794
|
+
/**
|
|
1795
|
+
* A list broken into rows of a size, so a long array reads as a block rather than a column.
|
|
1796
|
+
*
|
|
1797
|
+
* @template T
|
|
1798
|
+
* @param {T[]} items
|
|
1799
|
+
* @param {number} size
|
|
1800
|
+
* @returns {T[][]}
|
|
1801
|
+
*/
|
|
1802
|
+
function chunk(items, size) {
|
|
1803
|
+
/** @type {T[][]} */
|
|
1804
|
+
const rows = [];
|
|
1805
|
+
for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
|
|
1806
|
+
return rows;
|
|
1807
|
+
}
|