pi-better-background-tasks 0.2.4 → 0.2.6

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.
@@ -1,5 +1,16 @@
1
1
  import { runCommandOnce, spawnCommand } from "./process.js";
2
2
  import type { SpawnedProcess } from "./process.js";
3
+ import {
4
+ createTmuxSessionController,
5
+ resolveSshCommand,
6
+ } from "./shared-ssh-core/index.js";
7
+ import type {
8
+ RemoteRunner,
9
+ TmuxBootstrapOptions,
10
+ TmuxBootstrapResult,
11
+ TmuxPackageManager,
12
+ TmuxSessionPollResult,
13
+ } from "./shared-ssh-core/index.js";
3
14
  import type {
4
15
  CommandResult,
5
16
  CommandSpec,
@@ -9,13 +20,17 @@ import type {
9
20
  SshConnectionParams,
10
21
  } from "./types.js";
11
22
 
12
- export const DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS = 10;
13
- export const DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS = 120_000;
14
-
15
- export interface RemoteRunner {
16
- spawn(spec: CommandSpec, logPath: string, detached: boolean): SpawnedProcess;
17
- runOnce(spec: CommandSpec, maxBufferBytes?: number, timeoutMs?: number): Promise<CommandResult>;
18
- }
23
+ export {
24
+ DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS,
25
+ DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS,
26
+ } from "./shared-ssh-core/index.js";
27
+ export type {
28
+ RemoteRunner,
29
+ TmuxBootstrapOptions,
30
+ TmuxBootstrapResult,
31
+ TmuxPackageManager,
32
+ TmuxSessionPollResult,
33
+ };
19
34
 
20
35
  export interface SshRemoteTaskIntent {
21
36
  operation: "spawn" | "watch";
@@ -28,68 +43,6 @@ export interface SshRemoteTaskIntent {
28
43
  remote?: RemoteTaskParams;
29
44
  }
30
45
 
31
- export type TmuxPackageManager = "apt-get" | "dnf" | "yum" | "apk" | "pacman" | "zypper" | "brew";
32
-
33
- type TmuxBootstrapCapability = {
34
- target: string;
35
- tmuxPath: string;
36
- tmuxVersion: string;
37
- message: string;
38
- };
39
-
40
- type TmuxBootstrapGuidance = {
41
- target: string;
42
- mutated: boolean;
43
- verifyCommand: string;
44
- message: string;
45
- };
46
-
47
- export type TmuxBootstrapResult =
48
- | (TmuxBootstrapCapability & {
49
- status: "present";
50
- mutated: false;
51
- })
52
- | (TmuxBootstrapCapability & {
53
- status: "installed";
54
- packageManager: TmuxPackageManager;
55
- mutated: true;
56
- installCommand: string;
57
- verifyCommand: string;
58
- })
59
- | (TmuxBootstrapGuidance & {
60
- status: "needs_user";
61
- reason: "passwordless_sudo_unavailable" | "install_disabled";
62
- packageManager: TmuxPackageManager;
63
- mutated: false;
64
- installCommand: string;
65
- })
66
- | (TmuxBootstrapGuidance & {
67
- status: "unknown_package_manager";
68
- mutated: false;
69
- })
70
- | (TmuxBootstrapGuidance & {
71
- status: "install_failed";
72
- packageManager?: TmuxPackageManager;
73
- exitCode: number | null;
74
- installCommand?: string;
75
- })
76
- | (TmuxBootstrapGuidance & {
77
- status: "timed_out";
78
- packageManager?: TmuxPackageManager;
79
- installCommand?: string;
80
- });
81
-
82
- export interface TmuxBootstrapOptions {
83
- timeoutMs?: number;
84
- }
85
-
86
- export interface TmuxSessionPollResult {
87
- status: "running" | "missing" | "timed_out" | number;
88
- logSize: number;
89
- output: string;
90
- commandResult: CommandResult;
91
- }
92
-
93
46
  export interface ResolvedSshRemoteTask {
94
47
  commandSpec: CommandSpec;
95
48
  metadata: {
@@ -108,88 +61,19 @@ const processRemoteRunner: RemoteRunner = {
108
61
  spawn: spawnCommand,
109
62
  runOnce: runCommandOnce,
110
63
  };
111
-
112
- const REQUIRED_SSH_OPTIONS = new Set(["batchmode", "connecttimeout", "requesttty"]);
113
64
  const DIRECT_STOP_WARNING = "Direct SSH mode has weak stop semantics: stopping the local SSH client may leave the remote process running.";
114
- const TMUX_STATUS_PREFIX = "__PI_BG_STATUS__=";
115
- const TMUX_SIZE_PREFIX = "__PI_BG_SIZE__=";
116
- const TMUX_PATH_PREFIX = "__PI_BG_TMUX_PATH__=";
117
- const TMUX_VERSION_PREFIX = "__PI_BG_TMUX_VERSION__=";
118
- const TMUX_CAPTURE_CHUNK_BYTES = 256 * 1024;
119
- const TMUX_PROBE_COMMAND = [
120
- "tmux_path=$(command -v tmux) || exit 127",
121
- `printf '${TMUX_PATH_PREFIX}%s\\n' "$tmux_path"`,
122
- `printf '${TMUX_VERSION_PREFIX}%s\\n' "$("$tmux_path" -V)"`,
123
- ].join("; ");
124
- const TMUX_PACKAGE_MANAGERS: TmuxPackageManager[] = ["apt-get", "dnf", "yum", "apk", "pacman", "zypper", "brew"];
125
- const TMUX_INSTALL_COMMANDS: Record<TmuxPackageManager, string> = {
126
- "apt-get": "apt-get update && apt-get install -y tmux",
127
- dnf: "dnf install -y tmux",
128
- yum: "yum install -y tmux",
129
- apk: "apk add --no-cache tmux",
130
- pacman: "pacman -Sy --noconfirm tmux",
131
- zypper: "zypper --non-interactive install tmux",
132
- brew: "brew install tmux",
133
- };
134
- const TMUX_DETECT_COMMAND = [
135
- "remote_user=$(id -un) || exit 1",
136
- "remote_uid=$(id -u) || exit 1",
137
- "package_manager=''",
138
- `for candidate in ${TMUX_PACKAGE_MANAGERS.join(" ")}; do if command -v \"$candidate\" >/dev/null 2>&1; then package_manager=$candidate; break; fi; done`,
139
- "if [ \"$package_manager\" = brew ]; then privilege=direct; elif [ \"$remote_uid\" -eq 0 ]; then privilege=root; elif command -v sudo >/dev/null 2>&1 && sudo -n true >/dev/null 2>&1; then privilege=sudo; else privilege=needs_user; fi",
140
- "printf 'user=%s\\nuid=%s\\npm=%s\\nprivilege=%s\\n' \"$remote_user\" \"$remote_uid\" \"$package_manager\" \"$privilege\"",
141
- ].join("; ");
142
65
 
143
66
  export function expandSshRemoteTaskPreset(
144
67
  intent: SshRemoteTaskIntent,
145
68
  runner: RemoteRunner = processRemoteRunner,
146
69
  ): ResolvedSshRemoteTask {
147
- const command = requireValue(intent.command, "command is required when ssh is set");
148
- const host = requireToken(intent.ssh.host, "ssh.host is required");
149
- const user = optionalToken(intent.ssh.user, "ssh.user must not be empty or contain whitespace");
150
- const target = user ? `${user}@${host}` : host;
151
- const argv = [
152
- "ssh",
153
- "-o", "BatchMode=yes",
154
- "-o", `ConnectTimeout=${DEFAULT_SSH_CONNECT_TIMEOUT_SECONDS}`,
155
- "-T",
156
- ];
157
-
158
- if (intent.ssh.port !== undefined) {
159
- if (!Number.isInteger(intent.ssh.port) || intent.ssh.port < 1 || intent.ssh.port > 65_535) {
160
- throw new Error("ssh.port must be an integer between 1 and 65535");
161
- }
162
- argv.push("-p", String(intent.ssh.port));
163
- }
164
-
165
- const identityFile = optionalValue(intent.ssh.identity_file, "ssh.identity_file must not be empty");
166
- if (identityFile) argv.push("-i", identityFile);
167
- const jump = optionalValue(intent.ssh.jump, "ssh.jump must not be empty");
168
- if (jump) argv.push("-J", jump);
169
-
170
- for (const [rawKey, rawValue] of Object.entries(intent.ssh.options ?? {})) {
171
- const key = requireToken(rawKey, "ssh option names must not be empty or contain whitespace");
172
- if (REQUIRED_SSH_OPTIONS.has(key.toLowerCase())) continue;
173
- argv.push("-o", `${key}=${String(rawValue)}`);
174
- }
175
- argv.push("--", target, command);
176
-
177
- const commandSpec: CommandSpec = {
178
- command,
179
- argv,
180
- shell: false,
70
+ const resolved = resolveSshCommand({
71
+ command: intent.command,
181
72
  cwd: intent.cwd,
182
73
  env: intent.env,
183
- };
184
- const ssh: ResolvedSshIdentity = {
185
- host,
186
- ...(user ? { user } : {}),
187
- ...(intent.ssh.port !== undefined ? { port: intent.ssh.port } : {}),
188
- ...(identityFile ? { identityFile } : {}),
189
- ...(jump ? { jump } : {}),
190
- ...(intent.ssh.options ? { options: { ...intent.ssh.options } } : {}),
191
- target,
192
- };
74
+ ssh: intent.ssh,
75
+ });
76
+ const command = resolved.commandSpec.command!;
193
77
  const session = intent.operation === "spawn" ? intent.remote?.session ?? "tmux" : "direct";
194
78
  const installTmux = intent.operation === "spawn" && session === "tmux"
195
79
  ? intent.remote?.install_tmux !== false
@@ -205,441 +89,39 @@ export function expandSshRemoteTaskPreset(
205
89
  ...(sessionName ? { sessionName, bootstrapStatus: "pending", sessionStarted: false } : {}),
206
90
  ...(session === "direct" && intent.operation === "spawn" ? { warning: DIRECT_STOP_WARNING } : {}),
207
91
  };
208
-
209
- const requireSessionName = (): string => {
92
+ const controller = createTmuxSessionController({
93
+ ...resolved,
94
+ runner,
95
+ sessionName: sessionName ?? "pi-bg-unused",
96
+ command,
97
+ workdir: intent.remote?.workdir,
98
+ installTmux,
99
+ });
100
+ const requireTmuxSession = (): void => {
210
101
  if (!sessionName) throw new Error("taskId is required for tmux-backed SSH spawn lifecycle operations");
211
- return sessionName;
212
102
  };
213
103
 
214
104
  return {
215
- commandSpec,
216
- metadata: { ssh, remote },
217
- bootstrapTmux: async (options) => bootstrapTmux(
218
- commandSpec,
219
- target,
220
- runner,
221
- installTmux,
222
- resolveBootstrapTimeoutMs(options?.timeoutMs),
223
- ),
224
- startTmuxSession: (tmuxPath) => runner.runOnce(withRemoteCommand(
225
- commandSpec,
226
- tmuxStartCommand(tmuxPath, requireSessionName(), command, intent.remote?.workdir),
227
- )),
228
- pollTmuxSession: async (logOffset, timeoutMs) => parseTmuxPollResult(await runner.runOnce(withRemoteCommand(
229
- commandSpec,
230
- tmuxPollCommand(requireSessionName(), logOffset),
231
- ), undefined, timeoutMs), logOffset),
232
- killTmuxSession: () => runner.runOnce(withRemoteCommand(
233
- commandSpec,
234
- `tmux kill-session -t ${shellQuote(requireSessionName())}`,
235
- )),
236
- spawn: (logPath, detached) => runner.spawn(commandSpec, logPath, detached),
237
- runOnce: (maxBufferBytes, timeoutMs) => runner.runOnce(commandSpec, maxBufferBytes, timeoutMs),
105
+ commandSpec: resolved.commandSpec,
106
+ metadata: { ssh: resolved.identity, remote },
107
+ bootstrapTmux: (options) => controller.bootstrapTmux(options),
108
+ startTmuxSession: (tmuxPath) => {
109
+ requireTmuxSession();
110
+ return controller.startTmuxSession(tmuxPath);
111
+ },
112
+ pollTmuxSession: (logOffset, timeoutMs) => {
113
+ requireTmuxSession();
114
+ return controller.pollTmuxSession(logOffset, timeoutMs);
115
+ },
116
+ killTmuxSession: () => {
117
+ requireTmuxSession();
118
+ return controller.killTmuxSession();
119
+ },
120
+ spawn: (logPath, detached) => runner.spawn(resolved.commandSpec, logPath, detached),
121
+ runOnce: (maxBufferBytes, timeoutMs) => runner.runOnce(resolved.commandSpec, maxBufferBytes, timeoutMs),
238
122
  };
239
123
  }
240
124
 
241
125
  function sessionNameForTask(taskId: string): string {
242
126
  return `pi-bg-${taskId.replaceAll(/[^A-Za-z0-9_-]/g, "-")}`;
243
127
  }
244
-
245
- function tmuxLogPath(sessionName: string): string {
246
- return `/tmp/${sessionName}.log`;
247
- }
248
-
249
- function tmuxExitPath(sessionName: string): string {
250
- return `/tmp/${sessionName}.exit`;
251
- }
252
-
253
- function tmuxStartCommand(tmuxPath: string, sessionName: string, command: string, workdir?: string): string {
254
- const logPath = tmuxLogPath(sessionName);
255
- const exitPath = tmuxExitPath(sessionName);
256
- const script = [
257
- ...(workdir ? [`cd -- ${shellQuote(workdir)}`] : []),
258
- `sh -lc ${shellQuote(command)} >${shellQuote(logPath)} 2>&1`,
259
- "exit_code=$?",
260
- `printf '%s\\n' "$exit_code" >${shellQuote(exitPath)}`,
261
- "exit \"$exit_code\"",
262
- ].join("; ");
263
- return [
264
- `rm -f ${shellQuote(logPath)} ${shellQuote(exitPath)}`,
265
- `${shellQuote(tmuxPath)} new-session -d -s ${shellQuote(sessionName)} sh -lc ${shellQuote(script)}`,
266
- ].join("; ");
267
- }
268
-
269
- function tmuxPollCommand(sessionName: string, logOffset: number): string {
270
- const normalizedOffset = Math.max(0, Math.floor(logOffset));
271
- const logPath = tmuxLogPath(sessionName);
272
- const exitPath = tmuxExitPath(sessionName);
273
- return [
274
- "status=running",
275
- `if test -f ${shellQuote(exitPath)}; then status=$(cat ${shellQuote(exitPath)}); elif ! tmux has-session -t ${shellQuote(sessionName)} 2>/dev/null; then status=missing; fi`,
276
- `size=$(wc -c < ${shellQuote(logPath)} 2>/dev/null || printf '0')`,
277
- "size=$(printf '%s' \"$size\" | tr -d '[:space:]')",
278
- `next_offset=$(( ${normalizedOffset} + ${TMUX_CAPTURE_CHUNK_BYTES} ))`,
279
- "if test \"$next_offset\" -gt \"$size\"; then next_offset=$size; fi",
280
- "reported_status=$status",
281
- "if test \"$next_offset\" -lt \"$size\"; then reported_status=running; fi",
282
- `printf '${TMUX_STATUS_PREFIX}%s\\n${TMUX_SIZE_PREFIX}%s\\n' "$reported_status" "$next_offset"`,
283
- `if test "$next_offset" -gt ${normalizedOffset}; then tail -c +${normalizedOffset + 1} ${shellQuote(logPath)} | head -c $(( next_offset - ${normalizedOffset} )); fi`,
284
- ].join("; ");
285
- }
286
-
287
- function parseTmuxPollResult(result: CommandResult, logOffset: number): TmuxSessionPollResult {
288
- if (result.timedOut) {
289
- return {
290
- status: "timed_out",
291
- logSize: Math.max(0, Math.floor(logOffset)),
292
- output: result.stdout,
293
- commandResult: result,
294
- };
295
- }
296
- const [statusLine = "", sizeLine = "", ...outputLines] = result.stdout.split("\n");
297
- if (!statusLine.startsWith(TMUX_STATUS_PREFIX) || !sizeLine.startsWith(TMUX_SIZE_PREFIX)) {
298
- throw new Error(`remote tmux supervision returned an invalid response${result.stderr.trim() ? `: ${result.stderr.trim()}` : ""}`);
299
- }
300
- const rawStatus = statusLine.slice(TMUX_STATUS_PREFIX.length);
301
- const rawSize = sizeLine.slice(TMUX_SIZE_PREFIX.length);
302
- const logSize = Number(rawSize);
303
- if (!Number.isSafeInteger(logSize) || logSize < 0) {
304
- throw new Error(`remote tmux supervision returned invalid log size ${JSON.stringify(rawSize)}`);
305
- }
306
- const status = rawStatus === "running" || rawStatus === "missing"
307
- ? rawStatus
308
- : Number(rawStatus);
309
- if (typeof status === "number" && !Number.isInteger(status)) {
310
- throw new Error(`remote tmux supervision returned invalid status ${JSON.stringify(rawStatus)}`);
311
- }
312
- return {
313
- status,
314
- logSize,
315
- output: outputLines.join("\n"),
316
- commandResult: result,
317
- };
318
- }
319
-
320
- async function bootstrapTmux(
321
- commandSpec: CommandSpec,
322
- target: string,
323
- runner: RemoteRunner,
324
- installEnabled: boolean,
325
- timeoutMs: number,
326
- ): Promise<TmuxBootstrapResult> {
327
- const deadlineAt = Date.now() + timeoutMs;
328
- let timeoutContext: TmuxBootstrapTimeoutContext = {
329
- target,
330
- mutated: false,
331
- verifyCommand: sshGuidanceCommand(commandSpec, target, "command -v tmux && tmux -V"),
332
- stage: "probing tmux",
333
- };
334
- const run = async (remoteCommand: string): Promise<CommandResult> => {
335
- const remainingMs = deadlineAt - Date.now();
336
- if (remainingMs <= 0) throw new TmuxBootstrapTimeoutError();
337
- const result = await runner.runOnce(withRemoteCommand(commandSpec, remoteCommand), undefined, remainingMs);
338
- if (result.timedOut) throw new TmuxBootstrapTimeoutError();
339
- return result;
340
- };
341
-
342
- try {
343
- return await bootstrapTmuxWithinDeadline(run, commandSpec, target, installEnabled, timeoutContext, (context) => {
344
- timeoutContext = context;
345
- });
346
- } catch (error) {
347
- if (!(error instanceof TmuxBootstrapTimeoutError)) throw error;
348
- const remediation = timeoutContext.installCommand
349
- ? `Run: ${timeoutContext.installCommand} Then verify: ${timeoutContext.verifyCommand}`
350
- : `Install tmux manually if needed, then verify: ${timeoutContext.verifyCommand}`;
351
- return {
352
- status: "timed_out",
353
- target: timeoutContext.target,
354
- ...(timeoutContext.packageManager ? { packageManager: timeoutContext.packageManager } : {}),
355
- mutated: timeoutContext.mutated,
356
- ...(timeoutContext.installCommand ? { installCommand: timeoutContext.installCommand } : {}),
357
- verifyCommand: timeoutContext.verifyCommand,
358
- message: `tmux bootstrap timed out on ${timeoutContext.target} while ${timeoutContext.stage}. ${remediation}`,
359
- };
360
- }
361
- }
362
-
363
- interface TmuxBootstrapTimeoutContext {
364
- target: string;
365
- packageManager?: TmuxPackageManager;
366
- mutated: boolean;
367
- installCommand?: string;
368
- verifyCommand: string;
369
- stage: string;
370
- }
371
-
372
- class TmuxBootstrapTimeoutError extends Error {}
373
-
374
- async function bootstrapTmuxWithinDeadline(
375
- run: (remoteCommand: string) => Promise<CommandResult>,
376
- commandSpec: CommandSpec,
377
- target: string,
378
- installEnabled: boolean,
379
- initialTimeoutContext: TmuxBootstrapTimeoutContext,
380
- setTimeoutContext: (context: TmuxBootstrapTimeoutContext) => void,
381
- ): Promise<TmuxBootstrapResult> {
382
- setTimeoutContext(initialTimeoutContext);
383
- const probe = await run(TMUX_PROBE_COMMAND);
384
- const capability = parseTmuxCapability(probe);
385
- if (capability) {
386
- return {
387
- status: "present",
388
- target,
389
- ...capability,
390
- mutated: false,
391
- message: `${capability.tmuxVersion} is available at ${capability.tmuxPath} on ${target}.`,
392
- };
393
- }
394
- const initialVerifyCommand = sshGuidanceCommand(commandSpec, target, "command -v tmux && tmux -V");
395
- if (probe.exitCode !== 127) {
396
- const detail = resultDetail(probe);
397
- return {
398
- status: "install_failed",
399
- target,
400
- exitCode: probe.exitCode,
401
- mutated: false,
402
- verifyCommand: initialVerifyCommand,
403
- message: `tmux bootstrap could not probe ${target} (exit ${probe.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Install tmux manually if needed, then verify: ${initialVerifyCommand}`,
404
- };
405
- }
406
-
407
- setTimeoutContext({ ...initialTimeoutContext, stage: "detecting the package manager" });
408
- const detection = await run(TMUX_DETECT_COMMAND);
409
- if (detection.exitCode !== 0) {
410
- const detail = resultDetail(detection);
411
- return {
412
- status: "install_failed",
413
- target,
414
- exitCode: detection.exitCode,
415
- mutated: false,
416
- verifyCommand: initialVerifyCommand,
417
- message: `tmux bootstrap could not inspect ${target} for an installer (exit ${detection.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Install tmux manually if needed, then verify: ${initialVerifyCommand}`,
418
- };
419
- }
420
- const detected = parseDetection(detection);
421
- const guidanceTarget = detected && !target.includes("@") ? `${detected.user}@${target}` : target;
422
- const verifyCommand = sshGuidanceCommand(commandSpec, guidanceTarget, "command -v tmux && tmux -V");
423
- if (!detected || !isTmuxPackageManager(detected.packageManager)) {
424
- return {
425
- status: "unknown_package_manager",
426
- target: guidanceTarget,
427
- mutated: false,
428
- verifyCommand,
429
- message: `tmux is missing on ${guidanceTarget}, but none of ${formatPackageManagerList()} was found. Install tmux manually, then verify: ${verifyCommand}`,
430
- };
431
- }
432
-
433
- const packageManager = detected.packageManager;
434
- const baseInstallCommand = TMUX_INSTALL_COMMANDS[packageManager];
435
- const needsSudo = detected.privilege === "sudo" || detected.privilege === "needs_user";
436
- const humanInstallCommand = needsSudo ? withInteractiveSudo(baseInstallCommand) : baseInstallCommand;
437
- const installCommand = sshGuidanceCommand(commandSpec, guidanceTarget, humanInstallCommand, needsSudo);
438
- if (!installEnabled) {
439
- return {
440
- status: "needs_user",
441
- reason: "install_disabled",
442
- target: guidanceTarget,
443
- packageManager,
444
- mutated: false,
445
- installCommand,
446
- verifyCommand,
447
- message: `Automatic tmux installation is disabled for ${guidanceTarget}. Run: ${installCommand} Then verify: ${verifyCommand}`,
448
- };
449
- }
450
- if (detected.privilege === "needs_user") {
451
- return {
452
- status: "needs_user",
453
- reason: "passwordless_sudo_unavailable",
454
- target: guidanceTarget,
455
- packageManager,
456
- mutated: false,
457
- installCommand,
458
- verifyCommand,
459
- message: `tmux is missing on ${guidanceTarget} and automatic installation cannot use passwordless sudo. Run: ${installCommand} Then verify: ${verifyCommand}`,
460
- };
461
- }
462
-
463
- const machineInstallCommand = detected.privilege === "sudo"
464
- ? withNonInteractiveSudo(baseInstallCommand)
465
- : baseInstallCommand;
466
- setTimeoutContext({
467
- target: guidanceTarget,
468
- packageManager,
469
- mutated: false,
470
- installCommand,
471
- verifyCommand,
472
- stage: `running the ${packageManager} install`,
473
- });
474
- const install = await run(machineInstallCommand);
475
- if (install.exitCode !== 0) {
476
- const detail = resultDetail(install);
477
- return {
478
- status: "install_failed",
479
- target: guidanceTarget,
480
- packageManager,
481
- exitCode: install.exitCode,
482
- mutated: false,
483
- installCommand,
484
- verifyCommand,
485
- message: `Automatic tmux install with ${packageManager} failed on ${guidanceTarget} (exit ${install.exitCode ?? "unknown"})${detail ? `: ${detail}` : "."} Run: ${installCommand} Then verify: ${verifyCommand}`,
486
- };
487
- }
488
-
489
- setTimeoutContext({
490
- target: guidanceTarget,
491
- packageManager,
492
- mutated: true,
493
- installCommand,
494
- verifyCommand,
495
- stage: "verifying the installed tmux",
496
- });
497
- const reprobe = await run(TMUX_PROBE_COMMAND);
498
- const installedCapability = parseTmuxCapability(reprobe);
499
- if (!installedCapability) {
500
- return {
501
- status: "install_failed",
502
- target: guidanceTarget,
503
- packageManager,
504
- exitCode: reprobe.exitCode,
505
- mutated: true,
506
- installCommand,
507
- verifyCommand,
508
- message: `The ${packageManager} install command completed but tmux did not pass verification on ${guidanceTarget}${resultDetail(reprobe) ? `: ${resultDetail(reprobe)}` : "."} Run: ${installCommand} Then verify: ${verifyCommand}`,
509
- };
510
- }
511
- return {
512
- status: "installed",
513
- target: guidanceTarget,
514
- packageManager,
515
- ...installedCapability,
516
- mutated: true,
517
- installCommand,
518
- verifyCommand,
519
- message: `Installed tmux with ${packageManager} on ${guidanceTarget}; ${installedCapability.tmuxVersion} is available at ${installedCapability.tmuxPath}.`,
520
- };
521
- }
522
-
523
- function resolveBootstrapTimeoutMs(timeoutMs: number | undefined): number {
524
- if (timeoutMs === undefined) return DEFAULT_TMUX_BOOTSTRAP_TIMEOUT_MS;
525
- if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
526
- throw new Error("tmux bootstrap timeoutMs must be a positive finite number");
527
- }
528
- return Math.floor(timeoutMs);
529
- }
530
-
531
- function parseTmuxCapability(result: CommandResult): { tmuxPath: string; tmuxVersion: string } | undefined {
532
- if (result.exitCode !== 0) return undefined;
533
- let tmuxPath: string | undefined;
534
- let tmuxVersion: string | undefined;
535
- for (const line of result.stdout.split("\n")) {
536
- if (line.startsWith(TMUX_PATH_PREFIX)) tmuxPath = line.slice(TMUX_PATH_PREFIX.length);
537
- if (line.startsWith(TMUX_VERSION_PREFIX)) tmuxVersion = line.slice(TMUX_VERSION_PREFIX.length);
538
- }
539
- if (!tmuxPath || !tmuxVersion) return undefined;
540
- if (!tmuxPath.startsWith("/") || /\s/.test(tmuxPath)) return undefined;
541
- if (!/^tmux\b/i.test(tmuxVersion)) return undefined;
542
- return { tmuxPath, tmuxVersion };
543
- }
544
-
545
- interface TmuxDetection {
546
- user: string;
547
- packageManager: string;
548
- privilege: "root" | "sudo" | "direct" | "needs_user";
549
- }
550
-
551
- function parseDetection(result: CommandResult): TmuxDetection | undefined {
552
- if (result.exitCode !== 0) return undefined;
553
- const values = Object.fromEntries(result.stdout.trim().split("\n").map((line) => {
554
- const separator = line.indexOf("=");
555
- return separator < 0 ? [line, ""] : [line.slice(0, separator), line.slice(separator + 1)];
556
- }));
557
- if (!values.user || !/^(root|sudo|direct|needs_user)$/.test(values.privilege ?? "")) return undefined;
558
- return {
559
- user: values.user,
560
- packageManager: values.pm ?? "",
561
- privilege: values.privilege as TmuxDetection["privilege"],
562
- };
563
- }
564
-
565
- function isTmuxPackageManager(value: string | undefined): value is TmuxPackageManager {
566
- return TMUX_PACKAGE_MANAGERS.some((candidate) => candidate === value);
567
- }
568
-
569
- function formatPackageManagerList(): string {
570
- return `${TMUX_PACKAGE_MANAGERS.slice(0, -1).join(", ")}, or ${TMUX_PACKAGE_MANAGERS.at(-1)}`;
571
- }
572
-
573
- function resultDetail(result: CommandResult): string {
574
- return (result.stderr.trim() || result.stdout.trim()).replaceAll(/\s+/g, " ").slice(0, 500);
575
- }
576
-
577
- function withNonInteractiveSudo(command: string): string {
578
- return command.split(" && ").map((part) => `sudo -n ${part}`).join(" && ");
579
- }
580
-
581
- function withInteractiveSudo(command: string): string {
582
- return command.split(" && ").map((part) => `sudo ${part}`).join(" && ");
583
- }
584
-
585
- function sshGuidanceCommand(
586
- spec: CommandSpec,
587
- target: string,
588
- remoteCommand: string,
589
- tty = false,
590
- ): string {
591
- const argv = spec.argv ?? [];
592
- const separator = argv.lastIndexOf("--");
593
- const connectionArgs: string[] = [];
594
- for (let index = 1; index >= 0 && index < separator; index += 1) {
595
- const option = argv[index];
596
- if (option === "-T") continue;
597
- const value = argv[index + 1];
598
- if ((option === "-p" || option === "-i" || option === "-J") && value) {
599
- connectionArgs.push(option, shellQuote(value));
600
- index += 1;
601
- continue;
602
- }
603
- if (option === "-o" && value) {
604
- index += 1;
605
- const key = value.slice(0, value.indexOf("=")).toLowerCase();
606
- if (REQUIRED_SSH_OPTIONS.has(key)) continue;
607
- connectionArgs.push(option, shellQuote(value));
608
- }
609
- }
610
- const renderedArgs = connectionArgs.length > 0 ? ` ${connectionArgs.join(" ")}` : "";
611
- return `ssh${tty ? " -t" : ""}${renderedArgs} ${shellQuote(target)} ${shellQuote(remoteCommand)}`;
612
- }
613
-
614
- function shellQuote(value: string): string {
615
- return `'${value.replaceAll("'", `'\"'\"'`)}'`;
616
- }
617
-
618
- function withRemoteCommand(spec: CommandSpec, remoteCommand: string): CommandSpec {
619
- return {
620
- ...spec,
621
- command: remoteCommand,
622
- argv: [...(spec.argv?.slice(0, -1) ?? []), remoteCommand],
623
- };
624
- }
625
-
626
- function requireValue(value: string | undefined, message: string): string {
627
- if (!value || value.trim().length === 0) throw new Error(message);
628
- return value;
629
- }
630
-
631
- function requireToken(value: string | undefined, message: string): string {
632
- const resolved = requireValue(value, message).trim();
633
- if (/\s/.test(resolved)) throw new Error(message);
634
- return resolved;
635
- }
636
-
637
- function optionalValue(value: string | undefined, message: string): string | undefined {
638
- if (value === undefined) return undefined;
639
- return requireValue(value, message);
640
- }
641
-
642
- function optionalToken(value: string | undefined, message: string): string | undefined {
643
- if (value === undefined) return undefined;
644
- return requireToken(value, message);
645
- }