staysfixed 0.6.2 → 0.7.1

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,76 @@ 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
+ //
436
+ // `built.found === false` is NOT that on its own, and reading it as though it were sent a
437
+ // plain Node command-line tool's owner shopping for a build step it does not have. A
438
+ // script that runs straight from source is recorded as `found: false, how: "nothing to
439
+ // build — it runs from source"`, and this file used to ask a person to "name the command
440
+ // that builds it" about a file that was sitting right there and that the very same run
441
+ // had already worked out how to run. Two signals rule it out, and either is enough: the
442
+ // detector saying there is nothing to build, and a command already worked out for it.
443
+ const nothingToBuild = /nothing to build/i.test(String(product.built.how ?? ''));
444
+ const alreadyRunnable = Array.isArray(suggest.commands) && suggest.commands.length > 0;
445
+ if (product.kind === 'cli' && !product.built.found && !nothingToBuild && !alreadyRunnable) {
446
+ const build = typeof suggest.buildWith === 'string' ? String(suggest.buildWith) : null;
447
+ needs.push({
448
+ what: `${product.name}, built`,
449
+ 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.',
450
+ unlocks: 'every word of its help, what it exits with, and every file it touches',
451
+ fix: build
452
+ ? `${build}, then \`staysfixed init --force\` — the commands are filled in exactly from what the build wrote, and nothing has been edited by hand yet.`
453
+ : `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.`,
454
+ who: build ? 'the agent' : 'a person',
455
+ product: product.name,
456
+ topic: 'app',
395
457
  });
396
458
  }
397
459
 
@@ -410,7 +472,14 @@ function productNeeds(product, project) {
410
472
  topic: 'start',
411
473
  });
412
474
  }
413
- const needing = project.pages.filter((p) => p.needs.length > 0);
475
+ // Addresses with a changing part in them, whether they came from folder names or from a
476
+ // router. Both are the same problem and both go in one item, because being asked for the
477
+ // same thing twice under two headings is how somebody stops reading a list.
478
+ const fromRouter = /** @type {{url: string, names: string[]}[]} */ (suggest.screensNeedingValues ?? []);
479
+ const needing = [
480
+ ...project.pages.filter((p) => p.needs.length > 0),
481
+ ...fromRouter.map((one) => ({ url: one.url, file: 'the router', needs: one.names })),
482
+ ];
414
483
  if (needing.length > 0) {
415
484
  const names = [...new Set(needing.flatMap((p) => p.needs))];
416
485
  needs.push({
@@ -427,25 +496,35 @@ function productNeeds(product, project) {
427
496
 
428
497
  if (product.kind === 'server') {
429
498
  if (!suggest.start) {
499
+ // The product's own blocker where it has one: a server found by reading the code often
500
+ // has the answer written down beside it, in a deploy script or a container file. That
501
+ // is work for the agent — it can read the script — rather than a question for a person
502
+ // who would have to go and read the same script themselves.
503
+ const written = product.blockers.find((line) => /start/i.test(line) && /\.(sh|bash|ya?ml|mjs|js|ts)\b|Dockerfile/.test(line)) ?? null;
430
504
  needs.push({
431
505
  what: 'the command that starts the server',
432
506
  why: 'The routes can be listed by reading the code, but none of them can be asked anything until something is listening.',
433
507
  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',
508
+ fix: written ?? 'Put {"start": "..."} under "http" in the settings, and have it listen on the PORT it is given.',
509
+ who: project.scripts.start || written ? 'the agent' : 'a person',
436
510
  product: product.name,
437
511
  topic: 'start',
438
512
  });
439
513
  }
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
- });
514
+ // A server with nothing to store needs no way of putting anything back, and asking for
515
+ // one is the same fault as asking for a device id that does not exist: an item on a
516
+ // set-up list that can never be ticked off, sitting beside items that can.
517
+ if (suggest.stateless !== true) {
518
+ needs.push({
519
+ what: 'a way to put the data back how it was',
520
+ 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.',
521
+ unlocks: 'comparing two builds fairly instead of comparing two different sets of data',
522
+ 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.',
523
+ who: 'a person',
524
+ product: product.name,
525
+ topic: 'data',
526
+ });
527
+ }
449
528
  const withParts = project.routes.filter((route) => /:[A-Za-z_$]|\[[^\]]+\]|\{[^}]+\}/.test(route.name));
450
529
  if (withParts.length > 0) {
451
530
  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 +562,21 @@ function productNeeds(product, project) {
483
562
  return needs;
484
563
  }
485
564
 
565
+ /**
566
+ * Settings this app reads that could be how it says who it is.
567
+ *
568
+ * A guess about meaning, but never a guess about existence: every name here was read out of
569
+ * the project's own source, so the worst case is a question about a real variable rather than
570
+ * a request for one that was never there.
571
+ *
572
+ * @param {string[]} envNames
573
+ * @returns {string[]}
574
+ */
575
+ export function identityVariables(envNames) {
576
+ const looksLikeAnIdentity = /(DEVICE|MACHINE|INSTANCE|INSTALL|CLIENT|NODE|HOST|AGENT|PEER|REPLICA)[_-]?(ID|UUID|GUID|KEY|NAME|SLOT)\b/i;
577
+ return envNames.filter((name) => looksLikeAnIdentity.test(name));
578
+ }
579
+
486
580
  /**
487
581
  * Routes whose NAME says they do something that cannot be taken back.
488
582
  *
@@ -519,7 +613,14 @@ function machineNeeds(surface, product, covered) {
519
613
  product: product.name,
520
614
  }];
521
615
  }
616
+ /** @type {Set<string|undefined>} */
522
617
  const already = new Set(covered.map((need) => need.topic).filter(Boolean));
618
+ // A server with nothing to store has no data to put back, and doctor cannot know that: it
619
+ // answers "what is missing on this machine", so it asks for a way to restore a database on
620
+ // behalf of every server it sees. Telling somebody to install Docker for a switchboard that
621
+ // keeps nothing is the same fault as asking for a device id that does not exist — an item on
622
+ // a set-up list that can never be ticked off, sitting beside items that can.
623
+ if (product.suggest?.stateless === true) already.add('data');
523
624
  return surface.needs
524
625
  // Doctor answers "what is missing on this machine right now", and right now is before
525
626
  // this command has written anything. A need whose whole fix is "run staysfixed init",
@@ -593,6 +694,21 @@ function sortNeeds(readiness, project, machine) {
593
694
  who: 'the agent',
594
695
  });
595
696
  }
697
+ // Room to work. Three of the adapters copy the whole project into a scratch folder before
698
+ // running anything — which is right, a check must never write into somebody's working copy
699
+ // — and a repository carrying gigabytes of build output cannot be copied twice on a laptop
700
+ // that is nearly full. It is not a failure anybody could diagnose from the error: the copy
701
+ // simply stops. The whole answer is one command, and it is the agent's to run.
702
+ if (project.bulk.tooBig) {
703
+ all.push({
704
+ what: 'somewhere with room to copy this project into',
705
+ 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.`,
706
+ unlocks: 'every check that runs a command or boots a server or a website',
707
+ 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)`,
708
+ who: 'the agent',
709
+ topic: 'room',
710
+ });
711
+ }
596
712
  if (!project.isGitRepo) {
597
713
  all.push({
598
714
  what: 'this folder being a git repository',
@@ -676,7 +792,7 @@ export function proposeJourneys(project) {
676
792
  out.push({
677
793
  name: String(command.name),
678
794
  what: `run \`${String(command.run)}\` and compare what it printed, what it exited with and every file it touched`,
679
- from: 'package.json',
795
+ from: product.where === '.' ? 'package.json' : `the program built in ${product.where}/`,
680
796
  surface: 'cli',
681
797
  automatic: false,
682
798
  ready: true,
@@ -720,12 +836,22 @@ export function proposeJourneys(project) {
720
836
  }
721
837
  if (Array.isArray(suggest.screens) && suggest.screens.length > 0) {
722
838
  const many = suggest.screens.length;
839
+ const router = product.router?.kind ?? 'files';
840
+ // Where each screen came from decides what to call it, and one of these is the whole
841
+ // point: screens reached by clicking are NOT addresses, and a line that calls them
842
+ // pages would be describing something the run does not do.
843
+ const from = router === 'tabs' ? 'the strip of tabs in the source'
844
+ : router === 'hash' ? 'the router in the source'
845
+ : router === 'declared' ? 'the router in the source'
846
+ : 'the folder itself';
723
847
  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',
848
+ name: many === 1 ? 'the screen this app has' : 'every screen this app has',
849
+ what: router === 'tabs'
850
+ ? `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.`
851
+ : 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`,
852
+ from,
727
853
  surface: 'web',
728
- automatic: false,
854
+ automatic: router !== 'files',
729
855
  howMany: many,
730
856
  ready: Boolean(suggest.start),
731
857
  });
@@ -733,7 +859,7 @@ export function proposeJourneys(project) {
733
859
  }
734
860
  if (product.kind === 'android' && product.adapter === 'android') {
735
861
  out.push({
736
- name: 'open-the-app',
862
+ name: `open ${product.name}`,
737
863
  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
864
  from: 'the app itself',
739
865
  surface: 'android',
@@ -753,7 +879,7 @@ export function proposeJourneys(project) {
753
879
  }
754
880
  if (product.kind === 'electron') {
755
881
  out.push({
756
- name: 'open-the-app',
882
+ name: `open ${product.name}`,
757
883
  what: `open the app and read everything it shows and all ${project.doors.ipc} channels it registers`,
758
884
  from: 'the source',
759
885
  surface: 'electron',
@@ -830,11 +956,15 @@ function formatOf(file) {
830
956
  */
831
957
  export function configText(project) {
832
958
  const has = (/** @type {string} */ kind) => project.products.find((p) => p.kind === kind) ?? null;
959
+ // Several of a kind is normal, and it is the case that goes wrong quietly. One repository
960
+ // here makes two command-line programs; writing the first one's commands and stopping would
961
+ // have left the second unchecked with nothing anywhere saying so.
962
+ const all = (/** @type {string} */ kind) => project.products.filter((p) => p.kind === kind);
833
963
  const electron = has('electron');
834
964
  const web = has('web');
835
965
  const server = has('server');
836
- const cli = has('cli');
837
966
  const library = has('library');
967
+ const ios = has('ios');
838
968
 
839
969
  /** @type {string[]} */
840
970
  const out = [];
@@ -856,6 +986,27 @@ export function configText(project) {
856
986
  w(' *');
857
987
  w(' * EVERY OPTION THAT MATTERS IS IN THIS FILE. The ones that do not apply to this project are');
858
988
  w(' * commented out rather than left out, so nothing is hidden from you. Delete freely.');
989
+ if (project.products.length > 1) {
990
+ w(' *');
991
+ w(' * EACH ONE, AND WHAT SAID SO:');
992
+ for (const product of project.products) {
993
+ w(` * ${padTo(product.name, 34)} ${product.why}`);
994
+ }
995
+ }
996
+ if (project.bulk.tooBig || project.bulk.capped) {
997
+ w(' *');
998
+ w(' * BEFORE THE FIRST RUN, one fact about this folder. Commands, servers and websites are');
999
+ w(' * checked in a scratch COPY of the project, so a run can write anywhere it likes without');
1000
+ w(' * touching your working copy.');
1001
+ for (const line of wrapProse(project.bulk.why, 92)) w(` * ${line}`);
1002
+ if (project.bulk.tooBig) {
1003
+ w(' * So run the check somewhere with room, which is one command:');
1004
+ w(' *');
1005
+ w(` * git worktree add ../${path.basename(project.root)}-check HEAD`);
1006
+ w(` * cp ${path.basename(project.root)}/staysfixed.config.* ../${path.basename(project.root)}-check/`);
1007
+ w(` * cd ../${path.basename(project.root)}-check && npx staysfixed check`);
1008
+ }
1009
+ }
859
1010
  w(' */');
860
1011
  w('');
861
1012
  w('export default {');
@@ -874,9 +1025,16 @@ export function configText(project) {
874
1025
  w(' // This is the only channel that sees a door nobody has ever opened.');
875
1026
  w(' // ───────────────────────────────────────────────────────────────────────');
876
1027
  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'],");
1028
+ w(' // Folders to read. Left out, it reads the usual ones src, lib, app, bin, server,');
1029
+ w(' // pages, api, electron, main, packages — which is right for a repository that makes');
1030
+ w(' // one thing, and misses whole products in a repository that makes several. These are');
1031
+ w(' // the folders the products above actually live in, plus any folder of source that no');
1032
+ w(' // product claimed, because an unclaimed folder is exactly where a silent gap lives.');
1033
+ if (project.sourceFolders.length > 0) {
1034
+ w(` folders: [${project.sourceFolders.map((f) => JSON.stringify(f)).join(', ')}],`);
1035
+ } else {
1036
+ w(" // folders: ['src', 'lib'],");
1037
+ }
880
1038
  if (project.doors.read) {
881
1039
  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
1040
  }
@@ -890,23 +1048,43 @@ export function configText(project) {
890
1048
  w(' // connection recorded and then refused.');
891
1049
  w(' // ───────────────────────────────────────────────────────────────────────');
892
1050
  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 ?? []);
1051
+ w(' // Commands worth running. Only ever `--help`, and that is deliberate: a command listed');
1052
+ w(' // in a manifest could deploy, could publish, could wipe a database, and running one');
1053
+ w(' // because it was there would be this tool causing the very kind of damage it exists to');
1054
+ w(' // catch. Asking a program to describe itself is the one thing every command-line tool');
1055
+ w(' // does safely — and its help text is a precise description of everything it offers, so');
1056
+ w(' // a command that quietly disappears is caught by comparing it.');
1057
+ const cliProducts = all('cli');
1058
+ /** @type {any[]} */
1059
+ const commands = [];
1060
+ for (const one of cliProducts) for (const command of /** @type {any[]} */ (one.suggest?.commands ?? [])) commands.push(command);
1061
+ const unbuilt = cliProducts.filter((one) => !one.built.found);
896
1062
  if (commands.length > 0) {
897
1063
  w(' commands: [');
898
1064
  for (const command of commands) {
899
1065
  w(` { name: ${JSON.stringify(String(command.name))}, run: ${JSON.stringify(String(command.run))}, describe: ${JSON.stringify(String(command.describe ?? ''))} },`);
900
1066
  }
901
1067
  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.');
1068
+ w(' // Add any other command whose output you would notice changing. Each entry also takes:');
1069
+ w(' // cwd, stdin, env, timeoutMs, and irreversible: true for a command that would spend');
1070
+ w(' // money or send a message — that one is watched asking and never allowed to ask.');
905
1071
  } else {
906
1072
  w(" // commands: [{ name: 'help', run: 'node bin/cli.js --help', describe: 'print the help' }],");
907
1073
  w(' commands: [],');
908
1074
  }
1075
+ for (const one of unbuilt) {
1076
+ const build = typeof one.suggest?.buildWith === 'string' ? String(one.suggest.buildWith) : null;
1077
+ w('');
1078
+ w(` // ${one.where}/ holds a real command-line program that nothing in package.json names, so it`);
1079
+ w(' // was found by reading the code rather than the manifest — and it has not been built here');
1080
+ w(` // yet${one.suggest?.outDir ? `, so ${String(one.suggest.outDir)}/ is empty` : ''}. There is nothing to run until it is:`);
1081
+ w(` // ${build ?? 'build it the way this project builds it'}`);
1082
+ w(' // staysfixed init --force');
1083
+ w(' // The second line fills these commands in exactly from what the build wrote. Nothing');
1084
+ w(' // in this file has been edited by hand yet, so nothing is lost by rewriting it.');
1085
+ }
909
1086
  const imports = /** @type {any[]} */ (library?.suggest?.imports ?? []);
1087
+ w('');
910
1088
  w(' // Modules to import and compare the exports of.');
911
1089
  if (imports.length > 0) {
912
1090
  w(' imports: [');
@@ -926,13 +1104,33 @@ export function configText(project) {
926
1104
  w(' // ───────────────────────────────────────────────────────────────────────');
927
1105
  w(server ? ' http: {' : ' // http: {');
928
1106
  const httpOn = server ? ' ' : ' // ';
1107
+ if (server && server.where !== '.') {
1108
+ w(`${httpOn}// This is ${server.name}. ${server.why}`);
1109
+ }
929
1110
  w(`${httpOn}// The command that starts it. It must listen on the PORT it is given.`);
930
1111
  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',`);
1112
+ if (httpStart) {
1113
+ w(`${httpOn}start: ${JSON.stringify(String(httpStart))},`);
1114
+ } else {
1115
+ // The one thing that has to be filled in, and where the answer already is. A server found
1116
+ // by reading the code usually has a deploy script or a container file beside it that says
1117
+ // exactly how it is built and run — naming that file is the difference between work an
1118
+ // agent can finish on its own and a question somebody has to go and answer.
1119
+ for (const line of server?.blockers ?? []) for (const wrapped of wrapProse(line, 76)) w(`${httpOn}// ${wrapped}`);
1120
+ w(`${httpOn}// start: 'npm start',`);
1121
+ }
1122
+ if (server && server.suggest?.stateless === true) {
1123
+ w(`${httpOn}// NO "restore", and that is an answer rather than something left out. A restore command`);
1124
+ w(`${httpOn}// puts the data back so both builds see the same rows — and nothing this server installs`);
1125
+ w(`${httpOn}// stores anything, and there is no database beside it, so there is nothing to put back.`);
1126
+ w(`${httpOn}// If it does keep something in a way nothing here recognised, add it:`);
1127
+ w(`${httpOn}// restore: 'npm run db:reset',`);
1128
+ } else {
1129
+ w(`${httpOn}// A command that puts the data back how it was, so both builds see the same rows.`);
1130
+ w(`${httpOn}// Without it the second run sees whatever the first one wrote. A command that looks`);
1131
+ w(`${httpOn}// like it destroys data it cannot rebuild is refused rather than run.`);
1132
+ w(`${httpOn}// restore: 'npm run db:reset',`);
1133
+ }
936
1134
  w(`${httpOn}// One real value per changing part of a route address. A route with a part nobody`);
937
1135
  w(`${httpOn}// has given a value for is reported as never looked at, never quietly skipped.`);
938
1136
  w(`${httpOn}// samples: { id: '1', slug: 'a-real-one' },`);
@@ -968,6 +1166,12 @@ export function configText(project) {
968
1166
  const webStart = web?.suggest?.start;
969
1167
  const flatSite = !webStart && Array.isArray(web?.suggest?.screens) && web.suggest.screens.length > 0;
970
1168
  if (webStart) {
1169
+ // Why this command and not the obvious one. A development server never exits, serves
1170
+ // unbundled source, and wires a live-reload connection into every page — a second thing
1171
+ // moving under the comparison for reasons that have nothing to do with the change. So
1172
+ // wherever there is a way to build and then serve the build, that is what is written, and
1173
+ // the reason is written beside it.
1174
+ if (web?.startNote) for (const line of wrapProse(web.startNote, 76)) w(`${webOn}// ${line}`);
971
1175
  w(`${webOn}start: ${JSON.stringify(String(webStart))},`);
972
1176
  } else if (flatSite) {
973
1177
  w(`${webOn}// This is a site made of files rather than a program, so anything that serves this`);
@@ -981,12 +1185,26 @@ export function configText(project) {
981
1185
  w(`${webOn}// url: 'http://localhost:3000',`);
982
1186
  const screens = /** @type {any[]} */ (web?.suggest?.screens ?? []);
983
1187
  if (screens.length > 0) {
984
- w(`${webOn}// The pages to open. These are the HTML files found sitting in this folder.`);
1188
+ // WHERE THESE CAME FROM, and it is the most important comment in this file. Reading the
1189
+ // folder names finds one screen in a single-page app and reports it as the whole product.
1190
+ // So the router is read instead; and where there is no router at all, the screens are read
1191
+ // off the strip of tabs that switches between them and reached by CLICKING, because a
1192
+ // made-up address would land on the same screen every time and report it as checked.
1193
+ if (web?.router?.why) for (const line of wrapProse(web.router.why, 76)) w(`${webOn}// ${line}`);
985
1194
  w(`${webOn}screens: [`);
986
1195
  for (const screen of screens.slice(0, 40)) {
987
- w(`${webOn} { name: ${JSON.stringify(String(screen.name))}, url: ${JSON.stringify(String(screen.url))} },`);
1196
+ const steps = Array.isArray(screen.steps) && screen.steps.length > 0
1197
+ ? `, steps: [${screen.steps.map((/** @type {Record<string, string>} */ step) => `{ ${Object.entries(step).map(([key, value]) => `${key}: ${JSON.stringify(String(value))}`).join(', ')} }`).join(', ')}]`
1198
+ : '';
1199
+ const describe = typeof screen.describe === 'string' ? `, describe: ${JSON.stringify(String(screen.describe))}` : '';
1200
+ w(`${webOn} { name: ${JSON.stringify(String(screen.name))}, url: ${JSON.stringify(String(screen.url))}${steps}${describe} },`);
988
1201
  }
989
1202
  w(`${webOn}],`);
1203
+ if (screens.some((screen) => Array.isArray(screen.steps) && screen.steps.length > 0)) {
1204
+ w(`${webOn}// A click that finds nothing is reported as a screen that was NOT looked at, never as`);
1205
+ w(`${webOn}// a screen that was fine. If one of these names is not what the control actually says,`);
1206
+ w(`${webOn}// the run says so by name and the fix is one word here.`);
1207
+ }
990
1208
  } else if (project.pages.length > 0) {
991
1209
  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
1210
  w(`${webOn}// Add a screen only for something a walk has to DO rather than just open:`);
@@ -994,8 +1212,19 @@ export function configText(project) {
994
1212
  } else {
995
1213
  w(`${webOn}// screens: [{ name: 'the front page', url: '/' }],`);
996
1214
  }
997
- w(`${webOn}// One real value per changing part of a page address.`);
998
- w(`${webOn}// samples: { slug: 'a-real-one' },`);
1215
+ const waiting = /** @type {{url: string, names: string[]}[]} */ (web?.suggest?.screensNeedingValues ?? []);
1216
+ if (waiting.length > 0) {
1217
+ const names = [...new Set(waiting.flatMap((one) => one.names))];
1218
+ 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`);
1219
+ w(`${webOn}// changes — an id, a slug — and only somebody who knows the data knows a value that really`);
1220
+ w(`${webOn}// exists. ${waiting.slice(0, 6).map((one) => one.url).join(', ')}${waiting.length > 6 ? ' and others' : ''}.`);
1221
+ w(`${webOn}// Fill one value in per name and they start being opened. Until then they are reported as`);
1222
+ w(`${webOn}// never looked at, which is the point of naming them here rather than dropping them.`);
1223
+ w(`${webOn}// samples: { ${names.slice(0, 4).map((name) => `${name}: 'a-real-one'`).join(', ')} },`);
1224
+ } else {
1225
+ w(`${webOn}// One real value per changing part of a page address.`);
1226
+ w(`${webOn}// samples: { slug: 'a-real-one' },`);
1227
+ }
999
1228
  w(`${webOn}// Also: viewport { width, height, deviceScaleFactor }, colorScheme, timezone, locale,`);
1000
1229
  w(`${webOn}// allowHosts (addresses the page is allowed to reach), refuse, allowWrites,`);
1001
1230
  w(`${webOn}// timeoutMs, settleTimeoutMs, startTimeoutMs, env, nodeEnv, restore, everyStep.`);
@@ -1014,16 +1243,54 @@ export function configText(project) {
1014
1243
  w(`${elOn}// The built app. On a Mac that is the .app; on Windows the .exe.`);
1015
1244
  const binary = electron?.suggest?.binary;
1016
1245
  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'],`);
1246
+ const appId = electron?.suggest?.appId;
1247
+ if (appId) {
1248
+ w(`${elOn}// The application id, read out of your own packaging config. It is how the run tells the`);
1249
+ w(`${elOn}// window it opened from a window of the same app that was already on your screen.`);
1250
+ w(`${elOn}appId: ${JSON.stringify(String(appId))},`);
1251
+ }
1252
+ // The identity question, answered rather than asked. See identityVariables() for why this
1253
+ // is the shape it is: the whole point of a set-up list is that every line on it can be
1254
+ // ticked off, and a line asking for a variable that does not exist can never be.
1255
+ const identity = electron ? identityVariables(project.envNames) : [];
1256
+ if (identity.length > 0) {
1257
+ 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(', ')}.`);
1258
+ w(`${elOn}// If one of them carries a device or machine id, name it here and every run gets its own,`);
1259
+ w(`${elOn}// so two runs never claim one slot and fight over it — which looks exactly like a bug.`);
1260
+ w(`${elOn}// identityEnv: { ${identity[0]}: '{identity}' },`);
1261
+ } else if (electron && project.doors.read) {
1262
+ w(`${elOn}// NO "identityEnv", and this is an answer rather than something left out. If an app tells`);
1263
+ w(`${elOn}// a server who it is with a device id from its environment, two runs would claim one slot`);
1264
+ w(`${elOn}// and fight over it — that exact bug has happened to a real product. Every setting this app`);
1265
+ w(`${elOn}// reads out of its environment was listed by name, and not one of them carries a device or`);
1266
+ w(`${elOn}// machine id, so there is nothing to pass through. Every run already gets a settings folder`);
1267
+ w(`${elOn}// of its own, which is where an identity generated at first start would live.`);
1268
+ } else {
1269
+ w(`${elOn}// If your app tells a server who it is, name the setting that carries the id and it`);
1270
+ w(`${elOn}// is given a different one per run. Without this, two runs can claim the same slot`);
1271
+ w(`${elOn}// and fight over it — which looks exactly like a bug in your product.`);
1272
+ w(`${elOn}// identityEnv: { YOUR_APP_DEVICE_ID: '{identity}' },`);
1273
+ }
1274
+ const askable = electron ? channelsSafeToAsk(project.channels) : { safe: [], skipped: 0 };
1275
+ w(`${elOn}// Private channels asked to answer. Each becomes a journey of its own, so a channel that`);
1276
+ w(`${elOn}// stops ANSWERING is caught and not only one that stops existing.`);
1277
+ if (askable.safe.length > 0) {
1278
+ w(`${elOn}// These were picked out of the ${project.doors.ipc} channels in your source by NAME — every one of them`);
1279
+ w(`${elOn}// asks for something rather than doing something: get, list, status, read, about. The other`);
1280
+ w(`${elOn}// ${askable.skipped} were left out because their names say they write, or that they carry a secret. That is a`);
1281
+ w(`${elOn}// reading of a name and not a promise: if one of these turns out to change something,`);
1282
+ w(`${elOn}// delete the line. Nothing is ever asked of a channel that is not written here.`);
1283
+ w(`${elOn}exercise: [`);
1284
+ for (const line of chunk(askable.safe, 4)) w(`${elOn} ${line.map((name) => JSON.stringify(name)).join(', ')},`);
1285
+ w(`${elOn}],`);
1286
+ } else {
1287
+ w(`${elOn}// A channel is only ever asked when it is named here, because knocking on an unknown door`);
1288
+ w(`${elOn}// could do anything.`);
1289
+ w(`${elOn}// exercise: ['settings:read', 'sessions:list'],`);
1290
+ }
1024
1291
  w(`${elOn}// Walks through the window itself.`);
1025
1292
  w(`${elOn}// journeys: [{ name: 'opening a session', steps: [{ click: 'New session' }] }],`);
1026
- w(`${elOn}// Also: appId, args, env, windowMatch, startTimeoutMs, settleTries, settleGapMs.`);
1293
+ w(`${elOn}// Also: ${appId ? '' : 'appId, '}args, env, windowMatch, startTimeoutMs, settleTries, settleGapMs.`);
1027
1294
  w(electron ? ' },' : ' // },');
1028
1295
  w('');
1029
1296
 
@@ -1072,10 +1339,45 @@ export function configText(project) {
1072
1339
  w(`${winOn}// Also: args, cwd, journeys.`);
1073
1340
  w(windowsHere ? ' },' : ' // },');
1074
1341
  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.');
1342
+
1343
+ // ── ios ───────────────────────────────────────────────────────────────────
1344
+ const iosHere = ios?.adapter === 'ios';
1345
+ w(' // ───────────────────────────────────────────────────────────────────────');
1346
+ w(' // iPhone and iPad apps. Installed on a simulator, opened, and read — the same');
1347
+ w(' // roles, names and states a person hears read out to them. One build at a time.');
1348
+ w(' // Two builds on a real phone in your hand can never be compared side by side,');
1349
+ w(' // on any machine, ever — that is a fact about phones, not about this tool.');
1350
+ w(' // ───────────────────────────────────────────────────────────────────────');
1351
+ w(iosHere ? ' ios: {' : ' // ios: {');
1352
+ const iosOn = iosHere ? ' ' : ' // ';
1353
+ const iosApp = ios?.suggest?.app;
1354
+ w(`${iosOn}// The built app bundle for the simulator. Left out, it looks where builds land.`);
1355
+ w(iosApp ? `${iosOn}app: ${JSON.stringify(String(iosApp))},` : `${iosOn}// app: 'build/Debug-iphonesimulator/YourApp.app',`);
1356
+ w(`${iosOn}// Which simulator to use, and which system to run it on. Left out, it takes a sensible`);
1357
+ w(`${iosOn}// one and says which.`);
1358
+ w(`${iosOn}// deviceType: 'iPhone 17', runtime: 'iOS 26.4',`);
1359
+ w(`${iosOn}// Walks through the app. Left out, it opens the app and reads the first screen.`);
1360
+ w(`${iosOn}// journeys: [{ name: 'signing in', steps: [{ tap: 'Sign in' }] }],`);
1361
+ w(`${iosOn}// Addresses to open the app with, for a screen that is reached by a link.`);
1362
+ w(`${iosOn}// openUrls: ['yourapp://sessions'],`);
1363
+ w(`${iosOn}// Also: device, appearance, reset, logProcess.`);
1364
+ w(iosHere ? ' },' : ' // },');
1365
+ w('');
1366
+
1367
+ // Anything this repository makes that this copy of the tool has nothing to drive. Read from
1368
+ // what is actually loaded, never stated. A sentence hard-coded here would go on telling
1369
+ // somebody their iPhone app cannot be checked on the day the adapter that checks it landed —
1370
+ // and it would contradict the readiness this same command printed two lines earlier.
1371
+ const undrivable = project.products.filter((product) => product.adapter === null);
1372
+ if (undrivable.length > 0) {
1373
+ const one = undrivable.length === 1;
1374
+ 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.`);
1375
+ w(` // This copy of the tool has nothing in it that can drive ${one ? 'it' : 'them'}, so a setting would do`);
1376
+ w(` // nothing at all. ${one ? 'It is' : 'They are'} not being checked, \`staysfixed init\` says so every time it`);
1377
+ w(` // runs, and every clean result stays silent about ${one ? 'it' : 'them'}:`);
1378
+ for (const product of undrivable) {
1379
+ w(` // ${padTo(product.name, 30)} ${PRODUCT_KINDS[product.kind].what}`);
1380
+ }
1079
1381
  w('');
1080
1382
  }
1081
1383
 
@@ -1142,8 +1444,18 @@ function nextCommands(readiness, project) {
1142
1444
  /** @type {{command: string, what: string}[]} */
1143
1445
  const next = [];
1144
1446
  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.' });
1447
+ // The first run is worth taking as soon as ANYTHING here can be reached, not only once
1448
+ // everything can. Holding it back until every product is ready meant a project waiting on
1449
+ // one sample value was never told to start, and a first run that records three products out
1450
+ // of four is three products more than nothing — the reply says which ones it left out.
1451
+ const reachable = readiness.filter((r) => r.state !== 'not possible here');
1452
+ if (reachable.length > 0) {
1453
+ next.push({
1454
+ command: 'staysfixed check --paired',
1455
+ what: reachable.some((r) => r.state === 'ready')
1456
+ ? 'The first real run. It records what working looks like, so later runs have something to compare against.'
1457
+ : '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.',
1458
+ });
1147
1459
  }
1148
1460
  if (project.tests.files > 0) {
1149
1461
  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 +1704,114 @@ export async function run(ctx) {
1392
1704
  // the folder inside a project is called — both owned by src/core/paths.js, both re-stated
1393
1705
  // here because init is the one command whose whole job is those two facts.
1394
1706
  export { CONFIG_NAMES, DEFAULT_DIR };
1707
+
1708
+ /**
1709
+ * Which private channels are safe to knock on, read off their names.
1710
+ *
1711
+ * THE RULE THIS BENDS, AND WHY IT IS STILL THE RIGHT CALL. A channel between a desktop app's
1712
+ * two halves is only ever asked to answer when it is named in the settings, because knocking
1713
+ * on an unknown door could do anything. That rule stays. What changes is who does the naming:
1714
+ * leaving the list empty means somebody reads four hundred and fifty registrations by hand
1715
+ * before a single one of them is watched, and until they do, a channel that stops answering is
1716
+ * invisible. Nobody does that, so nothing gets watched.
1717
+ *
1718
+ * So the list is filled in from names that ASK for something rather than do something, and the
1719
+ * settings say plainly that it is a reading of a name rather than a promise about behaviour.
1720
+ * Three lines hold it:
1721
+ *
1722
+ * - only channels that hand a value back — a listener that answers nothing has nothing to
1723
+ * compare, and asking it is all risk and no reading;
1724
+ * - only names whose last word is one of a short list of asking words;
1725
+ * - and never a name with a secret in it. `browser-password:get` reads as an asking word and
1726
+ * would put somebody's password into a stored observation, which is the one mistake here
1727
+ * that cannot be taken back by deleting a line.
1728
+ *
1729
+ * @param {ProjectShape['channels']} channels
1730
+ * @returns {{safe: string[], skipped: number}}
1731
+ */
1732
+ export function channelsSafeToAsk(channels) {
1733
+ const asks = /(^|[:.\-/])(get|list|read|status|state|about|info|paths?|version|capabilities|count|summary|describe|available)$/i;
1734
+ // Anything whose name says it DOES something, wherever in the name it appears — not only at
1735
+ // the end. `settings:open-path` ends in a word that reads like asking and opens a window in
1736
+ // front of somebody; `chrome-import:open-privacy-settings` ends in "settings" and opens a
1737
+ // browser page. Both got through a rule that only looked at the last word, and both are the
1738
+ // kind of mistake that has to be impossible rather than unlikely.
1739
+ 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;
1740
+ // And anything that could hand back something private. A stored observation is written to
1741
+ // disk and read by an agent; a password or an ssh key in one is the single mistake here that
1742
+ // cannot be undone by deleting a line afterwards. Erring wide costs a channel going
1743
+ // unwatched, which is visible in the coverage ledger. Erring narrow costs a secret.
1744
+ const secret = /(password|secret|token|credential|api-?key|\bkeys?\b|keychain|passphrase|cookie|auth|login|account|identity|session-?id|private)/i;
1745
+ /** @type {string[]} */
1746
+ const safe = [];
1747
+ let skipped = 0;
1748
+ for (const channel of channels) {
1749
+ if (!channel.answers) {
1750
+ skipped += 1;
1751
+ continue;
1752
+ }
1753
+ if (secret.test(channel.name) || acts.test(channel.name) || !asks.test(channel.name)) {
1754
+ skipped += 1;
1755
+ continue;
1756
+ }
1757
+ safe.push(channel.name);
1758
+ }
1759
+ safe.sort();
1760
+ // A ceiling, because a settings file with four hundred lines of one array in it is a file
1761
+ // nobody scrolls past, and the coverage ledger names what was left out either way.
1762
+ const most = 60;
1763
+ if (safe.length > most) skipped += safe.length - most;
1764
+ return { safe: safe.slice(0, most), skipped };
1765
+ }
1766
+
1767
+ /**
1768
+ * A name padded out so a column of them lines up. Purely so a person can read the list.
1769
+ *
1770
+ * @param {string} text
1771
+ * @param {number} width
1772
+ * @returns {string}
1773
+ */
1774
+ function padTo(text, width) {
1775
+ return text.length >= width ? text : text + ' '.repeat(width - text.length);
1776
+ }
1777
+
1778
+ /**
1779
+ * A sentence broken into lines that fit inside a comment.
1780
+ *
1781
+ * @param {string} text
1782
+ * @param {number} width
1783
+ * @returns {string[]}
1784
+ */
1785
+ function wrapProse(text, width) {
1786
+ /** @type {string[]} */
1787
+ const lines = [];
1788
+ /** @type {string[]} */
1789
+ let current = [];
1790
+ let length = 0;
1791
+ for (const word of String(text).split(/\s+/).filter(Boolean)) {
1792
+ if (length > 0 && length + 1 + word.length > width) {
1793
+ lines.push(current.join(' '));
1794
+ current = [];
1795
+ length = 0;
1796
+ }
1797
+ current.push(word);
1798
+ length += (length > 0 ? 1 : 0) + word.length;
1799
+ }
1800
+ if (current.length > 0) lines.push(current.join(' '));
1801
+ return lines;
1802
+ }
1803
+
1804
+ /**
1805
+ * A list broken into rows of a size, so a long array reads as a block rather than a column.
1806
+ *
1807
+ * @template T
1808
+ * @param {T[]} items
1809
+ * @param {number} size
1810
+ * @returns {T[][]}
1811
+ */
1812
+ function chunk(items, size) {
1813
+ /** @type {T[][]} */
1814
+ const rows = [];
1815
+ for (let i = 0; i < items.length; i += size) rows.push(items.slice(i, i + size));
1816
+ return rows;
1817
+ }