@botbuddy/cli 1.59.9 → 1.60.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/bin/botbuddy.mjs +10 -1
- package/daemon/bin/darwin-arm64/botbuddy-daemon +0 -0
- package/daemon/bin/linux-x64/botbuddy-daemon +0 -0
- package/daemon-release/darwin-arm64.json +4 -4
- package/daemon-release/linux-x64.json +4 -4
- package/daemon-release/manifest.json +5 -5
- package/package.json +1 -1
- package/src/agent-doctor.mjs +23 -3
- package/src/api.mjs +7 -4
- package/src/commands.mjs +51 -1
- package/src/config.mjs +2 -2
- package/src/fixtures/bot-2600-update-command-run-rejections-2026-10-20.json +13 -0
- package/src/git-transport.mjs +476 -0
- package/src/run-delivery.mjs +496 -0
- package/src/run.mjs +79 -29
|
@@ -0,0 +1,476 @@
|
|
|
1
|
+
// BOT-2599 — keep git's SSH connection alive through a long pre-push hook.
|
|
2
|
+
//
|
|
3
|
+
// git opens the SSH transport and reads the remote's ref advertisement BEFORE it
|
|
4
|
+
// runs the pre-push hook (githooks(5)). A hook that runs for minutes leaves that
|
|
5
|
+
// connection idle until something on the path drops it, and the pack send then
|
|
6
|
+
// dies on a closed pipe (exit 141) after every gate passed. An SSH-level
|
|
7
|
+
// keepalive (`ServerAliveInterval` > 0) keeps it open.
|
|
8
|
+
//
|
|
9
|
+
// This module answers "will THIS clone's push connection carry a keepalive?" the
|
|
10
|
+
// way git itself picks the ssh command — GIT_SSH_COMMAND, then core.sshCommand,
|
|
11
|
+
// then GIT_SSH, then plain `ssh` — and, for ssh, the way ssh resolves options:
|
|
12
|
+
// an explicit `-o ServerAliveInterval=…` on the command line wins over every
|
|
13
|
+
// config file; otherwise `ssh -G` prints the effective value. `ssh -G` reads
|
|
14
|
+
// config only (no network), but a config can `Match exec`, so it is bounded and
|
|
15
|
+
// a timeout is a typed `unknown`, never a false `ok`.
|
|
16
|
+
//
|
|
17
|
+
// `--fix` writes `git config --local core.sshCommand` only when NOTHING is
|
|
18
|
+
// configured; an existing GIT_SSH_COMMAND, core.sshCommand or GIT_SSH is never
|
|
19
|
+
// overwritten — the report carries the exact edit instead. The remote URL is
|
|
20
|
+
// never reported (an HTTPS URL can embed a credential); only its host.
|
|
21
|
+
//
|
|
22
|
+
// Dependency-free (Node stdlib only); every git/ssh call goes through `run`
|
|
23
|
+
// (spawnSync-compatible) so the unit tests need no network and no real config.
|
|
24
|
+
|
|
25
|
+
import { spawnSync } from "node:child_process";
|
|
26
|
+
import { homedir } from "node:os";
|
|
27
|
+
import { basename } from "node:path";
|
|
28
|
+
|
|
29
|
+
export const GIT_TRANSPORT_CHECK = "git_transport";
|
|
30
|
+
export const KEEPALIVE_OPTIONS = "-o ServerAliveInterval=20 -o ServerAliveCountMax=60";
|
|
31
|
+
export const KEEPALIVE_SSH_COMMAND = `ssh ${KEEPALIVE_OPTIONS}`;
|
|
32
|
+
export const SSH_CONFIG_TIMEOUT_MS = 5_000;
|
|
33
|
+
const GIT_TIMEOUT_MS = 5_000;
|
|
34
|
+
|
|
35
|
+
/** Statuses that let a push proceed. `unknown` never does: it cannot prove a keepalive. */
|
|
36
|
+
export const GIT_TRANSPORT_PASSING = Object.freeze(new Set(["ok", "n/a"]));
|
|
37
|
+
|
|
38
|
+
function shellQuote(value) {
|
|
39
|
+
return `'${String(value).replaceAll("'", "'\\''")}'`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Classify a remote URL by transport. Only ssh remotes carry host/user/port;
|
|
44
|
+
* nothing else of the URL is kept (it may embed a credential).
|
|
45
|
+
*/
|
|
46
|
+
export function classifyRemoteUrl(url) {
|
|
47
|
+
const value = String(url ?? "").trim();
|
|
48
|
+
if (!value) return { transport: "none" };
|
|
49
|
+
const scheme = value.match(/^([a-z][a-z0-9+.-]*):\/\//i);
|
|
50
|
+
if (scheme) {
|
|
51
|
+
const name = scheme[1].toLowerCase();
|
|
52
|
+
if (name === "ssh" || name === "git+ssh" || name === "ssh+git") {
|
|
53
|
+
let parsed;
|
|
54
|
+
try {
|
|
55
|
+
parsed = new URL(`ssh://${value.slice(scheme[0].length)}`);
|
|
56
|
+
} catch {
|
|
57
|
+
return { transport: "ssh", host: null, user: null, port: null };
|
|
58
|
+
}
|
|
59
|
+
return {
|
|
60
|
+
transport: "ssh",
|
|
61
|
+
host: parsed.hostname.replace(/^\[|\]$/g, "") || null,
|
|
62
|
+
user: parsed.username ? decodeURIComponent(parsed.username) : null,
|
|
63
|
+
port: parsed.port || null,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
return { transport: name === "https" || name === "http" ? "https" : name };
|
|
67
|
+
}
|
|
68
|
+
// git's scp-like syntax, `[user@]host:path`, applies only when no slash comes
|
|
69
|
+
// before the first colon; anything else is a local path.
|
|
70
|
+
const scp = value.match(/^(?:([^@/:]+)@)?(\[[^\]/]+\]|[^:/]+):/);
|
|
71
|
+
if (scp) return { transport: "ssh", host: scp[2].replace(/^\[|\]$/g, ""), user: scp[1] ?? null, port: null };
|
|
72
|
+
return { transport: "local" };
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Split a git ssh command the way `sh -c` evaluates it (git runs
|
|
77
|
+
* core.sshCommand / GIT_SSH_COMMAND through the shell): words, quotes,
|
|
78
|
+
* backslash escapes, a leading `~` / `~/` (the home directory) and plain
|
|
79
|
+
* `$NAME` / `${NAME}` expansions from `env`. Returns null for anything a real
|
|
80
|
+
* shell would do that this cannot reproduce faithfully — command substitution,
|
|
81
|
+
* other parameter forms, `~user`, pipes, redirects, a leading comment, an
|
|
82
|
+
* unbalanced quote, or an unquoted expansion that word splitting or globbing
|
|
83
|
+
* would change — so the caller reports `unknown` instead of guessing.
|
|
84
|
+
*/
|
|
85
|
+
export function splitShellWords(text, env = {}) {
|
|
86
|
+
const words = [];
|
|
87
|
+
let current = "";
|
|
88
|
+
let inWord = false;
|
|
89
|
+
let quote = null;
|
|
90
|
+
const home = () => env.HOME || homedir() || null;
|
|
91
|
+
// `$NAME` / `${NAME}` at text[i] ("$"); returns { value, next } or null.
|
|
92
|
+
const expand = (i) => {
|
|
93
|
+
const braced = text[i + 1] === "{";
|
|
94
|
+
const m = text.slice(braced ? i + 2 : i + 1).match(/^[A-Za-z_][A-Za-z0-9_]*/);
|
|
95
|
+
if (!m) return null;
|
|
96
|
+
let next = i + 1 + (braced ? 1 : 0) + m[0].length;
|
|
97
|
+
if (braced) {
|
|
98
|
+
if (text[next] !== "}") return null;
|
|
99
|
+
next += 1;
|
|
100
|
+
}
|
|
101
|
+
return { value: env[m[0]] ?? "", next: next - 1 };
|
|
102
|
+
};
|
|
103
|
+
for (let i = 0; i < text.length; i += 1) {
|
|
104
|
+
const ch = text[i];
|
|
105
|
+
if (quote === "'") {
|
|
106
|
+
if (ch === "'") quote = null;
|
|
107
|
+
else current += ch;
|
|
108
|
+
continue;
|
|
109
|
+
}
|
|
110
|
+
if (quote === '"') {
|
|
111
|
+
if (ch === '"') quote = null;
|
|
112
|
+
else if (ch === "\\" && i + 1 < text.length && '"\\$`'.includes(text[i + 1])) current += text[++i];
|
|
113
|
+
else if (ch === "`") return null;
|
|
114
|
+
else if (ch === "$") {
|
|
115
|
+
const ex = expand(i);
|
|
116
|
+
if (!ex) return null;
|
|
117
|
+
current += ex.value;
|
|
118
|
+
i = ex.next;
|
|
119
|
+
} else current += ch;
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
if (ch === "'" || ch === '"') {
|
|
123
|
+
quote = ch;
|
|
124
|
+
inWord = true;
|
|
125
|
+
continue;
|
|
126
|
+
}
|
|
127
|
+
if (ch === "\\") {
|
|
128
|
+
if (i + 1 >= text.length) return null;
|
|
129
|
+
current += text[++i];
|
|
130
|
+
inWord = true;
|
|
131
|
+
continue;
|
|
132
|
+
}
|
|
133
|
+
if (/\s/.test(ch)) {
|
|
134
|
+
if (inWord) words.push(current);
|
|
135
|
+
current = "";
|
|
136
|
+
inWord = false;
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
if (ch === "~" && !inWord) {
|
|
140
|
+
// Tilde-prefix: only a bare `~` ending at `/`, whitespace or the end.
|
|
141
|
+
const after = text[i + 1];
|
|
142
|
+
if (after !== undefined && after !== "/" && !/\s/.test(after)) return null;
|
|
143
|
+
const dir = home();
|
|
144
|
+
if (!dir) return null;
|
|
145
|
+
current += dir;
|
|
146
|
+
inWord = true;
|
|
147
|
+
continue;
|
|
148
|
+
}
|
|
149
|
+
if (ch === "$") {
|
|
150
|
+
const ex = expand(i);
|
|
151
|
+
if (!ex || /[\s*?[]/.test(ex.value)) return null;
|
|
152
|
+
current += ex.value;
|
|
153
|
+
if (ex.value) inWord = true;
|
|
154
|
+
i = ex.next;
|
|
155
|
+
continue;
|
|
156
|
+
}
|
|
157
|
+
if ("|&;<>()`".includes(ch) || (ch === "#" && !inWord)) return null;
|
|
158
|
+
current += ch;
|
|
159
|
+
inWord = true;
|
|
160
|
+
}
|
|
161
|
+
if (quote) return null;
|
|
162
|
+
if (inWord) words.push(current);
|
|
163
|
+
return words;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
/** ssh's time format (`20`, `30s`, `1m`, `1h30m`) in seconds, or null. */
|
|
167
|
+
export function parseSshTime(value) {
|
|
168
|
+
const text = String(value ?? "").trim().toLowerCase();
|
|
169
|
+
if (/^\d+$/.test(text)) return Number(text);
|
|
170
|
+
const units = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
|
|
171
|
+
let total = 0;
|
|
172
|
+
let rest = text;
|
|
173
|
+
if (!rest) return null;
|
|
174
|
+
while (rest) {
|
|
175
|
+
const m = rest.match(/^(\d+)([smhdw])/);
|
|
176
|
+
if (!m) return null;
|
|
177
|
+
total += Number(m[1]) * units[m[2]];
|
|
178
|
+
rest = rest.slice(m[0].length);
|
|
179
|
+
}
|
|
180
|
+
return total;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// ssh(1) options that take an argument (`ssh -h` usage line).
|
|
184
|
+
const SSH_ARG_FLAGS = new Set("BbcDEeFIiJLlmOoPpQRSWw");
|
|
185
|
+
|
|
186
|
+
/**
|
|
187
|
+
* Parse ssh's own argument list (everything after the program) the way ssh's
|
|
188
|
+
* getopt does: `-o` values, and any positional word. A positional word in a
|
|
189
|
+
* configured ssh command is an embedded destination (`ssh bastion ssh …`): git
|
|
190
|
+
* appends its host and remote command AFTER it, so a probe that appended `-G`
|
|
191
|
+
* would become a remote command and connect. Returns null when an option is
|
|
192
|
+
* missing its argument.
|
|
193
|
+
*/
|
|
194
|
+
export function parseSshArgs(args) {
|
|
195
|
+
const options = [];
|
|
196
|
+
const positional = [];
|
|
197
|
+
// The arguments up to (not including) a `--` terminator: a probe must put its
|
|
198
|
+
// own options before the terminator, or ssh reads `-G` as the destination.
|
|
199
|
+
let optionArgs = args;
|
|
200
|
+
for (let i = 0; i < args.length; i += 1) {
|
|
201
|
+
const arg = args[i];
|
|
202
|
+
if (arg === "--") {
|
|
203
|
+
optionArgs = args.slice(0, i);
|
|
204
|
+
positional.push(...args.slice(i + 1));
|
|
205
|
+
break;
|
|
206
|
+
}
|
|
207
|
+
if (!arg.startsWith("-") || arg === "-") {
|
|
208
|
+
positional.push(arg);
|
|
209
|
+
continue;
|
|
210
|
+
}
|
|
211
|
+
for (let j = 1; j < arg.length; j += 1) {
|
|
212
|
+
const flag = arg[j];
|
|
213
|
+
if (!SSH_ARG_FLAGS.has(flag)) continue;
|
|
214
|
+
let value = arg.slice(j + 1);
|
|
215
|
+
if (!value) {
|
|
216
|
+
if (i + 1 >= args.length) return null;
|
|
217
|
+
value = args[++i];
|
|
218
|
+
}
|
|
219
|
+
if (flag === "o") options.push(value);
|
|
220
|
+
break;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
return { options, positional, optionArgs };
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* The ServerAliveInterval an ssh argument list sets explicitly, or null. ssh
|
|
228
|
+
* keeps the FIRST value it obtains and the command line beats every config
|
|
229
|
+
* file, so the first explicit `-o ServerAliveInterval` is authoritative.
|
|
230
|
+
*/
|
|
231
|
+
export function explicitServerAliveInterval(args) {
|
|
232
|
+
const parsed = parseSshArgs(args);
|
|
233
|
+
for (const option of parsed?.options ?? []) {
|
|
234
|
+
const m = option.match(/^\s*serveraliveinterval\s*(?:=\s*|\s+)(\S+)\s*$/i);
|
|
235
|
+
if (m) return parseSshTime(m[1]);
|
|
236
|
+
}
|
|
237
|
+
return null;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
/** Insert the keepalive options right after the program, so they win over any later `-o`. */
|
|
241
|
+
export function withKeepalive(command) {
|
|
242
|
+
const m = String(command).match(/^(\s*)(\S+)/);
|
|
243
|
+
if (!m) return KEEPALIVE_SSH_COMMAND;
|
|
244
|
+
return `${m[1]}${m[2]} ${KEEPALIVE_OPTIONS}${command.slice(m[0].length)}`;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function runQuiet(run, cmd, args, { cwd, env, timeout }) {
|
|
248
|
+
let res;
|
|
249
|
+
try {
|
|
250
|
+
res = run(cmd, args, { cwd, env, encoding: "utf8", timeout, stdio: ["ignore", "pipe", "pipe"] });
|
|
251
|
+
} catch (error) {
|
|
252
|
+
return { status: null, stdout: "", stderr: "", error };
|
|
253
|
+
}
|
|
254
|
+
return {
|
|
255
|
+
status: typeof res?.status === "number" ? res.status : null,
|
|
256
|
+
stdout: String(res?.stdout ?? ""),
|
|
257
|
+
stderr: String(res?.stderr ?? ""),
|
|
258
|
+
error: res?.error ?? null,
|
|
259
|
+
signal: res?.signal ?? null,
|
|
260
|
+
};
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function git(run, args, { cwd, env }) {
|
|
264
|
+
return runQuiet(run, "git", args, { cwd, env, timeout: GIT_TIMEOUT_MS });
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function gitConfigValue(run, key, ctx) {
|
|
268
|
+
const res = git(run, ["config", "--get", key], ctx);
|
|
269
|
+
return res.status === 0 ? res.stdout.replace(/\n$/, "") : null;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/** Which remote this push would use: branch pushRemote → remote.pushDefault → branch remote → origin. */
|
|
273
|
+
function defaultPushRemote(run, ctx) {
|
|
274
|
+
const head = git(run, ["symbolic-ref", "--quiet", "--short", "HEAD"], ctx);
|
|
275
|
+
const branch = head.status === 0 ? head.stdout.trim() : null;
|
|
276
|
+
return (branch && gitConfigValue(run, `branch.${branch}.pushRemote`, ctx))
|
|
277
|
+
|| gitConfigValue(run, "remote.pushDefault", ctx)
|
|
278
|
+
|| (branch && gitConfigValue(run, `branch.${branch}.remote`, ctx))
|
|
279
|
+
|| "origin";
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
/**
|
|
283
|
+
* Every URL this push may go to. A hook passes the actual destination (`$2`) as
|
|
284
|
+
* `url`; otherwise all of the remote's push URLs (`--all`: a remote can push to
|
|
285
|
+
* several) so the verdict covers each, not just the first.
|
|
286
|
+
*/
|
|
287
|
+
function resolveDestinations(run, { remote, url }, ctx) {
|
|
288
|
+
// A remote given as a URL (a push to a URL) is never echoed: it can embed a credential.
|
|
289
|
+
if (url) return { name: remote && classifyRemoteUrl(remote).transport === "local" ? remote : null, urls: [url] };
|
|
290
|
+
const name = remote || defaultPushRemote(run, ctx);
|
|
291
|
+
const res = git(run, ["remote", "get-url", "--push", "--all", name], ctx);
|
|
292
|
+
const urls = res.status === 0 ? res.stdout.split("\n").map((line) => line.trim()).filter(Boolean) : [];
|
|
293
|
+
if (urls.length) return { name, urls };
|
|
294
|
+
// A pre-push hook's $1 is the URL itself when the push named a URL, not a remote.
|
|
295
|
+
if (remote && classifyRemoteUrl(remote).transport !== "local") return { name: null, urls: [remote] };
|
|
296
|
+
return { name, urls: [], error: res.error ? "git_unavailable" : "no_push_remote" };
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
/** git's own order: GIT_SSH_COMMAND, core.sshCommand, GIT_SSH, then plain `ssh`. */
|
|
300
|
+
function resolveSshCommand(run, env, ctx) {
|
|
301
|
+
if (env.GIT_SSH_COMMAND) return { source: "GIT_SSH_COMMAND", command: env.GIT_SSH_COMMAND, words: splitShellWords(env.GIT_SSH_COMMAND, env) };
|
|
302
|
+
let res = git(run, ["config", "--show-scope", "--get", "core.sshCommand"], ctx);
|
|
303
|
+
if (res.status === 129) res = git(run, ["config", "--get", "core.sshCommand"], ctx); // git < 2.26
|
|
304
|
+
if (res.status === 0) {
|
|
305
|
+
const line = res.stdout.replace(/\n$/, "");
|
|
306
|
+
const tab = line.indexOf("\t");
|
|
307
|
+
const scoped = tab > 0 && /^(local|global|system|worktree|command)$/.test(line.slice(0, tab));
|
|
308
|
+
const command = scoped ? line.slice(tab + 1) : line;
|
|
309
|
+
return { source: "core.sshCommand", scope: scoped ? line.slice(0, tab) : null, command, words: splitShellWords(command, env) };
|
|
310
|
+
}
|
|
311
|
+
if (env.GIT_SSH) return { source: "GIT_SSH", command: env.GIT_SSH, words: [env.GIT_SSH] };
|
|
312
|
+
return { source: "ssh_config", command: "ssh", words: ["ssh"] };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function sshEffectiveInterval(run, optionArgs, program, remote, { env, timeout }) {
|
|
316
|
+
// The configured options, then the probe's, then `--` and the host: a host can
|
|
317
|
+
// never be read as an option, and a configured trailing `--` cannot swallow -G.
|
|
318
|
+
const args = [...optionArgs, "-G"];
|
|
319
|
+
if (remote.port) args.push("-p", remote.port);
|
|
320
|
+
if (remote.user) args.push("-l", remote.user);
|
|
321
|
+
args.push("--", remote.host);
|
|
322
|
+
const res = runQuiet(run, program, args, { env, timeout });
|
|
323
|
+
if (res.error?.code === "ETIMEDOUT" || (res.signal && res.status === null)) return { reason: "ssh_config_timeout" };
|
|
324
|
+
if (res.error?.code === "ENOENT") return { reason: "ssh_not_found" };
|
|
325
|
+
if (res.error || res.status !== 0) return { reason: "ssh_config_unreadable" };
|
|
326
|
+
const m = res.stdout.match(/^serveraliveinterval\s+(\S+)\s*$/im);
|
|
327
|
+
const interval = m ? parseSshTime(m[1]) : null;
|
|
328
|
+
return interval == null ? { reason: "ssh_config_unparseable" } : { interval };
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
function recoveryFor(report, resolved) {
|
|
332
|
+
if (report.status === "ok" || report.status === "n/a") return null;
|
|
333
|
+
if (resolved.source === "ssh_config") {
|
|
334
|
+
if (report.status === "needs_fix") {
|
|
335
|
+
return `nothing gives this push a keepalive (ssh config for ${report.host}: ServerAliveInterval ${report.server_alive_interval}); run \`bb doctor --fix\` — it sets git config --local core.sshCommand "${KEEPALIVE_SSH_COMMAND}" for this clone and its worktrees`;
|
|
336
|
+
}
|
|
337
|
+
return `\`ssh -G ${report.host}\` gave no answer (${report.reason}), so a keepalive cannot be proven; run \`bb doctor --fix\` to set an explicit one in core.sshCommand`;
|
|
338
|
+
}
|
|
339
|
+
if (report.reason === "ssh_command_has_destination") {
|
|
340
|
+
return `the ssh command embeds a destination (a nested connection such as \`ssh bastion ssh\`), so its keepalive cannot be proven without connecting and \`bb doctor --fix\` never edits it; use ProxyJump instead: ssh -J <bastion> ${KEEPALIVE_OPTIONS}`;
|
|
341
|
+
}
|
|
342
|
+
// Echo the configured command back only when it is plain ssh: another program
|
|
343
|
+
// (sshpass, a wrapper) can carry a secret on its command line.
|
|
344
|
+
const plainSsh = Boolean(resolved.words?.length) && basename(resolved.words[0]) === "ssh";
|
|
345
|
+
const suggested = plainSsh ? withKeepalive(resolved.command) : null;
|
|
346
|
+
const edit = suggested
|
|
347
|
+
? `add ${KEEPALIVE_OPTIONS} right after the program`
|
|
348
|
+
: `make its keepalive explicit with ${KEEPALIVE_OPTIONS}`;
|
|
349
|
+
if (resolved.source === "GIT_SSH_COMMAND") {
|
|
350
|
+
return `GIT_SSH_COMMAND overrides git config and \`bb doctor --fix\` never edits it; ${edit}${suggested ? `: export GIT_SSH_COMMAND=${shellQuote(suggested)}` : ""} (or unset it and run \`bb doctor --fix\`)`;
|
|
351
|
+
}
|
|
352
|
+
if (resolved.source === "GIT_SSH") {
|
|
353
|
+
return `GIT_SSH is set and \`bb doctor --fix\` never overrides it; export GIT_SSH_COMMAND=${shellQuote(`${resolved.command} ${KEEPALIVE_OPTIONS}`)} or unset GIT_SSH and run \`bb doctor --fix\``;
|
|
354
|
+
}
|
|
355
|
+
const where = resolved.scope && resolved.scope !== "command" ? ` --${resolved.scope}` : "";
|
|
356
|
+
const how = resolved.scope === "command" ? "it is passed with `git -c`; change it there" : null;
|
|
357
|
+
return `core.sshCommand${resolved.scope ? ` (${resolved.scope} config)` : ""} has no keepalive and \`bb doctor --fix\` never overwrites it; ${edit}${how ? ` — ${how}` : suggested ? `: git config${where} core.sshCommand ${shellQuote(suggested)}` : ""}`;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** The verdict for one push URL. `sshCommand()` resolves the ssh command once, on first need. */
|
|
361
|
+
function evaluateDestination(rawUrl, { run, env, sshTimeoutMs, sshCommand }) {
|
|
362
|
+
const url = classifyRemoteUrl(rawUrl);
|
|
363
|
+
if (url.transport !== "ssh") return { status: "n/a", transport: url.transport, reason: `${url.transport}_remote` };
|
|
364
|
+
if (!url.host) return { status: "unknown", transport: "ssh", host: null, reason: "unparseable_remote_url" };
|
|
365
|
+
// git refuses a host or user that starts with "-" (it would be read as an ssh
|
|
366
|
+
// option, CVE-2017-1000117); so does this probe, without running ssh.
|
|
367
|
+
if (url.host.startsWith("-") || url.user?.startsWith("-")) return { status: "unknown", transport: "ssh", host: null, reason: "unsafe_remote_url" };
|
|
368
|
+
const resolved = sshCommand();
|
|
369
|
+
const report = {
|
|
370
|
+
transport: "ssh", host: url.host, source: resolved.source,
|
|
371
|
+
...(resolved.scope ? { scope: resolved.scope } : {}),
|
|
372
|
+
server_alive_interval: null,
|
|
373
|
+
};
|
|
374
|
+
if (!resolved.words || resolved.words.length === 0) {
|
|
375
|
+
report.reason = "unparseable_ssh_command";
|
|
376
|
+
} else {
|
|
377
|
+
const isSsh = basename(resolved.words[0]) === "ssh";
|
|
378
|
+
const sshArgs = isSsh ? parseSshArgs(resolved.words.slice(1)) : null;
|
|
379
|
+
const explicit = explicitServerAliveInterval(resolved.words.slice(1));
|
|
380
|
+
if (isSsh && (!sshArgs || sshArgs.positional.length > 0)) {
|
|
381
|
+
// `ssh bastion ssh`: a nested connection. Never probe it (the probe would
|
|
382
|
+
// connect); its inner keepalive cannot be proven from here either.
|
|
383
|
+
report.reason = sshArgs ? "ssh_command_has_destination" : "unparseable_ssh_command";
|
|
384
|
+
} else if (explicit != null) {
|
|
385
|
+
report.server_alive_interval = explicit;
|
|
386
|
+
report.interval_origin = "command_line";
|
|
387
|
+
} else if (isSsh) {
|
|
388
|
+
const effective = sshEffectiveInterval(run, sshArgs.optionArgs, resolved.words[0], url, { env, timeout: sshTimeoutMs });
|
|
389
|
+
if (effective.reason) report.reason = effective.reason;
|
|
390
|
+
else {
|
|
391
|
+
report.server_alive_interval = effective.interval;
|
|
392
|
+
report.interval_origin = "ssh_config";
|
|
393
|
+
}
|
|
394
|
+
} else {
|
|
395
|
+
report.reason = "custom_ssh_program";
|
|
396
|
+
}
|
|
397
|
+
}
|
|
398
|
+
if (report.server_alive_interval == null) report.status = "unknown";
|
|
399
|
+
else report.status = report.server_alive_interval > 0 ? "ok" : "needs_fix";
|
|
400
|
+
return report;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function evaluate({ cwd, env, remote, url, run, sshTimeoutMs }) {
|
|
404
|
+
const ctx = { cwd, env };
|
|
405
|
+
const target = resolveDestinations(run, { remote, url }, ctx);
|
|
406
|
+
const base = { check: GIT_TRANSPORT_CHECK, remote: target.name ?? null };
|
|
407
|
+
if (target.urls.length === 0) {
|
|
408
|
+
if (target.error === "git_unavailable") return { report: { ...base, status: "unknown", transport: null, reason: "git_unavailable", recovery: "git could not run here (not on PATH, or the directory is missing); run the check from the clone you push from" } };
|
|
409
|
+
return { report: { ...base, status: "n/a", transport: "none", reason: "no_push_remote" } };
|
|
410
|
+
}
|
|
411
|
+
let resolved = null;
|
|
412
|
+
const sshCommand = () => (resolved ??= resolveSshCommand(run, env, ctx));
|
|
413
|
+
const results = target.urls.map((u) => evaluateDestination(u, { run, env, sshTimeoutMs, sshCommand }));
|
|
414
|
+
// The push passes only if every destination does: report the first that does not.
|
|
415
|
+
const primary = results.find((r) => !GIT_TRANSPORT_PASSING.has(r.status))
|
|
416
|
+
?? results.find((r) => r.transport === "ssh") ?? results[0];
|
|
417
|
+
const report = { ...base, ...primary };
|
|
418
|
+
if (results.length > 1) {
|
|
419
|
+
report.destinations = results.map((r) => ({
|
|
420
|
+
transport: r.transport, ...(r.host ? { host: r.host } : {}), status: r.status,
|
|
421
|
+
...(r.server_alive_interval != null ? { server_alive_interval: r.server_alive_interval } : {}),
|
|
422
|
+
...(r.reason ? { reason: r.reason } : {}),
|
|
423
|
+
}));
|
|
424
|
+
}
|
|
425
|
+
if (report.reason === "unparseable_remote_url" || report.reason === "unsafe_remote_url") report.recovery = "a push URL's host cannot be parsed safely; check `git remote get-url --push --all`";
|
|
426
|
+
else if (resolved) {
|
|
427
|
+
const recovery = recoveryFor(report, resolved);
|
|
428
|
+
if (recovery) report.recovery = recovery;
|
|
429
|
+
}
|
|
430
|
+
return { report, resolved };
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
/**
|
|
434
|
+
* Check (and with `fix`, repair) the keepalive on this clone's push transport.
|
|
435
|
+
* `url` is the push's actual destination (a pre-push hook's `$2`); without it
|
|
436
|
+
* every push URL of `remote` (default: the branch's push remote) is checked.
|
|
437
|
+
* Returns the report: { check, status: ok|needs_fix|n/a|unknown, remote,
|
|
438
|
+
* transport, host?, source?, scope?, server_alive_interval?, interval_origin?,
|
|
439
|
+
* reason?, recovery?, destinations? (when several URLs), fix? }.
|
|
440
|
+
*/
|
|
441
|
+
export function checkGitTransport({
|
|
442
|
+
cwd = process.cwd(), env = process.env, remote = null, url = null, fix = false,
|
|
443
|
+
run = spawnSync, sshTimeoutMs = SSH_CONFIG_TIMEOUT_MS,
|
|
444
|
+
} = {}) {
|
|
445
|
+
const first = evaluate({ cwd, env, remote, url, run, sshTimeoutMs });
|
|
446
|
+
if (!fix || GIT_TRANSPORT_PASSING.has(first.report.status) || !first.resolved) return first.report;
|
|
447
|
+
if (first.resolved.source !== "ssh_config") {
|
|
448
|
+
return { ...first.report, fix: { applied: false, reason: `existing_${first.resolved.source}_left_untouched` } };
|
|
449
|
+
}
|
|
450
|
+
const set = git(run, ["config", "--local", "core.sshCommand", KEEPALIVE_SSH_COMMAND], { cwd, env });
|
|
451
|
+
if (set.status !== 0) {
|
|
452
|
+
return { ...first.report, fix: { applied: false, reason: "git_config_failed" } };
|
|
453
|
+
}
|
|
454
|
+
// Report fixed only on proof: re-resolve exactly as a push would.
|
|
455
|
+
const after = evaluate({ cwd, env, remote, url, run, sshTimeoutMs }).report;
|
|
456
|
+
return { ...after, fix: { applied: true, set: `git config --local core.sshCommand "${KEEPALIVE_SSH_COMMAND}"`, verified: after.status === "ok" } };
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
/** One human line for `bb doctor` / `bb setup`. */
|
|
460
|
+
export function formatGitTransportLine(report) {
|
|
461
|
+
if (!report) return "git transport: unknown (check failed)";
|
|
462
|
+
const parts = [];
|
|
463
|
+
if (report.host) parts.push(`ssh ${report.host}`);
|
|
464
|
+
else if (report.transport && report.transport !== "none" && report.transport !== "ssh") parts.push(`${report.transport} remote`);
|
|
465
|
+
if (report.server_alive_interval != null) {
|
|
466
|
+
const via = report.interval_origin === "command_line" ? report.source
|
|
467
|
+
: report.source === "ssh_config" ? "ssh config" : `${report.source} → ssh config`;
|
|
468
|
+
parts.push(`ServerAliveInterval ${report.server_alive_interval} via ${via}`);
|
|
469
|
+
} else if (report.reason && !(report.transport && report.reason === `${report.transport}_remote`)) {
|
|
470
|
+
parts.push(report.reason);
|
|
471
|
+
}
|
|
472
|
+
const fixed = report.fix?.applied
|
|
473
|
+
? (report.fix.verified ? " — fixed: set core.sshCommand (local)" : " — fix applied but NOT verified")
|
|
474
|
+
: "";
|
|
475
|
+
return `git transport: ${report.status}${parts.length ? ` (${parts.join(", ")})` : ""}${fixed}`;
|
|
476
|
+
}
|