From 865e6937c2a14d3df22b6e6dd5b83d7f26828428 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Thu, 22 Jan 2026 16:17:58 +0100 Subject: [PATCH 1/4] Start using separate mutagen package --- clients/morphcloud/DEVELOPMENT.md | 11 +-- clients/morphcloud/package.json | 1 + clients/morphcloud/scripts/mutagen.sh | 32 -------- .../scripts/update-mutagen-assets.sh | 47 ------------ clients/morphcloud/src/cli.ts | 10 +++ clients/morphcloud/src/commands/mutagen.ts | 5 ++ clients/morphcloud/src/util/mutagen.ts | 73 ++++++------------- 7 files changed, 40 insertions(+), 139 deletions(-) delete mode 100755 clients/morphcloud/scripts/mutagen.sh delete mode 100755 clients/morphcloud/scripts/update-mutagen-assets.sh create mode 100644 clients/morphcloud/src/commands/mutagen.ts 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/package.json b/clients/morphcloud/package.json index 6129a660..bb3af2af 100644 --- a/clients/morphcloud/package.json +++ b/clients/morphcloud/package.json @@ -24,6 +24,7 @@ "lint:fix": "eslint --fix" }, "dependencies": { + "@nuanced-dev/mutagen": "^0.19", "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..7ddab1b7 100644 --- a/clients/morphcloud/src/cli.ts +++ b/clients/morphcloud/src/cli.ts @@ -69,4 +69,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..7259e981 --- /dev/null +++ b/clients/morphcloud/src/commands/mutagen.ts @@ -0,0 +1,5 @@ +import { spawnMutagen } from "../util/mutagen.js"; + +export async function mutagenCommand(args: string[]) { + await spawnMutagen(args); +} diff --git a/clients/morphcloud/src/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts index 482d306f..a5523184 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -1,8 +1,8 @@ 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 } from "@nuanced-dev/mutagen"; import { ensureConfigDirectory } from "./config.js"; import { getSshConfig, removeSshConfig } from "./ssh.js"; @@ -22,7 +22,7 @@ export class MutagenClient { async createSync(localDirectory: string): Promise { try { const sshConfig = await getSshConfig(this.instance); - await this.spawnMutagen( + await spawnMutagen( [ "sync", "create", @@ -42,7 +42,7 @@ export class MutagenClient { async findSync(_opts?: { ensureReady?: boolean }): 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) { @@ -57,7 +57,7 @@ export class MutagenClient { 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}`); } @@ -65,7 +65,7 @@ export class MutagenClient { 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}`); } @@ -74,7 +74,7 @@ export class MutagenClient { 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) { @@ -84,7 +84,7 @@ export class MutagenClient { 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(); if (stderr.includes(MISSING_SESSION_ERROR)) { @@ -96,50 +96,19 @@ export class MutagenClient { 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 = { +export async function spawnMutagen( + args: string[], + opts?: { env?: Record }, +): Promise { + const mutagenDataDir = join(await ensureConfigDirectory(), "mutagen"); + await mkdir(mutagenDataDir, { recursive: true }); + await mutagen(args, { + env: { + ...process.env, ...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; + }, + }); } From 8623ff6f3e96e6cfcf914a278b64bed0fac06fd1 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 23 Jan 2026 15:50:20 +0100 Subject: [PATCH 2/4] Update mutagen dependency --- clients/morphcloud/package.json | 2 +- clients/morphcloud/src/commands/mutagen.ts | 10 +++++++- clients/morphcloud/src/util/mutagen.ts | 29 ++++++++++++++-------- 3 files changed, 28 insertions(+), 13 deletions(-) diff --git a/clients/morphcloud/package.json b/clients/morphcloud/package.json index bb3af2af..ba17de92 100644 --- a/clients/morphcloud/package.json +++ b/clients/morphcloud/package.json @@ -24,7 +24,7 @@ "lint:fix": "eslint --fix" }, "dependencies": { - "@nuanced-dev/mutagen": "^0.19", + "@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/src/commands/mutagen.ts b/clients/morphcloud/src/commands/mutagen.ts index 7259e981..30e5fbc0 100644 --- a/clients/morphcloud/src/commands/mutagen.ts +++ b/clients/morphcloud/src/commands/mutagen.ts @@ -1,5 +1,13 @@ +import { MutagenError } from "@nuanced-dev/mutagen"; import { spawnMutagen } from "../util/mutagen.js"; export async function mutagenCommand(args: string[]) { - await spawnMutagen(args); + 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/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts index a5523184..1bac9fc8 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -2,7 +2,7 @@ import { join } from "path"; import process from "process"; import { mkdir } from "fs/promises"; import { Instance } from "morphcloud"; -import { mutagen } from "@nuanced-dev/mutagen"; +import { mutagen, MutagenError, MutagenResult } from "@nuanced-dev/mutagen"; import { ensureConfigDirectory } from "./config.js"; import { getSshConfig, removeSshConfig } from "./ssh.js"; @@ -36,7 +36,8 @@ 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}`); } } @@ -46,11 +47,13 @@ export class MutagenClient { // 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}`); } } } @@ -59,7 +62,8 @@ export class MutagenClient { try { 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}`); } } @@ -67,7 +71,8 @@ export class MutagenClient { try { 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}`); } } @@ -78,7 +83,8 @@ export class MutagenClient { 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}`); } } @@ -86,11 +92,12 @@ export class MutagenClient { try { 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); @@ -101,10 +108,10 @@ export class MutagenClient { export async function spawnMutagen( args: string[], opts?: { env?: Record }, -): Promise { +): Promise { const mutagenDataDir = join(await ensureConfigDirectory(), "mutagen"); await mkdir(mutagenDataDir, { recursive: true }); - await mutagen(args, { + return mutagen(args, { env: { ...process.env, ...opts?.env, From c5d4cd61d157ea099a40e75edd811210bc39c0f6 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 23 Jan 2026 18:39:41 +0100 Subject: [PATCH 3/4] Fix more bugs using mutagen and try to make it a little more robust --- clients/morphcloud/CHANGELOG.md | 2 +- clients/morphcloud/README.md | 20 ++++ clients/morphcloud/src/cli.ts | 13 +- .../morphcloud/src/commands/service-create.ts | 13 +- .../morphcloud/src/commands/service-status.ts | 25 +--- .../morphcloud/src/commands/workspace-list.ts | 28 +++++ .../src/commands/workspace-server.ts | 111 +++++++----------- .../src/commands/workspace-status.ts | 5 + clients/morphcloud/src/util/constants.ts | 2 + clients/morphcloud/src/util/mutagen.ts | 28 ++++- 10 files changed, 144 insertions(+), 103 deletions(-) create mode 100755 clients/morphcloud/src/commands/workspace-list.ts diff --git a/clients/morphcloud/CHANGELOG.md b/clients/morphcloud/CHANGELOG.md index 328283cd..a862f9fe 100644 --- a/clients/morphcloud/CHANGELOG.md +++ b/clients/morphcloud/CHANGELOG.md @@ -7,4 +7,4 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [0.1.0] - 2025-01-21 -Initial release. +Initial release using Nuanced LSP v0.5.x. 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/src/cli.ts b/clients/morphcloud/src/cli.ts index 7ddab1b7..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 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 1bac9fc8..d1eec539 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -7,6 +7,7 @@ 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) {} @@ -41,7 +42,7 @@ export class MutagenClient { } } - async findSync(_opts?: { ensureReady?: boolean }): Promise { + async findSync(): Promise { try { await spawnMutagen(["sync", "list", this.syncName()]); // TODO implement waiting on ready status @@ -88,6 +89,20 @@ export class MutagenClient { } } + 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 spawnMutagen(["sync", "terminate", this.syncName()]); @@ -111,11 +126,12 @@ export async function spawnMutagen( ): 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: { - ...process.env, - ...opts?.env, - MUTAGEN_DATA_DIRECTORY: mutagenDataDir, - }, + env, }); } From ca4d36086d393b3e1e816f256031436581ab9c80 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Fri, 23 Jan 2026 18:46:51 +0100 Subject: [PATCH 4/4] Bump version --- clients/morphcloud/CHANGELOG.md | 8 +++++++- clients/morphcloud/package.json | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/clients/morphcloud/CHANGELOG.md b/clients/morphcloud/CHANGELOG.md index a862f9fe..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 using Nuanced LSP v0.5.x. +Initial release. diff --git a/clients/morphcloud/package.json b/clients/morphcloud/package.json index ba17de92..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",