staysfixed 0.6.2 → 0.7.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/src/v2/doctor.js CHANGED
@@ -36,6 +36,7 @@ import { findConfigFile, rootForConfig } from '../core/paths.js';
36
36
  import { platformTag } from '../drive/find.js';
37
37
  import { isRepo } from '../core/git.js';
38
38
  import { surveyBrowsers, INSTALL_COMMAND, PORT_NEVER_USE } from './browsers.js';
39
+ import { POWERSHELL_PATHS } from './remote.js';
39
40
  import { messageOf, EXIT } from '../core/errors.js';
40
41
  import { say, ok, warn, fail, blank, heading, paint, mark, shortPath, setLogLevel } from '../core/log.js';
41
42
 
@@ -45,8 +46,12 @@ const exec = promisify(execFile);
45
46
  const PROBE_MS = 5_000;
46
47
  /** Reaching another machine is slower than reaching a binary, but not much. */
47
48
  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;
49
+ /**
50
+ * More hosts than this in one ssh config and we stop dialling; the list is a menu, not a
51
+ * queue. They are all dialled at once, so the number costs parallel ssh processes rather
52
+ * than seconds - and anything past the cap is named in the answer instead of dropped.
53
+ */
54
+ const MAX_HOSTS = 16;
50
55
 
51
56
  /**
52
57
  * The seven ways this tool can watch a product, in the order the design puts
@@ -82,6 +87,9 @@ export const CHANNELS = [
82
87
  * @property {boolean} reachable
83
88
  * @property {string} how How we found out, or why it did not answer.
84
89
  * @property {boolean} [windows] Reaches a real Windows desktop through powershell.exe.
90
+ * @property {string} [powershell] The absolute path to powershell.exe that answered, when one did.
91
+ * Kept because it is the evidence: "there is Windows behind this
92
+ * host" is a claim, and this is the file that proves it.
85
93
  */
86
94
 
87
95
  /**
@@ -141,6 +149,11 @@ export const CHANNELS = [
141
149
  * @property {Need[]} needs
142
150
  * @property {string} [instead] Only on `not possible here`: the nearest honest
143
151
  * alternative, so the answer is not just a refusal.
152
+ * @property {boolean} [notInThisProject] The reason is that there is nothing of this kind
153
+ * in this repository — NOT that the machine cannot do it.
154
+ * Told apart because "your Mac cannot check an iPhone app"
155
+ * is false on a Mac with Xcode on it, and a reader who is
156
+ * told that once stops believing the rest of the page.
144
157
  */
145
158
 
146
159
  /**
@@ -362,23 +375,31 @@ export function onPath(name) {
362
375
  * timeout as its own answer — a tool that will not reply is not a tool that is
363
376
  * missing, and telling somebody to install it would be wrong.
364
377
  *
378
+ * `out` merges the two streams because a version banner may come out of either -
379
+ * Java announces itself on stderr. `stdout` and `stderr` are kept apart as well,
380
+ * and anything that has to tell an ANSWER from a REFUSAL must read `stdout`. That
381
+ * distinction is not fussiness: github.com refuses `ssh github-x 'echo hello'` by
382
+ * writing `Invalid command: echo hello` to stderr, so a probe reading the merged
383
+ * text finds its own word in the refusal and calls the refusal a reply.
384
+ *
365
385
  * @param {string} file
366
386
  * @param {string[]} args
367
387
  * @param {number} [timeoutMs]
368
- * @returns {Promise<{ok: boolean, out: string, why: string, hung: boolean}>}
388
+ * @returns {Promise<{ok: boolean, out: string, stdout: string, stderr: string, why: string, hung: boolean}>}
369
389
  */
370
390
  async function ask(file, args, timeoutMs = PROBE_MS) {
371
391
  try {
372
392
  const { stdout, stderr } = await exec(file, args, { timeout: timeoutMs, maxBuffer: 4 << 20, windowsHide: true });
373
393
  // Java and a few others announce their version on stderr. Take whichever spoke.
374
- return { ok: true, out: String(stdout || stderr).trim(), why: '', hung: false };
394
+ return { ok: true, out: String(stdout || stderr).trim(), stdout: String(stdout ?? ''), stderr: String(stderr ?? ''), why: '', hung: false };
375
395
  } catch (error) {
376
396
  const e = /** @type {{killed?: boolean, signal?: string, stdout?: string, stderr?: string}} */ (Object(error));
377
397
  const hung = e.killed === true || e.signal === 'SIGTERM';
378
398
  const spoke = String(e.stdout || e.stderr || '').trim();
399
+ const streams = { stdout: String(e.stdout ?? ''), stderr: String(e.stderr ?? '') };
379
400
  // A non-zero exit that still printed a version is a success for our purposes.
380
- if (!hung && spoke !== '') return { ok: true, out: spoke, why: '', hung: false };
381
- return { ok: false, out: '', why: hung ? `it did not answer within ${Math.round(timeoutMs / 1000)}s` : messageOf(error), hung };
401
+ if (!hung && spoke !== '') return { ok: true, out: spoke, ...streams, why: '', hung: false };
402
+ return { ok: false, out: '', ...streams, why: hung ? `it did not answer within ${Math.round(timeoutMs / 1000)}s` : messageOf(error), hung };
382
403
  }
383
404
  }
384
405
 
@@ -548,13 +569,25 @@ async function findTools(cwd, browsers) {
548
569
 
549
570
  (async () => {
550
571
  const where = onPath('docker');
572
+ // The binary being there is not the question. Docker only restores a snapshot when
573
+ // its engine is actually RUNNING, and on a Mac the command sits on the path all day
574
+ // while Docker Desktop is shut. Asking the engine for its version is the difference
575
+ // between detecting and assuming, and assuming here promises a server comparison
576
+ // that would fall over the moment it was asked for.
577
+ const engine = where ? await ask(where, ['version', '--format', '{{.Server.Version}}']) : null;
578
+ const running = engine !== null && engine.ok && /\d/.test(engine.stdout);
551
579
  add({
552
580
  id: 'docker',
553
581
  name: 'Docker',
554
- found: where !== null,
582
+ found: running,
555
583
  where: where ?? undefined,
584
+ version: running ? versionIn(engine.stdout) : undefined,
556
585
  why: 'The usual way to restore the same database snapshot twice, which is what a server comparison needs.',
557
- fix: where ? undefined : 'Only needed to watch a server whose behaviour depends on its data.',
586
+ fix: running
587
+ ? undefined
588
+ : where
589
+ ? 'Docker is installed but its engine is not answering — start Docker Desktop, or the docker service, and run this again. Only needed to watch a server whose behaviour depends on its data.'
590
+ : 'Only needed to watch a server whose behaviour depends on its data.',
558
591
  automatic: false,
559
592
  });
560
593
  })(),
@@ -982,27 +1015,117 @@ function androidSdkTool(folder, name) {
982
1015
  */
983
1016
  export async function reachableHosts() {
984
1017
  if (!onPath('ssh')) return [];
985
- const names = (await sshConfigHosts()).slice(0, MAX_HOSTS);
1018
+ const names = await sshConfigHosts();
986
1019
  if (names.length === 0) return [];
987
1020
 
988
- return await Promise.all(
989
- names.map(async (name) => {
990
- const answer = await ask('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', name, 'echo staysfixed-reachable'], REACH_MS);
991
- if (!answer.ok || !answer.out.includes('staysfixed-reachable')) {
992
- return /** @type {HostReport} */ ({ name, reachable: false, how: answer.why || 'it did not answer' });
993
- }
994
- // A Linux shell that can see powershell.exe is a real Windows desktop
995
- // behind it — the cheapest Windows runner there is, and one nobody has to
996
- // provision. Worth one extra round trip to find out.
997
- const windows = await ask('ssh', ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5', name, 'command -v powershell.exe || command -v pwsh.exe'], REACH_MS);
998
- return /** @type {HostReport} */ ({
1021
+ const dialled = await Promise.all(names.slice(0, MAX_HOSTS).map((name) => describeHost(name)));
1022
+ // Anything past the cap is NAMED rather than dropped. A machine quietly left out of
1023
+ // this list is the same shape of bug as a folder quietly skipped while reading source:
1024
+ // the answer looks complete, and the runner somebody needed is simply not in it.
1025
+ const skipped = names.slice(MAX_HOSTS).map(
1026
+ (name) =>
1027
+ /** @type {HostReport} */ ({
999
1028
  name,
1000
- reachable: true,
1001
- how: 'it answered over ssh with the key you already have',
1002
- windows: windows.ok && windows.out.trim() !== '',
1003
- });
1004
- })
1029
+ reachable: false,
1030
+ how: `not dialled your ssh config names ${names.length} machines and this stops after ${MAX_HOSTS} so doctor stays quick. Nothing is known about this one either way.`,
1031
+ })
1005
1032
  );
1033
+ return [...dialled, ...skipped];
1034
+ }
1035
+
1036
+ /**
1037
+ * The word a machine has to say back before anything it reports is believed, and
1038
+ * it has to say it on standard output, on a line of its own.
1039
+ *
1040
+ * All three halves of that sentence were bought with a wrong answer on this Mac.
1041
+ * `ssh github-imza 'echo staysfixed-reachable'` is refused by github.com with
1042
+ * `Invalid command: echo staysfixed-reachable` — on stderr, and containing the word,
1043
+ * because the refusal quotes the command back. A probe that looked for the word
1044
+ * anywhere in either stream therefore listed github.com among the machines this tool
1045
+ * could run checks on, twice over: once as reachable, and once as a Windows desktop.
1046
+ */
1047
+ const ALIVE = 'staysfixed-reachable';
1048
+
1049
+ /**
1050
+ * What is on the other end of one ssh host name.
1051
+ *
1052
+ * Two rules, and both of them are scar tissue.
1053
+ *
1054
+ * READ STANDARD OUTPUT, AND MATCH THE WHOLE LINE. See `ALIVE`. A host that refuses
1055
+ * commands is not a machine that can run them, and it must not be listed as one.
1056
+ *
1057
+ * NO SHELL VARIABLES, NO LOOPS, NOTHING BUT LITERAL ARGUMENTS. `imza-pc` in this
1058
+ * machine's ssh config reaches a Windows box whose OpenSSH hands the command down
1059
+ * through a second shell, and every `$p` is expanded to nothing before the shell that
1060
+ * was meant to read it ever sees it: `for p in "A"; do echo "$p"; done` prints an empty
1061
+ * line there. `ls -d` with the paths written out is the same question asked in a way
1062
+ * no extra layer can eat.
1063
+ *
1064
+ * @param {string} name
1065
+ * @returns {Promise<HostReport>}
1066
+ */
1067
+ async function describeHost(name) {
1068
+ const ssh = ['-o', 'BatchMode=yes', '-o', 'ConnectTimeout=5'];
1069
+ const alive = await ask('ssh', [...ssh, name, `echo ${ALIVE}`], REACH_MS);
1070
+ if (!answered(alive)) return readHostProbe(name, alive, null);
1071
+
1072
+ // A shell that can SEE powershell.exe on the filesystem has a real Windows desktop
1073
+ // behind it — the cheapest Windows runner there is, and one nobody has to provision.
1074
+ // Asked of the filesystem, never of the path: powershell.exe is not on the PATH of a
1075
+ // non-interactive ssh session even on a machine configured to put it there, so
1076
+ // `command -v powershell.exe` answers "no" on a box with Windows sitting right behind
1077
+ // it. The one list of places to look lives in remote.js, which is the file that later
1078
+ // has to actually run one.
1079
+ const look = await ask('ssh', [...ssh, name, `ls -d ${POWERSHELL_PATHS.map((p) => `'${p}'`).join(' ')}`], REACH_MS);
1080
+ return readHostProbe(name, alive, look);
1081
+ }
1082
+
1083
+ /**
1084
+ * Did that machine actually say the word, on its own line, on standard output?
1085
+ * @param {{stdout: string}} alive
1086
+ * @returns {boolean}
1087
+ */
1088
+ function answered(alive) {
1089
+ return alive.stdout.split('\n').some((line) => line.trim() === ALIVE);
1090
+ }
1091
+
1092
+ /**
1093
+ * Turn the two probe answers into what we will say about that machine.
1094
+ *
1095
+ * Split out from the dialling so the decision can be tested against the exact bytes real
1096
+ * machines send back — a github.com refusal, an OpenSSH warning banner on stderr, a WSL
1097
+ * shell with Windows behind it — without any test needing a network or an ssh key.
1098
+ *
1099
+ * @param {string} name
1100
+ * @param {{stdout: string, stderr: string, why: string}} alive The `echo` probe.
1101
+ * @param {{stdout: string}|null} look The PowerShell probe, or null if we never got that far.
1102
+ * @returns {HostReport}
1103
+ */
1104
+ export function readHostProbe(name, alive, look) {
1105
+ if (!answered(alive)) {
1106
+ // Three different silences, and they mean different things to whoever reads this.
1107
+ // A host that talked but would not run the command is a git remote, not a machine
1108
+ // with a shell on it, and telling somebody their ssh key is broken would send them
1109
+ // off fixing something that already works.
1110
+ const spoke = (alive.stdout + alive.stderr).trim() !== '';
1111
+ return {
1112
+ name,
1113
+ reachable: false,
1114
+ how: spoke
1115
+ ? 'it answered, but it does not give you a shell — nothing can be run on it. A git host such as github.com looks exactly like this.'
1116
+ : alive.why || 'it did not answer',
1117
+ };
1118
+ }
1119
+
1120
+ const powershell = (look?.stdout ?? '')
1121
+ .split('\n')
1122
+ .map((line) => line.trim())
1123
+ .find((line) => POWERSHELL_PATHS.includes(line));
1124
+
1125
+ /** @type {HostReport} */
1126
+ const report = { name, reachable: true, how: 'it answered over ssh with the key you already have', windows: powershell !== undefined };
1127
+ if (powershell !== undefined) report.powershell = powershell;
1128
+ return report;
1006
1129
  }
1007
1130
 
1008
1131
  /**
@@ -1132,6 +1255,14 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1132
1255
  /** @type {Map<string, string>} */
1133
1256
  const impossible = new Map();
1134
1257
 
1258
+ /**
1259
+ * Of those, the ones whose reason is the PROJECT rather than the machine. A repository
1260
+ * with no iPhone app in it needs no simulator, and saying "iPhone apps cannot be done
1261
+ * here" on a Mac with Xcode installed is simply untrue.
1262
+ * @type {Set<string>}
1263
+ */
1264
+ const notInThisProject = new Set();
1265
+
1135
1266
  surfaces.push({
1136
1267
  id: 'cli',
1137
1268
  name: 'command-line tools and libraries',
@@ -1227,6 +1358,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1227
1358
  ],
1228
1359
  });
1229
1360
  if (desktopApp === null) {
1361
+ notInThisProject.add('electron');
1230
1362
  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.');
1231
1363
  }
1232
1364
 
@@ -1266,6 +1398,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1266
1398
  needs: phones.android === null || !canDrive('android') ? [] : androidWants,
1267
1399
  });
1268
1400
  if (phones.android === null) {
1401
+ notInThisProject.add('android');
1269
1402
  impossible.set(
1270
1403
  'android',
1271
1404
  '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.'
@@ -1290,6 +1423,7 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1290
1423
  if (!onAMac) {
1291
1424
  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.');
1292
1425
  } else if (phones.ios === null) {
1426
+ notInThisProject.add('ios');
1293
1427
  impossible.set(
1294
1428
  'ios',
1295
1429
  '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.'
@@ -1375,7 +1509,11 @@ function describeSurfaces(tools, hosts, configured, browsers, desktopApp, driver
1375
1509
 
1376
1510
  return surfaces.map((surface) => {
1377
1511
  const instead = impossible.get(surface.id);
1378
- return instead ? { ...surface, state: stateOf(surface, true), instead } : { ...surface, state: stateOf(surface, false) };
1512
+ if (!instead) return { ...surface, state: stateOf(surface, false) };
1513
+ /** @type {SurfaceReport} */
1514
+ const out = { ...surface, state: stateOf(surface, true), instead };
1515
+ if (notInThisProject.has(surface.id)) out.notInThisProject = true;
1516
+ return out;
1379
1517
  });
1380
1518
  }
1381
1519
 
@@ -1433,13 +1571,19 @@ function whatThisRunActuallyCovers(surfaces) {
1433
1571
  // clears its own list without mentioning it; only the rest reaches a person.
1434
1572
  const fixable = missing.filter((s) => s.state === 'the agent can fix this').map((s) => s.name);
1435
1573
  const needsPerson = missing.filter((s) => s.state === 'only a person can do this').map((s) => s.name);
1436
- const never = missing.filter((s) => s.state === 'not possible here').map((s) => s.name);
1574
+ const never = missing.filter((s) => s.state === 'not possible here');
1437
1575
  if (fixable.length) parts.push(`${plainList(fixable, true)} could be added here without asking anybody — the commands are in nextSteps.`);
1438
1576
  if (needsPerson.length) parts.push(`${plainList(needsPerson, true)} needs a person to do something first, and what that is is written out in full.`);
1439
- // "here" rather than "on this machine", because sometimes it is the project
1440
- // and not the machine a project with no desktop app in it needs no runner,
1441
- // and telling somebody their Mac cannot do it would be false.
1442
- if (never.length) parts.push(`${plainList(never, true)} cannot be done here at all, and the reason for each is in notCovered.`);
1577
+ // Two different reasons, said as two different sentences. Rolling them together put
1578
+ // "iPhone apps cannot be done here at all" on a Mac with Xcode and three simulator
1579
+ // runtimes on it, where the real reason was that this repository has no iPhone app in
1580
+ // it. Both sentences are true; only one of them was.
1581
+ const noSuchProduct = never.filter((s) => s.notInThisProject === true).map((s) => s.name);
1582
+ const noSuchMachine = never.filter((s) => s.notInThisProject !== true).map((s) => s.name);
1583
+ if (noSuchProduct.length) {
1584
+ parts.push(`There is nothing of ${noSuchProduct.length === 1 ? 'that kind' : 'those kinds'} in this repository — ${plainList(noSuchProduct)} — so there is nothing here to check, and that is not a limit of this machine.`);
1585
+ }
1586
+ if (noSuchMachine.length) parts.push(`${plainList(noSuchMachine, true)} cannot be done here at all, and the reason for each is in notCovered.`);
1443
1587
  }
1444
1588
  if (out.everything) parts.push('Nothing is being left out on this machine.');
1445
1589
  out.short = parts.join(' ');
@@ -1570,10 +1714,11 @@ export function describeCapabilities(caps) {
1570
1714
  lines.push(`These need a person, and only for the steps listed further down: ${byPerson.join('; ')}.`);
1571
1715
  }
1572
1716
  for (const surface of never) {
1573
- // "here" rather than "on this machine": sometimes it is the machine, and
1574
- // sometimes it is this project a project with no desktop app in it needs
1575
- // no Electron runner, and telling somebody their Mac cannot do it would be wrong.
1576
- lines.push(`Not possible here: ${surface.name}. ${surface.instead ?? surface.summary}`);
1717
+ // Which of the two reasons it is, in the heading rather than buried in the sentence
1718
+ // after it. Sometimes it is the machine, and sometimes it is this project and a
1719
+ // project with no desktop app in it is not a Mac that cannot open one.
1720
+ const why = surface.notInThisProject === true ? 'Nothing of this kind in this repository' : 'Not possible here';
1721
+ lines.push(`${why}: ${surface.name}. ${surface.instead ?? surface.summary}`);
1577
1722
  }
1578
1723
  if (byAgent.length > 0 || byPerson.length > 0 || never.length > 0) lines.push('');
1579
1724
 
@@ -1598,6 +1743,13 @@ export function describeCapabilities(caps) {
1598
1743
  lines.push(`Other machines it can already reach: ${runners.map((h) => h.name + (h.windows ? ' (has a real Windows desktop behind it)' : '')).join(', ')}.`);
1599
1744
  lines.push('');
1600
1745
  }
1746
+ // Said out loud, because a machine left undialled is a runner somebody may be
1747
+ // looking for, and a list that quietly stops short reads as a list that finished.
1748
+ const undialled = caps.hosts.filter((h) => h.how.startsWith('not dialled'));
1749
+ if (undialled.length > 0) {
1750
+ lines.push(`${undialled.length} more ${undialled.length === 1 ? 'machine in your ssh config was' : 'machines in your ssh config were'} not dialled: ${undialled.map((h) => h.name).join(', ')}. Nothing here says anything about ${undialled.length === 1 ? 'it' : 'them'}.`);
1751
+ lines.push('');
1752
+ }
1601
1753
 
1602
1754
  if (caps.nextSteps.length > 0) {
1603
1755
  lines.push('What would unlock more:');
@@ -1672,6 +1824,10 @@ export async function run(ctx) {
1672
1824
  if (runners.length > 0) {
1673
1825
  say(paint.grey(` machines it can already reach: ${runners.map((h) => h.name).join(', ')}`));
1674
1826
  }
1827
+ const undialled = caps.hosts.filter((h) => h.how.startsWith('not dialled'));
1828
+ if (undialled.length > 0) {
1829
+ say(paint.grey(` not dialled, so nothing is known about them: ${undialled.map((h) => h.name).join(', ')}`));
1830
+ }
1675
1831
 
1676
1832
  const noAdapter = (caps.drivers ?? []).filter((d) => !d.present);
1677
1833
  if (noAdapter.length > 0) {