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/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 name of the setting this app uses to know who it is',
389
- 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.',
390
- unlocks: 'running the old build and the new one safely, one after the other',
391
- fix: 'Look through the main process for an environment variable holding a device or machine id, and put {"identityEnv": {"THAT_VARIABLE": "{identity}"}} under "electron" in the settings. If the app has no such thing, delete the line and nothing is lost.',
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: 'identity',
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
- const needing = project.pages.filter((p) => p.needs.length > 0);
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.push({
441
- what: 'a way to put the data back how it was',
442
- 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.',
443
- unlocks: 'comparing two builds fairly instead of comparing two different sets of data',
444
- 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.',
445
- who: 'a person',
446
- product: product.name,
447
- topic: 'data',
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 page in this folder' : 'the pages in this folder',
725
- what: many === 1 ? 'open the single HTML file sitting in this folder' : `open each of the ${many} HTML files sitting in this folder`,
726
- from: 'the folder itself',
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: false,
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: 'open-the-app',
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: 'open-the-app',
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: src, lib, app, bin, server,');
878
- w(' // pages, api, electron, main, packages.');
879
- w(" // folders: ['src', 'lib'],");
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. Nothing is ever guessed here: a guess would mean running');
894
- w(' // something that deletes files. Add any command whose output you would notice changing.');
895
- const commands = /** @type {any[]} */ (cli?.suggest?.commands ?? []);
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(' // Each entry also takes: cwd, stdin, env, timeoutMs, and irreversible: true for a');
903
- w(' // command that would spend money or send a message that one is watched asking and');
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
- w(httpStart ? `${httpOn}start: ${JSON.stringify(String(httpStart))},` : `${httpOn}// start: 'npm start',`);
932
- w(`${httpOn}// A command that puts the data back how it was, so both builds see the same rows.`);
933
- w(`${httpOn}// Without it the second run sees whatever the first one wrote. A command that looks`);
934
- w(`${httpOn}// like it destroys data it cannot rebuild is refused rather than run.`);
935
- w(`${httpOn}// restore: 'npm run db:reset',`);
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
- w(`${webOn}// The pages to open. These are the HTML files found sitting in this folder.`);
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
- w(`${webOn} { name: ${JSON.stringify(String(screen.name))}, url: ${JSON.stringify(String(screen.url))} },`);
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
- w(`${webOn}// One real value per changing part of a page address.`);
998
- w(`${webOn}// samples: { slug: 'a-real-one' },`);
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
- w(`${elOn}// If your app tells a server who it is, name the setting that carries the id and it`);
1018
- w(`${elOn}// is given a different one per run. Without this, two runs can claim the same slot`);
1019
- w(`${elOn}// and fight over it which looks exactly like a bug in your product.`);
1020
- w(`${elOn}// identityEnv: { YOUR_APP_DEVICE_ID: '{identity}' },`);
1021
- w(`${elOn}// Private channels that are safe to ask — read-only ones. Each becomes its own`);
1022
- w(`${elOn}// journey, so a channel that stops answering is caught, not just one that stops existing.`);
1023
- w(`${elOn}// exercise: ['settings:read', 'sessions:list'],`);
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
- if (has('ios')) {
1076
- w(' // There is no "ios" section, and that is not an oversight: this copy of the tool has');
1077
- w(' // no iOS adapter in it, so a setting here would do nothing. The iPhone app in this');
1078
- w(' // repository is not being checked, and `staysfixed init` says so every time it runs.');
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
- if (readiness.some((r) => r.state === 'ready')) {
1146
- next.push({ command: 'staysfixed check --paired', what: 'The first real run. It records what working looks like, so later runs have something to compare against.' });
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
+ }