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 +26 -0
- package/package.json +1 -1
- package/src/guard/api.js +115 -36
- package/src/v2/browsers.js +32 -1
- package/src/v2/doctor.js +46 -9
- package/src/v2/init.js +9 -1
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.
|
|
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 {
|
|
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
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
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;
|
package/src/v2/browsers.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
1906
|
-
? '
|
|
1907
|
-
:
|
|
1908
|
-
? '
|
|
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:
|
|
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)
|
|
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. ` +
|