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/CHANGELOG.md +187 -0
- package/README.md +104 -43
- package/docs/design-v2.md +275 -0
- package/docs/getting-started.md +295 -0
- package/docs/guards.md +226 -0
- package/docs/how-it-stays-stable.md +315 -0
- package/docs/how-v2-works.md +403 -0
- package/docs/mcp.md +286 -0
- package/docs/running-it-in-ci.md +306 -0
- package/docs/watching.md +190 -0
- package/package.json +3 -3
- package/src/cli/index.js +34 -7
- package/src/core/config.js +12 -2
- package/src/v2/adapters/isolate.js +89 -0
- package/src/v2/cause.js +151 -12
- package/src/v2/check.js +345 -3
- package/src/v2/cli.js +71 -12
- package/src/v2/cluster.js +20 -3
- package/src/v2/coverage.js +40 -5
- package/src/v2/detect.js +1413 -20
- package/src/v2/doctor.js +191 -35
- package/src/v2/init.js +470 -57
- package/src/v2/mcp/tools.js +124 -11
- package/src/v2/normalise.js +54 -7
- package/src/v2/observation.js +56 -10
- package/src/v2/rank.js +212 -43
- package/src/v2/run.js +216 -31
- package/src/v2/selfcheck.js +312 -7
- package/src/v2/store.js +269 -45
- package/src/v2/watch/index.js +96 -17
- package/src/v2/watch/window.js +138 -20
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
|
-
/**
|
|
49
|
-
|
|
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:
|
|
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:
|
|
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 =
|
|
1018
|
+
const names = await sshConfigHosts();
|
|
986
1019
|
if (names.length === 0) return [];
|
|
987
1020
|
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
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:
|
|
1001
|
-
how:
|
|
1002
|
-
|
|
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
|
-
|
|
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')
|
|
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
|
-
//
|
|
1440
|
-
//
|
|
1441
|
-
//
|
|
1442
|
-
|
|
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
|
-
//
|
|
1574
|
-
//
|
|
1575
|
-
// no
|
|
1576
|
-
|
|
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) {
|