staysfixed 0.10.0 → 0.11.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 +125 -0
- package/README.md +17 -5
- package/docs/getting-started.md +10 -0
- package/docs/how-v2-works.md +5 -2
- package/package.json +2 -2
- package/src/guard/api.js +107 -3
- package/src/guard/run.js +154 -20
- package/src/report/console.js +235 -17
- package/src/report/html.js +75 -19
- package/src/types.js +5 -0
- package/src/v2/adapters/android-driver.js +62 -12
- package/src/v2/adapters/contract.js +18 -4
- package/src/v2/adapters/electron.js +96 -14
- package/src/v2/adapters/http.js +264 -23
- package/src/v2/adapters/ios-driver.js +22 -4
- package/src/v2/adapters/ios.js +5 -2
- package/src/v2/adapters/isolate.js +78 -5
- package/src/v2/adapters/process.js +350 -92
- package/src/v2/adapters/web-driver.js +23 -1
- package/src/v2/adapters/web.js +42 -3
- package/src/v2/adapters/windows.js +32 -15
- package/src/v2/check.js +319 -9
- package/src/v2/cli.js +345 -3
- package/src/v2/cluster.js +112 -4
- package/src/v2/coverage.js +208 -8
- package/src/v2/detect.js +182 -9
- package/src/v2/doctor.js +168 -30
- package/src/v2/init.js +88 -10
- package/src/v2/mcp/server.js +4 -1
- package/src/v2/mcp/tools.js +291 -24
- package/src/v2/observation.js +57 -5
- package/src/v2/reference.js +133 -14
- package/src/v2/refusal.js +389 -0
- package/src/v2/remote.js +24 -3
- package/src/v2/run.js +306 -16
- package/src/v2/sealed.js +14 -2
- package/src/v2/ship.js +286 -22
- package/src/v2/store.js +101 -2
- package/src/v2/types.js +5 -0
- package/src/v2/waiver.js +9 -2
- package/src/watch/panel.js +12 -1
package/src/v2/doctor.js
CHANGED
|
@@ -196,7 +196,8 @@ export const CHANNELS = [
|
|
|
196
196
|
* should be able to read it once and know what to call, what it will get back,
|
|
197
197
|
* and what it must not bother asking for here.
|
|
198
198
|
*
|
|
199
|
-
* @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean
|
|
199
|
+
* @param {{cwd?: string, configFile?: string, offline?: boolean, machines?: boolean,
|
|
200
|
+
* settingsText?: string}} [opts]
|
|
200
201
|
* @returns {Promise<Capabilities>}
|
|
201
202
|
*/
|
|
202
203
|
export async function capabilities(opts = {}) {
|
|
@@ -210,6 +211,28 @@ export async function capabilities(opts = {}) {
|
|
|
210
211
|
// sitting two folders up, and told the agent to go and build one that was already there.
|
|
211
212
|
const root = configFile ? rootForConfig(configFile) : cwd;
|
|
212
213
|
|
|
214
|
+
// The settings, as text, from whichever of the two places they live in.
|
|
215
|
+
//
|
|
216
|
+
// `init` is the second place. It works out what it is about to write and only then asks
|
|
217
|
+
// this function what the machine can do — so on a fresh project every question below was
|
|
218
|
+
// answered against NO settings at all, and the answers went straight into the readiness
|
|
219
|
+
// it printed. A plain Node command-line tool was told, by the same run that had just
|
|
220
|
+
// wired `node cli.js --help` into its settings, that it still needed "a command to run".
|
|
221
|
+
// Being sent to set up something the tool has already set up is how somebody decides this
|
|
222
|
+
// page is not worth reading. Measured 2026-08-31.
|
|
223
|
+
const settingsText = opts.settingsText ?? readTextOrNull(configFile);
|
|
224
|
+
// A machine NAMED in the settings is a machine the project has asked for. Doctor does not
|
|
225
|
+
// dial anybody's ssh config unasked — that rule stands and is right — but "you have not
|
|
226
|
+
// asked me to look" and "your own settings name this machine" are different situations,
|
|
227
|
+
// and treating them the same produced a flat untruth: with `windows: { host: "imza-pc" }`
|
|
228
|
+
// sitting in the settings, doctor answered "No Windows desktop is reachable from here"
|
|
229
|
+
// having never dialled anything. Measured 2026-08-31 against a Windows 11 machine that was
|
|
230
|
+
// reachable, signed in and unlocked the whole time. Only the named machines are dialled;
|
|
231
|
+
// the rest of the ssh config is still left alone.
|
|
232
|
+
const askedFor = hostsNamedInSettings(settingsText);
|
|
233
|
+
const settingsAreJson = opts.settingsText ? false : configFile !== null && configFile.endsWith('.json');
|
|
234
|
+
const hasSettings = settingsText !== null;
|
|
235
|
+
|
|
213
236
|
// The browser survey comes first because three different answers below depend
|
|
214
237
|
// on it, and asking this machine the same question three times would be both
|
|
215
238
|
// slow and a way for the three answers to disagree.
|
|
@@ -223,15 +246,16 @@ export async function capabilities(opts = {}) {
|
|
|
223
246
|
// the hosts list feeds exactly one surface: that one.
|
|
224
247
|
offline
|
|
225
248
|
? Promise.resolve(/** @type {HostReport[]} */ ([]))
|
|
226
|
-
: reachableHosts({ dial: opts.machines === true || desktopApp !== null }),
|
|
249
|
+
: reachableHosts({ dial: opts.machines === true || desktopApp !== null, only: askedFor }),
|
|
227
250
|
isRepo(root).catch(() => false),
|
|
228
251
|
findReference(root),
|
|
229
252
|
whatThisCopyCanDrive(),
|
|
230
|
-
phoneApps(root,
|
|
231
|
-
askTheAdapters(root),
|
|
253
|
+
phoneApps(root, settingsText),
|
|
254
|
+
askTheAdapters(root, settingsText, settingsAreJson),
|
|
232
255
|
]);
|
|
233
256
|
|
|
234
|
-
const
|
|
257
|
+
const wires = settingsText ? whatTheProcessBlockWires(settingsText) : { commands: 0, imports: 0 };
|
|
258
|
+
const surfaces = describeSurfaces(tools, hosts, hasSettings, browsers, desktopApp, drivers, phones, asked, wires);
|
|
235
259
|
|
|
236
260
|
/** @type {Capabilities} */
|
|
237
261
|
const caps = {
|
|
@@ -254,7 +278,7 @@ export async function capabilities(opts = {}) {
|
|
|
254
278
|
},
|
|
255
279
|
surfaces,
|
|
256
280
|
drivers,
|
|
257
|
-
covers: whatThisRunActuallyCovers(surfaces,
|
|
281
|
+
covers: whatThisRunActuallyCovers(surfaces, hasSettings),
|
|
258
282
|
browsers: {
|
|
259
283
|
willOpen: browsers.chosen,
|
|
260
284
|
borrowingYourOwn: browsers.borrowingHis,
|
|
@@ -875,21 +899,13 @@ function findDesktopApp(cwd) {
|
|
|
875
899
|
* design turns on never doing that.
|
|
876
900
|
*
|
|
877
901
|
* @param {string} root
|
|
878
|
-
* @param {string|null}
|
|
902
|
+
* @param {string|null} settingsText
|
|
879
903
|
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
|
|
880
904
|
*/
|
|
881
|
-
async function phoneApps(root,
|
|
882
|
-
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
try {
|
|
886
|
-
// Comments taken away first, for the same reason `findDesktopApp` does it: a
|
|
887
|
-
// commented-out `apk:` line is an example, not an Android app.
|
|
888
|
-
settings = withoutComments(readFileSync(configFile, 'utf8'));
|
|
889
|
-
} catch {
|
|
890
|
-
settings = '';
|
|
891
|
-
}
|
|
892
|
-
}
|
|
905
|
+
async function phoneApps(root, settingsText) {
|
|
906
|
+
// Comments taken away first, for the same reason `findDesktopApp` does it: a
|
|
907
|
+
// commented-out `apk:` line is an example, not an Android app.
|
|
908
|
+
const settings = settingsText ? withoutComments(settingsText) : '';
|
|
893
909
|
|
|
894
910
|
/**
|
|
895
911
|
* @param {string} key
|
|
@@ -1065,9 +1081,12 @@ const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
|
|
|
1065
1081
|
* take the rest of the answer with it.
|
|
1066
1082
|
*
|
|
1067
1083
|
* @param {string} root
|
|
1084
|
+
* @param {string|null} [settingsText] The settings as text — from disk, or from what
|
|
1085
|
+
* `init` is about to write.
|
|
1086
|
+
* @param {boolean} [settingsAreJson]
|
|
1068
1087
|
* @returns {Promise<Map<string, Need[]>>}
|
|
1069
1088
|
*/
|
|
1070
|
-
async function askTheAdapters(root) {
|
|
1089
|
+
async function askTheAdapters(root, settingsText = null, settingsAreJson = false) {
|
|
1071
1090
|
/** @type {Map<string, Need[]>} */
|
|
1072
1091
|
const out = new Map();
|
|
1073
1092
|
/** @type {{adapters: {name: string, detect: (p: any) => Promise<any>}[]}} */
|
|
@@ -1081,10 +1100,9 @@ async function askTheAdapters(root) {
|
|
|
1081
1100
|
/** @type {Record<string, any>} */
|
|
1082
1101
|
let config = {};
|
|
1083
1102
|
try {
|
|
1084
|
-
const file = findConfigFile(root);
|
|
1085
1103
|
// Read as text and parsed only when it is JSON. Doctor never runs a person's code to
|
|
1086
1104
|
// answer a question about their machine, and a settings file may be JavaScript.
|
|
1087
|
-
if (
|
|
1105
|
+
if (settingsText && settingsAreJson) config = JSON.parse(settingsText);
|
|
1088
1106
|
// But "not JSON" was being treated as "says nothing", and `init` writes JavaScript — so
|
|
1089
1107
|
// for almost every project every adapter was asked what it needs while being handed an
|
|
1090
1108
|
// EMPTY config. It then asked for the very thing the settings already named: a project
|
|
@@ -1094,7 +1112,7 @@ async function askTheAdapters(root) {
|
|
|
1094
1112
|
// The few values the adapters need to answer honestly are read out of the TEXT instead,
|
|
1095
1113
|
// scoped to their own block so one block's `app` can never be read as another's. Still no
|
|
1096
1114
|
// code is run, which was the whole point of the rule.
|
|
1097
|
-
else if (
|
|
1115
|
+
else if (settingsText) config = { ...config, ...settingsFromText(settingsText) };
|
|
1098
1116
|
} catch {
|
|
1099
1117
|
config = {};
|
|
1100
1118
|
}
|
|
@@ -1210,7 +1228,7 @@ function androidSdkTool(folder, name) {
|
|
|
1210
1228
|
* answers is a runner the tool already has, and it must never appear in the
|
|
1211
1229
|
* result as something to go and set up.
|
|
1212
1230
|
*
|
|
1213
|
-
* @param {{dial?: boolean}} [opts]
|
|
1231
|
+
* @param {{dial?: boolean, only?: string[]}} [opts]
|
|
1214
1232
|
* @returns {Promise<HostReport[]>}
|
|
1215
1233
|
*/
|
|
1216
1234
|
export async function reachableHosts(opts = {}) {
|
|
@@ -1233,6 +1251,24 @@ export async function reachableHosts(opts = {}) {
|
|
|
1233
1251
|
// because a machine quietly left out of the answer is the same bug as a folder quietly
|
|
1234
1252
|
// skipped while reading source: the list looks complete and the runner somebody needed is
|
|
1235
1253
|
// simply not in it.
|
|
1254
|
+
// The settings named these, so they are dialled even when nothing else is. Everything
|
|
1255
|
+
// else in the ssh config stays untouched and is still listed by name.
|
|
1256
|
+
const named = (opts.only ?? []).filter((name) => names.includes(name));
|
|
1257
|
+
if (opts.dial !== true && named.length > 0) {
|
|
1258
|
+
const dialledByName = await Promise.all(named.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
|
|
1259
|
+
const rest = names
|
|
1260
|
+
.filter((name) => !named.includes(name))
|
|
1261
|
+
.map(
|
|
1262
|
+
(name) =>
|
|
1263
|
+
/** @type {HostReport} */ ({
|
|
1264
|
+
name,
|
|
1265
|
+
reachable: false,
|
|
1266
|
+
how: 'named in your ssh config and deliberately NOT dialled. Your settings do not mention it and nothing here needs it. `staysfixed doctor --machines` checks them all.',
|
|
1267
|
+
})
|
|
1268
|
+
);
|
|
1269
|
+
return [...dialledByName, ...rest];
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1236
1272
|
if (opts.dial !== true) {
|
|
1237
1273
|
return names.map(
|
|
1238
1274
|
(name) =>
|
|
@@ -1414,6 +1450,73 @@ export function readHostProbe(name, alive, look) {
|
|
|
1414
1450
|
return report;
|
|
1415
1451
|
}
|
|
1416
1452
|
|
|
1453
|
+
/**
|
|
1454
|
+
* Every machine the settings name by ssh host. These are the ones doctor may dial without
|
|
1455
|
+
* being asked twice, because naming a machine in your own settings IS the ask.
|
|
1456
|
+
*
|
|
1457
|
+
* @param {string|null} text
|
|
1458
|
+
* @returns {string[]}
|
|
1459
|
+
*/
|
|
1460
|
+
export function hostsNamedInSettings(text) {
|
|
1461
|
+
if (!text) return [];
|
|
1462
|
+
const clean = withoutComments(text);
|
|
1463
|
+
const out = new Set();
|
|
1464
|
+
for (const hit of clean.matchAll(/["']?host["']?\s*:\s*["'`]([^"'`]+)["'`]/g)) out.add(hit[1]);
|
|
1465
|
+
return [...out];
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The settings as text, or null when there are none to read yet.
|
|
1470
|
+
*
|
|
1471
|
+
* Null and empty are different answers here. "There is no settings file" is what makes a
|
|
1472
|
+
* surface unready; "the settings say nothing about this surface" is a different sentence.
|
|
1473
|
+
*
|
|
1474
|
+
* @param {string|null} file
|
|
1475
|
+
* @returns {string|null}
|
|
1476
|
+
*/
|
|
1477
|
+
function readTextOrNull(file) {
|
|
1478
|
+
if (!file) return null;
|
|
1479
|
+
try {
|
|
1480
|
+
return readFileSync(file, 'utf8');
|
|
1481
|
+
} catch {
|
|
1482
|
+
return null;
|
|
1483
|
+
}
|
|
1484
|
+
}
|
|
1485
|
+
|
|
1486
|
+
/**
|
|
1487
|
+
* How much of this project is actually wired for the command-line surface.
|
|
1488
|
+
*
|
|
1489
|
+
* The `cli` surface was hard-coded READY with "Fully covered here" — it never looked at the
|
|
1490
|
+
* project at all. So on a settings file whose `process` block wires no commands and nothing
|
|
1491
|
+
* to import, doctor said command-line tools were fully covered, and a check then answered
|
|
1492
|
+
* "Nothing that worked has changed" having run not one command. Measured 2026-08-31.
|
|
1493
|
+
*
|
|
1494
|
+
* Counted out of the text, like everything else here, because the settings may be JavaScript
|
|
1495
|
+
* and doctor never runs a person's code to answer a question about their machine.
|
|
1496
|
+
*
|
|
1497
|
+
* @param {string} text
|
|
1498
|
+
* @returns {{commands: number, imports: number}}
|
|
1499
|
+
*/
|
|
1500
|
+
function whatTheProcessBlockWires(text) {
|
|
1501
|
+
const clean = withoutComments(text);
|
|
1502
|
+
const at = /["']?process["']?\s*:\s*\{/.exec(clean);
|
|
1503
|
+
if (!at) return { commands: 0, imports: 0 };
|
|
1504
|
+
let depth = 0;
|
|
1505
|
+
let end = at.index + at[0].length;
|
|
1506
|
+
for (; end < clean.length; end += 1) {
|
|
1507
|
+
if (clean[end] === '{') depth += 1;
|
|
1508
|
+
else if (clean[end] === '}') {
|
|
1509
|
+
if (depth === 0) break;
|
|
1510
|
+
depth -= 1;
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
const inside = clean.slice(at.index + at[0].length, end);
|
|
1514
|
+
return {
|
|
1515
|
+
commands: (inside.match(/["']?run["']?\s*:/g) ?? []).length,
|
|
1516
|
+
imports: (inside.match(/["']?module["']?\s*:/g) ?? []).length,
|
|
1517
|
+
};
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1417
1520
|
/**
|
|
1418
1521
|
* The handful of settings an adapter needs to say what it is missing, read out of a
|
|
1419
1522
|
* JavaScript settings file WITHOUT running it.
|
|
@@ -1435,6 +1538,10 @@ function settingsFromText(text) {
|
|
|
1435
1538
|
electron: ['binary'],
|
|
1436
1539
|
web: ['url', 'start'],
|
|
1437
1540
|
http: ['start', 'url'],
|
|
1541
|
+
// Left out until 2026-08-31, and the Windows adapter was handed an empty settings object
|
|
1542
|
+
// as a result: it could not see the machine the settings named, nor the built program,
|
|
1543
|
+
// so it asked for both while both were sitting in the file.
|
|
1544
|
+
windows: ['host', 'remoteExe', 'exe'],
|
|
1438
1545
|
};
|
|
1439
1546
|
for (const [block, keys] of Object.entries(wanted)) {
|
|
1440
1547
|
const at = new RegExp(`["']?${block}["']?\\s*:\\s*\\{`).exec(clean);
|
|
@@ -1566,9 +1673,12 @@ async function findReference(root) {
|
|
|
1566
1673
|
* @param {DriverReport[]} drivers What this copy of the tool can drive at all.
|
|
1567
1674
|
* @param {{android: FoundApp|null, ios: FoundApp|null}} phones
|
|
1568
1675
|
* @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
|
|
1676
|
+
* @param {{commands: number, imports: number}} [wires]
|
|
1677
|
+
* What this project's own settings wire for the command-line surface. A surface with
|
|
1678
|
+
* nothing wired covers nothing here, whatever this machine could do.
|
|
1569
1679
|
* @returns {SurfaceReport[]}
|
|
1570
1680
|
*/
|
|
1571
|
-
function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked) {
|
|
1681
|
+
function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked, wires = { commands: 0, imports: 0 }) {
|
|
1572
1682
|
/** @param {string} surface */
|
|
1573
1683
|
const canDrive = (surface) => drivers.find((d) => d.surface === surface)?.present !== false;
|
|
1574
1684
|
/** @param {string} surface */
|
|
@@ -1578,6 +1688,9 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1578
1688
|
const browser = browsers.chosen !== null;
|
|
1579
1689
|
const ownBrowser = browsers.chosen !== null && !browsers.chosen.everyday;
|
|
1580
1690
|
const windowsHost = hosts.find((h) => h.reachable && h.windows === true);
|
|
1691
|
+
// Nothing was dialled at all: every host carries the sentence that says so. That is a
|
|
1692
|
+
// different answer from "they were dialled and none of them runs Windows".
|
|
1693
|
+
const nobodyWasDialled = hosts.length > 0 && hosts.every((h) => /NOT dialled|not dialled/.test(String(h.how ?? '')));
|
|
1581
1694
|
|
|
1582
1695
|
/** Every channel that needs no driver at all — a child process is enough. */
|
|
1583
1696
|
const withoutADriver = ['effects', 'complaints', 'results', 'contract', 'counters'];
|
|
@@ -1597,14 +1710,31 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1597
1710
|
*/
|
|
1598
1711
|
const notInThisProject = new Set();
|
|
1599
1712
|
|
|
1713
|
+
// READY is about this PROJECT, not about this machine. Hard-coded ready meant doctor said
|
|
1714
|
+
// command-line tools were "fully covered here" on a settings file that wires no commands
|
|
1715
|
+
// and nothing to import — and a check then answered "Nothing that worked has changed"
|
|
1716
|
+
// having run not one command.
|
|
1717
|
+
const wiredForCli = configured && wires.commands + wires.imports > 0;
|
|
1600
1718
|
surfaces.push({
|
|
1601
1719
|
id: 'cli',
|
|
1602
1720
|
name: 'command-line tools and libraries',
|
|
1603
|
-
status: 'ready',
|
|
1604
|
-
summary:
|
|
1721
|
+
status: wiredForCli ? 'ready' : 'partial',
|
|
1722
|
+
summary: wiredForCli
|
|
1723
|
+
? 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.'
|
|
1724
|
+
: configured
|
|
1725
|
+
? 'This machine can cover it in full — what a command printed, what it exited with, what it wrote, what it called out to, what it exports — but these settings wire no command to run and nothing to import, so a check runs none of it and a clean result says nothing about any of it.'
|
|
1726
|
+
: 'This machine can cover it in full, but nothing is set up in this folder yet, so a check cannot run here at all.',
|
|
1605
1727
|
canCheck: withoutADriver,
|
|
1606
1728
|
cannotCheck: ['meaning', 'pixels'],
|
|
1607
|
-
needs:
|
|
1729
|
+
needs: wiredForCli
|
|
1730
|
+
? []
|
|
1731
|
+
: [{
|
|
1732
|
+
what: 'a command to run, or something to import',
|
|
1733
|
+
why: 'Nothing here is walked otherwise, and a run that walks nothing still finishes and still says nothing changed.',
|
|
1734
|
+
fix: 'Add `process: { commands: [{ name: "help", run: "node bin/cli.js --help" }] }` to your settings, or `imports: [{ name: "the package entry", module: "index.js" }]`.',
|
|
1735
|
+
automatic: true,
|
|
1736
|
+
unlocks: 'Everything a command does — what it printed, what it exited with, what it wrote to disk, what it reached for.',
|
|
1737
|
+
}],
|
|
1608
1738
|
});
|
|
1609
1739
|
|
|
1610
1740
|
surfaces.push({
|
|
@@ -1830,7 +1960,12 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
|
|
|
1830
1960
|
name: 'native Windows apps',
|
|
1831
1961
|
status: windowsUsable ? 'partial' : 'unavailable',
|
|
1832
1962
|
summary: !windowsHost
|
|
1833
|
-
?
|
|
1963
|
+
? nobodyWasDialled
|
|
1964
|
+
// Not "no Windows desktop is reachable" — nothing was dialled, so that is not known.
|
|
1965
|
+
// The two were the same sentence until 2026-08-31, and it stated as a fact about the
|
|
1966
|
+
// world an answer that came from a decision not to look.
|
|
1967
|
+
? 'No machine was dialled, so whether a Windows desktop can be reached from here is unknown. This is usually fine: an Electron product on Windows is watched over the debug port instead, from any machine. `staysfixed doctor --machines` asks the machines in your ssh config, and naming one under `windows: { host: "..." }` in your settings asks that one every time.'
|
|
1968
|
+
: 'No Windows desktop is reachable from here. This is usually fine: an Electron product on Windows is watched over the debug port instead, from any machine.'
|
|
1834
1969
|
: !windowsDriver
|
|
1835
1970
|
? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
|
|
1836
1971
|
// The adapter's own paragraph, not a second one written here. It knows whether
|
|
@@ -1970,7 +2105,10 @@ function whatThisRunActuallyCovers(surfaces, setUpHere = true) {
|
|
|
1970
2105
|
if (noSuchProduct.length) {
|
|
1971
2106
|
parts.push(`There is nothing of ${noSuchProduct.length === 1 ? 'that kind' : 'those kinds'} in this repository — ${plainList(noSuchProduct)} — so there is nothing here to check, and that is not a limit of this machine.`);
|
|
1972
2107
|
}
|
|
1973
|
-
|
|
2108
|
+
// "the reason for each is listed above", not "is in notCovered": this lands in
|
|
2109
|
+
// covers.short, which `staysfixed doctor` and `staysfixed coverage` both print at a
|
|
2110
|
+
// person. A JSON field name is not somewhere a person can go and look.
|
|
2111
|
+
if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is listed above.`);
|
|
1974
2112
|
}
|
|
1975
2113
|
if (out.everything) parts.push('Nothing is being left out on this machine.');
|
|
1976
2114
|
out.short = parts.join(' ');
|
package/src/v2/init.js
CHANGED
|
@@ -163,12 +163,24 @@ export async function plan(options = {}) {
|
|
|
163
163
|
const root = existing ? rootForConfig(existing) : cwd;
|
|
164
164
|
|
|
165
165
|
const project = await detectProject({ root, readCode: options.readCode });
|
|
166
|
-
|
|
166
|
+
|
|
167
|
+
// The settings are worked out BEFORE the machine is asked, and handed over. Doctor reads
|
|
168
|
+
// the settings to answer half its questions, and on a fresh project there is no settings
|
|
169
|
+
// file yet — so it used to answer every one of them against nothing, and init printed the
|
|
170
|
+
// result as this project's readiness. A plain Node command-line tool was told it needed
|
|
171
|
+
// "a command to run" by the same run that had already written `node cli.js --help` into
|
|
172
|
+
// its settings. Settings that already exist are read from disk as before; this only hands
|
|
173
|
+
// over the ones that are about to be written.
|
|
174
|
+
const config = await planConfig(root, project, existing);
|
|
175
|
+
const machine = await readMachine({
|
|
176
|
+
cwd: root,
|
|
177
|
+
offline: options.offline,
|
|
178
|
+
settingsText: config.exists ? undefined : config.text,
|
|
179
|
+
});
|
|
167
180
|
|
|
168
181
|
const readiness = readinessFor(project, machine);
|
|
169
182
|
const journeys = proposeJourneys(project);
|
|
170
183
|
const needs = sortNeeds(readiness, project, machine);
|
|
171
|
-
const config = await planConfig(root, project, existing);
|
|
172
184
|
const covers = whatItCovers(readiness);
|
|
173
185
|
|
|
174
186
|
return {
|
|
@@ -250,13 +262,13 @@ export async function init(options = {}) {
|
|
|
250
262
|
* survey throws, and the honest degradation is "nothing is known about this machine", which
|
|
251
263
|
* makes every surface a person's problem rather than silently a ready one.
|
|
252
264
|
*
|
|
253
|
-
* @param {{cwd: string, offline?: boolean}} opts
|
|
265
|
+
* @param {{cwd: string, offline?: boolean, settingsText?: string}} opts
|
|
254
266
|
* @returns {Promise<Capabilities|null>}
|
|
255
267
|
*/
|
|
256
268
|
async function readMachine(opts) {
|
|
257
269
|
try {
|
|
258
270
|
const { capabilities } = await import('./doctor.js');
|
|
259
|
-
return await capabilities({ cwd: opts.cwd, offline: opts.offline });
|
|
271
|
+
return await capabilities({ cwd: opts.cwd, offline: opts.offline, settingsText: opts.settingsText });
|
|
260
272
|
} catch {
|
|
261
273
|
return null;
|
|
262
274
|
}
|
|
@@ -385,6 +397,31 @@ function insteadFor(product, project) {
|
|
|
385
397
|
}
|
|
386
398
|
}
|
|
387
399
|
|
|
400
|
+
/**
|
|
401
|
+
* Is the file a package says other code should import actually sitting there?
|
|
402
|
+
*
|
|
403
|
+
* Asked before this command tells anybody a library "can be checked here now". The answer
|
|
404
|
+
* has to allow for the shorthands package.json is allowed to use — `"main": "index"` and
|
|
405
|
+
* `"main": "./lib"` are both perfectly ordinary and both name something real — so the same
|
|
406
|
+
* endings and the same folder entry point Node itself would try are tried here. Erring on
|
|
407
|
+
* the side of "it is there" is the safe direction for THIS question: a way in that exists
|
|
408
|
+
* and is not recognised would put a job on somebody's list that they cannot do anything
|
|
409
|
+
* about, and a way in that is missing is caught the moment a check actually runs.
|
|
410
|
+
*
|
|
411
|
+
* Exported so a test can ask about one path without building a whole project.
|
|
412
|
+
*
|
|
413
|
+
* @param {string} root The project's own folder.
|
|
414
|
+
* @param {string} where Which folder inside it this product lives in. '.' for the root.
|
|
415
|
+
* @param {string} module Exactly what package.json said, e.g. './index.js'.
|
|
416
|
+
* @returns {boolean}
|
|
417
|
+
*/
|
|
418
|
+
export function isThereOnDisk(root, where, module) {
|
|
419
|
+
const base = path.resolve(root, where === '' ? '.' : where, module);
|
|
420
|
+
const endings = ['', '.js', '.mjs', '.cjs', '.json', '.node', '.ts'];
|
|
421
|
+
if (endings.some((end) => existsSync(base + end))) return true;
|
|
422
|
+
return ['index.js', 'index.mjs', 'index.cjs', 'index.json'].some((name) => existsSync(path.join(base, name)));
|
|
423
|
+
}
|
|
424
|
+
|
|
388
425
|
/**
|
|
389
426
|
* What this particular product is short of, from what was actually found on disk.
|
|
390
427
|
*
|
|
@@ -554,6 +591,36 @@ function productNeeds(product, project) {
|
|
|
554
591
|
});
|
|
555
592
|
}
|
|
556
593
|
|
|
594
|
+
// A way in that package.json promises and the folder has not got.
|
|
595
|
+
//
|
|
596
|
+
// package.json is a DECLARATION, not a fact. `"exports": {".": "./index.js"}` in a
|
|
597
|
+
// repository with no index.js in it reads, to everything upstream of here, as a perfectly
|
|
598
|
+
// good library with a perfectly good entry point — so nothing was outstanding, the product
|
|
599
|
+
// came back "ready", and this command told somebody "the library other code imports can be
|
|
600
|
+
// checked here now" and "right now a check here covers it in full", about a file that was
|
|
601
|
+
// not there. Measured 2026-08-31 on a package whose entry had never been built. An import
|
|
602
|
+
// that cannot resolve walks nothing, so "in full" covered nothing at all — which is the one
|
|
603
|
+
// shape of answer this tool exists to make impossible.
|
|
604
|
+
const missingWaysIn = (Array.isArray(suggest.imports) ? suggest.imports : [])
|
|
605
|
+
.map((one) => String(one?.module ?? ''))
|
|
606
|
+
.filter((module) => module !== '' && (module.startsWith('.') || path.isAbsolute(module)))
|
|
607
|
+
.filter((module) => !isThereOnDisk(project.root, product.where, module));
|
|
608
|
+
if (missingWaysIn.length > 0) {
|
|
609
|
+
const one = missingWaysIn.length === 1;
|
|
610
|
+
const build = project.scripts.build;
|
|
611
|
+
needs.push({
|
|
612
|
+
what: `${plainList(missingWaysIn)} — the ${one ? 'file' : 'files'} other code is told to import, ${one ? 'which is' : 'which are'} not there`,
|
|
613
|
+
why: `package.json points other projects at ${one ? 'that file' : 'those files'}, and nothing is at that path. There is nothing to import, so a check would compare none of what this library exports — and a clean result would be a clean result about nothing.`,
|
|
614
|
+
unlocks: 'every name this library exports, and what those exports actually do',
|
|
615
|
+
fix: build
|
|
616
|
+
? `Run \`${build}\` — that is what writes ${one ? 'it' : 'them'} — then \`staysfixed init --force\`. If the entry in package.json is simply pointing at the wrong path, correct it there instead.`
|
|
617
|
+
: `Either create ${plainList(missingWaysIn)}, or correct the "exports" (or "main") entry in package.json so it names the file that really is the way in.`,
|
|
618
|
+
who: build ? 'the agent' : 'a person',
|
|
619
|
+
product: product.name,
|
|
620
|
+
topic: 'commands',
|
|
621
|
+
});
|
|
622
|
+
}
|
|
623
|
+
|
|
557
624
|
// A command-line program that has to be built before it can be run. This is what a product
|
|
558
625
|
// nothing in package.json names looks like on a fresh clone: the source is there, the
|
|
559
626
|
// program is real, and the file that would be run does not exist yet.
|
|
@@ -938,13 +1005,18 @@ export function proposeJourneys(project) {
|
|
|
938
1005
|
}
|
|
939
1006
|
if (product.kind === 'library' && Array.isArray(suggest.imports)) {
|
|
940
1007
|
for (const entry of suggest.imports) {
|
|
1008
|
+
const module = String(entry.module);
|
|
941
1009
|
out.push({
|
|
942
1010
|
name: String(entry.name),
|
|
943
|
-
what: `import ${
|
|
1011
|
+
what: `import ${module} and compare what it exports`,
|
|
944
1012
|
from: 'package.json',
|
|
945
1013
|
surface: 'library',
|
|
946
1014
|
automatic: false,
|
|
947
|
-
|
|
1015
|
+
// Only if the file is really there. package.json naming an entry does not put one
|
|
1016
|
+
// on the disk, and this line printed with no caveat beside it — "import ./index.js
|
|
1017
|
+
// and compare what it exports" — about a file that did not exist. A journey listed
|
|
1018
|
+
// as ready is a promise that a check will walk it.
|
|
1019
|
+
ready: isThereOnDisk(project.root, product.where, module),
|
|
948
1020
|
});
|
|
949
1021
|
}
|
|
950
1022
|
}
|
|
@@ -1300,9 +1372,11 @@ export function configText(project) {
|
|
|
1300
1372
|
w(' // ───────────────────────────────────────────────────────────────────────');
|
|
1301
1373
|
w(web ? ' web: {' : ' // web: {');
|
|
1302
1374
|
const webOn = web ? ' ' : ' // ';
|
|
1303
|
-
w(`${webOn}// The command that starts it, listening on the PORT it is given
|
|
1304
|
-
w(`${webOn}// an address: one address can only serve one build, so with an
|
|
1305
|
-
w(`${webOn}// halves of the comparison read the same running copy and prove
|
|
1375
|
+
w(`${webOn}// The command that starts it, listening on the PORT it is given and on 127.0.0.1.`);
|
|
1376
|
+
w(`${webOn}// Much better than an address: one address can only serve one build, so with an`);
|
|
1377
|
+
w(`${webOn}// address alone both halves of the comparison read the same running copy and prove`);
|
|
1378
|
+
w(`${webOn}// nothing. A command that ignores the port it was handed is named within a second`);
|
|
1379
|
+
w(`${webOn}// or two, by name, rather than after a minute and a half of waiting.`);
|
|
1306
1380
|
const webStart = web?.suggest?.start;
|
|
1307
1381
|
const flatSite = !webStart && Array.isArray(web?.suggest?.screens) && web.suggest.screens.length > 0;
|
|
1308
1382
|
if (webStart) {
|
|
@@ -1319,7 +1393,11 @@ export function configText(project) {
|
|
|
1319
1393
|
w(`${webOn}// package the first time it runs, and that is a decision rather than a default.`);
|
|
1320
1394
|
w(`${webOn}// start: 'npx --yes serve -l $PORT .',`);
|
|
1321
1395
|
} else {
|
|
1322
|
-
w(`${webOn}//
|
|
1396
|
+
w(`${webOn}// It has to listen on the PORT it is given AND on 127.0.0.1, and both halves`);
|
|
1397
|
+
w(`${webOn}// matter: measured on 2026-08-31, Vite ignores the PORT and HOST it is handed`);
|
|
1398
|
+
w(`${webOn}// in the environment and binds the name "localhost", which on a Mac is the IPv6`);
|
|
1399
|
+
w(`${webOn}// loopback — so the site comes up somewhere these settings never said.`);
|
|
1400
|
+
w(`${webOn}// start: 'npm run dev -- --port $PORT --strictPort --host 127.0.0.1',`);
|
|
1323
1401
|
}
|
|
1324
1402
|
w(`${webOn}// Or, if it is already running somewhere and you accept the weaker answer:`);
|
|
1325
1403
|
w(`${webOn}// url: 'http://localhost:3000',`);
|
package/src/v2/mcp/server.js
CHANGED
|
@@ -219,7 +219,10 @@ export async function serveMcp(opts = {}) {
|
|
|
219
219
|
}
|
|
220
220
|
await enqueue(async () => {
|
|
221
221
|
try {
|
|
222
|
-
|
|
222
|
+
// `audience: 'agent'` is the default in tools.js and is written out anyway: it is
|
|
223
|
+
// the field that decides whose name goes on a sealed intent and on a waiver, and
|
|
224
|
+
// a record of who declared something must never rest on a default being right.
|
|
225
|
+
const result = await callTool(name, args, { root, cwd, version, protocolVersion, audience: 'agent' });
|
|
223
226
|
reply(id, result);
|
|
224
227
|
} catch (e) {
|
|
225
228
|
// A tool that blows up is still a RESULT, not a protocol error: the
|