Skip to content

Commit 26624fd

Browse files
committed
feat(remote): auto-manage mutagen for remote sync
1 parent 6254f67 commit 26624fd

9 files changed

Lines changed: 577 additions & 21 deletions

File tree

docs/guides/remote-node-laptop-e2e.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ If you only need initial setup, use [Remote node quickstart](remote-node-quickst
2424
1. Both laptops have `hack` installed.
2525
2. Remote laptop is reachable via SSH from controller (`user@host`).
2626
3. Remote laptop has gateway enabled (`hack gateway setup`).
27-
4. `mutagen` is installed on controller (`mutagen version`).
27+
4. Controller can write to `~/.hack/bin` (Mutagen is auto-managed by `hack`).
2828
5. Optional but recommended: both laptops are on Tailscale.
2929

3030
## Build artifacts on controller
@@ -203,6 +203,9 @@ cat ~/.hack/registry/runs/<run-id>/summary.md
203203
8. `probe_failed (404): not_found` before workspace ensure
204204
- Cause: remote node is running an older daemon build that does not expose `/v1/node/git/probe`.
205205
- Fix: update `hack` on the remote node to the same build as controller, restart daemon, then retry.
206+
9. `Mutagen binary was not found on this machine`
207+
- Cause: controller is missing mutagen and auto-install failed (permissions/network/tooling).
208+
- Fix: run `hack doctor --fix` (or `hack global install`) and retry.
206209

207210
## Evidence capture
208211

src/commands/doctor.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,10 @@ import {
3939
writeTextFileIfChanged,
4040
} from "../lib/fs.ts";
4141
import { resolveHackInvocation } from "../lib/hack-cli.ts";
42+
import {
43+
ensureBundledMutagenInstalled,
44+
getMutagenPath,
45+
} from "../lib/mutagen.ts";
4246
import { isMac } from "../lib/os.ts";
4347
import {
4448
findProjectContext,
@@ -139,6 +143,7 @@ const handleDoctor: CommandHandlerFor<typeof doctorSpec> = async ({
139143
checkTool({ name: "zellij (sessions)", cmd: "zellij", optional: true })
140144
)
141145
);
146+
results.push(await runCheck(s, "mutagen", () => checkMutagenBinary()));
142147
results.push(
143148
await runCheck(s, "go", () =>
144149
checkTool({ name: "go (optional)", cmd: "go", optional: true })
@@ -488,6 +493,33 @@ function checkOptionalFzf(): CheckResult {
488493
return { name: "fzf (optional)", status: "ok", message: fzf };
489494
}
490495

496+
async function checkMutagenBinary(): Promise<CheckResult> {
497+
const mutagen = getMutagenPath();
498+
if (!mutagen) {
499+
return {
500+
name: "mutagen",
501+
status: "warn",
502+
message: "Not found (run: hack doctor --fix)",
503+
};
504+
}
505+
506+
const version = await exec([mutagen, "version"], { stdin: "ignore" });
507+
if (version.exitCode !== 0) {
508+
return {
509+
name: "mutagen",
510+
status: "warn",
511+
message: `${mutagen} (version probe failed)`,
512+
};
513+
}
514+
515+
const firstLine = version.stdout.trim().split("\n")[0]?.trim();
516+
return {
517+
name: "mutagen",
518+
status: "ok",
519+
message: firstLine && firstLine.length > 0 ? firstLine : mutagen,
520+
};
521+
}
522+
491523
async function checkDockerRunning(): Promise<CheckResult> {
492524
const res = await exec(["docker", "info"], { stdin: "ignore" });
493525
return {
@@ -1408,6 +1440,8 @@ async function runDoctorFix(): Promise<void> {
14081440
return;
14091441
}
14101442

1443+
await maybeInstallMutagenForDoctorFix();
1444+
14111445
const dockerOk = await dockerInfoOk();
14121446
if (!dockerOk) {
14131447
note("Docker is not reachable; cannot apply fixes.", "doctor");
@@ -1440,6 +1474,34 @@ async function runDoctorFix(): Promise<void> {
14401474
await maybeMigrateDnsmasq();
14411475
}
14421476

1477+
async function maybeInstallMutagenForDoctorFix(): Promise<void> {
1478+
if (getMutagenPath()) {
1479+
return;
1480+
}
1481+
1482+
const okMutagen = await confirmOrThrow({
1483+
message: "Install managed mutagen at ~/.hack/bin/mutagen?",
1484+
initialValue: true,
1485+
});
1486+
if (!okMutagen) {
1487+
return;
1488+
}
1489+
1490+
const installed = await ensureBundledMutagenInstalled();
1491+
if (installed.ok) {
1492+
note(
1493+
installed.installed
1494+
? `Installed mutagen at ${installed.mutagenPath}`
1495+
: `Mutagen already installed at ${installed.mutagenPath}`,
1496+
"doctor"
1497+
);
1498+
return;
1499+
}
1500+
1501+
const detail = installed.message ? `: ${installed.message}` : "";
1502+
note(`Mutagen install failed (${installed.reason}${detail})`, "doctor");
1503+
}
1504+
14431505
async function confirmOrThrow(opts: {
14441506
readonly message: string;
14451507
readonly initialValue: boolean;

src/commands/global.ts

Lines changed: 29 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,10 @@ import {
5858
import { getString, isRecord } from "../lib/guards.ts";
5959
import { resolveHackInvocation } from "../lib/hack-cli.ts";
6060
import { parseJsonLines } from "../lib/json-lines.ts";
61+
import {
62+
ensureBundledMutagenInstalled,
63+
getMutagenPath,
64+
} from "../lib/mutagen.ts";
6165
import { isMac } from "../lib/os.ts";
6266
import { exec, execOrThrow, findExecutableInPath, run } from "../lib/shell.ts";
6367
import { resolveSessionsMuxMode } from "../mux/mux-config.ts";
@@ -487,6 +491,31 @@ async function globalInstall(): Promise<number> {
487491
}
488492
}
489493

494+
s.start("Ensuring mutagen…");
495+
const mutagen = await ensureBundledMutagenInstalled();
496+
if (mutagen.ok) {
497+
s.stop(
498+
mutagen.installed
499+
? "Installed managed mutagen"
500+
: "mutagen already installed"
501+
);
502+
} else {
503+
const systemMutagen = getMutagenPath();
504+
s.stop(
505+
systemMutagen ? "mutagen available on PATH" : "mutagen not installed"
506+
);
507+
if (!systemMutagen) {
508+
const detail = mutagen.message ? `: ${mutagen.message}` : "";
509+
logger.warn({
510+
message: `mutagen install skipped (${mutagen.reason}${detail})`,
511+
});
512+
logger.warn({
513+
message:
514+
"Remote sync may fail without mutagen. Repair with: hack doctor --fix",
515+
});
516+
}
517+
}
518+
490519
if (isMac()) {
491520
await ensureMacChafa();
492521
await ensureMacMkcert();

src/commands/update.ts

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,10 @@ import type { CommandHandlerFor } from "../cli/command.ts";
66
import { defineCommand, defineOption, withHandler } from "../cli/command.ts";
77
import { optJson } from "../cli/options.ts";
88
import { ensureDir } from "../lib/fs.ts";
9+
import {
10+
ensureBundledMutagenInstalled,
11+
getMutagenPath,
12+
} from "../lib/mutagen.ts";
913
import {
1014
compareVersions,
1115
downloadAndExtractRelease,
@@ -222,6 +226,8 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({
222226
);
223227
}
224228

229+
const mutagenProvision = await ensureMutagenAfterUpdate();
230+
225231
return writeResult({
226232
json: args.options.json === true,
227233
result: {
@@ -233,6 +239,7 @@ const handleUpdate: CommandHandlerFor<Spec> = async ({
233239
target,
234240
binaryPath: bin.path,
235241
assetsDir,
242+
mutagen: mutagenProvision,
236243
},
237244
human: `Updated to v${latestVersion}.`,
238245
});
@@ -277,6 +284,43 @@ function resolveAssetsDir(): string {
277284
return resolve(".hack", "assets");
278285
}
279286

287+
type MutagenProvisionResult = {
288+
readonly available: boolean;
289+
readonly installed: boolean;
290+
readonly path: string | null;
291+
readonly warning?: string;
292+
};
293+
294+
async function ensureMutagenAfterUpdate(): Promise<MutagenProvisionResult> {
295+
const existing = getMutagenPath();
296+
if (existing) {
297+
return {
298+
available: true,
299+
installed: false,
300+
path: existing,
301+
};
302+
}
303+
304+
const installed = await ensureBundledMutagenInstalled();
305+
if (installed.ok) {
306+
return {
307+
available: true,
308+
installed: installed.installed,
309+
path: installed.mutagenPath,
310+
};
311+
}
312+
313+
const fallback = getMutagenPath();
314+
return {
315+
available: Boolean(fallback),
316+
installed: false,
317+
path: fallback,
318+
warning: installed.message
319+
? `${installed.reason}: ${installed.message}`
320+
: installed.reason,
321+
};
322+
}
323+
280324
async function resolveSelfUpdateBinaryPath(): Promise<
281325
| { readonly ok: true; readonly path: string }
282326
| { readonly ok: false; readonly error: string }
@@ -382,6 +426,7 @@ type UpdateOutput =
382426
};
383427
readonly binaryPath: string;
384428
readonly assetsDir: string;
429+
readonly mutagen?: MutagenProvisionResult;
385430
}
386431
| {
387432
readonly ok: false;

0 commit comments

Comments
 (0)