staysfixed 0.3.0 → 0.4.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/README.md +534 -402
- package/package.json +8 -3
- package/src/cli/index.js +14 -0
- package/src/v2/adapters/android-driver.js +1705 -0
- package/src/v2/adapters/android.js +1117 -0
- package/src/v2/adapters/contract.js +565 -0
- package/src/v2/adapters/electron.js +1594 -0
- package/src/v2/adapters/http.js +733 -0
- package/src/v2/adapters/ios-driver.js +1551 -0
- package/src/v2/adapters/ios.js +989 -0
- package/src/v2/adapters/isolate.js +739 -0
- package/src/v2/adapters/process.js +920 -0
- package/src/v2/adapters/source.js +1241 -0
- package/src/v2/adapters/web-driver.js +1532 -0
- package/src/v2/adapters/web.js +1009 -0
- package/src/v2/adapters/windows.js +1329 -0
- package/src/v2/browsers.js +1203 -0
- package/src/v2/cause.js +364 -0
- package/src/v2/check.js +1331 -0
- package/src/v2/ci.js +1209 -0
- package/src/v2/cli.js +657 -0
- package/src/v2/cluster.js +372 -0
- package/src/v2/coverage.js +1116 -0
- package/src/v2/detect.js +1199 -0
- package/src/v2/doctor.js +1690 -0
- package/src/v2/escalate.js +679 -0
- package/src/v2/init.js +1394 -0
- package/src/v2/intent.js +659 -0
- package/src/v2/journeys/from-routes.js +498 -0
- package/src/v2/journeys/from-suite.js +988 -0
- package/src/v2/journeys/index.js +651 -0
- package/src/v2/journeys/record.js +516 -0
- package/src/v2/mcp/server.js +374 -0
- package/src/v2/mcp/tools.js +1571 -0
- package/src/v2/normalise.js +783 -0
- package/src/v2/observation.js +877 -0
- package/src/v2/rank.js +672 -0
- package/src/v2/reference.js +1051 -0
- package/src/v2/remote.js +911 -0
- package/src/v2/run.js +964 -0
- package/src/v2/sealed.js +564 -0
- package/src/v2/selfcheck.js +564 -0
- package/src/v2/ship.js +684 -0
- package/src/v2/store.js +703 -0
- package/src/v2/types.js +503 -0
- package/src/v2/waiver.js +511 -0
- package/src/watch/panel.js +73 -44
package/src/v2/doctor.js
ADDED
|
@@ -0,0 +1,1690 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `staysfixed doctor` for v2 — the machine-readable answer to one question:
|
|
3
|
+
* *what can this tool actually check on THIS machine, right now, and what is
|
|
4
|
+
* stopping it from checking the rest?*
|
|
5
|
+
*
|
|
6
|
+
* v1's doctor explained why the tool could not run in one project. This one has
|
|
7
|
+
* a bigger job. The requirement is that the tool describes itself to the AI that
|
|
8
|
+
* installs it: nothing about wiring this up should ever need a human to read
|
|
9
|
+
* documentation. So everything here comes back as data first and prose second —
|
|
10
|
+
* `capabilities()` builds the object, `describeCapabilities()` turns it into
|
|
11
|
+
* plain English, and both the CLI and the MCP tool `staysfixed_capabilities`
|
|
12
|
+
* read the same object. There is only one source of truth about this machine.
|
|
13
|
+
*
|
|
14
|
+
* Two rules hold the file together.
|
|
15
|
+
*
|
|
16
|
+
* DETECT, NEVER ASK. An SSH host that already works must never be presented as
|
|
17
|
+
* something to set up. Neither must a browser that is installed, a simulator
|
|
18
|
+
* runtime that exists, or a repository that is already a git repository. Every
|
|
19
|
+
* "needs" entry in the result had to fail a real probe first.
|
|
20
|
+
*
|
|
21
|
+
* IT NEVER THROWS AND IT NEVER HANGS. Somebody running doctor is already stuck.
|
|
22
|
+
* Every probe has a timeout, every failure becomes a note, and the exit code
|
|
23
|
+
* says how bad it is rather than crashing. `xcrun simctl` in particular has hung
|
|
24
|
+
* on a Mac before — so the CoreSimulator binary is called directly where it
|
|
25
|
+
* exists, and a hang is reported as a hang instead of taking the process with it.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import os from 'node:os';
|
|
29
|
+
import path from 'node:path';
|
|
30
|
+
import fsp from 'node:fs/promises';
|
|
31
|
+
import { existsSync, accessSync, readFileSync, readdirSync, constants as fsConstants } from 'node:fs';
|
|
32
|
+
import { execFile } from 'node:child_process';
|
|
33
|
+
import { promisify } from 'node:util';
|
|
34
|
+
|
|
35
|
+
import { findConfigFile } from '../core/paths.js';
|
|
36
|
+
import { platformTag } from '../drive/find.js';
|
|
37
|
+
import { isRepo } from '../core/git.js';
|
|
38
|
+
import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
|
|
39
|
+
import { messageOf, EXIT } from '../core/errors.js';
|
|
40
|
+
import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
|
|
41
|
+
|
|
42
|
+
const exec = promisify(execFile);
|
|
43
|
+
|
|
44
|
+
/** Nothing this file asks a question of is allowed to think about it for longer. */
|
|
45
|
+
const PROBE_MS = 5_000;
|
|
46
|
+
/** Reaching another machine is slower than reaching a binary, but not much. */
|
|
47
|
+
const REACH_MS = 8_000;
|
|
48
|
+
/** More hosts than this in one ssh config and we stop dialling; the list is a menu, not a queue. */
|
|
49
|
+
const MAX_HOSTS = 8;
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* The seven ways this tool can watch a product, in the order the design puts
|
|
53
|
+
* them: meaning first, pixels last and only ever as evidence.
|
|
54
|
+
*
|
|
55
|
+
* @type {{id: string, name: string, what: string}[]}
|
|
56
|
+
*/
|
|
57
|
+
export const CHANNELS = [
|
|
58
|
+
{ id: 'meaning', name: 'the meaning tree', what: 'What the screen says each control is, is called, and can do. Never the raw markup.' },
|
|
59
|
+
{ id: 'effects', name: 'effects out', what: 'Network calls made, files written, processes started, storage written.' },
|
|
60
|
+
{ id: 'complaints', name: 'complaints', what: 'Console messages, errors on stderr, crashes, exit codes.' },
|
|
61
|
+
{ id: 'results', name: 'results', what: 'What it printed, what the API answered, what it exports.' },
|
|
62
|
+
{ id: 'contract', name: 'the contract in the source', what: 'Routes, exported functions, IPC channels — read out of the code without running it.' },
|
|
63
|
+
{ id: 'counters', name: 'counters and timing', what: 'How many of a thing, and roughly how long — coarse on purpose.' },
|
|
64
|
+
{ id: 'pixels', name: 'pixels', what: 'A picture, last, and only as evidence for something another channel already found.' },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
/**
|
|
68
|
+
* @typedef {object} ToolReport
|
|
69
|
+
* @property {string} id
|
|
70
|
+
* @property {string} name What a person calls it.
|
|
71
|
+
* @property {boolean} found
|
|
72
|
+
* @property {string} [where] Full path, when we know it.
|
|
73
|
+
* @property {string} [version]
|
|
74
|
+
* @property {string} why What this tool is needed for, in plain words.
|
|
75
|
+
* @property {string} [fix] The exact thing that would install it.
|
|
76
|
+
* @property {boolean} [automatic] True when the tool could install it without a person.
|
|
77
|
+
*/
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* @typedef {object} HostReport
|
|
81
|
+
* @property {string} name
|
|
82
|
+
* @property {boolean} reachable
|
|
83
|
+
* @property {string} how How we found out, or why it did not answer.
|
|
84
|
+
* @property {boolean} [windows] Reaches a real Windows desktop through powershell.exe.
|
|
85
|
+
*/
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* @typedef {object} Need
|
|
89
|
+
* @property {string} what
|
|
90
|
+
* @property {string} why
|
|
91
|
+
* @property {string} fix The exact command, or the exact thing a person has to do.
|
|
92
|
+
* @property {boolean} automatic True when Stays Fixed could do it itself.
|
|
93
|
+
* @property {string} [unlocks] What becomes checkable once it is there, in plain English.
|
|
94
|
+
* A person asked to spend half an hour on a download deserves
|
|
95
|
+
* to be told what they get for it, in a sentence, before they
|
|
96
|
+
* start — and an agent relaying the ask needs the same sentence.
|
|
97
|
+
*/
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* What THIS COPY of the tool can drive, which is a different question from what this
|
|
101
|
+
* machine could run.
|
|
102
|
+
*
|
|
103
|
+
* A Mac with Xcode, a simulator and Appium on it can run an iPhone app. That says nothing
|
|
104
|
+
* about whether Stays Fixed has an adapter that knows how to drive one. Answering the
|
|
105
|
+
* first question and reporting it as the second is how a surface gets called ready while
|
|
106
|
+
* every journey aimed at it walks nothing — and a journey nothing walked, reported as
|
|
107
|
+
* covered, is the worst thing this tool can produce.
|
|
108
|
+
*
|
|
109
|
+
* @typedef {object} DriverReport
|
|
110
|
+
* @property {string} surface
|
|
111
|
+
* @property {boolean} present
|
|
112
|
+
* @property {string} why Plain English, filled in either way.
|
|
113
|
+
*/
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* The four states of the design, and the fourth is the one that matters.
|
|
117
|
+
*
|
|
118
|
+
* - `ready` It works here now. Nothing to say.
|
|
119
|
+
* - `the agent can fix this` Everything in the way installs without a person:
|
|
120
|
+
* the agent runs the commands and never mentions it.
|
|
121
|
+
* - `only a person can do this` A licence, a device, a password, a pair of hands.
|
|
122
|
+
* The needs say what to do, why, and what it unlocks.
|
|
123
|
+
* - `not possible here` No command on this machine changes the answer. Say
|
|
124
|
+
* so, say the nearest honest alternative in `instead`,
|
|
125
|
+
* and stop offering it.
|
|
126
|
+
*
|
|
127
|
+
* @typedef {'ready'|'the agent can fix this'|'only a person can do this'|'not possible here'} SurfaceState
|
|
128
|
+
*/
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* @typedef {object} SurfaceReport
|
|
132
|
+
* @property {string} id
|
|
133
|
+
* @property {string} name
|
|
134
|
+
* @property {'ready'|'partial'|'unavailable'} status How much of it works: all, some, none.
|
|
135
|
+
* @property {SurfaceState} state Who has to act, which is a different question from
|
|
136
|
+
* how much works. A partly-covered surface still has
|
|
137
|
+
* somebody who can widen it.
|
|
138
|
+
* @property {string} summary One plain sentence.
|
|
139
|
+
* @property {string[]} canCheck Channel ids usable here on this machine.
|
|
140
|
+
* @property {string[]} cannotCheck Channel ids that are out of reach here.
|
|
141
|
+
* @property {Need[]} needs
|
|
142
|
+
* @property {string} [instead] Only on `not possible here`: the nearest honest
|
|
143
|
+
* alternative, so the answer is not just a refusal.
|
|
144
|
+
*/
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* @typedef {object} Capabilities
|
|
148
|
+
* @property {{name: string, version: string, generatedAt: string}} tool
|
|
149
|
+
* @property {{platform: string, arch: string, release: string, node: string, cpus: number, memoryGb: number, tag: string}} machine
|
|
150
|
+
* @property {{root: string, configFile: string|null, isGitRepo: boolean, hasReference: boolean, referenceNote: string}} project
|
|
151
|
+
* @property {SurfaceReport[]} surfaces
|
|
152
|
+
* @property {DriverReport[]} drivers
|
|
153
|
+
* @property {Covers} covers
|
|
154
|
+
* @property {{willOpen: import('./browsers.js').BrowserFound|null, borrowingYourOwn: boolean, note: string, install: string|null, found: import('./browsers.js').BrowserFound[], neverTouches: string[], leftovers: string}} browsers
|
|
155
|
+
* @property {{id: string, name: string, what: string, availableOn: string[]}[]} channels
|
|
156
|
+
* @property {ToolReport[]} tools
|
|
157
|
+
* @property {HostReport[]} hosts
|
|
158
|
+
* @property {Need[]} nextSteps
|
|
159
|
+
* @property {string[]} limits Things it will never be able to see, on any machine.
|
|
160
|
+
* @property {{mcp: Record<string, unknown>, commands: {command: string, what: string}[], results: Record<string, string>}} wiring
|
|
161
|
+
*/
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Everything this machine can and cannot do, as one JSON-safe object.
|
|
165
|
+
*
|
|
166
|
+
* This is what `staysfixed_capabilities` returns over MCP, unchanged. An agent
|
|
167
|
+
* should be able to read it once and know what to call, what it will get back,
|
|
168
|
+
* and what it must not bother asking for here.
|
|
169
|
+
*
|
|
170
|
+
* @param {{cwd?: string, configFile?: string, offline?: boolean}} [opts]
|
|
171
|
+
* @returns {Promise<Capabilities>}
|
|
172
|
+
*/
|
|
173
|
+
export async function capabilities(opts = {}) {
|
|
174
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
175
|
+
const offline = opts.offline === true || process.env.STAYSFIXED_OFFLINE !== undefined;
|
|
176
|
+
const configFile = opts.configFile ? path.resolve(cwd, opts.configFile) : findConfigFile(cwd);
|
|
177
|
+
const root = configFile ? path.dirname(configFile) : cwd;
|
|
178
|
+
|
|
179
|
+
// The browser survey comes first because three different answers below depend
|
|
180
|
+
// on it, and asking this machine the same question three times would be both
|
|
181
|
+
// slow and a way for the three answers to disagree.
|
|
182
|
+
const browsers = await surveyBrowsers().catch(() => /** @type {import('./browsers.js').BrowserSurvey} */ ({ found: [], chosen: null, borrowingHis: false, note: 'The browsers on this machine could not be checked, so nothing here says whether a web page can be opened.', install: INSTALL_COMMAND }));
|
|
183
|
+
const desktopApp = findDesktopApp(cwd);
|
|
184
|
+
|
|
185
|
+
const [tools, hosts, repo, reference, drivers, phones, asked] = await Promise.all([
|
|
186
|
+
findTools(cwd, browsers),
|
|
187
|
+
offline ? Promise.resolve(/** @type {HostReport[]} */ ([])) : reachableHosts(),
|
|
188
|
+
isRepo(root).catch(() => false),
|
|
189
|
+
findReference(root),
|
|
190
|
+
whatThisCopyCanDrive(),
|
|
191
|
+
phoneApps(root, configFile),
|
|
192
|
+
askTheAdapters(root),
|
|
193
|
+
]);
|
|
194
|
+
|
|
195
|
+
const surfaces = describeSurfaces(tools, hosts, configFile !== null, browsers, desktopApp, drivers, phones, asked);
|
|
196
|
+
|
|
197
|
+
/** @type {Capabilities} */
|
|
198
|
+
const caps = {
|
|
199
|
+
tool: { name: 'staysfixed', version: versionOfThisTool(), generatedAt: new Date().toISOString() },
|
|
200
|
+
machine: {
|
|
201
|
+
platform: process.platform,
|
|
202
|
+
arch: process.arch,
|
|
203
|
+
release: os.release(),
|
|
204
|
+
node: process.version,
|
|
205
|
+
cpus: os.cpus().length,
|
|
206
|
+
memoryGb: Math.round((os.totalmem() / 1024 ** 3) * 10) / 10,
|
|
207
|
+
tag: platformTag(),
|
|
208
|
+
},
|
|
209
|
+
project: {
|
|
210
|
+
root,
|
|
211
|
+
configFile,
|
|
212
|
+
isGitRepo: repo,
|
|
213
|
+
hasReference: reference.found,
|
|
214
|
+
referenceNote: reference.note,
|
|
215
|
+
},
|
|
216
|
+
surfaces,
|
|
217
|
+
drivers,
|
|
218
|
+
covers: whatThisRunActuallyCovers(surfaces),
|
|
219
|
+
browsers: {
|
|
220
|
+
willOpen: browsers.chosen,
|
|
221
|
+
borrowingYourOwn: browsers.borrowingHis,
|
|
222
|
+
note: browsers.note,
|
|
223
|
+
install: browsers.install,
|
|
224
|
+
found: browsers.found,
|
|
225
|
+
neverTouches: [
|
|
226
|
+
'It never opens your browser profile. Every run gets a throwaway one, and it is deleted afterwards.',
|
|
227
|
+
`It never takes port ${PORT_NEVER_USE}, which another session on this machine already owns. It asks the operating system for a free one instead.`,
|
|
228
|
+
'It never quits a browser it did not start. Anything it stops had to be running from a scratch profile this tool created.',
|
|
229
|
+
'Nothing it opened outlives the run — not on a clean finish, not on an error, not on Ctrl-C.',
|
|
230
|
+
],
|
|
231
|
+
leftovers: 'staysfixed browsers --clean quits anything an interrupted run left running, and only ever something started by this tool.',
|
|
232
|
+
},
|
|
233
|
+
channels: CHANNELS.map((channel) => ({
|
|
234
|
+
...channel,
|
|
235
|
+
availableOn: surfaces.filter((s) => s.canCheck.includes(channel.id)).map((s) => s.id),
|
|
236
|
+
})),
|
|
237
|
+
tools,
|
|
238
|
+
hosts,
|
|
239
|
+
nextSteps: nextSteps(surfaces, reference, repo),
|
|
240
|
+
limits: PERMANENT_LIMITS,
|
|
241
|
+
wiring: WIRING,
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
return caps;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* The things this tool cannot see anywhere, on any machine, by design. They are
|
|
249
|
+
* listed in the capabilities object on purpose: an agent that knows the blind
|
|
250
|
+
* spots stops treating a clean run as proof of something it never covered.
|
|
251
|
+
*
|
|
252
|
+
* @type {string[]}
|
|
253
|
+
*/
|
|
254
|
+
const PERMANENT_LIMITS = [
|
|
255
|
+
'Anything irreversible is watched at the moment it is asked for — the same charge, for the same amount, to the same place — and never allowed to happen. If a bug only appears after the payment settles or the email lands, this tool cannot see it.',
|
|
256
|
+
'A migration that destroys data is refused rather than run twice. The refusal is reported as a gap in coverage, never as a pass.',
|
|
257
|
+
'Subtracting the product’s own wobble hides intermittent bugs. A race that already existed and got worse will not show. Running the new build twice recovers half of this by flagging anything newly unstable — only half.',
|
|
258
|
+
'It checks the journeys it has. It cannot enumerate every possible state, and the coverage ledger names the doors it has never opened rather than pretending they are covered.',
|
|
259
|
+
'Two builds of a real phone in your hand cannot be run side by side. Real devices fall back to comparing against the stored record, and say so.',
|
|
260
|
+
'If the old build can no longer be compiled, comparison falls back to the stored record from the last time it ran. That is genuinely weaker and announces itself on every run.',
|
|
261
|
+
];
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* Everything an agent needs to wire this up and to read what comes back.
|
|
265
|
+
* Written once, here, so the README, the MCP server and `doctor --json` cannot
|
|
266
|
+
* drift apart.
|
|
267
|
+
*/
|
|
268
|
+
const WIRING = {
|
|
269
|
+
mcp: {
|
|
270
|
+
mcpServers: {
|
|
271
|
+
staysfixed: { command: 'npx', args: ['-y', 'staysfixed', 'mcp'], cwd: '/absolute/path/to/your/project' },
|
|
272
|
+
},
|
|
273
|
+
},
|
|
274
|
+
commands: [
|
|
275
|
+
{ command: 'staysfixed check', what: 'Run the difference engine. Answers with only the differences it could not explain.' },
|
|
276
|
+
{ command: 'staysfixed check --against <ref>', what: 'Compare against a named marker or commit instead of the newest reference.' },
|
|
277
|
+
{ command: 'staysfixed check --paired', what: 'Boot the old build live from the start rather than trusting the stored record. Slower, stronger.' },
|
|
278
|
+
{ command: 'staysfixed check --journeys <source>', what: 'Where the steps come from: suite, code, recorded, or a path to a journeys file.' },
|
|
279
|
+
{ command: 'staysfixed check --json', what: 'The whole result as one JSON object and nothing else. This is the shape the MCP tool returns.' },
|
|
280
|
+
{ command: 'staysfixed check --selfcheck', what: 'Run the corpus of deliberately broken builds and prove the engine still catches them.' },
|
|
281
|
+
{ command: 'staysfixed check --pictures', what: 'The version 1 picture check, unchanged, for anyone who was already using it.' },
|
|
282
|
+
{ command: 'staysfixed doctor --json', what: 'This object. The first call any agent should make.' },
|
|
283
|
+
],
|
|
284
|
+
// Field for field, this is the Verdict defined in src/v2/types.js. An agent
|
|
285
|
+
// reads this to know what it will get back without being taught, so a name
|
|
286
|
+
// here that the engine does not actually produce is worse than no entry at
|
|
287
|
+
// all: it sends the agent looking for something that will never arrive.
|
|
288
|
+
results: {
|
|
289
|
+
shape: 'Every check answers with one Verdict object. `staysfixed check --json` prints exactly that and nothing else, and the MCP tool returns the same object.',
|
|
290
|
+
ok: 'true when nothing unintended survived. false when something did, or when something that used to give the same answer every time stopped doing so.',
|
|
291
|
+
mode: '"paired" means the old build was booted and walked here, in this minute. "stored-record" means it was compared against what the old build wrote down the last time it ran, which is genuinely weaker.',
|
|
292
|
+
modeWarning: 'Present whenever the run was weaker than a full paired one, written in the words to repeat to a person. Absent on a paired run.',
|
|
293
|
+
reference: 'Which build counted as working. An EMPTY id means nothing was on record as working, so the run proves nothing about the product either way.',
|
|
294
|
+
candidate: 'Which build was checked.',
|
|
295
|
+
findings: 'Ranked, worst first. Each one is a cluster of differences that share a cause, not a single path. This is the only part an agent needs to read.',
|
|
296
|
+
'findings[].title': 'One plain sentence naming what changed. No test ids, no jargon.',
|
|
297
|
+
'findings[].why': 'The likely cause, said plainly, and hedged when it is a guess.',
|
|
298
|
+
'findings[].class': 'money, sign-in, data-loss, crash, guard, or ordinary.',
|
|
299
|
+
'findings[].sealed': 'true when the class is one no agent may wave through. It goes to a person, whatever the agent believes it meant to change.',
|
|
300
|
+
'findings[].rank': 'Higher is more urgent. Distance from the code you changed is the biggest term, because a break far from your edit is the definition of a side effect.',
|
|
301
|
+
'findings[].differences': 'The addresses under this one finding, each with what it was, what it is, and whether the path appeared or vanished.',
|
|
302
|
+
differencesReal: 'How many differences survived the wobble floor.',
|
|
303
|
+
differencesNoise: 'How many were the product disagreeing with itself and were subtracted. Evidence that the quiet is earned.',
|
|
304
|
+
newlyUnstable: 'Addresses that were steady before your change and disagree with themselves now. Treat these as findings even though no value "changed".',
|
|
305
|
+
coverage: 'What was walked — and in coverage.gaps, everything that was not, each with what would unlock it. An unopened door is visible here instead of silently passing.',
|
|
306
|
+
summary: 'One paragraph of plain English covering all of the above. Safe to quote to a person word for word.',
|
|
307
|
+
durationMs: 'How long the whole check took.',
|
|
308
|
+
},
|
|
309
|
+
};
|
|
310
|
+
|
|
311
|
+
/**
|
|
312
|
+
* The version out of package.json, read without importing the CLI — doctor has
|
|
313
|
+
* to work when everything else in the project is broken.
|
|
314
|
+
* @returns {string}
|
|
315
|
+
*/
|
|
316
|
+
function versionOfThisTool() {
|
|
317
|
+
try {
|
|
318
|
+
/** @type {{version?: string}} */
|
|
319
|
+
const pkg = JSON.parse(readFileSync(new URL('../../package.json', import.meta.url), 'utf8'));
|
|
320
|
+
return pkg.version ?? '0.0.0';
|
|
321
|
+
} catch {
|
|
322
|
+
return '0.0.0';
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// ── probes ──────────────────────────────────────────────────────────────────
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Is there an executable of this name on PATH? Answered by looking, not by
|
|
330
|
+
* spawning `which`: doctor asks this a dozen times and a dozen processes to
|
|
331
|
+
* answer a question the filesystem already knows is waste.
|
|
332
|
+
*
|
|
333
|
+
* @param {string} name
|
|
334
|
+
* @returns {string|null}
|
|
335
|
+
*/
|
|
336
|
+
export function onPath(name) {
|
|
337
|
+
const parts = (process.env.PATH ?? '').split(path.delimiter).filter(Boolean);
|
|
338
|
+
const suffixes = process.platform === 'win32' ? (process.env.PATHEXT ?? '.EXE;.CMD;.BAT').split(';') : [''];
|
|
339
|
+
for (const dir of parts) {
|
|
340
|
+
for (const suffix of suffixes) {
|
|
341
|
+
const candidate = path.join(dir, name + suffix);
|
|
342
|
+
try {
|
|
343
|
+
accessSync(candidate, fsConstants.X_OK);
|
|
344
|
+
return candidate;
|
|
345
|
+
} catch {
|
|
346
|
+
// Not here, or not runnable by us. Both mean "keep looking".
|
|
347
|
+
}
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
return null;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Ask a program what version it is. Never throws, never hangs, and treats a
|
|
355
|
+
* timeout as its own answer — a tool that will not reply is not a tool that is
|
|
356
|
+
* missing, and telling somebody to install it would be wrong.
|
|
357
|
+
*
|
|
358
|
+
* @param {string} file
|
|
359
|
+
* @param {string[]} args
|
|
360
|
+
* @param {number} [timeoutMs]
|
|
361
|
+
* @returns {Promise<{ok: boolean, out: string, why: string, hung: boolean}>}
|
|
362
|
+
*/
|
|
363
|
+
async function ask(file, args, timeoutMs = PROBE_MS) {
|
|
364
|
+
try {
|
|
365
|
+
const { stdout, stderr } = await exec(file, args, { timeout: timeoutMs, maxBuffer: 4 << 20, windowsHide: true });
|
|
366
|
+
// Java and a few others announce their version on stderr. Take whichever spoke.
|
|
367
|
+
return { ok: true, out: String(stdout || stderr).trim(), why: '', hung: false };
|
|
368
|
+
} catch (error) {
|
|
369
|
+
const e = /** @type {{killed?: boolean, signal?: string, stdout?: string, stderr?: string}} */ (Object(error));
|
|
370
|
+
const hung = e.killed === true || e.signal === 'SIGTERM';
|
|
371
|
+
const spoke = String(e.stdout || e.stderr || '').trim();
|
|
372
|
+
// A non-zero exit that still printed a version is a success for our purposes.
|
|
373
|
+
if (!hung && spoke !== '') return { ok: true, out: spoke, why: '', hung: false };
|
|
374
|
+
return { ok: false, out: '', why: hung ? `it did not answer within ${Math.round(timeoutMs / 1000)}s` : messageOf(error), hung };
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
/**
|
|
379
|
+
* The first thing in a version banner that looks like a version.
|
|
380
|
+
* @param {string} text
|
|
381
|
+
* @returns {string|undefined}
|
|
382
|
+
*/
|
|
383
|
+
function versionIn(text) {
|
|
384
|
+
const found = /\d+\.\d+(\.\d+)?/.exec(text);
|
|
385
|
+
return found ? found[0] : undefined;
|
|
386
|
+
}
|
|
387
|
+
|
|
388
|
+
/**
|
|
389
|
+
* Everything on this machine that could widen what the tool is able to watch.
|
|
390
|
+
*
|
|
391
|
+
* @param {string} cwd
|
|
392
|
+
* @param {import('./browsers.js').BrowserSurvey} browsers Already taken, because
|
|
393
|
+
* the browser question is asked in three places and must be answered once.
|
|
394
|
+
* @returns {Promise<ToolReport[]>}
|
|
395
|
+
*/
|
|
396
|
+
async function findTools(cwd, browsers) {
|
|
397
|
+
/** @type {ToolReport[]} */
|
|
398
|
+
const reports = [];
|
|
399
|
+
|
|
400
|
+
/**
|
|
401
|
+
* @param {ToolReport} report
|
|
402
|
+
*/
|
|
403
|
+
const add = (report) => {
|
|
404
|
+
reports.push(report);
|
|
405
|
+
};
|
|
406
|
+
|
|
407
|
+
add({ id: 'node', name: 'Node', found: true, where: process.execPath, version: process.version.replace(/^v/, ''), why: 'Runs the tool itself and every command-line product it watches.' });
|
|
408
|
+
|
|
409
|
+
await Promise.all([
|
|
410
|
+
(async () => {
|
|
411
|
+
const where = onPath('git');
|
|
412
|
+
const version = where ? versionIn((await ask(where, ['--version'])).out) : undefined;
|
|
413
|
+
add({
|
|
414
|
+
id: 'git',
|
|
415
|
+
name: 'git',
|
|
416
|
+
found: where !== null,
|
|
417
|
+
where: where ?? undefined,
|
|
418
|
+
version,
|
|
419
|
+
why: 'Names the reference build, and ranks a difference by how far it sits from the code you changed.',
|
|
420
|
+
fix: where ? undefined : 'Install git. Without it every difference is ranked as though you edited nothing.',
|
|
421
|
+
automatic: false,
|
|
422
|
+
});
|
|
423
|
+
})(),
|
|
424
|
+
|
|
425
|
+
(async () => {
|
|
426
|
+
// One entry, not two. Whether the browser found is one we may open is a
|
|
427
|
+
// real and separate question, but it belongs to the web surface and to the
|
|
428
|
+
// browsers block, where it can be said in a sentence — a second row in a
|
|
429
|
+
// flat list of tool names would read as two browsers rather than one fact
|
|
430
|
+
// about the one we have.
|
|
431
|
+
const chosen = browsers.chosen;
|
|
432
|
+
add({
|
|
433
|
+
id: 'browser',
|
|
434
|
+
name: 'a browser to drive pages with',
|
|
435
|
+
found: chosen !== null,
|
|
436
|
+
where: chosen?.binary,
|
|
437
|
+
version: chosen?.version,
|
|
438
|
+
why: 'Opens a web page so what the screen says each control is and does can be read off it.',
|
|
439
|
+
fix: chosen ? undefined : `${INSTALL_COMMAND} — nothing to sign up for and nobody to ask.`,
|
|
440
|
+
automatic: true,
|
|
441
|
+
});
|
|
442
|
+
})(),
|
|
443
|
+
|
|
444
|
+
(async () => {
|
|
445
|
+
const installed = hasModule(cwd, 'playwright') || hasModule(cwd, 'playwright-core');
|
|
446
|
+
// The browsers Playwright downloads live outside any project and survive
|
|
447
|
+
// every reinstall, so finding them means half the work is already done and
|
|
448
|
+
// telling somebody to download them again would be wrong.
|
|
449
|
+
const downloaded = playwrightBrowsersDir();
|
|
450
|
+
add({
|
|
451
|
+
id: 'playwright',
|
|
452
|
+
name: 'Playwright',
|
|
453
|
+
found: installed,
|
|
454
|
+
where: downloaded ?? undefined,
|
|
455
|
+
why: 'Reads a page’s meaning tree properly, and brings a browser of its own so yours is left alone.',
|
|
456
|
+
fix: installed
|
|
457
|
+
? undefined
|
|
458
|
+
: downloaded
|
|
459
|
+
? `Its browsers are already downloaded in ${downloaded}, so only the package is missing: npm install --save-dev playwright`
|
|
460
|
+
: INSTALL_COMMAND,
|
|
461
|
+
automatic: true,
|
|
462
|
+
});
|
|
463
|
+
})(),
|
|
464
|
+
|
|
465
|
+
(async () => {
|
|
466
|
+
const app = findDesktopApp(cwd);
|
|
467
|
+
add({
|
|
468
|
+
id: 'electron',
|
|
469
|
+
name: 'a desktop app to check',
|
|
470
|
+
found: app !== null,
|
|
471
|
+
where: app?.where,
|
|
472
|
+
why: 'A desktop app is driven straight over its own debugging port, so no browser is needed for it — only the app itself.',
|
|
473
|
+
fix: app ? undefined : 'Only needed if the product you are watching is a desktop app. If it is, name the built app in your settings under app.binary.',
|
|
474
|
+
automatic: false,
|
|
475
|
+
});
|
|
476
|
+
})(),
|
|
477
|
+
|
|
478
|
+
(async () => {
|
|
479
|
+
const where = onPath('adb') ?? androidSdkTool('platform-tools', 'adb');
|
|
480
|
+
add({
|
|
481
|
+
id: 'adb',
|
|
482
|
+
name: 'adb',
|
|
483
|
+
found: where !== null,
|
|
484
|
+
where: where ?? undefined,
|
|
485
|
+
why: 'Installs an APK on an emulator and reads what it is doing.',
|
|
486
|
+
fix: where ? undefined : 'Install the Android platform tools, then add them to PATH (usually ~/Library/Android/sdk/platform-tools).',
|
|
487
|
+
automatic: true,
|
|
488
|
+
});
|
|
489
|
+
})(),
|
|
490
|
+
|
|
491
|
+
(async () => {
|
|
492
|
+
const where = onPath('emulator') ?? androidSdkTool('emulator', 'emulator');
|
|
493
|
+
add({
|
|
494
|
+
id: 'emulator',
|
|
495
|
+
name: 'the Android emulator',
|
|
496
|
+
found: where !== null,
|
|
497
|
+
where: where ?? undefined,
|
|
498
|
+
why: 'The only place two builds of an Android app can be run one after the other on this machine.',
|
|
499
|
+
fix: where ? undefined : 'Install the Android SDK emulator and one system image.',
|
|
500
|
+
automatic: true,
|
|
501
|
+
});
|
|
502
|
+
|
|
503
|
+
// An emulator with no virtual device on it is a program that cannot start
|
|
504
|
+
// anything, and reporting it as a runner would send a check off to boot a phone
|
|
505
|
+
// that does not exist. Two separate facts, so they get two separate answers.
|
|
506
|
+
const avds = where ? await ask(where, ['-list-avds'], PROBE_MS) : null;
|
|
507
|
+
const names = (avds?.out ?? '').split('\n').map((line) => line.trim()).filter((line) => line !== '' && !line.includes(' '));
|
|
508
|
+
add({
|
|
509
|
+
id: 'avd',
|
|
510
|
+
name: 'an Android phone to run it on',
|
|
511
|
+
found: names.length > 0,
|
|
512
|
+
where: where ?? undefined,
|
|
513
|
+
version: names[0],
|
|
514
|
+
why: 'The emulator is the program; a virtual device is the phone it runs. Without one there is nothing to install the app onto.',
|
|
515
|
+
fix: names.length > 0 ? undefined : 'sdkmanager "system-images;android-35;google_apis;arm64-v8a" && avdmanager create avd -n staysfixed -k "system-images;android-35;google_apis;arm64-v8a"',
|
|
516
|
+
automatic: true,
|
|
517
|
+
});
|
|
518
|
+
})(),
|
|
519
|
+
|
|
520
|
+
(async () => {
|
|
521
|
+
if (process.platform !== 'darwin') {
|
|
522
|
+
add({ id: 'simulator', name: 'the iOS simulator', found: false, why: 'Runs the iPhone build.', fix: 'iOS can only be watched from a Mac.', automatic: false });
|
|
523
|
+
return;
|
|
524
|
+
}
|
|
525
|
+
const sim = await simulatorReport();
|
|
526
|
+
add(sim);
|
|
527
|
+
})(),
|
|
528
|
+
|
|
529
|
+
(async () => {
|
|
530
|
+
const where = onPath('ssh');
|
|
531
|
+
add({
|
|
532
|
+
id: 'ssh',
|
|
533
|
+
name: 'ssh',
|
|
534
|
+
found: where !== null,
|
|
535
|
+
where: where ?? undefined,
|
|
536
|
+
why: 'Reaches the other machines you already have, so a platform this Mac cannot run is checked where it can.',
|
|
537
|
+
fix: where ? undefined : 'Install an SSH client.',
|
|
538
|
+
automatic: false,
|
|
539
|
+
});
|
|
540
|
+
})(),
|
|
541
|
+
|
|
542
|
+
(async () => {
|
|
543
|
+
const where = onPath('docker');
|
|
544
|
+
add({
|
|
545
|
+
id: 'docker',
|
|
546
|
+
name: 'Docker',
|
|
547
|
+
found: where !== null,
|
|
548
|
+
where: where ?? undefined,
|
|
549
|
+
why: 'The usual way to restore the same database snapshot twice, which is what a server comparison needs.',
|
|
550
|
+
fix: where ? undefined : 'Only needed to watch a server whose behaviour depends on its data.',
|
|
551
|
+
automatic: false,
|
|
552
|
+
});
|
|
553
|
+
})(),
|
|
554
|
+
]);
|
|
555
|
+
|
|
556
|
+
reports.sort((a, b) => a.id.localeCompare(b.id));
|
|
557
|
+
return reports;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
/**
|
|
561
|
+
* The iOS simulator, asked carefully.
|
|
562
|
+
*
|
|
563
|
+
* `xcrun simctl` has hung on a Mac in this house before and taken the caller
|
|
564
|
+
* with it, so CoreSimulator's own binary is preferred when it is there, and a
|
|
565
|
+
* hang is reported as a hang rather than as an absence.
|
|
566
|
+
*
|
|
567
|
+
* @returns {Promise<ToolReport>}
|
|
568
|
+
*/
|
|
569
|
+
async function simulatorReport() {
|
|
570
|
+
const direct = '/Library/Developer/PrivateFrameworks/CoreSimulator.framework/Versions/A/Resources/bin/simctl';
|
|
571
|
+
const binary = existsSync(direct) ? direct : onPath('xcrun');
|
|
572
|
+
if (!binary) {
|
|
573
|
+
return {
|
|
574
|
+
id: 'simulator',
|
|
575
|
+
name: 'the iOS simulator',
|
|
576
|
+
found: false,
|
|
577
|
+
why: 'Runs the iPhone build so its screens can be read.',
|
|
578
|
+
fix: 'Install Xcode and its command line tools.',
|
|
579
|
+
automatic: false,
|
|
580
|
+
};
|
|
581
|
+
}
|
|
582
|
+
const args = binary === direct ? ['list', 'runtimes', '-j'] : ['simctl', 'list', 'runtimes', '-j'];
|
|
583
|
+
const answer = await ask(binary, args, PROBE_MS);
|
|
584
|
+
if (answer.hung) {
|
|
585
|
+
return {
|
|
586
|
+
id: 'simulator',
|
|
587
|
+
name: 'the iOS simulator',
|
|
588
|
+
found: false,
|
|
589
|
+
where: binary,
|
|
590
|
+
why: 'Runs the iPhone build so its screens can be read.',
|
|
591
|
+
fix: 'simctl is installed but did not answer. Open Simulator.app once, or run `sudo xcode-select --reset`, then try again.',
|
|
592
|
+
automatic: false,
|
|
593
|
+
};
|
|
594
|
+
}
|
|
595
|
+
/** @type {string[]} */
|
|
596
|
+
let runtimes = [];
|
|
597
|
+
try {
|
|
598
|
+
const parsed = /** @type {{runtimes?: {name?: string, isAvailable?: boolean}[]}} */ (JSON.parse(answer.out));
|
|
599
|
+
runtimes = (parsed.runtimes ?? []).filter((r) => r.isAvailable !== false).map((r) => String(r.name ?? '')).filter(Boolean);
|
|
600
|
+
} catch {
|
|
601
|
+
runtimes = [];
|
|
602
|
+
}
|
|
603
|
+
return {
|
|
604
|
+
id: 'simulator',
|
|
605
|
+
name: 'the iOS simulator',
|
|
606
|
+
found: runtimes.length > 0,
|
|
607
|
+
where: binary,
|
|
608
|
+
version: runtimes[0],
|
|
609
|
+
why: 'Runs the iPhone build so its screens can be read.',
|
|
610
|
+
fix: runtimes.length > 0 ? undefined : 'Install at least one iOS runtime in Xcode under Settings, Platforms.',
|
|
611
|
+
automatic: false,
|
|
612
|
+
};
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
/**
|
|
616
|
+
* Is a package installed for this project?
|
|
617
|
+
* @param {string} cwd
|
|
618
|
+
* @param {string} name
|
|
619
|
+
* @returns {boolean}
|
|
620
|
+
*/
|
|
621
|
+
function hasModule(cwd, name) {
|
|
622
|
+
let dir = path.resolve(cwd);
|
|
623
|
+
for (;;) {
|
|
624
|
+
if (existsSync(path.join(dir, 'node_modules', name, 'package.json'))) return true;
|
|
625
|
+
const up = path.dirname(dir);
|
|
626
|
+
if (up === dir) return false;
|
|
627
|
+
dir = up;
|
|
628
|
+
}
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
/**
|
|
632
|
+
* Is there a desktop app in this project to check?
|
|
633
|
+
*
|
|
634
|
+
* Asked without running anything and without importing the project's settings.
|
|
635
|
+
* A settings file may be JavaScript, and doctor must never execute a person's
|
|
636
|
+
* code to answer a question about their machine — so the file is READ as text
|
|
637
|
+
* and looked at, never loaded.
|
|
638
|
+
*
|
|
639
|
+
* Three answers count, in order of how sure they make us: settings that name an
|
|
640
|
+
* app, a built app sitting in the usual output folder, and Electron in the
|
|
641
|
+
* project's dependencies.
|
|
642
|
+
*
|
|
643
|
+
* @param {string} cwd
|
|
644
|
+
* @returns {{where: string, how: string}|null}
|
|
645
|
+
*/
|
|
646
|
+
function findDesktopApp(cwd) {
|
|
647
|
+
const configFile = findConfigFile(cwd);
|
|
648
|
+
if (configFile) {
|
|
649
|
+
try {
|
|
650
|
+
const text = readFileSync(configFile, 'utf8');
|
|
651
|
+
const named = /["']?binary["']?\s*:\s*["'`]([^"'`]+)["'`]/.exec(text);
|
|
652
|
+
if (named) return { where: named[1], how: 'your settings name it under app.binary' };
|
|
653
|
+
if (/["']?kind["']?\s*:\s*["'`]electron["'`]/.test(text)) {
|
|
654
|
+
return { where: configFile, how: 'your settings say this project is a desktop app' };
|
|
655
|
+
}
|
|
656
|
+
} catch {
|
|
657
|
+
// Unreadable settings are not an answer either way; keep looking.
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const root = configFile ? path.dirname(configFile) : cwd;
|
|
662
|
+
const ext = process.platform === 'darwin' ? '.app' : process.platform === 'win32' ? '.exe' : '.AppImage';
|
|
663
|
+
for (const folder of ['dist', 'out', 'release', 'build']) {
|
|
664
|
+
const dir = path.join(root, folder);
|
|
665
|
+
let entries = [];
|
|
666
|
+
try {
|
|
667
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
668
|
+
} catch {
|
|
669
|
+
continue;
|
|
670
|
+
}
|
|
671
|
+
for (const entry of entries) {
|
|
672
|
+
if (entry.name.endsWith(ext)) return { where: path.join(dir, entry.name), how: 'it is built and sitting in ' + folder + '/' };
|
|
673
|
+
// electron-builder puts the app one level down, in a per-platform folder.
|
|
674
|
+
if (!entry.isDirectory()) continue;
|
|
675
|
+
try {
|
|
676
|
+
for (const inner of readdirSync(path.join(dir, entry.name))) {
|
|
677
|
+
if (inner.endsWith(ext)) return { where: path.join(dir, entry.name, inner), how: 'it is built and sitting in ' + folder + '/' + entry.name + '/' };
|
|
678
|
+
}
|
|
679
|
+
} catch {
|
|
680
|
+
// Not readable. Not an answer.
|
|
681
|
+
}
|
|
682
|
+
}
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
if (hasModule(cwd, 'electron')) {
|
|
686
|
+
return { where: root, how: 'this project depends on Electron, so it makes one — but nothing says where the built app is' };
|
|
687
|
+
}
|
|
688
|
+
return null;
|
|
689
|
+
}
|
|
690
|
+
|
|
691
|
+
/**
|
|
692
|
+
* A built thing this tool could be pointed at, and how we know it is there.
|
|
693
|
+
* @typedef {{where: string, how: string}} FoundApp
|
|
694
|
+
*/
|
|
695
|
+
|
|
696
|
+
/**
|
|
697
|
+
* Is there a phone app in this project to check?
|
|
698
|
+
*
|
|
699
|
+
* The same three answers as the desktop app, in the same order of how sure they make us:
|
|
700
|
+
* settings that name one, a built artifact sitting where the toolchain puts it, and a
|
|
701
|
+
* project layout that says one gets made. Settings are READ as text and never loaded — a
|
|
702
|
+
* settings file may be JavaScript, and doctor must never run somebody's code to answer a
|
|
703
|
+
* question about their machine.
|
|
704
|
+
*
|
|
705
|
+
* Asking this at all is the point. A Mac with Xcode on it can run an iPhone app; that says
|
|
706
|
+
* nothing about whether THIS project has one. Telling somebody with a website to go and
|
|
707
|
+
* install thirty gigabytes of Xcode is asking for work that changes nothing, and the whole
|
|
708
|
+
* design turns on never doing that.
|
|
709
|
+
*
|
|
710
|
+
* @param {string} root
|
|
711
|
+
* @param {string|null} configFile
|
|
712
|
+
* @returns {Promise<{android: FoundApp|null, ios: FoundApp|null}>}
|
|
713
|
+
*/
|
|
714
|
+
async function phoneApps(root, configFile) {
|
|
715
|
+
/** @type {string} */
|
|
716
|
+
let settings = '';
|
|
717
|
+
if (configFile) {
|
|
718
|
+
try {
|
|
719
|
+
settings = readFileSync(configFile, 'utf8');
|
|
720
|
+
} catch {
|
|
721
|
+
settings = '';
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* @param {string} key
|
|
727
|
+
* @returns {FoundApp|null}
|
|
728
|
+
*/
|
|
729
|
+
const named = (key) => {
|
|
730
|
+
const found = new RegExp(`["']?${key}["']?\\s*:\\s*["'\`]([^"'\`]+)["'\`]`).exec(settings);
|
|
731
|
+
return found ? { where: found[1], how: `your settings name it under ${key}` } : null;
|
|
732
|
+
};
|
|
733
|
+
|
|
734
|
+
/**
|
|
735
|
+
* @param {string[]} folders
|
|
736
|
+
* @param {(name: string) => boolean} wanted
|
|
737
|
+
* @returns {FoundApp|null}
|
|
738
|
+
*/
|
|
739
|
+
const built = (folders, wanted) => {
|
|
740
|
+
for (const folder of folders) {
|
|
741
|
+
const dir = path.join(root, folder);
|
|
742
|
+
/** @type {string[]} */
|
|
743
|
+
let entries = [];
|
|
744
|
+
try {
|
|
745
|
+
entries = readdirSync(dir);
|
|
746
|
+
} catch {
|
|
747
|
+
continue;
|
|
748
|
+
}
|
|
749
|
+
for (const entry of entries) {
|
|
750
|
+
if (wanted(entry)) return { where: path.join(dir, entry), how: `it is built and sitting in ${folder}/` };
|
|
751
|
+
}
|
|
752
|
+
}
|
|
753
|
+
return null;
|
|
754
|
+
};
|
|
755
|
+
|
|
756
|
+
/** @param {string} rel */
|
|
757
|
+
const there = (rel) => existsSync(path.join(root, rel));
|
|
758
|
+
|
|
759
|
+
const android =
|
|
760
|
+
named('apk') ??
|
|
761
|
+
built(['dist', 'out', 'build', 'release', path.join('android', 'app', 'build', 'outputs', 'apk', 'release')], (name) => name.endsWith('.apk')) ??
|
|
762
|
+
(there(path.join('android', 'build.gradle')) || there(path.join('android', 'build.gradle.kts')) || there('build.gradle') || there('build.gradle.kts')
|
|
763
|
+
? { where: path.join(root, 'android'), how: 'this project builds one, but nothing says where the built APK is' }
|
|
764
|
+
: null);
|
|
765
|
+
|
|
766
|
+
const ios =
|
|
767
|
+
named('xcworkspace') ??
|
|
768
|
+
built(['dist', 'out', 'build', 'release'], (name) => name.endsWith('.app')) ??
|
|
769
|
+
(there(path.join('ios', 'Podfile')) || readdirSafe(path.join(root, 'ios')).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
|
|
770
|
+
? { where: path.join(root, 'ios'), how: 'this project builds one, but nothing says where the built app is' }
|
|
771
|
+
: readdirSafe(root).some((n) => n.endsWith('.xcodeproj') || n.endsWith('.xcworkspace'))
|
|
772
|
+
? { where: root, how: 'there is an Xcode project here, but nothing says where the built app is' }
|
|
773
|
+
: null);
|
|
774
|
+
|
|
775
|
+
return { android, ios };
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* "an electron journey", not "a electron journey". A small thing, and the sort of small
|
|
780
|
+
* thing that makes a reader trust the rest of the sentence less.
|
|
781
|
+
* @param {string} word
|
|
782
|
+
* @returns {string}
|
|
783
|
+
*/
|
|
784
|
+
function an(word) {
|
|
785
|
+
return `${/^[aeiou]/i.test(word) ? 'an' : 'a'} ${word}`;
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
/**
|
|
789
|
+
* @param {string} dir
|
|
790
|
+
* @returns {string[]}
|
|
791
|
+
*/
|
|
792
|
+
function readdirSafe(dir) {
|
|
793
|
+
try {
|
|
794
|
+
return readdirSync(dir);
|
|
795
|
+
} catch {
|
|
796
|
+
return [];
|
|
797
|
+
}
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
/**
|
|
801
|
+
* Does this stop everything, or only narrow it? Read out of the sentence written for it in
|
|
802
|
+
* {@link askTheAdapters}, which is the only place that sentence comes from.
|
|
803
|
+
* @param {Need} need
|
|
804
|
+
* @returns {boolean}
|
|
805
|
+
*/
|
|
806
|
+
function blocks(need) {
|
|
807
|
+
return need.why.startsWith('Nothing on this platform');
|
|
808
|
+
}
|
|
809
|
+
|
|
810
|
+
/**
|
|
811
|
+
* The platforms that arrive as an adapter of their own, and know their own requirements.
|
|
812
|
+
* The built-in five are described by hand above, because they are older than this
|
|
813
|
+
* mechanism and their wording is tested; these three answer for themselves.
|
|
814
|
+
*/
|
|
815
|
+
const ADAPTERS_THAT_ANSWER_FOR_THEMSELVES = ['android', 'ios', 'windows'];
|
|
816
|
+
|
|
817
|
+
/**
|
|
818
|
+
* Ask each separate adapter what IT is missing, in its own words.
|
|
819
|
+
*
|
|
820
|
+
* This is "detect rather than ask" carried all the way through. An adapter knows what it
|
|
821
|
+
* needs; this file does not, and a list of program names kept here is a second opinion
|
|
822
|
+
* about the same question — the shape of bug this whole tool exists to catch. It was
|
|
823
|
+
* already wrong once: this file asked for Appium on behalf of an Android adapter that does
|
|
824
|
+
* not use Appium, and somebody would have spent twenty minutes installing it for nothing.
|
|
825
|
+
*
|
|
826
|
+
* Every call is raced against a timeout and every failure becomes silence. Doctor is what
|
|
827
|
+
* somebody runs when they are already stuck, and an adapter that will not answer must not
|
|
828
|
+
* take the rest of the answer with it.
|
|
829
|
+
*
|
|
830
|
+
* @param {string} root
|
|
831
|
+
* @returns {Promise<Map<string, Need[]>>}
|
|
832
|
+
*/
|
|
833
|
+
async function askTheAdapters(root) {
|
|
834
|
+
/** @type {Map<string, Need[]>} */
|
|
835
|
+
const out = new Map();
|
|
836
|
+
/** @type {{adapters: {name: string, detect: (p: any) => Promise<any>}[]}} */
|
|
837
|
+
let engine;
|
|
838
|
+
try {
|
|
839
|
+
engine = /** @type {any} */ (await import('./check.js')).loadAdapters ? await (await import('./check.js')).loadAdapters() : { adapters: [] };
|
|
840
|
+
} catch {
|
|
841
|
+
return out;
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** @type {Record<string, any>} */
|
|
845
|
+
let config = {};
|
|
846
|
+
try {
|
|
847
|
+
const file = findConfigFile(root);
|
|
848
|
+
// Read as text and parsed only when it is JSON. Doctor never runs a person's code to
|
|
849
|
+
// answer a question about their machine, and a settings file may be JavaScript.
|
|
850
|
+
if (file && file.endsWith('.json')) config = JSON.parse(readFileSync(file, 'utf8'));
|
|
851
|
+
} catch {
|
|
852
|
+
config = {};
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
await Promise.all(
|
|
856
|
+
ADAPTERS_THAT_ANSWER_FOR_THEMSELVES.map(async (name) => {
|
|
857
|
+
const adapter = engine.adapters.find((a) => a.name === name);
|
|
858
|
+
if (!adapter) return;
|
|
859
|
+
try {
|
|
860
|
+
/** @type {{missing?: {what?: string, unlocks?: string, howToGet?: string, blocking?: boolean}[]}} */
|
|
861
|
+
const detection = await Promise.race([
|
|
862
|
+
adapter.detect({ root, config: config[name] ?? {} }),
|
|
863
|
+
new Promise((resolve) => setTimeout(() => resolve({ missing: [] }), REACH_MS * 2)),
|
|
864
|
+
]);
|
|
865
|
+
const needs = (detection?.missing ?? [])
|
|
866
|
+
.filter((m) => typeof m.what === 'string' && m.what !== '')
|
|
867
|
+
.map((m) => /** @type {Need} */ ({
|
|
868
|
+
what: String(m.what),
|
|
869
|
+
why: m.blocking === true ? 'Nothing on this platform can be checked at all without it.' : 'It widens what can be watched here.',
|
|
870
|
+
fix: String(m.howToGet ?? ''),
|
|
871
|
+
// Whether a person is needed is read out of the words, because the adapter
|
|
872
|
+
// contract has no field for it. A licence, an account, a pair of hands or a
|
|
873
|
+
// device is a person; everything else is a command the agent just runs. Being
|
|
874
|
+
// wrong in this direction only ever means telling somebody about a step they
|
|
875
|
+
// did not have to take, which is far cheaper than the other way round.
|
|
876
|
+
automatic: !/licen[cs]e|apple id|app store|plug|pair of hands|somebody has to|a person|sign in|log in/i.test(String(m.howToGet ?? '')),
|
|
877
|
+
unlocks: String(m.unlocks ?? ''),
|
|
878
|
+
}))
|
|
879
|
+
.filter((need) => need.fix !== '');
|
|
880
|
+
out.set(name, needs);
|
|
881
|
+
} catch {
|
|
882
|
+
// An adapter that cannot answer contributes nothing, and the machine survey stands.
|
|
883
|
+
}
|
|
884
|
+
}),
|
|
885
|
+
);
|
|
886
|
+
return out;
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
/**
|
|
890
|
+
* What THIS COPY of Stays Fixed knows how to drive.
|
|
891
|
+
*
|
|
892
|
+
* Asked of the engine rather than assumed, and asked in a try/catch, because doctor is the
|
|
893
|
+
* call somebody makes when everything else is broken and it must answer even then. A
|
|
894
|
+
* surface with no adapter behind it can never be reported as ready, whatever this machine
|
|
895
|
+
* has installed on it: the machine could run the app, and nothing here would open it.
|
|
896
|
+
*
|
|
897
|
+
* @returns {Promise<DriverReport[]>}
|
|
898
|
+
*/
|
|
899
|
+
async function whatThisCopyCanDrive() {
|
|
900
|
+
/** @type {DriverReport[]} */
|
|
901
|
+
const out = [];
|
|
902
|
+
try {
|
|
903
|
+
const engine = await import('./check.js');
|
|
904
|
+
const { adapters, missing } = await engine.loadAdapters();
|
|
905
|
+
for (const [surface, name] of Object.entries(engine.ADAPTER_FOR_SURFACE)) {
|
|
906
|
+
const present = adapters.some((a) => a.name === name);
|
|
907
|
+
out.push({
|
|
908
|
+
surface,
|
|
909
|
+
present,
|
|
910
|
+
why: present
|
|
911
|
+
? `The ${name} adapter is in this copy, so ${an(surface)} journey has something to walk it.`
|
|
912
|
+
: missing.get(surface) ?? `There is no ${name} adapter in this copy, so nothing would walk ${an(surface)} journey.`,
|
|
913
|
+
});
|
|
914
|
+
}
|
|
915
|
+
} catch (e) {
|
|
916
|
+
// The engine would not load. The surfaces that arrive as a separate adapter are the
|
|
917
|
+
// ones that must not be guessed at, so they are reported as absent with the real
|
|
918
|
+
// reason rather than left to default to a yes. The built-in five live in the very
|
|
919
|
+
// file that would not load, so a check on this copy is not running at all — and both
|
|
920
|
+
// the command line and the MCP surface say that in their own words already.
|
|
921
|
+
const why = `This copy could not be asked what it can drive: ${messageOf(e)}`;
|
|
922
|
+
for (const surface of ['android', 'ios', 'windows']) out.push({ surface, present: false, why });
|
|
923
|
+
}
|
|
924
|
+
return out;
|
|
925
|
+
}
|
|
926
|
+
|
|
927
|
+
/**
|
|
928
|
+
* Where Playwright keeps the browsers it downloaded, if it has downloaded any.
|
|
929
|
+
* @returns {string|null}
|
|
930
|
+
*/
|
|
931
|
+
function playwrightBrowsersDir() {
|
|
932
|
+
const home = os.homedir();
|
|
933
|
+
const candidates =
|
|
934
|
+
process.platform === 'darwin'
|
|
935
|
+
? [path.join(home, 'Library', 'Caches', 'ms-playwright')]
|
|
936
|
+
: process.platform === 'win32'
|
|
937
|
+
? [path.join(home, 'AppData', 'Local', 'ms-playwright')]
|
|
938
|
+
: [path.join(home, '.cache', 'ms-playwright')];
|
|
939
|
+
for (const dir of candidates) if (existsSync(dir)) return dir;
|
|
940
|
+
return null;
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
/**
|
|
944
|
+
* An Android SDK tool in the place the SDK normally puts it.
|
|
945
|
+
* @param {string} folder
|
|
946
|
+
* @param {string} name
|
|
947
|
+
* @returns {string|null}
|
|
948
|
+
*/
|
|
949
|
+
function androidSdkTool(folder, name) {
|
|
950
|
+
const roots = [process.env.ANDROID_HOME, process.env.ANDROID_SDK_ROOT, path.join(os.homedir(), 'Library', 'Android', 'sdk'), path.join(os.homedir(), 'Android', 'Sdk')];
|
|
951
|
+
for (const root of roots) {
|
|
952
|
+
if (!root) continue;
|
|
953
|
+
const candidate = path.join(root, folder, name);
|
|
954
|
+
if (existsSync(candidate)) return candidate;
|
|
955
|
+
}
|
|
956
|
+
return null;
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
// ── other machines ──────────────────────────────────────────────────────────
|
|
960
|
+
|
|
961
|
+
/**
|
|
962
|
+
* The hosts this machine can already reach, found by reading the SSH config and
|
|
963
|
+
* then actually dialling them.
|
|
964
|
+
*
|
|
965
|
+
* This is the sharpest form of "detect rather than ask" in the file. A host that
|
|
966
|
+
* answers is a runner the tool already has, and it must never appear in the
|
|
967
|
+
* result as something to go and set up.
|
|
968
|
+
*
|
|
969
|
+
* @returns {Promise<HostReport[]>}
|
|
970
|
+
*/
|
|
971
|
+
export async function reachableHosts() {
|
|
972
|
+
if (!onPath('ssh')) return [];
|
|
973
|
+
const names = (await sshConfigHosts()).slice(0, MAX_HOSTS);
|
|
974
|
+
if (names.length === 0) return [];
|
|
975
|
+
|
|
976
|
+
return await Promise.all(
|
|
977
|
+
names.map(async (name) => {
|
|
978
|
+
const answer = await ask('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', name, 'echo staysfixed-reachable'], REACH_MS);
|
|
979
|
+
if (!answer.ok || !answer.out.includes('staysfixed-reachable')) {
|
|
980
|
+
return /** @type {HostReport} */ ({ name, reachable: false, how: answer.why || 'it did not answer' });
|
|
981
|
+
}
|
|
982
|
+
// A Linux shell that can see powershell.exe is a real Windows desktop
|
|
983
|
+
// behind it — the cheapest Windows runner there is, and one nobody has to
|
|
984
|
+
// provision. Worth one extra round trip to find out.
|
|
985
|
+
const windows = await ask('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', name, 'command -v powershell.exe || command -v pwsh.exe'], REACH_MS);
|
|
986
|
+
return /** @type {HostReport} */ ({
|
|
987
|
+
name,
|
|
988
|
+
reachable: true,
|
|
989
|
+
how: 'it answered over ssh with the key you already have',
|
|
990
|
+
windows: windows.ok && windows.out.trim() !== '',
|
|
991
|
+
});
|
|
992
|
+
})
|
|
993
|
+
);
|
|
994
|
+
}
|
|
995
|
+
|
|
996
|
+
/**
|
|
997
|
+
* Host names out of ~/.ssh/config. Patterns are skipped: `Host *` is a rule, not
|
|
998
|
+
* a machine, and dialling it would be meaningless.
|
|
999
|
+
* @returns {Promise<string[]>}
|
|
1000
|
+
*/
|
|
1001
|
+
async function sshConfigHosts() {
|
|
1002
|
+
/** @type {string[]} */
|
|
1003
|
+
const names = [];
|
|
1004
|
+
let text = '';
|
|
1005
|
+
try {
|
|
1006
|
+
text = await fsp.readFile(path.join(os.homedir(), '.ssh', 'config'), 'utf8');
|
|
1007
|
+
} catch {
|
|
1008
|
+
return names;
|
|
1009
|
+
}
|
|
1010
|
+
for (const line of text.split('\n')) {
|
|
1011
|
+
const found = /^\s*Host\s+(.+?)\s*$/i.exec(line);
|
|
1012
|
+
if (!found) continue;
|
|
1013
|
+
for (const word of found[1].split(/\s+/)) {
|
|
1014
|
+
if (word.includes('*') || word.includes('?') || word.startsWith('!')) continue;
|
|
1015
|
+
if (!names.includes(word)) names.push(word);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
return names;
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Is there a reference to compare against yet?
|
|
1023
|
+
*
|
|
1024
|
+
* The cold start is real and worth saying out loud: on an existing product there
|
|
1025
|
+
* is nothing to compare against until it has been shipped once with the hook in
|
|
1026
|
+
* place, and pretending otherwise is how a tool reports a false all-clear.
|
|
1027
|
+
*
|
|
1028
|
+
* @param {string} root
|
|
1029
|
+
* @returns {Promise<{found: boolean, note: string}>}
|
|
1030
|
+
*/
|
|
1031
|
+
async function findReference(root) {
|
|
1032
|
+
// The store owns where it keeps things, so this asks it rather than guessing
|
|
1033
|
+
// at folder names. Guessing is how doctor ends up telling somebody with a
|
|
1034
|
+
// perfectly good reference to go and record one.
|
|
1035
|
+
/** @type {import('./types.js').Store|null} */
|
|
1036
|
+
let store = null;
|
|
1037
|
+
try {
|
|
1038
|
+
const { openStore } = await import('./store.js');
|
|
1039
|
+
store = openStore({ root });
|
|
1040
|
+
} catch {
|
|
1041
|
+
store = null;
|
|
1042
|
+
}
|
|
1043
|
+
if (!store) {
|
|
1044
|
+
return { found: false, note: 'This copy has no store to look in, so nothing has been recorded and there is nothing to compare a new build against.' };
|
|
1045
|
+
}
|
|
1046
|
+
|
|
1047
|
+
/** @type {Record<string, {buildId?: string, setAt?: string, setBy?: string}>} */
|
|
1048
|
+
let pointers = {};
|
|
1049
|
+
try {
|
|
1050
|
+
const parsed = JSON.parse(await fsp.readFile(store.referencesFile, 'utf8'));
|
|
1051
|
+
if (parsed && typeof parsed === 'object') pointers = parsed;
|
|
1052
|
+
} catch {
|
|
1053
|
+
// No file, or an unreadable one. Both mean no reference, and neither is
|
|
1054
|
+
// worth an error: the answer below is the same either way.
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
const named = Object.entries(pointers).filter(([, p]) => typeof p?.buildId === 'string' && p.buildId !== '');
|
|
1058
|
+
if (named.length > 0) {
|
|
1059
|
+
const said = named
|
|
1060
|
+
.map(([product, p]) => `${product} is on ${p.buildId}${p.setAt ? `, set ${p.setAt.slice(0, 10)}` : ''}${p.setBy ? ` by ${p.setBy}` : ''}`)
|
|
1061
|
+
.join('; ');
|
|
1062
|
+
return { found: true, note: `A build is on record as working: ${said}. The records are in ${shortPath(store.dir)}.` };
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
// Captures with no pointer is a real and different state: the tool has been
|
|
1066
|
+
// run, but nobody has said which build counts as working yet.
|
|
1067
|
+
let builds = 0;
|
|
1068
|
+
try {
|
|
1069
|
+
builds = (await fsp.readdir(store.buildsDir)).length;
|
|
1070
|
+
} catch {
|
|
1071
|
+
builds = 0;
|
|
1072
|
+
}
|
|
1073
|
+
if (builds > 0) {
|
|
1074
|
+
return {
|
|
1075
|
+
found: false,
|
|
1076
|
+
note: `${builds} ${builds === 1 ? 'build has' : 'builds have'} been recorded in ${shortPath(store.dir)}, but none of them is marked as the one that works, so there is still nothing to compare against. The mark is made when you ship.`,
|
|
1077
|
+
};
|
|
1078
|
+
}
|
|
1079
|
+
return {
|
|
1080
|
+
found: false,
|
|
1081
|
+
note: 'Nothing recorded yet, so there is nothing to compare against. Run `staysfixed check --paired` once, or ship once with the reference hook in place.',
|
|
1082
|
+
};
|
|
1083
|
+
}
|
|
1084
|
+
|
|
1085
|
+
// ── what that adds up to ────────────────────────────────────────────────────
|
|
1086
|
+
|
|
1087
|
+
/**
|
|
1088
|
+
* Turn the raw findings into the only thing anybody actually wants: for each
|
|
1089
|
+
* kind of product, can this machine watch it, and if not, what exactly is in
|
|
1090
|
+
* the way.
|
|
1091
|
+
*
|
|
1092
|
+
* @param {ToolReport[]} tools
|
|
1093
|
+
* @param {HostReport[]} hosts
|
|
1094
|
+
* @param {boolean} configured
|
|
1095
|
+
* @param {import('./browsers.js').BrowserSurvey} browsers
|
|
1096
|
+
* @param {{where: string, how: string}|null} desktopApp
|
|
1097
|
+
* @param {DriverReport[]} drivers What this copy of the tool can drive at all.
|
|
1098
|
+
* @param {{android: FoundApp|null, ios: FoundApp|null}} phones
|
|
1099
|
+
* @param {Map<string, Need[]>} asked What each separate adapter says IT is missing.
|
|
1100
|
+
* @returns {SurfaceReport[]}
|
|
1101
|
+
*/
|
|
1102
|
+
function describeSurfaces(tools, hosts, configured, browsers, desktopApp, drivers, phones, asked) {
|
|
1103
|
+
/** @param {string} surface */
|
|
1104
|
+
const canDrive = (surface) => drivers.find((d) => d.surface === surface)?.present !== false;
|
|
1105
|
+
/** @param {string} surface */
|
|
1106
|
+
const noDriver = (surface) => drivers.find((d) => d.surface === surface)?.why ?? '';
|
|
1107
|
+
/** @param {string} id */
|
|
1108
|
+
const have = (id) => tools.some((t) => t.id === id && t.found);
|
|
1109
|
+
const browser = browsers.chosen !== null;
|
|
1110
|
+
const ownBrowser = browsers.chosen !== null && !browsers.chosen.everyday;
|
|
1111
|
+
const windowsHost = hosts.find((h) => h.reachable && h.windows === true);
|
|
1112
|
+
|
|
1113
|
+
/** Every channel that needs no driver at all — a child process is enough. */
|
|
1114
|
+
const withoutADriver = ['effects', 'complaints', 'results', 'contract', 'counters'];
|
|
1115
|
+
|
|
1116
|
+
/** @type {Omit<SurfaceReport, 'state'>[]} */
|
|
1117
|
+
const surfaces = [];
|
|
1118
|
+
|
|
1119
|
+
/** Surfaces nothing on this machine could ever unlock, and what to do instead. */
|
|
1120
|
+
/** @type {Map<string, string>} */
|
|
1121
|
+
const impossible = new Map();
|
|
1122
|
+
|
|
1123
|
+
surfaces.push({
|
|
1124
|
+
id: 'cli',
|
|
1125
|
+
name: 'command-line tools and libraries',
|
|
1126
|
+
status: 'ready',
|
|
1127
|
+
summary: 'Fully covered here. What it printed, what it exited with, what it wrote, what it called out to, and what it exports.',
|
|
1128
|
+
canCheck: withoutADriver,
|
|
1129
|
+
cannotCheck: ['meaning', 'pixels'],
|
|
1130
|
+
needs: [],
|
|
1131
|
+
});
|
|
1132
|
+
|
|
1133
|
+
surfaces.push({
|
|
1134
|
+
id: 'server',
|
|
1135
|
+
name: 'servers and APIs',
|
|
1136
|
+
status: have('docker') ? 'ready' : 'partial',
|
|
1137
|
+
summary: have('docker')
|
|
1138
|
+
? 'Covered. Requests and answers are compared, and the schema is compared after any migration.'
|
|
1139
|
+
: 'Mostly covered. Without a way to restore the same data twice, anything that depends on stored data cannot be compared fairly.',
|
|
1140
|
+
canCheck: withoutADriver,
|
|
1141
|
+
cannotCheck: ['meaning', 'pixels'],
|
|
1142
|
+
needs: have('docker')
|
|
1143
|
+
? []
|
|
1144
|
+
: [
|
|
1145
|
+
{
|
|
1146
|
+
what: 'a database snapshot that can be restored twice',
|
|
1147
|
+
why: 'Both builds have to see identical data or every difference is really a data difference.',
|
|
1148
|
+
fix: 'Install Docker, or point the settings at a dump file the tool may restore.',
|
|
1149
|
+
automatic: false,
|
|
1150
|
+
unlocks: 'Anything on your server that depends on stored data — totals, lists, permissions — gets compared fairly instead of being left out.',
|
|
1151
|
+
},
|
|
1152
|
+
],
|
|
1153
|
+
});
|
|
1154
|
+
|
|
1155
|
+
surfaces.push({
|
|
1156
|
+
id: 'web',
|
|
1157
|
+
name: 'web apps and sites',
|
|
1158
|
+
// A machine with only the person's own browser CAN check a website, so this is
|
|
1159
|
+
// not "unavailable" — but it is not "ready" either, because running it borrows
|
|
1160
|
+
// the browser they use. Calling that ready is how a tool ends up making somebody's
|
|
1161
|
+
// machine worse and reporting a clean run while it does.
|
|
1162
|
+
status: ownBrowser ? 'ready' : browser ? 'partial' : 'unavailable',
|
|
1163
|
+
summary: ownBrowser
|
|
1164
|
+
? `Covered, including what the screen says each control is and does. It opens ${browsers.chosen?.name}, which is a separate application from the browser you use.`
|
|
1165
|
+
: browser
|
|
1166
|
+
? 'Can be checked, but only by opening the browser you use yourself. It runs invisibly on a throwaway profile, so your settings and tabs are safe — but on a Mac it shares an application slot with your browser, so clicking your browser icon during a check may wake the hidden copy instead of opening your window.'
|
|
1167
|
+
: 'Cannot run here: there is no browser on this machine that will open a page.',
|
|
1168
|
+
canCheck: browser ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
1169
|
+
cannotCheck: browser ? [] : CHANNELS.map((c) => c.id),
|
|
1170
|
+
needs: ownBrowser
|
|
1171
|
+
? []
|
|
1172
|
+
: [
|
|
1173
|
+
{
|
|
1174
|
+
what: browser ? 'a browser of its own, so yours is left alone' : 'a browser to open pages with',
|
|
1175
|
+
why: browser
|
|
1176
|
+
? 'Two copies of one browser share a single slot on a Mac. Its own browser is what stops a check in the background answering when you click your browser icon.'
|
|
1177
|
+
: 'A page has to actually open before anything can be read off it.',
|
|
1178
|
+
fix: INSTALL_COMMAND,
|
|
1179
|
+
automatic: true,
|
|
1180
|
+
unlocks: browser
|
|
1181
|
+
? 'Checks stop borrowing the browser you use, so a run in the background can never answer when you click your own browser icon.'
|
|
1182
|
+
: 'Your website gets checked: every page, what each control says it is and does, what calls go out, and what the console complains about.',
|
|
1183
|
+
},
|
|
1184
|
+
],
|
|
1185
|
+
});
|
|
1186
|
+
|
|
1187
|
+
// A desktop app needs NO browser. It is its own Chromium and it opens its own
|
|
1188
|
+
// debugging port; the tool speaks to that port directly. Version 1's doctor said
|
|
1189
|
+
// otherwise and it was simply wrong — it would have told somebody with a perfectly
|
|
1190
|
+
// checkable Electron app to go and install Chrome.
|
|
1191
|
+
const namedApp = configured && desktopApp !== null && desktopApp.how.startsWith('your settings');
|
|
1192
|
+
surfaces.push({
|
|
1193
|
+
id: 'electron',
|
|
1194
|
+
name: 'Electron desktop apps',
|
|
1195
|
+
status: desktopApp === null ? 'unavailable' : namedApp ? 'ready' : 'partial',
|
|
1196
|
+
summary:
|
|
1197
|
+
desktopApp === null
|
|
1198
|
+
? 'Nothing to check: no desktop app was found in this project, and the settings do not name one.'
|
|
1199
|
+
: namedApp
|
|
1200
|
+
? `Covered. It opens ${desktopApp.where} with its own scratch data folder, drives it over its own debugging port — no browser involved — and reads the IPC channels straight out of the source.`
|
|
1201
|
+
: `A desktop app was found (${desktopApp.how}), but nothing says which built app to open, so a check would have to guess.`,
|
|
1202
|
+
canCheck: desktopApp === null ? [] : [...withoutADriver, 'meaning', 'pixels'],
|
|
1203
|
+
cannotCheck: desktopApp === null ? CHANNELS.map((c) => c.id) : [],
|
|
1204
|
+
needs:
|
|
1205
|
+
desktopApp === null || namedApp
|
|
1206
|
+
? []
|
|
1207
|
+
: [
|
|
1208
|
+
{
|
|
1209
|
+
what: 'settings naming the built app',
|
|
1210
|
+
why: 'Two builds of one desktop app fight over its single-instance lock and its data folder, so the tool has to know exactly which file to open and give each run its own folder.',
|
|
1211
|
+
fix: `Run \`staysfixed init\`, or set app.binary to ${desktopApp.where}.`,
|
|
1212
|
+
automatic: true,
|
|
1213
|
+
unlocks: 'Your desktop app gets checked end to end, including every IPC channel the code registers — the doors no screenshot has ever seen.',
|
|
1214
|
+
},
|
|
1215
|
+
],
|
|
1216
|
+
});
|
|
1217
|
+
if (desktopApp === null) {
|
|
1218
|
+
impossible.set('electron', 'This project has no desktop app in it. If yours is built somewhere else, name the built app in your settings under app.binary and this becomes available — nothing else is needed, and no browser is needed for it at all.');
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Three separate questions, and folding any two of them together is how a surface gets
|
|
1222
|
+
// called ready while nothing is ever walked on it: is there an Android app here to
|
|
1223
|
+
// check, does this copy of the tool know how to drive one, and does this machine have
|
|
1224
|
+
// what it takes to run it.
|
|
1225
|
+
// What Android needs is asked of the Android adapter, never guessed at from a list of
|
|
1226
|
+
// program names kept here. A second opinion about the same question is how a tool ends
|
|
1227
|
+
// up telling somebody to install Appium for an adapter that does not use Appium — and
|
|
1228
|
+
// being sent shopping for something that changes nothing is the fastest way to make a
|
|
1229
|
+
// person stop reading this page.
|
|
1230
|
+
const androidWants = asked.get('android') ?? [];
|
|
1231
|
+
const androidMissing = androidWants.map((need) => need.what);
|
|
1232
|
+
const androidBlocked = androidWants.some(blocks);
|
|
1233
|
+
const androidReady = androidWants.length === 0 && canDrive('android');
|
|
1234
|
+
// Missing something that stops everything and missing something that only narrows what
|
|
1235
|
+
// is watched are different answers, and rolling the second into the first tells somebody
|
|
1236
|
+
// their Android app cannot be checked when most of it can.
|
|
1237
|
+
const androidPartly = !androidReady && !androidBlocked && canDrive('android') && phones.android !== null;
|
|
1238
|
+
surfaces.push({
|
|
1239
|
+
id: 'android',
|
|
1240
|
+
name: 'Android apps',
|
|
1241
|
+
status: phones.android === null || !canDrive('android') ? 'unavailable' : androidReady ? 'ready' : androidPartly ? 'partial' : 'unavailable',
|
|
1242
|
+
summary:
|
|
1243
|
+
phones.android === null
|
|
1244
|
+
? 'Nothing to check: no Android app was found in this project, and the settings do not name one.'
|
|
1245
|
+
: !canDrive('android')
|
|
1246
|
+
? `An Android app is here (${phones.android.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('android')}`
|
|
1247
|
+
: androidReady
|
|
1248
|
+
? `Covered against the stored record. It installs ${phones.android.where} on a virtual device, walks it, and reads what each control on the screen is and does. Whether two emulator snapshots restore identically is still unproven, so a paired run is not offered — and the run says which mode it used.`
|
|
1249
|
+
: androidPartly
|
|
1250
|
+
? `Most of your Android app can be checked: every screen another app can reach is opened and read. What is missing is ${plainList(androidMissing)}, and without ${androidMissing.length === 1 ? 'it' : 'them'} nothing is typed, pressed or saved — so a clean result covers the screens and not what the app DOES.`
|
|
1251
|
+
: `An Android app is here (${phones.android.how}), and ${plainList(androidMissing)} ${androidMissing.length === 1 ? 'is' : 'are'} still missing. ${androidWants.every((n) => n.automatic) ? `${androidMissing.length === 1 ? 'It installs' : 'They all install'} without anybody clicking anything, so nobody needs to be asked.` : 'Some of it needs a person, and each one says what it is and what it unlocks.'}`,
|
|
1252
|
+
canCheck: androidReady || androidPartly ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
1253
|
+
cannotCheck: androidReady || androidPartly ? [] : CHANNELS.map((c) => c.id),
|
|
1254
|
+
needs: phones.android === null || !canDrive('android') ? [] : androidWants,
|
|
1255
|
+
});
|
|
1256
|
+
if (phones.android === null) {
|
|
1257
|
+
impossible.set(
|
|
1258
|
+
'android',
|
|
1259
|
+
'This project has no Android app in it, so there is nothing here for an emulator to run. If yours is built somewhere else, name the built APK in your settings under android.apk and this becomes available — nothing else is needed.'
|
|
1260
|
+
);
|
|
1261
|
+
} else if (!canDrive('android')) {
|
|
1262
|
+
impossible.set(
|
|
1263
|
+
'android',
|
|
1264
|
+
`${noDriver('android')} Nothing you install on this machine changes that. Update Stays Fixed to a copy that has it; until then your Android app is not being checked by anything, and everything else on this list still is.`
|
|
1265
|
+
);
|
|
1266
|
+
}
|
|
1267
|
+
|
|
1268
|
+
const onAMac = process.platform === 'darwin';
|
|
1269
|
+
const iosMachine = onAMac && have('simulator');
|
|
1270
|
+
// Whatever the iPhone adapter says it is missing, in its own words — the same as Android
|
|
1271
|
+
// and Windows. Nothing here keeps a list of program names on its behalf: this file asked
|
|
1272
|
+
// for Appium on Android's behalf once, for an adapter that does not use Appium, and it
|
|
1273
|
+
// would have cost somebody twenty minutes for nothing.
|
|
1274
|
+
const iosWants = asked.get('ios') ?? [];
|
|
1275
|
+
const iosBlocked = iosWants.some(blocks);
|
|
1276
|
+
const iosReady = iosMachine && canDrive('ios') && phones.ios !== null && iosWants.length === 0;
|
|
1277
|
+
const iosPartly = iosMachine && canDrive('ios') && phones.ios !== null && !iosReady && !iosBlocked;
|
|
1278
|
+
if (!onAMac) {
|
|
1279
|
+
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.');
|
|
1280
|
+
} else if (phones.ios === null) {
|
|
1281
|
+
impossible.set(
|
|
1282
|
+
'ios',
|
|
1283
|
+
'This project has no iPhone app in it, so there is nothing for the simulator to run. If yours is built somewhere else, name the built .app in your settings under ios.app and this becomes available.'
|
|
1284
|
+
);
|
|
1285
|
+
} else if (!canDrive('ios')) {
|
|
1286
|
+
impossible.set(
|
|
1287
|
+
'ios',
|
|
1288
|
+
`${noDriver('ios')} Nothing you install on this machine changes that. Update Stays Fixed to a copy that has it; until then your iPhone app is not being checked by anything, and everything else on this list still is.`
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
surfaces.push({
|
|
1292
|
+
id: 'ios',
|
|
1293
|
+
name: 'iPhone apps, on the simulator',
|
|
1294
|
+
status: iosReady ? 'ready' : iosPartly ? 'partial' : 'unavailable',
|
|
1295
|
+
summary: !onAMac
|
|
1296
|
+
? 'Cannot run here: iOS needs a Mac.'
|
|
1297
|
+
: phones.ios === null
|
|
1298
|
+
? 'Nothing to check: no iPhone app was found in this project, and the settings do not name one.'
|
|
1299
|
+
: !canDrive('ios')
|
|
1300
|
+
? `An iPhone app is here (${phones.ios.how}), and this copy of Stays Fixed cannot drive one. ${noDriver('ios')}`
|
|
1301
|
+
: !iosMachine
|
|
1302
|
+
? 'Cannot run here: no usable iOS runtime was found, so there is no simulator to boot the app on.'
|
|
1303
|
+
: iosReady
|
|
1304
|
+
? `Covered on the simulator, against the stored record. It boots ${phones.ios.where} on a simulator of its own and reads what each control on the screen is and does. A real iPhone in your hand cannot be compared side by side and never will be — two builds cannot exist on it at once.`
|
|
1305
|
+
: `The simulator is here and the app is here. What is missing is ${plainList(iosWants.map((n) => n.what))}, so a clean result would cover less than it looks like.`,
|
|
1306
|
+
canCheck: iosReady || iosPartly ? [...withoutADriver, 'meaning', 'pixels'] : [],
|
|
1307
|
+
cannotCheck: iosReady || iosPartly ? [] : CHANNELS.map((c) => c.id),
|
|
1308
|
+
needs:
|
|
1309
|
+
!onAMac || phones.ios === null || !canDrive('ios')
|
|
1310
|
+
? []
|
|
1311
|
+
: [
|
|
1312
|
+
// Xcode and its runtimes are a download nobody can do for you: it needs an
|
|
1313
|
+
// Apple ID, a licence agreement and about thirty gigabytes.
|
|
1314
|
+
...(!have('simulator')
|
|
1315
|
+
? [
|
|
1316
|
+
{
|
|
1317
|
+
what: 'Xcode with at least one iOS runtime',
|
|
1318
|
+
why: 'The simulator is the only place two builds of an iPhone app can be run one after the other.',
|
|
1319
|
+
fix: 'Install Xcode from the App Store, open it once to accept the licence, then add an iOS runtime under Settings, Platforms. It is about thirty gigabytes and it needs an Apple ID, which is why nobody can do it for you.',
|
|
1320
|
+
automatic: false,
|
|
1321
|
+
unlocks: 'Your iPhone app gets checked before every release, on a simulator, without you opening anything.',
|
|
1322
|
+
},
|
|
1323
|
+
]
|
|
1324
|
+
: []),
|
|
1325
|
+
...iosWants,
|
|
1326
|
+
],
|
|
1327
|
+
});
|
|
1328
|
+
|
|
1329
|
+
// A native Windows window can only be read from Windows. The tool does not need a
|
|
1330
|
+
// Windows machine of its own: any ssh host that reaches one — including a WSL shell on
|
|
1331
|
+
// it — is a runner, and one of those is usually already in somebody's ssh config. That
|
|
1332
|
+
// is "detect rather than ask" at its sharpest: a runner that already answers must never
|
|
1333
|
+
// be presented as something to go and set up.
|
|
1334
|
+
const windowsDriver = canDrive('windows');
|
|
1335
|
+
surfaces.push({
|
|
1336
|
+
id: 'windows',
|
|
1337
|
+
name: 'native Windows apps',
|
|
1338
|
+
status: windowsHost && windowsDriver ? 'partial' : 'unavailable',
|
|
1339
|
+
summary: !windowsHost
|
|
1340
|
+
? '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.'
|
|
1341
|
+
: !windowsDriver
|
|
1342
|
+
? `A real Windows desktop is reachable through ${windowsHost.name}, and this copy of Stays Fixed cannot drive one. ${noDriver('windows')}`
|
|
1343
|
+
: `A real Windows desktop is already reachable through ${windowsHost.name}, and nothing has to be installed on it — the program that reads the screen is sent down the ssh connection each run and disappears when it closes. Two builds still cannot run at once, because Windows shows one desktop, so runs are one after the other and the comparison is weaker here than anywhere else.`,
|
|
1344
|
+
canCheck: windowsHost && windowsDriver ? withoutADriver : [],
|
|
1345
|
+
cannotCheck: windowsHost && windowsDriver ? ['meaning', 'pixels'] : CHANNELS.map((c) => c.id),
|
|
1346
|
+
// Asked of the Windows adapter, which knows what it needs — the name of a machine and
|
|
1347
|
+
// the built program — rather than kept as a second opinion here. The one thing added
|
|
1348
|
+
// is the host name, because doctor found it by dialling and the adapter cannot.
|
|
1349
|
+
needs:
|
|
1350
|
+
windowsHost && windowsDriver
|
|
1351
|
+
? (asked.get('windows') ?? []).map((need) => ({ ...need, fix: need.fix.replace(/"the-ssh-host-name"/g, `"${windowsHost.name}"`) }))
|
|
1352
|
+
: [],
|
|
1353
|
+
});
|
|
1354
|
+
if (windowsHost && !windowsDriver) {
|
|
1355
|
+
impossible.set('windows', `${noDriver('windows')} Nothing you install on that machine changes it. Update Stays Fixed to a copy that has it.`);
|
|
1356
|
+
}
|
|
1357
|
+
if (!windowsHost) {
|
|
1358
|
+
impossible.set(
|
|
1359
|
+
'windows',
|
|
1360
|
+
'A native Windows window can only be read from Windows itself, and no Windows desktop answers from here. If your Windows product is Electron — most are — it is already covered over its debug port and you need nothing. If it is genuinely native, this becomes possible the day an SSH host in your config reaches a Windows machine.'
|
|
1361
|
+
);
|
|
1362
|
+
}
|
|
1363
|
+
|
|
1364
|
+
return surfaces.map((surface) => {
|
|
1365
|
+
const instead = impossible.get(surface.id);
|
|
1366
|
+
return instead ? { ...surface, state: stateOf(surface, true), instead } : { ...surface, state: stateOf(surface, false) };
|
|
1367
|
+
});
|
|
1368
|
+
}
|
|
1369
|
+
|
|
1370
|
+
/**
|
|
1371
|
+
* What a green run on this machine would and would not actually mean.
|
|
1372
|
+
*
|
|
1373
|
+
* @typedef {object} Covers
|
|
1374
|
+
* @property {string} short A short paragraph, safe to repeat to a person word for word.
|
|
1375
|
+
* @property {string[]} covered Kinds of product a check here looks at in full.
|
|
1376
|
+
* @property {{name: string, why: string}[]} partly Looked at, but not completely, and why.
|
|
1377
|
+
* @property {{name: string, why: string, whoFixes: SurfaceState}[]} notCovered
|
|
1378
|
+
* @property {boolean} everything True only when nothing at all is left out.
|
|
1379
|
+
*/
|
|
1380
|
+
|
|
1381
|
+
/**
|
|
1382
|
+
* The honest-degradation sentence, and the whole reason it exists.
|
|
1383
|
+
*
|
|
1384
|
+
* A project where only the web adapter works is still useful. What it must never
|
|
1385
|
+
* do is report a clean run that quietly means less than it looks like. So the
|
|
1386
|
+
* capabilities object carries, in one place, the words to say instead: this
|
|
1387
|
+
* covers your website; your iPhone app is not being checked, and here is why.
|
|
1388
|
+
*
|
|
1389
|
+
* @param {SurfaceReport[]} surfaces
|
|
1390
|
+
* @returns {Covers}
|
|
1391
|
+
*/
|
|
1392
|
+
function whatThisRunActuallyCovers(surfaces) {
|
|
1393
|
+
// Three buckets, not two. Folding "partly" into "covered" is exactly the
|
|
1394
|
+
// over-claim this function exists to stop: an iPhone app whose screens cannot
|
|
1395
|
+
// be read is not a covered iPhone app.
|
|
1396
|
+
const full = surfaces.filter((s) => s.status === 'ready');
|
|
1397
|
+
const some = surfaces.filter((s) => s.status === 'partial');
|
|
1398
|
+
const missing = surfaces.filter((s) => s.status === 'unavailable');
|
|
1399
|
+
|
|
1400
|
+
/** @type {Covers} */
|
|
1401
|
+
const out = {
|
|
1402
|
+
short: '',
|
|
1403
|
+
covered: full.map((s) => s.name),
|
|
1404
|
+
partly: some.map((s) => ({ name: s.name, why: s.summary })),
|
|
1405
|
+
notCovered: missing.map((s) => ({ name: s.name, why: s.instead ?? s.summary, whoFixes: s.state })),
|
|
1406
|
+
everything: missing.length === 0 && some.length === 0,
|
|
1407
|
+
};
|
|
1408
|
+
|
|
1409
|
+
if (full.length === 0 && some.length === 0) {
|
|
1410
|
+
out.short = 'Nothing on this machine can be checked yet, so a run here would prove nothing at all about your product.';
|
|
1411
|
+
return out;
|
|
1412
|
+
}
|
|
1413
|
+
|
|
1414
|
+
/** @type {string[]} */
|
|
1415
|
+
const parts = [];
|
|
1416
|
+
parts.push(full.length > 0 ? `A check here covers ${plainList(out.covered)} in full.` : 'A check here covers nothing in full.');
|
|
1417
|
+
if (some.length > 0) parts.push(`It covers ${plainList(some.map((s) => s.name))} only partly — read the summary for each before treating a clean result as proof.`);
|
|
1418
|
+
if (missing.length > 0) {
|
|
1419
|
+
parts.push(`It does NOT check ${plainList(missing.map((s) => s.name))} at all, so a clean result says nothing whatever about ${missing.length === 1 ? 'that' : 'those'}.`);
|
|
1420
|
+
// Naming who can fix it is what turns a limitation into an action. The agent
|
|
1421
|
+
// clears its own list without mentioning it; only the rest reaches a person.
|
|
1422
|
+
const fixable = missing.filter((s) => s.state === 'the agent can fix this').map((s) => s.name);
|
|
1423
|
+
const needsPerson = missing.filter((s) => s.state === 'only a person can do this').map((s) => s.name);
|
|
1424
|
+
const never = missing.filter((s) => s.state === 'not possible here').map((s) => s.name);
|
|
1425
|
+
if (fixable.length) parts.push(`${plainList(fixable, true)} could be added here without asking anybody — the commands are in nextSteps.`);
|
|
1426
|
+
if (needsPerson.length) parts.push(`${plainList(needsPerson, true)} needs a person to do something first, and what that is is written out in full.`);
|
|
1427
|
+
// "here" rather than "on this machine", because sometimes it is the project
|
|
1428
|
+
// and not the machine — a project with no desktop app in it needs no runner,
|
|
1429
|
+
// and telling somebody their Mac cannot do it would be false.
|
|
1430
|
+
if (never.length) parts.push(`${plainList(never, true)} cannot be done here at all, and the reason for each is in notCovered.`);
|
|
1431
|
+
}
|
|
1432
|
+
if (out.everything) parts.push('Nothing is being left out on this machine.');
|
|
1433
|
+
out.short = parts.join(' ');
|
|
1434
|
+
return out;
|
|
1435
|
+
}
|
|
1436
|
+
|
|
1437
|
+
/**
|
|
1438
|
+
* "a, b and c" — because a comma-separated list reads like a machine wrote it.
|
|
1439
|
+
* @param {string[]} items
|
|
1440
|
+
* @param {boolean} [capitalise]
|
|
1441
|
+
* @returns {string}
|
|
1442
|
+
*/
|
|
1443
|
+
function plainList(items, capitalise = false) {
|
|
1444
|
+
const list = items.length <= 1 ? (items[0] ?? 'nothing') : `${items.slice(0, -1).join(', ')} and ${items[items.length - 1]}`;
|
|
1445
|
+
return capitalise ? list.charAt(0).toUpperCase() + list.slice(1) : list;
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
/**
|
|
1449
|
+
* Who has to act, which is a different question from how much works.
|
|
1450
|
+
*
|
|
1451
|
+
* A surface with nothing in the way is ready. A surface whose every obstacle is
|
|
1452
|
+
* a command belongs to the agent, and a person should never hear about it. One
|
|
1453
|
+
* obstacle needing a licence, a device or a pair of hands makes the whole thing
|
|
1454
|
+
* a person's. And a surface that is blocked with nothing that would unblock it
|
|
1455
|
+
* is not a to-do list — it is a no, and it says so.
|
|
1456
|
+
*
|
|
1457
|
+
* @param {Omit<SurfaceReport, 'state'>} surface
|
|
1458
|
+
* @param {boolean} impossible
|
|
1459
|
+
* @returns {SurfaceState}
|
|
1460
|
+
*/
|
|
1461
|
+
function stateOf(surface, impossible) {
|
|
1462
|
+
if (surface.status === 'ready') return 'ready';
|
|
1463
|
+
if (impossible) return 'not possible here';
|
|
1464
|
+
if (surface.needs.length === 0) return 'not possible here';
|
|
1465
|
+
return surface.needs.every((need) => need.automatic) ? 'the agent can fix this' : 'only a person can do this';
|
|
1466
|
+
}
|
|
1467
|
+
|
|
1468
|
+
/**
|
|
1469
|
+
* The shortest list of things that would widen coverage most, most useful first.
|
|
1470
|
+
* Only ever built from things that actually failed a probe.
|
|
1471
|
+
*
|
|
1472
|
+
* @param {SurfaceReport[]} surfaces
|
|
1473
|
+
* @param {{found: boolean, note: string}} reference
|
|
1474
|
+
* @param {boolean} repo
|
|
1475
|
+
* @returns {Need[]}
|
|
1476
|
+
*/
|
|
1477
|
+
function nextSteps(surfaces, reference, repo) {
|
|
1478
|
+
/** @type {Need[]} */
|
|
1479
|
+
const steps = [];
|
|
1480
|
+
|
|
1481
|
+
if (!reference.found) {
|
|
1482
|
+
steps.push({
|
|
1483
|
+
what: 'record a reference',
|
|
1484
|
+
why: 'Until one build has been recorded there is nothing to compare a new one against, and a clean result would mean nothing.',
|
|
1485
|
+
fix: 'staysfixed check --paired',
|
|
1486
|
+
automatic: true,
|
|
1487
|
+
unlocks: 'Every check after this one has something to compare against, so "nothing changed" starts meaning something.',
|
|
1488
|
+
});
|
|
1489
|
+
}
|
|
1490
|
+
if (!repo) {
|
|
1491
|
+
steps.push({
|
|
1492
|
+
what: 'make this a git repository',
|
|
1493
|
+
why: 'Without it, a difference cannot be ranked by how far it sits from the code you changed — which is the whole way side effects rise to the top.',
|
|
1494
|
+
fix: 'git init',
|
|
1495
|
+
automatic: false,
|
|
1496
|
+
unlocks: 'Differences get sorted by how far they sit from your edit, so a side effect lands at the top instead of somewhere in the middle.',
|
|
1497
|
+
});
|
|
1498
|
+
}
|
|
1499
|
+
for (const surface of surfaces) {
|
|
1500
|
+
// A surface nothing on this machine can unlock has no next step. Listing one
|
|
1501
|
+
// would be asking somebody to do work that changes nothing.
|
|
1502
|
+
if (surface.state === 'not possible here') continue;
|
|
1503
|
+
for (const need of surface.needs) {
|
|
1504
|
+
if (!steps.some((s) => s.what === need.what)) steps.push(need);
|
|
1505
|
+
}
|
|
1506
|
+
}
|
|
1507
|
+
// The ones the tool can do itself go first: an agent should clear those before
|
|
1508
|
+
// it bothers a person with the rest.
|
|
1509
|
+
steps.sort((a, b) => Number(b.automatic) - Number(a.automatic));
|
|
1510
|
+
return steps;
|
|
1511
|
+
}
|
|
1512
|
+
|
|
1513
|
+
// ── words ───────────────────────────────────────────────────────────────────
|
|
1514
|
+
|
|
1515
|
+
/**
|
|
1516
|
+
* The same object, said out loud. Used by the CLI, and quoted verbatim into the
|
|
1517
|
+
* MCP answer so an agent and a person are never told different things.
|
|
1518
|
+
*
|
|
1519
|
+
* @param {Capabilities} caps
|
|
1520
|
+
* @returns {string[]}
|
|
1521
|
+
*/
|
|
1522
|
+
export function describeCapabilities(caps) {
|
|
1523
|
+
/** @type {string[]} */
|
|
1524
|
+
const lines = [];
|
|
1525
|
+
const ready = caps.surfaces.filter((s) => s.status === 'ready').map((s) => s.name);
|
|
1526
|
+
const partial = caps.surfaces.filter((s) => s.status === 'partial').map((s) => s.name);
|
|
1527
|
+
const out = caps.surfaces.filter((s) => s.status === 'unavailable').map((s) => s.name);
|
|
1528
|
+
|
|
1529
|
+
lines.push(`Stays Fixed ${caps.tool.version} on ${caps.machine.tag}, Node ${caps.machine.node}.`);
|
|
1530
|
+
lines.push('');
|
|
1531
|
+
lines.push(ready.length > 0 ? `It can check, here and now: ${ready.join('; ')}.` : 'It cannot fully check anything on this machine yet.');
|
|
1532
|
+
if (partial.length > 0) lines.push(`Partly: ${partial.join('; ')}.`);
|
|
1533
|
+
if (out.length > 0) lines.push(`Not here: ${out.join('; ')}.`);
|
|
1534
|
+
lines.push('');
|
|
1535
|
+
// What a clean run would actually mean. This is the one paragraph that must
|
|
1536
|
+
// never be dropped: without it, "nothing changed" reads as "your product is
|
|
1537
|
+
// fine" on a machine that never opened half of it.
|
|
1538
|
+
if (caps.covers?.short) {
|
|
1539
|
+
lines.push('WHAT A CLEAN RUN HERE WOULD MEAN');
|
|
1540
|
+
lines.push(caps.covers.short);
|
|
1541
|
+
lines.push('');
|
|
1542
|
+
}
|
|
1543
|
+
if (caps.browsers) {
|
|
1544
|
+
lines.push(caps.browsers.note);
|
|
1545
|
+
lines.push('');
|
|
1546
|
+
}
|
|
1547
|
+
|
|
1548
|
+
// Who has to act. The whole point of splitting these out is that the first
|
|
1549
|
+
// list is nobody's problem — the agent just does it and never mentions it —
|
|
1550
|
+
// and the last list is not a to-do at all.
|
|
1551
|
+
const byAgent = caps.surfaces.filter((s) => s.state === 'the agent can fix this').map((s) => s.name);
|
|
1552
|
+
const byPerson = caps.surfaces.filter((s) => s.state === 'only a person can do this').map((s) => s.name);
|
|
1553
|
+
const never = caps.surfaces.filter((s) => s.state === 'not possible here');
|
|
1554
|
+
if (byAgent.length > 0) {
|
|
1555
|
+
lines.push(`Nobody needs to be asked about these — the tool can set them up itself: ${byAgent.join('; ')}.`);
|
|
1556
|
+
}
|
|
1557
|
+
if (byPerson.length > 0) {
|
|
1558
|
+
lines.push(`These need a person, and only for the steps listed further down: ${byPerson.join('; ')}.`);
|
|
1559
|
+
}
|
|
1560
|
+
for (const surface of never) {
|
|
1561
|
+
// "here" rather than "on this machine": sometimes it is the machine, and
|
|
1562
|
+
// sometimes it is this project — a project with no desktop app in it needs
|
|
1563
|
+
// no Electron runner, and telling somebody their Mac cannot do it would be wrong.
|
|
1564
|
+
lines.push(`Not possible here: ${surface.name}. ${surface.instead ?? surface.summary}`);
|
|
1565
|
+
}
|
|
1566
|
+
if (byAgent.length > 0 || byPerson.length > 0 || never.length > 0) lines.push('');
|
|
1567
|
+
|
|
1568
|
+
// What this COPY can drive, said separately from what this machine can run. They are
|
|
1569
|
+
// two different questions and folding them together is how somebody ends up aiming a
|
|
1570
|
+
// check at a phone on a machine that could run one, and getting a clean answer about
|
|
1571
|
+
// nothing at all.
|
|
1572
|
+
const noAdapter = (caps.drivers ?? []).filter((d) => !d.present);
|
|
1573
|
+
if (noAdapter.length > 0) {
|
|
1574
|
+
lines.push('This copy of Stays Fixed has no adapter for these, so a check aimed at one would walk nothing whatever. It refuses by name rather than checking something else:');
|
|
1575
|
+
for (const driver of noAdapter) lines.push(` ${driver.surface} — ${driver.why}`);
|
|
1576
|
+
lines.push('');
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
if (!caps.project.hasReference) {
|
|
1580
|
+
lines.push(caps.project.referenceNote);
|
|
1581
|
+
lines.push('');
|
|
1582
|
+
}
|
|
1583
|
+
|
|
1584
|
+
const runners = caps.hosts.filter((h) => h.reachable);
|
|
1585
|
+
if (runners.length > 0) {
|
|
1586
|
+
lines.push(`Other machines it can already reach: ${runners.map((h) => h.name + (h.windows ? ' (has a real Windows desktop behind it)' : '')).join(', ')}.`);
|
|
1587
|
+
lines.push('');
|
|
1588
|
+
}
|
|
1589
|
+
|
|
1590
|
+
if (caps.nextSteps.length > 0) {
|
|
1591
|
+
lines.push('What would unlock more:');
|
|
1592
|
+
for (const step of caps.nextSteps) {
|
|
1593
|
+
lines.push(` ${step.what} — ${step.why}`);
|
|
1594
|
+
lines.push(` ${step.automatic ? 'the tool can do this itself: ' : 'somebody has to: '}${step.fix}`);
|
|
1595
|
+
// What they get for it. A person asked to spend half an hour on a download and not
|
|
1596
|
+
// told what it buys them has been given a chore rather than a choice.
|
|
1597
|
+
if (step.unlocks) lines.push(` what that gets you: ${step.unlocks}`);
|
|
1598
|
+
}
|
|
1599
|
+
lines.push('');
|
|
1600
|
+
}
|
|
1601
|
+
|
|
1602
|
+
lines.push('What it will never see, on any machine:');
|
|
1603
|
+
for (const limit of caps.limits) lines.push(` ${limit}`);
|
|
1604
|
+
return lines;
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
/**
|
|
1608
|
+
* `staysfixed doctor` — the command.
|
|
1609
|
+
*
|
|
1610
|
+
* @param {import('../cli/index.js').CliContext} ctx
|
|
1611
|
+
* @returns {Promise<number>}
|
|
1612
|
+
*/
|
|
1613
|
+
export async function run(ctx) {
|
|
1614
|
+
const caps = await capabilities({ cwd: ctx.cwd, configFile: ctx.configFile, offline: ctx.bool('offline') });
|
|
1615
|
+
|
|
1616
|
+
if (ctx.bool('json')) {
|
|
1617
|
+
// Nothing but the object may reach standard output. Doctor is the first call
|
|
1618
|
+
// an agent makes, and one stray human sentence in front of it is a parse
|
|
1619
|
+
// error rather than a warning.
|
|
1620
|
+
setLogLevel({ quiet: true });
|
|
1621
|
+
process.stdout.write(JSON.stringify(caps, null, 2) + '\n');
|
|
1622
|
+
return caps.surfaces.some((s) => s.status !== 'unavailable') ? EXIT.ok : EXIT.failed;
|
|
1623
|
+
}
|
|
1624
|
+
|
|
1625
|
+
heading('Stays Fixed — what it can check on this machine');
|
|
1626
|
+
blank();
|
|
1627
|
+
|
|
1628
|
+
for (const surface of caps.surfaces) {
|
|
1629
|
+
if (surface.status === 'ready') ok(`${surface.name}: ${surface.summary}`);
|
|
1630
|
+
else if (surface.status === 'partial') warn(`${surface.name}: ${surface.summary}`);
|
|
1631
|
+
else fail(`${surface.name}: ${surface.summary}`);
|
|
1632
|
+
|
|
1633
|
+
if (surface.state === 'not possible here') {
|
|
1634
|
+
// No list of things to install. There is nothing to install; there is only
|
|
1635
|
+
// the nearest honest alternative, and then this stops being offered.
|
|
1636
|
+
if (surface.instead) say(paint.grey(` ${mark.info} ${surface.instead}`));
|
|
1637
|
+
continue;
|
|
1638
|
+
}
|
|
1639
|
+
for (const need of surface.needs) {
|
|
1640
|
+
say(paint.grey(` ${mark.info} ${need.what} — ${need.automatic ? 'the tool can do this itself: ' : 'somebody has to: '}${need.fix}`));
|
|
1641
|
+
}
|
|
1642
|
+
}
|
|
1643
|
+
|
|
1644
|
+
blank();
|
|
1645
|
+
if (caps.covers?.short) {
|
|
1646
|
+
say(paint.grey(` ${mark.info} ${caps.covers.short}`));
|
|
1647
|
+
blank();
|
|
1648
|
+
}
|
|
1649
|
+
if (caps.browsers?.borrowingYourOwn) warn(caps.browsers.note);
|
|
1650
|
+
else if (caps.browsers?.willOpen) ok(caps.browsers.note);
|
|
1651
|
+
else if (caps.browsers) fail(caps.browsers.note);
|
|
1652
|
+
|
|
1653
|
+
blank();
|
|
1654
|
+
const found = caps.tools.filter((t) => t.found);
|
|
1655
|
+
say(paint.grey(` found: ${found.map((t) => t.name + (t.version ? ` ${t.version}` : '')).join(', ') || 'nothing but Node'}`));
|
|
1656
|
+
const missing = caps.tools.filter((t) => !t.found);
|
|
1657
|
+
if (missing.length > 0) say(paint.grey(` missing: ${missing.map((t) => t.name).join(', ')}`));
|
|
1658
|
+
|
|
1659
|
+
const runners = caps.hosts.filter((h) => h.reachable);
|
|
1660
|
+
if (runners.length > 0) {
|
|
1661
|
+
say(paint.grey(` machines it can already reach: ${runners.map((h) => h.name).join(', ')}`));
|
|
1662
|
+
}
|
|
1663
|
+
|
|
1664
|
+
const noAdapter = (caps.drivers ?? []).filter((d) => !d.present);
|
|
1665
|
+
if (noAdapter.length > 0) {
|
|
1666
|
+
blank();
|
|
1667
|
+
say(paint.grey(` no adapter in this copy for: ${noAdapter.map((d) => d.surface).join(', ')} — a check aimed at one refuses by name rather than checking something else`));
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
blank();
|
|
1671
|
+
if (!caps.project.hasReference) warn(caps.project.referenceNote);
|
|
1672
|
+
else ok(caps.project.referenceNote);
|
|
1673
|
+
|
|
1674
|
+
if (caps.nextSteps.length > 0) {
|
|
1675
|
+
blank();
|
|
1676
|
+
heading('What would unlock more');
|
|
1677
|
+
for (const step of caps.nextSteps) {
|
|
1678
|
+
say(` ${step.what}`);
|
|
1679
|
+
say(paint.grey(` ${step.why}`));
|
|
1680
|
+
say(paint.grey(` ${step.automatic ? 'the tool can do this itself: ' : 'somebody has to: '}${step.fix}`));
|
|
1681
|
+
if (step.unlocks) say(paint.grey(` what that gets you: ${step.unlocks}`));
|
|
1682
|
+
}
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
blank();
|
|
1686
|
+
say(paint.grey(' The same thing as JSON, which is what an agent should read: staysfixed doctor --json'));
|
|
1687
|
+
blank();
|
|
1688
|
+
|
|
1689
|
+
return EXIT.ok;
|
|
1690
|
+
}
|