staysfixed 0.12.0 → 0.14.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -6,7 +6,88 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
- Nothing yet.
9
+ ## [0.14.0] — 2026-09-02
10
+
11
+ Four defects, every one of them found by WATCHING this tool work rather than by reading it.
12
+ The owner could not find its live window, then recorded four minutes of it running and the
13
+ recording contained three faults nobody had noticed in a month of testing.
14
+
15
+ ### A system dialog can no longer stall a run in silence
16
+
17
+ A real check froze for two of its four minutes behind a macOS alert — *"A keychain cannot be
18
+ found to store 'Terminal Deck Key'"* — while a journey waited for a person who was never going
19
+ to arrive. The run finished and reported one journey with no answer, and never said why.
20
+
21
+ Every adapter here starts the product under test in a throwaway settings folder, which is
22
+ exactly the condition that makes an application ask the operating system for something it has
23
+ never been granted. These dialogs are a normal consequence of how this tool works, so they are
24
+ its problem.
25
+
26
+ - Modal boxes belonging to an application **this run started** are noticed, closed and reported
27
+ with their own words. Nothing else is touched.
28
+ - The button pressed comes from a fixed list that decline, dismiss or close — never one that
29
+ grants, resets, deletes or agrees. On the alert that started this, the other button was
30
+ *Reset To Defaults*, on a keychain, and it was the default.
31
+ - A dialog with nothing safe on it is **left exactly as it is** and named in the result instead.
32
+ - `AXModal` is the test, not the subrole: an ordinary Terminal window reports the same
33
+ `AXDialog` subrole the real alert does, so matching on that would send this tool hunting for
34
+ buttons to press in somebody's actual work.
35
+
36
+ ### The report survives the findings
37
+
38
+ When a verdict landed, everything the run had spent four minutes drawing — the walk, every
39
+ journey and its address count, the wobble, what survived, what was not checked — vanished. The
40
+ footer holding "Needs a person" had no cap, so seventeen findings grew it past the height of the
41
+ panel and squeezed the body to **eighteen pixels holding 41,115** of content.
42
+
43
+ The footer is capped and its list scrolls inside itself; the body keeps a floor. Held both ways
44
+ by a guard, including against the first over-correction, which lost the same detail from the
45
+ other end by letting the footer collapse to its label.
46
+
47
+ ### The live window says that it exists
48
+
49
+ `--watch` is off by default, the window opens behind your work on purpose, and nothing anywhere
50
+ ever mentioned it. Three correct decisions adding up to a feature nobody could find. `check` now
51
+ says one line on a project that has a screen: that the window is there and how to open it, or,
52
+ when it is open, which side of the screen it went to.
53
+
54
+ ### Something added is no longer described as a deletion
55
+
56
+ An escalation read *"SessionBar is there now and was not before"* and then, one line below,
57
+ *"Say whether that deletion is meant to happen."* A sealed class says what a change TOUCHES; it
58
+ says nothing about which way it moved, and the sentence a person is asked to rule on has to
59
+ describe what actually happened.
60
+
61
+
62
+ ### The self-check corpus knows about the five false all-clears found on 2026-08-31
63
+
64
+ Five defects were found and fixed that day, each of which could have blessed a broken build,
65
+ and not one of them would have been caught by the corpus that is supposed to make that
66
+ impossible. There is a case for each now, and every one of them was made to fail against the
67
+ code as it stood before its fix before it was believed.
68
+
69
+ - **A build that crashes on its first line is no longer a build that agrees with itself.** Two
70
+ builds that crash the same way used to agree at every address, and agreement is the silence
71
+ this tool reads as "nothing changed".
72
+ - **Coverage this build took away is never handed back as a pass.** The engine decided that
73
+ correctly and the bookkeeping then assigned the verdict rather than narrowing it.
74
+ - **A standard cut from a tree git does not have is never walked out of git.** The old build
75
+ was exported from the commit, walked, and a real regression was dropped as something it does
76
+ too.
77
+ - **With nothing edited, nothing can be a change.** The build being checked and the build on
78
+ record were one build, and whatever flickered between two runs was reported as a change
79
+ nobody made.
80
+ - **What "working" means moves only when somebody ships.** Re-running a check on the shipped
81
+ build quietly replaced the standard with whatever that run happened to see.
82
+
83
+ ### A guard can hand `evaluate` a function
84
+
85
+ `page.evaluate` took JavaScript as text and nothing else. Passing it a function — the obvious
86
+ thing, and what every other tool in this space accepts — answered `The app refused the request
87
+ "Runtime.evaluate": Invalid parameters`, which is the debug protocol talking about its own wire
88
+ format to somebody who had done nothing wrong. It now takes either, and the two things it
89
+ genuinely cannot run — a built-in, and a function that asks for an argument it has no way to
90
+ be given — say so in a sentence with an example in it.
10
91
 
11
92
  ## [0.12.0] — 2026-08-31
12
93
 
@@ -140,7 +221,9 @@ adapter and tests turned out to hold at least one defect that only running it co
140
221
  record is filed under a fingerprint of the tree that was walked, because the files checked
141
222
  are not the files git has. Paired mode walked the commit and called it the old build, so an
142
223
  address the record holds a real value for was reported as *"is there now and was not
143
- before"*.
224
+ before"*. Asking for a paired run against such a reference is now refused outright, naming
225
+ the reason and what to do instead, rather than quietly giving you something weaker; an
226
+ ordinary check still falls back to the stored record and says so.
144
227
 
145
228
  ### It reported things as checked that were never checked
146
229
 
package/docs/guards.md CHANGED
@@ -126,6 +126,24 @@ export default {
126
126
  | `app.read(file)` | Read a project file as text. |
127
127
  | `app.project` | The resolved config and paths. |
128
128
 
129
+ ### Running your own JavaScript in the page
130
+
131
+ `page.evaluate` takes it either way round — as a piece of JavaScript written as
132
+ text, or as a function it will call inside the app:
133
+
134
+ ```js
135
+ const title = await app.page.evaluate('document.title');
136
+ const title = await app.page.evaluate(() => document.title);
137
+ ```
138
+
139
+ The function is run with nothing passed to it, so one that asks for an argument
140
+ is refused rather than quietly handed `undefined`. Close over what it needs, or
141
+ write the value into the JavaScript itself:
142
+
143
+ ```js
144
+ await app.page.evaluate(`document.title === ${JSON.stringify(expected)}`);
145
+ ```
146
+
129
147
  ---
130
148
 
131
149
  ## The `expect` style
@@ -309,6 +309,16 @@ RUN`, and the reason is in the coverage as a hole. This is not a tolerance — n
309
309
  number here decides whether any single difference is real. It decides one thing:
310
310
  whether this run has earned the right to use the word clean.
311
311
 
312
+ **It is asked once per journey, not once per run.** Asking it only of everything
313
+ added together is how a page that told you nothing gets averaged away by nine
314
+ pages that behaved: 179 unsteady addresses out of 2849 is not a storm, while four
315
+ of that run's twelve journeys were unsteady at 69%, 75% and twice 100% of their
316
+ own. `noAnswerJourneys(wobbles)` is the same one comparison asked journey by
317
+ journey, and any journey it names takes the pass away and is listed by name in the
318
+ summary's first sentence. A journey walked twice that came back with no addresses
319
+ at all is in that list too — two empty walks agree about everything, and that is
320
+ not a measurement.
321
+
312
322
  ### A finding
313
323
 
314
324
  A cluster of differences that share a cause, which is what the agent reads:
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.12.0",
3
+ "version": "0.14.0",
4
4
  "description": "Prove that what already worked still works after an agent changed the code. Picture checks, guards for fixed bugs, a pre-release walkthrough, and known-good markers \u2014 as a CLI and as an MCP server.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -16,7 +16,10 @@ import { EXIT } from '../core/errors.js';
16
16
  * @returns {Promise<number>}
17
17
  */
18
18
  export async function run(ctx) {
19
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
19
+ // `opening: false` approving moves a picture the last check already took. It launches
20
+ // nothing. On a project with no screen there is simply never anything waiting, and saying
21
+ // that in one line is the true answer; refusing to load the settings at all was not.
22
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
20
23
  const all = ctx.bool('all');
21
24
  const names = ctx.args.filter((a) => a.trim() !== '');
22
25
 
package/src/cli/flake.js CHANGED
@@ -17,7 +17,10 @@ import { EXIT } from '../core/errors.js';
17
17
  * @returns {Promise<number>}
18
18
  */
19
19
  export async function run(ctx) {
20
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
20
+ // `opening: false` the register is a file on disk and reading it opens nothing. Without
21
+ // this, the one command whose whole job is to name unreliable checks was itself refused on
22
+ // every command-line tool and library this tool sets up. Measured 2026-08-31.
23
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
21
24
  const history = await loadHistory(project.paths.historyFile);
22
25
 
23
26
  const forgive = ctx.str('clear');
package/src/cli/mark.js CHANGED
@@ -13,7 +13,11 @@ import { StaysFixedError, EXIT } from '../core/errors.js';
13
13
  * @returns {Promise<number>}
14
14
  */
15
15
  export async function run(ctx) {
16
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
16
+ // `opening: false` a marker is a commit, a date and a set of fingerprints written to a
17
+ // file. Nothing is opened and nothing is photographed, so a project with no screen can pin
18
+ // a known-good version exactly like any other. It was refused on every one of them until
19
+ // 2026-08-31, which took `trace` down with it: there was nothing to trace back to.
20
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
17
21
 
18
22
  if (ctx.bool('list')) return showAll(project);
19
23
 
package/src/cli/status.js CHANGED
@@ -8,6 +8,7 @@ import path from 'node:path';
8
8
  import { loadProject } from '../core/config.js';
9
9
  import { projectStatus } from '../run.js';
10
10
  import { printStatus } from '../report/console.js';
11
+ import { say, blank, heading, paint } from '../core/log.js';
11
12
  import { EXIT } from '../core/errors.js';
12
13
 
13
14
  /**
@@ -56,8 +57,59 @@ export function versionTwoState(root) {
56
57
  * @returns {Promise<number>}
57
58
  */
58
59
  export async function run(ctx) {
59
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
60
+ // `opening: false` this command reads files and prints them. It says so in its own first
61
+ // line. Loading the settings through the check that insists on something to open made the
62
+ // fastest command in the tool refuse outright on every command-line tool, library and
63
+ // server it had just set up, with a paragraph about an `app` key version 2 never writes.
64
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
60
65
  const status = await projectStatus(project);
61
66
  printStatus(/** @type {any} */ ({ ...status, v2: versionTwoState(project.paths?.root ?? ctx.cwd) }));
67
+ printWhatIsSetUp(project.config);
62
68
  return EXIT.ok;
63
69
  }
70
+
71
+ /**
72
+ * What these settings actually cover, in one short list.
73
+ *
74
+ * The promise of this command is "what is set up here", and on a project with no screen the
75
+ * picture counters answer it with four zeroes — every one of them true, and together they
76
+ * read as "nothing is set up" about a project whose settings name three commands and two
77
+ * folders of source. So the settings are read back in the words they were written in.
78
+ *
79
+ * Everything here is read out of the settings file. Nothing is inferred and nothing is
80
+ * counted that is not there, because a status line that overstates what is covered is the
81
+ * one kind of wrong this tool cannot afford.
82
+ *
83
+ * @param {import('../types.js').ResolvedConfig} config
84
+ * @returns {void}
85
+ */
86
+ function printWhatIsSetUp(config) {
87
+ const any = /** @type {Record<string, any>} */ (/** @type {unknown} */ (config));
88
+ /** @type {string[]} */
89
+ const lines = [];
90
+
91
+ const commands = Array.isArray(any.process?.commands) ? any.process.commands.filter((/** @type {any} */ c) => c && !c.skip) : [];
92
+ const imports = Array.isArray(any.process?.imports) ? any.process.imports : [];
93
+ if (commands.length > 0) lines.push(`${commands.length} ${commands.length === 1 ? 'command' : 'commands'} to run and compare word for word`);
94
+ if (imports.length > 0) lines.push(`${imports.length} ${imports.length === 1 ? 'library entry' : 'library entries'} to import and compare what ${imports.length === 1 ? 'it exports' : 'they export'}`);
95
+ if (any.source && typeof any.source === 'object') {
96
+ const folders = Array.isArray(any.source.folders) ? any.source.folders : [];
97
+ lines.push(folders.length > 0 ? `the code in ${folders.join(', ')}, read without running it` : 'the code, read without running it');
98
+ }
99
+ if (any.http && typeof any.http === 'object') lines.push('a server, booted on a spare port and asked for every route');
100
+ if (any.web && typeof any.web === 'object') {
101
+ const screens = Array.isArray(any.web.screens) ? any.web.screens.length : 0;
102
+ lines.push(screens > 0 ? `a website, and ${screens} ${screens === 1 ? 'screen' : 'screens'} of it` : 'a website');
103
+ }
104
+ if (any.electron && typeof any.electron === 'object') lines.push('a desktop app');
105
+ if (any.android && typeof any.android === 'object') lines.push('an Android app');
106
+ if (any.ios && typeof any.ios === 'object') lines.push('an iPhone app');
107
+ if (any.windows && typeof any.windows === 'object') lines.push('a native Windows app');
108
+
109
+ if (lines.length === 0) return;
110
+ heading('What these settings cover');
111
+ for (const line of lines) say(` ${paint.grey('·')} ${line}`);
112
+ blank();
113
+ say(paint.grey(` Run ${paint.cyan('staysfixed check')} to walk it, and ${paint.cyan('staysfixed coverage')} for what a run did NOT look at.`));
114
+ blank();
115
+ }
package/src/cli/trace.js CHANGED
@@ -6,7 +6,7 @@ import { loadProject } from '../core/config.js';
6
6
  import { projectStatus } from '../run.js';
7
7
  import { traceScreens } from '../marker/trace.js';
8
8
  import { printTrace } from '../report/console.js';
9
- import { say, paint } from '../core/log.js';
9
+ import { say, blank, paint } from '../core/log.js';
10
10
  import { resultPicture } from '../core/paths.js';
11
11
  import { sha256File } from '../core/hash.js';
12
12
  import { EXIT } from '../core/errors.js';
@@ -16,12 +16,37 @@ import { EXIT } from '../core/errors.js';
16
16
  * @returns {Promise<number>}
17
17
  */
18
18
  export async function run(ctx) {
19
- const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile });
19
+ // `opening: false` tracing compares fingerprints already written down against markers
20
+ // already written down. It opens nothing. Where there is nothing recorded to trace, the
21
+ // report says so in its own words further down; being refused before it could even look
22
+ // was the wrong answer, and it named a settings key that version 2 never writes.
23
+ const project = await loadProject({ cwd: ctx.cwd, configFile: ctx.configFile, opening: false });
20
24
 
21
25
  const asked = ctx.args.filter((a) => a.trim() !== '');
22
26
  const changed = asked.length > 0 ? [] : await changedInLastRun(project);
23
27
  const names = asked.length > 0 ? asked : changed;
24
28
 
29
+ // A project with nothing to photograph cannot be traced, and it is owed a sentence saying
30
+ // why rather than a shrug and a suggestion it has already followed.
31
+ //
32
+ // The generic ending is "There is nothing to trace yet. Pin a good version first with
33
+ // `staysfixed mark`." — which, on a project that had just pinned one, told somebody to go
34
+ // and do the thing they had done thirty seconds earlier. Measured 2026-08-31 on a Python
35
+ // command-line tool. Tracing follows a SCREEN backwards, so on a product with no screen
36
+ // the answer is not "not yet", it is "not this kind of project", and saying the second one
37
+ // stops somebody working through a list that was never going to end.
38
+ if (asked.length === 0 && names.length === 0 && project.config.screens.length === 0) {
39
+ blank();
40
+ say('There is no screen in this project to trace.');
41
+ say(paint.grey('Tracing follows one screen back through your markers to the commit where it stopped'));
42
+ say(paint.grey('looking right, so it needs a picture to follow. These settings name none.'));
43
+ blank();
44
+ say(`For what changed in a project without a screen, run ${paint.cyan('staysfixed check')} — it compares every`);
45
+ say('word a command printed, what it exited with and every file it touched.');
46
+ blank();
47
+ return EXIT.ok;
48
+ }
49
+
25
50
  if (asked.length === 0) {
26
51
  if (names.length === 0) {
27
52
  say(paint.grey('Nothing is different right now, so this looks at every screen there is a record of.'));
@@ -83,9 +83,40 @@ export const DEFAULT_MCP = {
83
83
  allowMark: false,
84
84
  };
85
85
 
86
+ /**
87
+ * The `app.kind` of a project that has nothing to open at all.
88
+ *
89
+ * A command-line tool, a library and a plain server are all perfectly ordinary products with
90
+ * no screen anywhere in them, and every command that only READS what is on disk — status,
91
+ * flake, mark, trace, approve — works on one exactly as well as it works on a website. They
92
+ * were all refused anyway, because the only way to load settings was through a check that
93
+ * insisted on something to open. This value is how a command says "I open nothing, so do not
94
+ * ask", and it is a made-up word on purpose: nothing can accidentally match it, and anything
95
+ * that reads it and does not understand it fails loudly rather than photographing a guess.
96
+ */
97
+ export const NOTHING_TO_OPEN = 'nothing-to-open';
98
+
99
+ /**
100
+ * Do these settings name anything this tool could open and photograph?
101
+ *
102
+ * Exported so a command can ask before it tries, and say something true about this project
103
+ * instead of the same refusal for every shape of product.
104
+ *
105
+ * @param {import('../types.js').ResolvedConfig} config
106
+ * @returns {boolean}
107
+ */
108
+ export function hasSomethingToOpen(config) {
109
+ // Read as a plain string on purpose. The declared shape of `app.kind` is version 1's two
110
+ // words, and this third one is deliberately outside it — see NOTHING_TO_OPEN above.
111
+ return /** @type {string} */ (config?.app?.kind) !== NOTHING_TO_OPEN;
112
+ }
113
+
86
114
  /**
87
115
  * Find, import and resolve the config.
88
- * @param {{cwd?: string, configFile?: string}} [opts]
116
+ * @param {{cwd?: string, configFile?: string, opening?: boolean}} [opts]
117
+ * `opening: false` is a command promising it will not open or photograph anything — it
118
+ * only reads what is already on disk. Settings with no screen in them are then a normal,
119
+ * correct shape rather than a reason to refuse.
89
120
  * @returns {Promise<import('../types.js').Project>}
90
121
  */
91
122
  export async function loadProject(opts = {}) {
@@ -98,7 +129,7 @@ export async function loadProject(opts = {}) {
98
129
  }
99
130
  const raw = await importConfig(file);
100
131
  const root = rootForConfig(file);
101
- const config = resolveConfig(raw, file);
132
+ const config = resolveConfig(raw, file, { opening: opts.opening });
102
133
  const paths = pathsFor(root, file, config.dir);
103
134
  // `guards` is the one folder a project is free to move, so the config wins over the
104
135
  // default layout. Without this the setting silently did nothing and the tool reported
@@ -136,9 +167,11 @@ async function importConfig(file) {
136
167
  * Fill in defaults and reject anything that would fail later in a confusing way.
137
168
  * @param {unknown} raw
138
169
  * @param {string} file
170
+ * @param {{opening?: boolean}} [opts]
171
+ * `opening: false` from a command that only reads what is on disk. See {@link loadProject}.
139
172
  * @returns {import('../types.js').ResolvedConfig}
140
173
  */
141
- export function resolveConfig(raw, file = '(inline)') {
174
+ export function resolveConfig(raw, file = '(inline)', opts = {}) {
142
175
  if (!raw || typeof raw !== 'object') {
143
176
  throw new StaysFixedError(`${path.basename(file)} did not export a config object.`, {
144
177
  hint: 'It should `export default { app: { ... }, screens: [ ... ] }`.',
@@ -158,8 +191,8 @@ export function resolveConfig(raw, file = '(inline)') {
158
191
  // Where the address is actually knowable, take it and let the command work. Booting is
159
192
  // version 2's job and these commands cannot do it, so `web.start` alone is not enough —
160
193
  // that case falls through to the message below, which now says so honestly.
194
+ const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
161
195
  if ((!c.app || typeof c.app !== 'object')) {
162
- const v2 = /** @type {Record<string, any>} */ (/** @type {unknown} */ (c));
163
196
  if (v2.web && typeof v2.web === 'object' && typeof v2.web.url === 'string' && v2.web.url) {
164
197
  c.app = { kind: 'web', url: v2.web.url };
165
198
  } else if (v2.electron && typeof v2.electron === 'object' && typeof v2.electron.binary === 'string' && v2.electron.binary) {
@@ -167,15 +200,49 @@ export function resolveConfig(raw, file = '(inline)') {
167
200
  }
168
201
  }
169
202
 
203
+ // The screens come across with the address, and they did not before.
204
+ //
205
+ // Half a bridge is worse than none: on a version 2 website `walk` and `approve` were
206
+ // handed the address and then found nothing to photograph, because version 2 keeps its
207
+ // screens under `web` and these commands only ever looked at the top level. So the
208
+ // commands ran, opened a browser, and reported an empty walk of a site with six pages in
209
+ // its settings — measured 2026-08-31. Only a screen with a name and a plain address is
210
+ // carried over; anything reached by clicking is version 2's to walk, not this half's, and
211
+ // inventing a journey out of one would put a screen in the report nobody can reach.
212
+ if (c.app && typeof c.app === 'object' && !Array.isArray(c.screens)) {
213
+ const fromV2 = Array.isArray(v2.web?.screens) ? v2.web.screens : [];
214
+ const carried = fromV2.filter((/** @type {any} */ s) => s && typeof s.name === 'string' && typeof s.url === 'string' && s.url !== '');
215
+ if (carried.length > 0) c.screens = carried.map((/** @type {any} */ s) => ({ name: s.name, url: s.url }));
216
+ }
217
+
218
+ // A command that opens nothing must never be refused for having nothing to open.
219
+ //
220
+ // `status`, `flake`, `mark`, `trace` and `approve` all do their whole job by reading files
221
+ // this tool has already written. Every one of them was dead on the settings this tool's own
222
+ // `init` writes for a command-line tool, a library or a server — five commands offered in
223
+ // `--help`, all answering with a paragraph about an `app` key that version 2 never writes
224
+ // and that nobody running them had ever seen. Measured 2026-08-31 on a Python command-line
225
+ // tool and on a plain Node one; both were set up by `staysfixed init` seconds earlier.
226
+ if ((!c.app || typeof c.app !== 'object') && opts.opening === false) {
227
+ c.app = /** @type {any} */ ({ kind: NOTHING_TO_OPEN });
228
+ }
229
+
170
230
  if (!c.app || typeof c.app !== 'object') {
171
- // Every command that lands here status, walk, approve, mark, trace, flake, and
172
- // `check --pictures` works by OPENING something and photographing it. A settings
173
- // file with no `app` in it is the normal, correct shape for a command-line tool, a
174
- // library or a server: there is nothing to open, and telling somebody to go and add a
175
- // web address they do not have sends them off inventing one. So the message says which
176
- // half of the tool needs it, and names the half that does not.
231
+ // Only the commands that really do open something land here now `walk`, and
232
+ // `check --pictures`. They photograph a screen, so a project with no screen anywhere in
233
+ // it genuinely cannot be walked, and the honest thing is to say which product this is
234
+ // and stop offering it.
235
+ //
236
+ // What this message must never do is name a key the person has not got. `app` is version
237
+ // 1's word for the thing to open; the settings `staysfixed init` writes today have no
238
+ // `app` in them and never will, so telling somebody to add one sends them editing a file
239
+ // against a shape nothing else in the tool uses. Where there IS advice worth giving, it
240
+ // is given in the words their own settings file already uses — `url` inside the `web`
241
+ // block — and only for a file that is written that way.
177
242
  const anything = /** @type {Record<string, unknown>} */ (/** @type {unknown} */ (c));
178
- const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => anything[k] && typeof anything[k] === 'object');
243
+ const versionTwo = ['process', 'source', 'http', 'web', 'electron', 'android', 'ios', 'windows']
244
+ .filter((k) => anything[k] && typeof anything[k] === 'object');
245
+ const notVisual = ['process', 'http', 'source', 'android', 'ios', 'windows'].filter((k) => versionTwo.includes(k));
179
246
  // A project that DOES have a screen, described the version 2 way, must never be told it
180
247
  // has none. It is told the true thing instead: this half of the tool photographs an
181
248
  // address you can point it at, and version 2 finds the address by booting the product,
@@ -186,23 +253,36 @@ export function resolveConfig(raw, file = '(inline)') {
186
253
  hint: "`staysfixed check` covers it exactly as it is — it boots `web.start` and finds the address itself. These picture commands need one they can point at, so add `url: 'http://localhost:3000'` beside `start` in the `web` block if you want them too.",
187
254
  });
188
255
  }
256
+ if (versionTwo.length > 0) {
257
+ throw new StaysFixedError(
258
+ `This command photographs a screen, and this project has none — these settings describe ${plainList(versionTwo.map(describeBlock))}.`,
259
+ {
260
+ hint: `Nothing is missing and nothing needs adding. Run \`staysfixed check\`, which covers ${notVisual.length === versionTwo.length ? 'exactly what is here' : 'all of it'} without a picture.`
261
+ + (versionTwo.includes('web') ? " If you want the picture commands on the site too, add `url: 'http://localhost:3000'` inside the `web` block so there is an address to point at." : ''),
262
+ },
263
+ );
264
+ }
189
265
  throw new StaysFixedError('These settings do not name anything to open, and this command works by opening your product and photographing it.', {
190
- hint: notVisual.length
191
- ? `That is the right shape for what this project is — ${notVisual.join(', ')} settings need nothing to open. Run \`staysfixed check\`, which covers it without a picture. If there IS a screen here too, add \`app: { kind: 'web', url: 'http://localhost:3000' }\` or \`app: { kind: 'electron', binary: '...' }\`.`
192
- : "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
266
+ hint: "Add `app: { kind: 'web', url: 'http://localhost:3000' }` or `app: { kind: 'electron', binary: '...' }`. If your product has no screen at all, `staysfixed check` covers it without one.",
193
267
  });
194
268
  }
195
- const kind = c.app.kind;
196
- if (kind !== 'web' && kind !== 'electron') {
197
- throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
198
- }
199
- if (kind === 'web' && !c.app.url && !c.app.attach) {
200
- throw new StaysFixedError('A web app needs `app.url` — the address to open.');
201
- }
202
- if (kind === 'electron' && !c.app.binary && !c.app.attach) {
203
- throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
204
- hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
205
- });
269
+ // Read as a plain string, because a project with nothing to open carries a third value
270
+ // that is deliberately outside version 1's two see NOTHING_TO_OPEN at the top.
271
+ const kind = /** @type {string} */ (c.app.kind);
272
+ // A command that told us it opens nothing gets no further questions. Asking a project with
273
+ // no screen for an address or a binary is the refusal this whole branch exists to stop.
274
+ if (kind !== NOTHING_TO_OPEN) {
275
+ if (kind !== 'web' && kind !== 'electron') {
276
+ throw new StaysFixedError(`app.kind must be 'web' or 'electron' (found ${JSON.stringify(kind)}).`);
277
+ }
278
+ if (kind === 'web' && !c.app.url && !c.app.attach) {
279
+ throw new StaysFixedError('A web app needs `app.url` — the address to open.');
280
+ }
281
+ if (kind === 'electron' && !c.app.binary && !c.app.attach) {
282
+ throw new StaysFixedError('An Electron app needs `app.binary` — the path to the executable.', {
283
+ hint: 'On macOS that is inside the bundle: /Applications/Your App.app/Contents/MacOS/Your App',
284
+ });
285
+ }
206
286
  }
207
287
 
208
288
  const screens = (c.screens ?? []).map((s, i) => resolveScreen(s, i));
@@ -285,3 +365,34 @@ export function settingsForScreen(config, screen) {
285
365
  masks: [...config.masks, ...(screen.masks ?? [])],
286
366
  };
287
367
  }
368
+
369
+ /**
370
+ * What one block of version 2 settings actually is, in words somebody who did not write this
371
+ * tool would use. Said out loud in a refusal so the sentence names this project rather than
372
+ * naming a missing key.
373
+ *
374
+ * @param {string} key
375
+ * @returns {string}
376
+ */
377
+ function describeBlock(key) {
378
+ return /** @type {Record<string,string>} */ ({
379
+ process: 'commands to run and libraries to import',
380
+ source: 'code to read without running it',
381
+ http: 'a server to boot and ask for its routes',
382
+ web: 'a website to open',
383
+ electron: 'a desktop app to open',
384
+ android: 'an Android app',
385
+ ios: 'an iPhone app',
386
+ windows: 'a native Windows app',
387
+ })[key] ?? key;
388
+ }
389
+
390
+ /**
391
+ * A list a person reads out loud: "a, b and c". Two commas and an "and" beats three commas.
392
+ * @param {string[]} items
393
+ * @returns {string}
394
+ */
395
+ function plainList(items) {
396
+ if (items.length <= 1) return items[0] ?? '';
397
+ return `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
398
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * Stopping a command AND everything that command started — on every operating system.
3
+ *
4
+ * Almost every command this tool runs is really a shell: `npm test`, `npm run dev`,
5
+ * `poetry run uvicorn ...`. The shell then starts the real program, which often starts
6
+ * another one. So the process this tool holds a handle to is the shell, and the work is
7
+ * happening in its children and grandchildren.
8
+ *
9
+ * Killing the shell is not killing the work. On Linux the children carry on with a new
10
+ * parent; on a Mac they usually die with the shell, which is exactly why this was invisible
11
+ * for so long. The answer there is to put the shell and everything it starts into one
12
+ * process GROUP and signal the group, which is what a negative process id means.
13
+ *
14
+ * WINDOWS HAS NO PROCESS GROUPS OF THAT KIND, and the code here used to say so and give up —
15
+ * it killed the one process it knew about and hoped. It does not work: measured on a real
16
+ * Windows 11 machine on 2026-08-31, a command the guard had already given up on kept running,
17
+ * finished its work and wrote its file, because only `cmd.exe` had been killed and the `node`
18
+ * underneath it never noticed. Windows does have an answer, it is just spelled differently:
19
+ * `taskkill /T` walks the tree of children and stops all of them. This file is the one place
20
+ * that difference is written down, so no caller has to remember it again.
21
+ */
22
+
23
+ import { spawnSync } from 'node:child_process';
24
+
25
+ /** Windows spells "stop this and everything under it" as a command, not a signal. */
26
+ const TASKKILL = 'taskkill';
27
+
28
+ /**
29
+ * Stop a process and every process it started.
30
+ *
31
+ * Safe to call more than once, safe to call on something already gone, and safe to call from
32
+ * an exit handler — nothing here is asynchronous, because a process on its way out has no
33
+ * event loop left to wait on.
34
+ *
35
+ * @param {number|null|undefined} pid The process this tool started.
36
+ * @param {'SIGTERM'|'SIGKILL'} signal SIGTERM asks; SIGKILL insists.
37
+ * @param {{child?: import('node:child_process').ChildProcess|null}} [opts]
38
+ * The handle, when the caller has one. It is the fallback if the tree walk fails.
39
+ * @returns {boolean} true when something was asked to stop, false when there was nothing to ask.
40
+ */
41
+ export function stopTree(pid, signal, opts = {}) {
42
+ const child = opts.child ?? null;
43
+ if (!pid) {
44
+ if (!child) return false;
45
+ return tryKill(child, signal);
46
+ }
47
+
48
+ if (process.platform === 'win32') {
49
+ // `/T` takes the children, `/F` insists — and on Windows BOTH are always used, including
50
+ // for the polite SIGTERM. There is no polite stop for a console program there: `taskkill`
51
+ // without `/F` sends a window a close message, which `node` has no window to receive, so
52
+ // it refuses with "this process can only be terminated forcefully" and nothing stops.
53
+ //
54
+ // Nothing is lost by that, because Node's own `child.kill('SIGTERM')` on Windows is
55
+ // already an outright TerminateProcess — the ONLY difference this line makes is that the
56
+ // children go too. Measured on a real Windows 11 machine on 2026-08-31: the first version
57
+ // of this file tried the polite form, watched it refuse, and fell back to killing the
58
+ // shell alone — which left the server running AND made it look like it had stopped,
59
+ // because the handle the caller was watching had gone. The scratch folder could then not
60
+ // be deleted, and the whole of `waiting.test.js` failed on it.
61
+ const ran = spawnSync(TASKKILL, ['/pid', String(pid), '/T', '/F'], { stdio: 'ignore', windowsHide: true, timeout: 10_000 });
62
+ if (ran.status === 0) return true;
63
+ // taskkill was not there, or the process had already finished — which is the outcome the
64
+ // caller wanted anyway. Stopping the one process this tool definitely knows about is
65
+ // weaker than stopping the tree, and it is a great deal better than leaving it running.
66
+ return child ? tryKill(child, signal) : false;
67
+ }
68
+
69
+ try {
70
+ // A negative process id is the GROUP. This is the line that takes the watchers,
71
+ // bundlers and servers down with the shell that started them.
72
+ process.kill(-pid, signal);
73
+ return true;
74
+ } catch {
75
+ // No group — which happens when the caller did not start it detached — or it is already
76
+ // gone. Either way, ask the one process we know about.
77
+ if (child) return tryKill(child, signal);
78
+ try {
79
+ process.kill(pid, signal);
80
+ return true;
81
+ } catch {
82
+ return false;
83
+ }
84
+ }
85
+ }
86
+
87
+ /**
88
+ * @param {import('node:child_process').ChildProcess} child
89
+ * @param {'SIGTERM'|'SIGKILL'} signal
90
+ * @returns {boolean}
91
+ */
92
+ function tryKill(child, signal) {
93
+ try {
94
+ return child.kill(signal);
95
+ } catch {
96
+ // Already gone, which is the outcome wanted.
97
+ return false;
98
+ }
99
+ }
100
+
101
+ /**
102
+ * Should a command be started in a process group of its own?
103
+ *
104
+ * Only where process groups exist. On Windows `detached: true` does something else entirely —
105
+ * it gives the child its own console WINDOW, which flashes up on the person's screen in the
106
+ * middle of a check and is never what this tool wants. Windows gets its tree walk from
107
+ * `stopTree` instead, which needs nothing at spawn time.
108
+ */
109
+ export const OWN_PROCESS_GROUP = process.platform !== 'win32';