staysfixed 0.11.0 → 0.11.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/CHANGELOG.md CHANGED
@@ -8,6 +8,32 @@ numbers follow [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
8
8
 
9
9
  Nothing yet.
10
10
 
11
+ ## [0.11.1] — 2026-08-31
12
+
13
+ Two defects that a Mac could never have shown, both caught by CI on Linux minutes after
14
+ 0.11.0 went out. Both were verified as broken and then as fixed on a real Linux machine
15
+ rather than by reading the code.
16
+
17
+ - **A command a guard had been abandoned mid-flight kept running.** Killing the shell is not
18
+ killing what the shell started: a command runs through a shell, so the signal reaches the
19
+ shell and the program it started carries on with a new parent. On macOS the shell usually
20
+ takes its child with it and this was invisible; on Linux it does not, and a command the run
21
+ had given up on finished its work 800 milliseconds later and wrote its file. It now runs in
22
+ its own process group and the group is what gets signalled. (`exec` does not document
23
+ `detached`; `spawn` does, so this moved to `spawn` rather than resting on behaviour that
24
+ happens to work.)
25
+ - **The ordinary way of closing a browser left its throwaway profile behind.** One `rm` is a
26
+ snapshot, and a browser is not one process: the parent exiting says nothing about its
27
+ renderers, which are still writing into the profile while they are reaped. The last-resort
28
+ path had learned this; the polite path — the one every ordinary run uses — swallowed the
29
+ failure with a bare catch. It passes on macOS and on an idle Linux box and fails on a loaded
30
+ one, which is exactly how a race behaves.
31
+ - **`doctor` asked the machine before it asked the project.** For iPhone and Windows apps the
32
+ platform test came first, so on Linux a project containing no iPhone app was told its
33
+ non-existent app could not be reached from this machine — a machine reason given for a
34
+ project fact, which is the exact conflation that sends somebody to install thirty gigabytes
35
+ of Xcode for nothing. The project is asked first now, on every surface and every platform.
36
+
11
37
  ## [0.11.0] — 2026-08-31
12
38
 
13
39
  Fifty-odd defects, found by running the thing rather than reading it. Two rounds of seven
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "staysfixed",
3
- "version": "0.11.0",
3
+ "version": "0.11.1",
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",
package/src/guard/api.js CHANGED
@@ -15,7 +15,7 @@
15
15
  * off IS the guard's own words, in the guard's own order.
16
16
  */
17
17
 
18
- import { exec } from 'node:child_process';
18
+ import { spawn } from 'node:child_process';
19
19
  import fsp from 'node:fs/promises';
20
20
  import path from 'node:path';
21
21
  import { StaysFixedError } from '../core/errors.js';
@@ -298,41 +298,120 @@ export function makeGuardApi(page, project, opts = {}) {
298
298
 
299
299
  /** @type {Promise<{code: number, stdout: string, stderr: string}>} */
300
300
  const finished = new Promise((resolve, reject) => {
301
- exec(
302
- cmd,
303
- // `signal` is what actually kills the child. A guard that shells out to something
304
- // long-running was the clearest case of a timed-out guard holding a real resource:
305
- // the run had reported and moved on, and the command was still going.
306
- { cwd, timeout: timeoutMs, maxBuffer: MAX_OUTPUT, encoding: 'utf8', signal: givenUp },
307
- (error, stdout, stderr) => {
308
- const out = String(stdout ?? '');
309
- let err = String(stderr ?? '');
310
- let code = 0;
311
-
312
- if (error) {
313
- const e = /** @type {any} */ (error);
314
- // Stopped because the run gave up on this guard, not because the command ran
315
- // long. Saying "stopped after 60 seconds" here would be a made-up reason.
316
- if (givenUp?.aborted && (e.name === 'AbortError' || e.killed)) {
317
- reject(
318
- new GuardAbandoned(
319
- 'The run gave up on this guard while this command was still going, so the command was stopped.',
320
- ),
321
- );
322
- return;
323
- }
324
- if (e.killed || e.signal) {
325
- // 124 is what `timeout(1)` uses, so a guard can spot it.
326
- code = 124;
327
- err += `\n(the command was stopped after ${humanTime(timeoutMs)})`;
328
- } else {
329
- code = typeof e.code === 'number' ? e.code : 1;
330
- }
331
- }
332
-
333
- resolve({ code, stdout: out, stderr: err });
334
- },
335
- );
301
+ // `spawn` with `shell: true` rather than `exec`, for one reason: `detached`.
302
+ //
303
+ // KILLING THE SHELL IS NOT KILLING WHAT THE SHELL STARTED. A command runs through a
304
+ // shell, so a signal reaches the shell and the program it started carries on with a
305
+ // new parent. On a Mac the shell usually takes its child with it and this was
306
+ // invisible; on Linux it does not, and a command the run had given up on finished its
307
+ // work 800ms later, wrote its file, and proved it — caught by CI on 2026-08-31
308
+ // against a green Mac suite. `detached` puts the shell and everything it starts in
309
+ // one process group, and the group is what gets signalled.
310
+ //
311
+ // `exec` cannot do this: `detached` is not one of its documented options. It happens
312
+ // to be passed through today, and a tool built on not lying should not rest on that.
313
+ const child = spawn(cmd, {
314
+ cwd,
315
+ shell: true,
316
+ detached: process.platform !== 'win32',
317
+ windowsHide: true,
318
+ });
319
+
320
+ let out = '';
321
+ let err = '';
322
+ let tooMuch = false;
323
+ /** @type {'ran'|'gave up'|'ran out of time'} */
324
+ let how = 'ran';
325
+ let done = false;
326
+
327
+ /** Stop the shell AND everything it started. */
328
+ const stopEverything = () => {
329
+ if (!child.pid) return;
330
+ try {
331
+ if (process.platform === 'win32') child.kill('SIGKILL');
332
+ else process.kill(-child.pid, 'SIGKILL');
333
+ } catch {
334
+ // Already gone, which is the good case.
335
+ }
336
+ };
337
+
338
+ child.stdout?.setEncoding('utf8');
339
+ child.stderr?.setEncoding('utf8');
340
+ child.stdout?.on('data', (/** @type {string} */ chunk) => {
341
+ if (out.length + chunk.length > MAX_OUTPUT) {
342
+ tooMuch = true;
343
+ out = (out + chunk).slice(0, MAX_OUTPUT);
344
+ stopEverything();
345
+ return;
346
+ }
347
+ out += chunk;
348
+ });
349
+ child.stderr?.on('data', (/** @type {string} */ chunk) => {
350
+ if (err.length + chunk.length > MAX_OUTPUT) {
351
+ tooMuch = true;
352
+ err = (err + chunk).slice(0, MAX_OUTPUT);
353
+ stopEverything();
354
+ return;
355
+ }
356
+ err += chunk;
357
+ });
358
+
359
+ const ranOut = setTimeout(() => {
360
+ how = 'ran out of time';
361
+ stopEverything();
362
+ }, timeoutMs);
363
+ if (typeof ranOut.unref === 'function') ranOut.unref();
364
+
365
+ const gaveUp = () => {
366
+ how = 'gave up';
367
+ stopEverything();
368
+ };
369
+ givenUp?.addEventListener('abort', gaveUp, { once: true });
370
+ if (givenUp?.aborted) gaveUp();
371
+
372
+ /** @param {number} code */
373
+ const finish = (code) => {
374
+ if (done) return;
375
+ done = true;
376
+ clearTimeout(ranOut);
377
+ givenUp?.removeEventListener('abort', gaveUp);
378
+
379
+ // Stopped because the run gave up on this guard, not because the command ran long.
380
+ // Saying "stopped after 60 seconds" here would be a made-up reason.
381
+ if (how === 'gave up') {
382
+ reject(
383
+ new GuardAbandoned(
384
+ 'The run gave up on this guard while this command was still going, so the command was stopped.',
385
+ ),
386
+ );
387
+ return;
388
+ }
389
+ if (how === 'ran out of time') {
390
+ // 124 is what `timeout(1)` uses, so a guard can spot it.
391
+ resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after ${humanTime(timeoutMs)})` });
392
+ return;
393
+ }
394
+ if (tooMuch) {
395
+ resolve({ code: 124, stdout: out, stderr: `${err}\n(the command was stopped after printing more than this tool will keep)` });
396
+ return;
397
+ }
398
+ resolve({ code, stdout: out, stderr: err });
399
+ };
400
+
401
+ child.on('error', (/** @type {any} */ e) => {
402
+ if (done) return;
403
+ done = true;
404
+ clearTimeout(ranOut);
405
+ givenUp?.removeEventListener('abort', gaveUp);
406
+ if (how === 'gave up') {
407
+ reject(new GuardAbandoned('The run gave up on this guard while this command was still going, so the command was stopped.'));
408
+ return;
409
+ }
410
+ resolve({ code: 1, stdout: out, stderr: `${err}\n${String(e?.message ?? e)}` });
411
+ });
412
+ // `close`, not `exit`: exit fires when the shell ends, and everything it printed has
413
+ // to have been read before the answer is handed back.
414
+ child.on('close', (/** @type {number|null} */ code) => finish(typeof code === 'number' ? code : 1));
336
415
  });
337
416
 
338
417
  const outcome = await finished;
@@ -532,6 +532,37 @@ function installGuards() {
532
532
  }
533
533
  }
534
534
 
535
+ /**
536
+ * Take the throwaway profile away, and keep taking it away until it stays gone.
537
+ *
538
+ * One `rm` is a snapshot. A browser is not one process: the parent exiting says nothing about
539
+ * its renderers, and while they are being reaped they are still writing into the profile — so
540
+ * the sweep starts, a file appears behind it, the folder is not empty, and the profile
541
+ * outlives the run. That is the one thing "nothing it opened outlives the run" promises.
542
+ *
543
+ * The last-resort path (`killNow`) learned this and this one did not, so the polite close —
544
+ * which is the one every ordinary run uses — swallowed the failure with a bare `.catch()` and
545
+ * left the folder behind. It passed on macOS and on an idle Linux box, and failed on a loaded
546
+ * CI runner, which is exactly how a race behaves. Measured 2026-08-31.
547
+ *
548
+ * @param {string} home
549
+ * @returns {Promise<void>}
550
+ */
551
+ async function removeStubbornly(home) {
552
+ for (let attempt = 0; attempt < 6; attempt += 1) {
553
+ try {
554
+ await fsp.rm(home, { recursive: true, force: true });
555
+ if (!fs.existsSync(home)) return;
556
+ } catch {
557
+ // A file recreated a millisecond after the sweep began. Wait for whoever wrote it to
558
+ // finish dying, and go round again.
559
+ }
560
+ await new Promise((resolve) => setTimeout(resolve, 25 * (attempt + 1)));
561
+ }
562
+ // Still there. Untidy rather than harmful, and exactly what `staysfixed browsers --clean`
563
+ // is for — but never reported as success.
564
+ }
565
+
535
566
  /**
536
567
  * The last-resort cleanup: no promises, no awaiting, no politeness.
537
568
  * @param {number|null} pid
@@ -900,7 +931,7 @@ export async function openBrowser(opts = {}) {
900
931
  closing ??= (async () => {
901
932
  live.delete(id);
902
933
  await stopProcess(child, GRACE_MS);
903
- await fsp.rm(home, { recursive: true, force: true }).catch(() => {});
934
+ await removeStubbornly(home);
904
935
  })();
905
936
  return closing;
906
937
  };
package/src/v2/doctor.js CHANGED
@@ -898,9 +898,15 @@ function findDesktopApp(cwd) {
898
898
  * install thirty gigabytes of Xcode is asking for work that changes nothing, and the whole
899
899
  * design turns on never doing that.
900
900
  *
901
+ * Windows is answered here too, for the same reason and by the same rule: a repository with
902
+ * no Windows program in it does not need a Windows machine, and saying "no Windows desktop
903
+ * can be reached from here" about one sends somebody looking for a machine they will never
904
+ * use. Only the settings can answer it — a native Windows build is not something this file
905
+ * can go and find in a folder.
906
+ *
901
907
  * @param {string} root
902
908
  * @param {string|null} settingsText
903
- * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
909
+ * @returns {Promise<{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}>}
904
910
  */
905
911
  async function phoneApps(root, settingsText) {
906
912
  // Comments taken away first, for the same reason `findDesktopApp` does it: a
@@ -977,7 +983,9 @@ async function phoneApps(root, settingsText) {
977
983
  ? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
978
984
  : null);
979
985
 
980
- return { android, ios };
986
+ const windows = named('remoteExe') ?? namedPath('exe', (v) => /\.exe$/i.test(v));
987
+
988
+ return { android, ios, windows };
981
989
  }
982
990
 
983
991
  /**
@@ -1671,7 +1679,7 @@ async function findReference(root) {
1671
1679
  * @param {import('./browsers.js').BrowserSurvey} browsers
1672
1680
  * @param {{where: string, how: string}|null} desktopApp
1673
1681
  * @param {DriverReport[]} drivers What this copy of the tool can drive at all.
1674
- * @param {{android: FoundApp|null, ios: FoundApp|null}} phones
1682
+ * @param {{android: FoundApp|null, ios: FoundApp|null, windows: FoundApp|null}} phones
1675
1683
  * @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
1676
1684
  * @param {{commands: number, imports: number}} [wires]
1677
1685
  * What this project's own settings wire for the command-line surface. A surface with
@@ -1884,7 +1892,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1884
1892
  const iosBlocked = iosWants.some(blocks);
1885
1893
  const iosReady = iosMachine && canDrive('ios') && phones.ios !== null && iosWants.length === 0;
1886
1894
  const iosPartly = iosMachine && canDrive('ios') && phones.ios !== null && !iosReady && !iosBlocked;
1887
- if (!onAMac) {
1895
+ // THE PROJECT IS ASKED BEFORE THE MACHINE, and the order is the whole point. "There is no
1896
+ // iPhone app in this repository" and "this machine cannot run one" are different sentences
1897
+ // with different things to do about them, and a machine reason given for a project that has
1898
+ // no iPhone app in it sends somebody to install thirty gigabytes of Xcode for nothing. On a
1899
+ // Mac this was already right; on Linux the platform test came first, so every project on
1900
+ // every Linux machine was told its non-existent iPhone app was out of reach. Caught by CI
1901
+ // on 2026-08-31 — the Mac suite was green and said nothing about it.
1902
+ if (phones.ios === null) {
1903
+ notInThisProject.add('ios');
1904
+ impossible.set(
1905
+ 'ios',
1906
+ 'This project has no iPhone app in it, so there is nothing for a simulator to run. If yours is built somewhere else, name the built .app in your settings under ios.app.'
1907
+ );
1908
+ } else if (!onAMac) {
1888
1909
  impossible.set('ios', 'An iPhone build can only be run on a Mac. Everything else on this list is unaffected — check the iPhone app from a Mac, and let this machine cover the rest.');
1889
1910
  } else if (phones.ios === null) {
1890
1911
  notInThisProject.add('ios');
@@ -1902,10 +1923,10 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1902
1923
  id: 'ios',
1903
1924
  name: 'iPhone apps, on the simulator',
1904
1925
  status: iosReady ? 'ready' : iosPartly ? 'partial' : 'unavailable',
1905
- summary: !onAMac
1906
- ? 'Cannot run here: iOS needs a Mac.'
1907
- : phones.ios === null
1908
- ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1926
+ summary: phones.ios === null
1927
+ ? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
1928
+ : !onAMac
1929
+ ? 'Cannot run here: iOS needs a Mac.'
1909
1930
  : !canDrive('ios')
1910
1931
  ? `An iPhone app is here (${phones.ios.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('ios')}`
1911
1932
  : !iosMachine
@@ -1949,6 +1970,20 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1949
1970
  // is "detect rather than ask" at its sharpest: a runner that already answers must never
1950
1971
  // be presented as something to go and set up.
1951
1972
  const windowsDriver = canDrive('windows');
1973
+ // Same rule as iPhone and Android: the project is asked before the machine. A repository
1974
+ // with no native Windows program in it does not need a Windows machine, and "no Windows
1975
+ // desktop is reachable from here" about one is a machine reason given for a project fact.
1976
+ // Caught by CI on 2026-08-31, where a Linux runner reported native Windows apps as out of
1977
+ // reach for a project that contains none.
1978
+ if (phones.windows === null) {
1979
+ notInThisProject.add('windows');
1980
+ impossible.set(
1981
+ 'windows',
1982
+ 'This project has no native Windows program named in its settings, so there is nothing to open on a Windows desktop. '
1983
+ + 'If yours is built somewhere else, name it under windows.remoteExe (already on that machine) or windows.exe (copied over each run). '
1984
+ + 'Most Windows products are Electron, and those are covered over their debug port from any machine.'
1985
+ );
1986
+ }
1952
1987
  // A Windows desktop nobody has signed into is not a runner. There is nothing on it to read
1953
1988
  // — no windows, no controls — so calling it "partly covered" would be the exact over-claim
1954
1989
  // this file exists to prevent. The question can only be asked when the runner started
@@ -1959,7 +1994,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1959
1994
  id: 'windows',
1960
1995
  name: 'native Windows apps',
1961
1996
  status: windowsUsable ? 'partial' : 'unavailable',
1962
- summary: !windowsHost
1997
+ summary: phones.windows === null
1998
+ ? 'Nothing to check: this project names no native Windows program in its settings. Most Windows products are Electron, and those are covered over their debug port from any machine.'
1999
+ : !windowsHost
1963
2000
  ? nobodyWasDialled
1964
2001
  // Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
1965
2002
  // The two were the same sentence until 2026-08-31, and it stated as a fact about the
package/src/v2/init.js CHANGED
@@ -1893,7 +1893,15 @@ export async function run(ctx) {
1893
1893
  const others = (result.plan?.readiness ?? [])
1894
1894
  .map((/** @type {any} */ r) => String(r.product ?? ''))
1895
1895
  .filter((/** @type {string} */ n, /** @type {number} */ i, /** @type {string[]} */ all) => n !== '' && all.indexOf(n) === i);
1896
- if (result.written.length > 0) ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1896
+ if (result.written.length > 0) {
1897
+ ok(`Written: ${result.written.map((f) => shortPath(f)).join(', ')}`);
1898
+ // Worth one line, because of what happens if it is not done. These files are part of the
1899
+ // build now: until they are committed the working tree is not what git has, so the first
1900
+ // reference gets cut from a tree that has no commit of its own — and a later check cannot
1901
+ // put that build back on the machine to walk it live. It falls back to the stored record,
1902
+ // says so, and is weaker for it. One `git add` avoids the whole thing.
1903
+ say('Commit them before you ship. Settings that are not committed leave the first reference tied to a build git does not have, and a later check can then only compare against the record rather than running the old build live.');
1904
+ }
1897
1905
  if (others.length > 1) {
1898
1906
  warn(
1899
1907
  `Those settings describe ONE product. ${others.length} were found here (${others.join(', ')}), and the others are not covered by this file. ` +