diff --git a/clients/morphcloud/CHANGELOG.md b/clients/morphcloud/CHANGELOG.md index 328283cd..74d82491 100644 --- a/clients/morphcloud/CHANGELOG.md +++ b/clients/morphcloud/CHANGELOG.md @@ -5,6 +5,12 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.1.1] - 2025-01-23 + +Release because initial release was broken. + +Fixed Nuanced LSP dependency to v0.5.x. + ## [0.1.0] - 2025-01-21 Initial release. diff --git a/clients/morphcloud/DEVELOPMENT.md b/clients/morphcloud/DEVELOPMENT.md index 0af4397a..ca20d126 100644 --- a/clients/morphcloud/DEVELOPMENT.md +++ b/clients/morphcloud/DEVELOPMENT.md @@ -39,18 +39,13 @@ npm run dev This project uses Mutagen for file syncing. It uses an isolated configuration so it does not interfere with other uses of Mutagen on the system. -To run `mutagen` with the right configuration to see the file syncs created by this project, run: +The CLI has a hidden `mutagen` command that allows you to run Mutagen with the right configuration. For example: ```bash -scripts/mutagen.sh +npm run dev mutagen sync list ``` -We use a [modified](https://github.com/nuanced-dev/mutagen/tree/hendrikvanantwerpen/custom-ssh-config) Mutagen version. The binaries are shipped as part of the NPM package. - -If these need changing: - -- Run `go run scripts/build.go --mode=release-slim` in a Mutagen checkout -- Run `scripts/updarte-mutagen-assets.sh /path/to/mutagen/checkout` +We use a [modified](https://github.com/nuanced-dev/mutagen/) Mutagen version which is published as [@nuanced-dev/mutagen](https://www.npmjs.com/package/@nuanced-dev/mutagen). ## Release diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md index 25703fea..219542d7 100644 --- a/clients/morphcloud/README.md +++ b/clients/morphcloud/README.md @@ -12,6 +12,7 @@ _Note that this client is experimental and not considered stable!_ **Known issues:** +- Mutagen easily gets into error states, where resuming syncs hangs or setting up syncs fails. Often the only way to recover is removing the mutagen data directory (`~/.nuanced/mutagen`). - File syncs are sometimes not properly resumed, in which case the workspace instance needs to be recreated. - Morph Cloud retains snapshots for instances that have been deleted. These snapshots do not inherit the metadata of the instance and we currently cannot clean them up. @@ -69,6 +70,25 @@ nuanced-lsp-morphcloud workspace delete /path/to/workspace This can be useful if you want to recreate the workspace instance. +**List workspaces:** + +```bash +nuanced-lsp-morphcloud workspace list +``` + +## Troubleshooting + +**Mutagen sync setup fails:** + +Starting a workspace server fails with an error like the following: + +``` +Connecting to agent (POSIX)... +Error: unable to connect to beta: unable to connect to endpoint: unable to dial agent endpoint: unable to handshake with agent process: unable to receive server magic number: EOF (error output: No user exists for uid 504) +``` + +Mutagen got in a bad state. Remove `~/.nuanced/mutagen` to have syncs recreated. + ## License This work is licensed under the terms of the MIT license. For a copy, see [LICENSE](LICENSE) or . diff --git a/clients/morphcloud/package.json b/clients/morphcloud/package.json index 6129a660..18b3b04d 100644 --- a/clients/morphcloud/package.json +++ b/clients/morphcloud/package.json @@ -1,6 +1,6 @@ { "name": "@nuanced-dev/lsp-morphcloud", - "version": "0.1.0", + "version": "0.1.1", "description": "CLI to run Nuanced LSP on Morphcloud", "license": "MIT", "author": "Nuanced", @@ -24,6 +24,7 @@ "lint:fix": "eslint --fix" }, "dependencies": { + "@nuanced-dev/mutagen": "^0.19.0-dev.1", "child-process-promise": "^2.2.1", "commander": "^14.0.2", "node-machine-id": "^1.1.12", diff --git a/clients/morphcloud/scripts/mutagen.sh b/clients/morphcloud/scripts/mutagen.sh deleted file mode 100755 index 7d79abe0..00000000 --- a/clients/morphcloud/scripts/mutagen.sh +++ /dev/null @@ -1,32 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" -PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)" - -OS=$(uname -s | tr '[:upper:]' '[:lower:]') -ARCH=$(uname -m) - -case "$OS" in - darwin) OS_NAME="darwin" ;; - linux) OS_NAME="linux" ;; - *) echo "Unsupported OS: $OS" >&2; exit 1 ;; -esac - -case "$ARCH" in - x86_64|amd64) ARCH_NAME="x64" ;; - arm64|aarch64) ARCH_NAME="arm64" ;; - *) echo "Unsupported architecture: $ARCH" >&2; exit 1 ;; -esac - -MUTAGEN_BIN="$PROJECT_ROOT/bin/mutagen/${OS_NAME}_${ARCH_NAME}/mutagen" - -if [[ ! -x "$MUTAGEN_BIN" ]]; then - echo "Mutagen binary not found or not executable: $MUTAGEN_BIN" >&2 - exit 1 -fi - -export MUTAGEN_DATA_DIRECTORY="$HOME/.nuanced/mutagen" - -exec "$MUTAGEN_BIN" "$@" diff --git a/clients/morphcloud/scripts/update-mutagen-assets.sh b/clients/morphcloud/scripts/update-mutagen-assets.sh deleted file mode 100755 index 25ebacd5..00000000 --- a/clients/morphcloud/scripts/update-mutagen-assets.sh +++ /dev/null @@ -1,47 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -if [ $# -ne 1 ]; then - echo "Usage: $0 " >&2 - exit 1 -fi - -MUTAGEN_DIR="$1" -RELEASE_DIR="$MUTAGEN_DIR/build/release" -SCRIPT_DIR="$(dirname "$0")" -BIN_DIR="$SCRIPT_DIR/../bin/mutagen" - -if [ ! -d "$RELEASE_DIR" ]; then - echo "Error: Release directory not found: $RELEASE_DIR" >&2 - exit 1 -fi - -rm -rf "$BIN_DIR" -mkdir -p "$BIN_DIR" - -unpack () { - source_platform="$1" - target_platform="$2" - if ! ls "$RELEASE_DIR/mutagen_${source_platform}_v"*".tar.gz"; then - echo "Error: Missing release for platform $source_platform" - exit 1 - fi - target_dir="$BIN_DIR/$target_platform" - if [ -e "$target_dir" ]; then - echo "Error: Directory already exists for $target_platform" - exit 1 - fi - mkdir -p "$target_dir" - tar -xzf "$RELEASE_DIR/mutagen_${source_platform}_v"*".tar.gz" -C "$target_dir" -} - -unpack darwin_amd64 darwin_x64 -unpack darwin_arm64 darwin_arm64 - -unpack linux_amd64 linux_x64 -unpack linux_arm64 linux_arm64 - -unpack windows_amd64 windows_x64 - -echo "Successfully updated mutagen assets in $BIN_DIR" diff --git a/clients/morphcloud/src/cli.ts b/clients/morphcloud/src/cli.ts index 8a140d94..557bef3e 100644 --- a/clients/morphcloud/src/cli.ts +++ b/clients/morphcloud/src/cli.ts @@ -45,9 +45,16 @@ workspace .description("Run LSP server for a workspace") .argument("", "Workspace directory") .action(async (directory: string) => { - const { workspaceServer: workspaceLsp } = - await import("./commands/workspace-server.js"); - workspaceLsp(directory).catch(console.error); + const { workspaceServer } = await import("./commands/workspace-server.js"); + workspaceServer(directory).catch(console.error); + }); + +workspace + .command("list") + .description("Show workspace instances") + .action(async () => { + const { workspaceList } = await import("./commands/workspace-list.js"); + workspaceList().catch(console.error); }); workspace @@ -69,4 +76,14 @@ workspace workspaceStop(directory).catch(console.error); }); +program + .command("mutagen", { hidden: true }) + .description("Run mutagen command") + .allowUnknownOption() + .argument("[args...]", "Mutagen arguments") + .action(async (args: string[]) => { + const { mutagenCommand } = await import("./commands/mutagen.js"); + mutagenCommand(args).catch(console.error); + }); + program.parse(); diff --git a/clients/morphcloud/src/commands/mutagen.ts b/clients/morphcloud/src/commands/mutagen.ts new file mode 100644 index 00000000..30e5fbc0 --- /dev/null +++ b/clients/morphcloud/src/commands/mutagen.ts @@ -0,0 +1,13 @@ +import { MutagenError } from "@nuanced-dev/mutagen"; +import { spawnMutagen } from "../util/mutagen.js"; + +export async function mutagenCommand(args: string[]) { + try { + const o = await spawnMutagen(args); + console.log(o.stdout); + } catch (e) { + const me = e as MutagenError; + console.error(me.stderr); + process.exitCode = me.exitCode; + } +} diff --git a/clients/morphcloud/src/commands/service-create.ts b/clients/morphcloud/src/commands/service-create.ts index c99d2aa9..17a47a9b 100755 --- a/clients/morphcloud/src/commands/service-create.ts +++ b/clients/morphcloud/src/commands/service-create.ts @@ -2,6 +2,7 @@ import { Instance, MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_LSP_ROLE, NUANCED_LSP_ROLE_SERVICE, + NUANCED_LSP_VERSION, } from "../util/constants.js"; import { execOrThrow, @@ -76,10 +77,14 @@ export async function serviceCreate() { console.log(` Installed ${dockerVersion.trim()}`); console.log("- Installing Nuanced LSP..."); - await execOrThrow(serviceInstance, "npm install -g @nuanced-dev/lsp", { - verbose: VERBOSE, - prefix: " | ", - }); + await execOrThrow( + serviceInstance, + `npm install -g @nuanced-dev/lsp${NUANCED_LSP_VERSION}`, + { + verbose: VERBOSE, + prefix: " | ", + }, + ); const nuancedVersion = await execOrThrow( serviceInstance, "nuanced-lsp --version", diff --git a/clients/morphcloud/src/commands/service-status.ts b/clients/morphcloud/src/commands/service-status.ts index afdad05d..37203710 100755 --- a/clients/morphcloud/src/commands/service-status.ts +++ b/clients/morphcloud/src/commands/service-status.ts @@ -1,40 +1,19 @@ import { MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_LSP_ROLE, - LABEL_NUANCED_LSP_WORKSPACE_PATH, - LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME, NUANCED_LSP_ROLE_SERVICE, - NUANCED_LSP_ROLE_WORKSPACE, } from "../util/constants.js"; -import { - findSnapshot, - formatInstanceStatus, - listInstances, -} from "../util/morphcloud.js"; +import { findSnapshot } from "../util/morphcloud.js"; export async function serviceStatus() { const client = new MorphCloudClient(); try { - console.log("Service snapshots:"); const serviceSnapshot = await findSnapshot(client, { metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }, }); console.log( - `- service${" ".repeat(13)} : ${serviceSnapshot ? serviceSnapshot.id : ""}`, + `Service snapshot: ${serviceSnapshot ? serviceSnapshot.id : ""}`, ); - console.log(); - console.log("Workspace instances:"); - for (const instance of await listInstances(client, { - metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE }, - })) { - const hostname = - instance.metadata![LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME] ?? ""; - const path = instance.metadata![LABEL_NUANCED_LSP_WORKSPACE_PATH]!; - const label = `${hostname}:${path}`; - console.log( - `- ${label.padEnd(40)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, - ); - } } catch (error) { throw new Error(`Service status failed: ${error}`); } diff --git a/clients/morphcloud/src/commands/workspace-list.ts b/clients/morphcloud/src/commands/workspace-list.ts new file mode 100755 index 00000000..53a45ac4 --- /dev/null +++ b/clients/morphcloud/src/commands/workspace-list.ts @@ -0,0 +1,28 @@ +import { MorphCloudClient } from "morphcloud"; +import { + LABEL_NUANCED_LSP_ROLE, + LABEL_NUANCED_LSP_WORKSPACE_PATH, + LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME, + NUANCED_LSP_ROLE_WORKSPACE, +} from "../util/constants.js"; +import { formatInstanceStatus, listInstances } from "../util/morphcloud.js"; + +export async function workspaceList() { + const client = new MorphCloudClient(); + try { + console.log("Workspace instances:"); + for (const instance of await listInstances(client, { + metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE }, + })) { + const hostname = + instance.metadata![LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME] ?? ""; + const path = instance.metadata![LABEL_NUANCED_LSP_WORKSPACE_PATH]!; + const label = `${hostname}:${path}`; + console.log( + `- ${label.padEnd(40)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, + ); + } + } catch (error) { + throw new Error(`Service status failed: ${error}`); + } +} diff --git a/clients/morphcloud/src/commands/workspace-server.ts b/clients/morphcloud/src/commands/workspace-server.ts index 8dff3a25..510c33df 100755 --- a/clients/morphcloud/src/commands/workspace-server.ts +++ b/clients/morphcloud/src/commands/workspace-server.ts @@ -24,64 +24,33 @@ import { ProcessRc } from "../util/process-rc.js"; async function ensureWorkspaceInstance( client: MorphCloudClient, metadata: Record, - workspaceRealDir: string, -): Promise<{ instance: Instance; mutagen: MutagenClient }> { +): Promise { let workspaceInstance = await findInstance(client, { metadata, ensureReady: true, }); + if (workspaceInstance) { + return; + } - let mutagen: MutagenClient; - - if (!workspaceInstance) { - console.error("Creating workspace instance..."); - const serviceSnapshot = await findSnapshot(client, { - metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }, - }); - if (!serviceSnapshot) { - throw new Error("Missing service snapshot. Run setup.ts first."); - } - workspaceInstance = await startInstance(client, serviceSnapshot, { - metadata, - }); - - console.error("Starting Nuanced LSP container..."); - await execOrThrow( - workspaceInstance, - "nuanced-lsp up --container-name nuanced-lsp workspace", - ); - - console.error(`Workspace instance: ${workspaceInstance.id}`); - - mutagen = new MutagenClient(workspaceInstance); - - console.error("Creating file sync..."); - try { - await mutagen.createSync(workspaceRealDir); - await mutagen.flushSync(); - } catch (e) { - throw new Error(`Failed to setup file sync: ${e}`); - } - } else { - console.error(`Workspace instance: ${workspaceInstance.id}`); - - mutagen = new MutagenClient(workspaceInstance); - - if (!(await mutagen.findSync({ ensureReady: true }))) { - throw new Error( - "File sync not found for existing workspace. Delete the workspace and start over.", - ); - } - - console.error("Resuming file sync..."); - try { - await mutagen.resumeSync(); - } catch (e) { - throw new Error(`Failed to resume file sync: ${e}`); - } + console.error("Creating workspace instance..."); + const serviceSnapshot = await findSnapshot(client, { + metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }, + }); + if (!serviceSnapshot) { + throw new Error("Missing service snapshot. Run setup.ts first."); } + workspaceInstance = await startInstance(client, serviceSnapshot, { + metadata, + }); - return { instance: workspaceInstance, mutagen }; + console.error("Starting Nuanced LSP container..."); + await execOrThrow( + workspaceInstance, + "nuanced-lsp up --container-name nuanced-lsp workspace", + ); + + console.error(`Created workspace instance: ${workspaceInstance.id}`); } export async function workspaceServer(workspaceDir: string) { @@ -102,27 +71,37 @@ export async function workspaceServer(workspaceDir: string) { let mutagen: MutagenClient | undefined; try { await processRc.acquire(async () => { - const result = await ensureWorkspaceInstance( - client, - metadata, - workspaceRealDir, - ); - workspaceInstance = result.instance; - mutagen = result.mutagen; + await ensureWorkspaceInstance(client, metadata); }); + workspaceInstance = await findInstance(client, { + metadata, + ensureReady: true, + }); if (!workspaceInstance) { - workspaceInstance = await findInstance(client, { - metadata, - ensureReady: true, - }); - if (!workspaceInstance) { - throw new Error("Workspace instance not found after acquire"); + throw new Error("Workspace instance not found after acquire"); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + + mutagen = new MutagenClient(workspaceInstance); + + console.error("Setting up file sync..."); + try { + if (!(await mutagen.findSync())) { + console.error("Creating file sync..."); + await mutagen.createSync(workspaceRealDir); + } else { + console.error("Resuming file sync..."); + await mutagen.resumeSync(); + await mutagen.flushSync(); } - console.error(`Workspace instance: ${workspaceInstance.id}`); + console.error("Waiting for file sync..."); + await mutagen.waitForSyncReady(); + } catch (e) { + throw new Error(`File sync failure: ${e}`); } - console.error("Executing nuanced-lsp server..."); + console.error("Starting nuanced-lsp server..."); const ssh = await workspaceInstance.ssh(); const cmd = "nuanced-lsp"; const args = ["server", "--container-name", "nuanced-lsp"]; diff --git a/clients/morphcloud/src/commands/workspace-status.ts b/clients/morphcloud/src/commands/workspace-status.ts index 0c709a6c..f2086b3e 100755 --- a/clients/morphcloud/src/commands/workspace-status.ts +++ b/clients/morphcloud/src/commands/workspace-status.ts @@ -8,6 +8,7 @@ import { MACHINE_ID, } from "../util/constants.js"; import { findInstance } from "../util/morphcloud.js"; +import { getSshConfig } from "../util/ssh.js"; export async function workspaceStatus(workspaceDir: string) { const client = new MorphCloudClient(); @@ -25,6 +26,10 @@ export async function workspaceStatus(workspaceDir: string) { console.log( `${workspaceRealDir.padEnd(2)} : ${workspaceInstance?.id ?? ""}`, ); + if (workspaceInstance) { + // refreshed the SSH config--can be useful when manually debugging Mutagen issues + await getSshConfig(workspaceInstance); + } } catch (error) { throw new Error(`Workspace status failed: ${error}`); } diff --git a/clients/morphcloud/src/util/constants.ts b/clients/morphcloud/src/util/constants.ts index 9e409b4e..e149f86a 100644 --- a/clients/morphcloud/src/util/constants.ts +++ b/clients/morphcloud/src/util/constants.ts @@ -1,5 +1,7 @@ import nodeMachineId from "node-machine-id"; +export const NUANCED_LSP_VERSION = "@^0.5"; + export const MACHINE_ID = nodeMachineId.machineIdSync(); export const LABEL_NUANCED_LSP_ROLE = "nuanced-lsp.role"; diff --git a/clients/morphcloud/src/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts index 482d306f..d1eec539 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -1,12 +1,13 @@ import { join } from "path"; -import { platform, arch } from "os"; -import { mkdir, access } from "fs/promises"; +import process from "process"; +import { mkdir } from "fs/promises"; import { Instance } from "morphcloud"; -import { spawn } from "child-process-promise"; +import { mutagen, MutagenError, MutagenResult } from "@nuanced-dev/mutagen"; import { ensureConfigDirectory } from "./config.js"; import { getSshConfig, removeSshConfig } from "./ssh.js"; const MISSING_SESSION_ERROR = "unable to locate requested session"; +const SYNC_READY_STATUS = "Status: Watching for changes"; export class MutagenClient { constructor(private readonly instance: Instance) {} @@ -22,7 +23,7 @@ export class MutagenClient { async createSync(localDirectory: string): Promise { try { const sshConfig = await getSshConfig(this.instance); - await this.spawnMutagen( + await spawnMutagen( [ "sync", "create", @@ -36,110 +37,101 @@ export class MutagenClient { { env: { MUTAGEN_SSH_CONFIG_BETA: sshConfig.configPath } }, ); } catch (e) { - throw new Error(`Failed to create sync: ${(e as any).stderr}`); + const me = e as MutagenError; + throw new Error(`Failed to create sync: ${me.stderr}`); } } - async findSync(_opts?: { ensureReady?: boolean }): Promise { + async findSync(): Promise { try { - await this.spawnMutagen(["sync", "list", this.syncName()]); + await spawnMutagen(["sync", "list", this.syncName()]); // TODO implement waiting on ready status return true; } catch (e) { - const stderr = (e as any).stderr.toLowerCase(); + const me = e as MutagenError; + + const stderr = me.stderr.toLowerCase(); if (stderr.includes(MISSING_SESSION_ERROR)) { return false; } else { - throw new Error(`Failed to find sync: ${(e as any).stderr}`); + throw new Error(`Failed to find sync: ${me.stderr}`); } } } async flushSync(): Promise { try { - await this.spawnMutagen(["sync", "flush", this.syncName()]); + await spawnMutagen(["sync", "flush", this.syncName()]); } catch (e) { - throw new Error(`Failed to flush sync: ${(e as any).stderr}`); + const me = e as MutagenError; + throw new Error(`Failed to flush sync: ${me.stderr}`); } } async pauseSync(): Promise { try { - await this.spawnMutagen(["sync", "pause", this.syncName()]); + await spawnMutagen(["sync", "pause", this.syncName()]); } catch (e) { - throw new Error(`Failed to pause sync: ${(e as any).stderr}`); + const me = e as MutagenError; + throw new Error(`Failed to pause sync: ${me.stderr}`); } } async resumeSync(): Promise { try { const sshConfig = await getSshConfig(this.instance); - await this.spawnMutagen(["sync", "resume", this.syncName()], { + await spawnMutagen(["sync", "resume", this.syncName()], { env: { MUTAGEN_SSH_CONFIG_BETA: sshConfig.configPath }, }); } catch (e) { - throw new Error(`Failed to resume sync: ${(e as any).stderr}`); + const me = e as MutagenError; + throw new Error(`Failed to resume sync: ${me.stderr}`); + } + } + + async waitForSyncReady(): Promise { + while (true) { + try { + const res = await spawnMutagen(["sync", "list", this.syncName()]); + if (res.stdout.includes(SYNC_READY_STATUS)) { + return; + } + } catch (e) { + const me = e as MutagenError; + throw new Error(`Error waiting for sync: ${me.stderr}`); + } } } async stopSync(): Promise { try { - await this.spawnMutagen(["sync", "terminate", this.syncName()]); + await spawnMutagen(["sync", "terminate", this.syncName()]); } catch (e) { - const stderr = (e as any).stderr.toLowerCase(); + const me = e as MutagenError; + const stderr = me.stderr.toLowerCase(); if (stderr.includes(MISSING_SESSION_ERROR)) { return; } else { - throw new Error(`Failed to stop sync: ${(e as any).stderr}`); + throw new Error(`Failed to stop sync: ${me.stderr}`); } } finally { await removeSshConfig(this.instance); } } - - private async spawnMutagen( - args: string[], - opts?: { env?: Record }, - ): Promise { - const mutagenDataDir = join(await ensureConfigDirectory(), "mutagen"); - await mkdir(mutagenDataDir, { recursive: true }); - - const spawnCommand = await getMutagenBinaryPath(); - const spawnArgs = [...args]; - const spawnEnv = { - ...opts?.env, - MUTAGEN_DATA_DIRECTORY: mutagenDataDir, - }; - await spawn(spawnCommand, spawnArgs, { - capture: ["stderr"], - env: { - ...process.env, - ...spawnEnv, - }, - }); - } } -async function getMutagenBinaryPath(): Promise { - const platformArch = `${platform()}_${arch()}`; - const binaryName = platform() === "win32" ? "mutagen.exe" : "mutagen"; - const binPath = join( - import.meta.dirname, - "..", - "..", - "bin", - "mutagen", - platformArch, - binaryName, - ); - - try { - await access(binPath); - } catch { - throw new Error( - `Mutagen not supported for platform ${platform()} and architecture ${arch()}. Missing ${binPath}.`, - ); - } - - return binPath; +export async function spawnMutagen( + args: string[], + opts?: { env?: Record }, +): Promise { + const mutagenDataDir = join(await ensureConfigDirectory(), "mutagen"); + await mkdir(mutagenDataDir, { recursive: true }); + const env = { + ...process.env, + ...opts?.env, + MUTAGEN_DATA_DIRECTORY: mutagenDataDir, + }; + return mutagen(args, { + env, + }); }