From 8f5a8f3bb4df98a5833551525f93751c9255722f Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 16 Dec 2025 16:07:06 +0100 Subject: [PATCH 01/14] Import managed lsp pacakge --- clients/managed/.gitattributes | 1 + clients/managed/.gitignore | 12 + clients/managed/README.md | 269 ++++++++++++++++++ .../managed/bin/mutagen/darwin_arm64/mutagen | 3 + .../darwin_arm64/mutagen-agents.tar.gz | 3 + .../managed/bin/mutagen/darwin_x64/mutagen | 3 + .../mutagen/darwin_x64/mutagen-agents.tar.gz | 3 + .../managed/bin/mutagen/linux_arm64/mutagen | 3 + .../mutagen/linux_arm64/mutagen-agents.tar.gz | 3 + clients/managed/bin/mutagen/linux_x64/mutagen | 3 + .../mutagen/linux_x64/mutagen-agents.tar.gz | 3 + .../mutagen/windows_x64/mutagen-agents.tar.gz | 3 + .../bin/mutagen/windows_x64/mutagen.exe | 3 + clients/managed/eslint.config.mjs | 116 ++++++++ clients/managed/package.json | 44 +++ clients/managed/script/mutagen | 32 +++ .../managed/script/update-mutagen-assets.sh | 47 +++ .../script/update-nuanced-lsp-archive.sh | 12 + clients/managed/src/service-setup.ts | 250 ++++++++++++++++ clients/managed/src/service-status.ts | 31 ++ clients/managed/src/util/config.ts | 30 ++ clients/managed/src/util/constants.ts | 20 ++ clients/managed/src/util/digest.ts | 14 + clients/managed/src/util/morphcloud.ts | 118 ++++++++ clients/managed/src/util/mutagen.ts | 123 ++++++++ clients/managed/src/util/process-rc.ts | 92 ++++++ clients/managed/src/util/sleep.ts | 3 + clients/managed/src/util/ssh.ts | 83 ++++++ clients/managed/src/workspace-lsp.ts | 126 ++++++++ clients/managed/src/workspace-status.ts | 33 +++ clients/managed/src/workspace-stop.ts | 44 +++ clients/managed/tsconfig.json | 19 ++ 32 files changed, 1549 insertions(+) create mode 100644 clients/managed/.gitattributes create mode 100644 clients/managed/.gitignore create mode 100644 clients/managed/README.md create mode 100755 clients/managed/bin/mutagen/darwin_arm64/mutagen create mode 100644 clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz create mode 100755 clients/managed/bin/mutagen/darwin_x64/mutagen create mode 100644 clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz create mode 100755 clients/managed/bin/mutagen/linux_arm64/mutagen create mode 100644 clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz create mode 100755 clients/managed/bin/mutagen/linux_x64/mutagen create mode 100644 clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz create mode 100644 clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz create mode 100755 clients/managed/bin/mutagen/windows_x64/mutagen.exe create mode 100644 clients/managed/eslint.config.mjs create mode 100644 clients/managed/package.json create mode 100755 clients/managed/script/mutagen create mode 100755 clients/managed/script/update-mutagen-assets.sh create mode 100755 clients/managed/script/update-nuanced-lsp-archive.sh create mode 100755 clients/managed/src/service-setup.ts create mode 100755 clients/managed/src/service-status.ts create mode 100644 clients/managed/src/util/config.ts create mode 100644 clients/managed/src/util/constants.ts create mode 100644 clients/managed/src/util/digest.ts create mode 100644 clients/managed/src/util/morphcloud.ts create mode 100644 clients/managed/src/util/mutagen.ts create mode 100644 clients/managed/src/util/process-rc.ts create mode 100644 clients/managed/src/util/sleep.ts create mode 100644 clients/managed/src/util/ssh.ts create mode 100755 clients/managed/src/workspace-lsp.ts create mode 100755 clients/managed/src/workspace-status.ts create mode 100755 clients/managed/src/workspace-stop.ts create mode 100644 clients/managed/tsconfig.json diff --git a/clients/managed/.gitattributes b/clients/managed/.gitattributes new file mode 100644 index 00000000..a1ebe2b9 --- /dev/null +++ b/clients/managed/.gitattributes @@ -0,0 +1 @@ +bin/**/* filter=lfs diff=lfs merge=lfs -text diff --git a/clients/managed/.gitignore b/clients/managed/.gitignore new file mode 100644 index 00000000..29369d35 --- /dev/null +++ b/clients/managed/.gitignore @@ -0,0 +1,12 @@ +# NPM +dist/ +node_modules/ + +# Archives +*.tar.gz +*.tgz +*.zip + +# Keep everything in bin (MUST come after exclusion rules!) +!bin/** + diff --git a/clients/managed/README.md b/clients/managed/README.md new file mode 100644 index 00000000..385fe3c5 --- /dev/null +++ b/clients/managed/README.md @@ -0,0 +1,269 @@ +# Morph Cloud Examples + +A collection of TypeScript examples demonstrating [Morph Cloud](https://cloud.morph.so)'s snapshot and instance management capabilities. + +## Overview + +This project contains three example scripts that showcase Morph Cloud's ability to capture and restore complete computational states: + +1. **Counter Example** (`index.ts`) - Demonstrates how background processes persist across snapshots +2. **Clone & Build** (`clone-and-build.ts`) - Shows how build artifacts are preserved in snapshots +3. **Cleanup** (`cleanup.ts`) - Utility to delete all instances and snapshots + +## Prerequisites + +Before running these examples, you'll need: + +1. **Morph Cloud API Key** + - Sign up at [cloud.morph.so](https://cloud.morph.so) + - Generate an API key from your dashboard + +2. **GitHub Personal Access Token** (for clone-and-build example only) + - Create a token at [github.com/settings/tokens](https://github.com/settings/tokens) + - Requires `repo` scope for private repositories + +## Installation + +```bash +# Clone this repository +git clone https://github.com/nuanced-dev/morph-example.git +cd morph-example + +# Install dependencies +npm install + +# Build the TypeScript project +npm run build +``` + +## Configuration + +Set your environment variables: + +```bash +# Required for all examples +export MORPH_API_KEY='your_morph_api_key_here' + +# Required only for clone-and-build example +export GITHUB_TOKEN='your_github_token_here' +``` + +## Examples + +### 1. Counter Example + +**What it does:** +- Creates a snapshot and starts an instance +- Starts a background counter that increments every 5 seconds +- Creates a snapshot of the running instance (with the counter running) +- Starts a new instance from that snapshot +- Verifies the counter continues from where it left off + +**What it demonstrates:** +- Background processes survive snapshots +- Process state is perfectly preserved +- New instances resume execution seamlessly + +**How to run:** +```bash +npm run counter +``` + +**Expected output:** +``` +Created snapshot: snapshot_xxxxx +Started instance: morphvm_xxxxx +Initial counter: 1 +Counter after waiting: 3 +Created snapshot: snapshot_yyyyy +Started new instance: morphvm_yyyyy +Counter in new instance: 5 +โœจ Success! The background process continued exactly where it left off. +``` + +--- + +### 2. Clone & Build Example + +**What it does:** +1. Creates a base snapshot with a minimal VM image +2. Installs development tools (Git, GitHub CLI, Bun, Python) +3. Clones a private GitHub repository +4. Runs the full build process (install dependencies + compile) +5. Creates a snapshot of the built project +6. Starts a new instance from the snapshot +7. Reruns the build to verify everything is cached + +**What it demonstrates:** +- Complete development environments can be snapshotted +- Build artifacts and dependencies are preserved +- Subsequent builds are nearly instant (no reinstall needed) +- Starting from a snapshot is much faster than rebuilding + +**Configuration:** + +Edit `src/clone-and-build.ts` to customize: + +```typescript +const CONFIG = { + githubToken: process.env.GITHUB_TOKEN, + repo: 'your-org/your-repo', // Change this to your repo + clonePath: '/root/project', + buildCommands: [ + 'bun install', // Change to npm/yarn if needed + 'bun run build' + ], + verifyCommand: 'bun run build --ignore-scripts', +}; +``` + +**How to run:** +```bash +export GITHUB_TOKEN='your_token' +export MORPH_API_KEY='your_key' +npm run clone-and-build +``` + +**Expected output:** +``` +๐Ÿ“ฆ Step 1: Creating base snapshot... +โœ… Created base snapshot: snapshot_xxxxx + +๐Ÿ”ง Step 2: Starting instance from base snapshot... +โœ… Started instance: morphvm_xxxxx + +๐Ÿ”จ Step 3: Installing git, GitHub CLI, and build tools... +โœ… Installed Python 3.x +โœ… Installed gh version 2.83.0 +โœ… Installed Bun 1.3.1 + +๐Ÿ“ฅ Step 4: Cloning private repository... +โœ… Successfully cloned repository + +๐Ÿ”จ Step 5: Building the repository... +โœ… First build completed in 69.08s + +๐Ÿ“ธ Step 6: Creating snapshot of built repository... +โœ… Created built snapshot: snapshot_yyyyy + +๐Ÿ”„ Step 7: Starting new instance from built snapshot... +โœ… Started new instance: morphvm_zzzzz + +๐Ÿ” Step 8: Verifying build state is preserved... +โœ… Verification build completed in 1.23s + +๐Ÿ“Š Results Summary: +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• + First build time: 69.08s + Verification build time: 1.23s + Time saved: 67.85s + Speedup: 56.1x faster +โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• +``` + +--- + +### 3. Cleanup Utility + +**What it does:** +- Lists all running instances and stops them +- Lists all snapshots and deletes them +- Provides progress feedback with error handling + +**When to use:** +- After testing/experimenting with Morph Cloud +- To clean up resources and avoid charges +- When you want to start fresh + +**How to run:** +```bash +npm run cleanup +``` + +**Expected output:** +``` +๐Ÿงน Starting cleanup of all instances and snapshots... + +๐Ÿ“‹ Fetching all instances... +Found 3 instance(s): + + ๐Ÿ—‘๏ธ Deleting instance morphvm_xxxxx (status: ready)... + โœ… Deleted instance morphvm_xxxxx + ๐Ÿ—‘๏ธ Deleting instance morphvm_yyyyy (status: ready)... + โœ… Deleted instance morphvm_yyyyy + ๐Ÿ—‘๏ธ Deleting instance morphvm_zzzzz (status: paused)... + โœ… Deleted instance morphvm_zzzzz + +๐Ÿ“‹ Fetching all snapshots... +Found 5 snapshot(s): + + ๐Ÿ—‘๏ธ Deleting snapshot snapshot_xxxxx (status: ready)... + โœ… Deleted snapshot snapshot_xxxxx + ... + +โœจ Cleanup complete! +``` + +## Available Scripts + +```bash +# Build the TypeScript project +npm run build + +# Run the counter example +npm run counter + +# Run the clone-and-build example +npm run clone-and-build + +# Clean up all instances and snapshots +npm run cleanup +``` + +## Project Structure + +``` +morph-example/ +โ”œโ”€โ”€ src/ +โ”‚ โ”œโ”€โ”€ counter.ts # Counter example +โ”‚ โ”œโ”€โ”€ clone-and-build.ts # Clone & build example +โ”‚ โ””โ”€โ”€ cleanup.ts # Cleanup utility +โ”œโ”€โ”€ dist/ # Compiled JavaScript (generated) +โ”œโ”€โ”€ package.json +โ”œโ”€โ”€ tsconfig.json +โ””โ”€โ”€ README.md +``` + +## Troubleshooting + +### "API key not found" error +Make sure you've set the `MORPH_API_KEY` environment variable: +```bash +export MORPH_API_KEY='your_key_here' +``` + +### "Failed to clone repository" error +1. Verify your `GITHUB_TOKEN` is set +2. Ensure the token has `repo` scope +3. Check that the repo URL in `CONFIG` is correct + +### Build errors with native modules +The clone-and-build script installs Python and build tools automatically. If you still encounter issues: +- Check that `python3` and `build-essential` are installed +- Verify the `PYTHON` environment variable is set to `/usr/bin/python3` + +### Slow verification build +If the verification build is still slow (>5s), prebuild scripts may be regenerating files. Make sure `verifyCommand` uses `--ignore-scripts`: +```typescript +verifyCommand: 'bun run build --ignore-scripts' +``` + +## Learn More + +- [Morph Cloud Documentation](https://cloud.morph.so/docs) +- [Morph Cloud API Reference](https://cloud.morph.so/docs/api-reference) +- [morphcloud npm package](https://www.npmjs.com/package/morphcloud) + +## License + +ISC diff --git a/clients/managed/bin/mutagen/darwin_arm64/mutagen b/clients/managed/bin/mutagen/darwin_arm64/mutagen new file mode 100755 index 00000000..e1816d50 --- /dev/null +++ b/clients/managed/bin/mutagen/darwin_arm64/mutagen @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:cab3d665f382edb7fc20fd255d6ebcdb7b964f1b59e29c11b1f6dfdc6bceadfe +size 12863714 diff --git a/clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz b/clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz new file mode 100644 index 00000000..dfd16b47 --- /dev/null +++ b/clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996798a88b94cb7e98e441dbc32d498acb90aa3bcd03d0d0d014ed48de5bcc19 +size 93892428 diff --git a/clients/managed/bin/mutagen/darwin_x64/mutagen b/clients/managed/bin/mutagen/darwin_x64/mutagen new file mode 100755 index 00000000..735ca86c --- /dev/null +++ b/clients/managed/bin/mutagen/darwin_x64/mutagen @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:251ae38937c4b5e823af0c20be839174db69514f4e9478579317d94e1579861a +size 13681256 diff --git a/clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz b/clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz new file mode 100644 index 00000000..dfd16b47 --- /dev/null +++ b/clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996798a88b94cb7e98e441dbc32d498acb90aa3bcd03d0d0d014ed48de5bcc19 +size 93892428 diff --git a/clients/managed/bin/mutagen/linux_arm64/mutagen b/clients/managed/bin/mutagen/linux_arm64/mutagen new file mode 100755 index 00000000..4066d3e7 --- /dev/null +++ b/clients/managed/bin/mutagen/linux_arm64/mutagen @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:21dd4ae9f1fe4422adfdbbeb278e3fe52b5db8d5b40101aa8cf13dda86cc7f4f +size 12714168 diff --git a/clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz b/clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz new file mode 100644 index 00000000..dfd16b47 --- /dev/null +++ b/clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996798a88b94cb7e98e441dbc32d498acb90aa3bcd03d0d0d014ed48de5bcc19 +size 93892428 diff --git a/clients/managed/bin/mutagen/linux_x64/mutagen b/clients/managed/bin/mutagen/linux_x64/mutagen new file mode 100755 index 00000000..d28a7b77 --- /dev/null +++ b/clients/managed/bin/mutagen/linux_x64/mutagen @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b692bf8be612735bb73d6ef213dcae9391e9d318686984f509b7586bb044b399 +size 13525176 diff --git a/clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz b/clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz new file mode 100644 index 00000000..dfd16b47 --- /dev/null +++ b/clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996798a88b94cb7e98e441dbc32d498acb90aa3bcd03d0d0d014ed48de5bcc19 +size 93892428 diff --git a/clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz b/clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz new file mode 100644 index 00000000..dfd16b47 --- /dev/null +++ b/clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:996798a88b94cb7e98e441dbc32d498acb90aa3bcd03d0d0d014ed48de5bcc19 +size 93892428 diff --git a/clients/managed/bin/mutagen/windows_x64/mutagen.exe b/clients/managed/bin/mutagen/windows_x64/mutagen.exe new file mode 100755 index 00000000..f7fda5ab --- /dev/null +++ b/clients/managed/bin/mutagen/windows_x64/mutagen.exe @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:fd4a0f57dbedff4580fc467d6b8ae415239ac42afd7fbba15bc113c58fdd6b5b +size 14144000 diff --git a/clients/managed/eslint.config.mjs b/clients/managed/eslint.config.mjs new file mode 100644 index 00000000..07c47d2c --- /dev/null +++ b/clients/managed/eslint.config.mjs @@ -0,0 +1,116 @@ +import js from "@eslint/js"; +import typescript from "typescript-eslint"; +import prettierConfigRecommended from "eslint-plugin-prettier/recommended"; +import importPlugin from "eslint-plugin-import"; +import stylistic from "@stylistic/eslint-plugin"; + +export default [ + js.configs.recommended, + ...typescript.configs.recommended, + prettierConfigRecommended, + importPlugin.flatConfigs.recommended, + importPlugin.flatConfigs.typescript, + { + files: ["**/*.ts", "**/*.tsx"], + settings: { + "import/resolver": { + typescript: true, + node: true, + }, + }, + languageOptions: { + parserOptions: { + ecmaVersion: "latest", + sourceType: "commonjs", + tsconfigRootDir: import.meta.dirname, + }, + globals: { + // global + clearTimeout: "readonly", + fetch: "readonly", + global: "readonly", + setTimeout: "readonly", + Response: "readonly", + // commonjs + __dirname: "readonly", + __filename: "readonly", + // node + AbortController: "readonly", + Buffer: "readonly", + BufferEncoding: "readonly", + NodeJS: "readonly", + }, + }, + plugins: { + "@stylistic": stylistic, + }, + rules: { + "no-console": "off", + "@typescript-eslint/explicit-function-return-type": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + argsIgnorePattern: "^_", + varsIgnorePattern: "^_", + destructuredArrayIgnorePattern: "^_", + }, + ], + "prettier/prettier": "error", + "@stylistic/comma-dangle": ["error", "always-multiline"], + "@stylistic/eol-last": ["error", "always"], + "@stylistic/no-multiple-empty-lines": ["error", { max: 1, maxEOF: 0 }], + "@stylistic/no-trailing-spaces": "error", + "@stylistic/quotes": ["error", "double"], + "@stylistic/semi": ["error", "always"], + "import/first": "error", + "import/no-extraneous-dependencies": "error", + "import/no-unassigned-import": "error", + "import/no-unresolved": "error", + "import/order": "error", + }, + }, + { + // inherits the rules for all *.ts files above + files: ["**/__tests__/**/*.ts", "**/*.test.ts", "**/*.spec.ts"], + languageOptions: { + globals: { + // jest + afterAll: "readonly", + afterEach: "readonly", + beforeAll: "readonly", + beforeEach: "readonly", + describe: "readonly", + expect: "readonly", + it: "readonly", + jest: "readonly", + test: "readonly", + }, + }, + rules: { + "import/order": [ + "error", + { + pathGroups: [ + { + pattern: "mock-fs{,.js}", + patternOptions: { matchBase: true }, + group: "builtin", + position: "before", + }, + ], + pathGroupsExcludedImportTypes: ["builtin", "external"], + }, + ], + }, + }, + { + ignores: [ + "coverage/", + "dist/", + "*.config.js", + "node_modules/", + "tests/workspaces/*/", + ], + }, +]; diff --git a/clients/managed/package.json b/clients/managed/package.json new file mode 100644 index 00000000..763e234f --- /dev/null +++ b/clients/managed/package.json @@ -0,0 +1,44 @@ +{ + "name": "@nuanced-dev/managed-lsp", + "description": "", + "files": [ + "bin", + "dist" + ], + "scripts": { + "build": "tsc && chmod +x dist/*.js", + "setup": "tsx src/service-setup.ts", + "lsp": "tsx src/workspace-lsp.ts", + "lint": "eslint", + "lint:fix": "eslint --fix" + }, + "keywords": [], + "author": "", + "license": "ISC", + "type": "commonjs", + "devDependencies": { + "@eslint/js": "^9.37.0", + "@stylistic/eslint-plugin": "^5.4.0", + "@types/child-process-promise": "^2.2.6", + "@types/node": "^24.10.0", + "@types/node-ssh": "^7.0.6", + "@types/proper-lockfile": "^4.1.4", + "@types/ssh2": "^1.15.5", + "eslint": "^9.33.0", + "eslint-config-prettier": "^10.1.5", + "eslint-import-resolver-typescript": "^4.4.4", + "eslint-plugin-import": "^2.32.0", + "eslint-plugin-prettier": "^5.4.1", + "javascript-obfuscator": "^4.0.0", + "morphcloud": "^0.0.20", + "tsx": "^4.21.0", + "typescript": "^5.9.3", + "typescript-eslint": "^8.45.0" + }, + "dependencies": { + "child-process-promise": "^2.2.1", + "node-machine-id": "^1.1.12", + "proper-lockfile": "^4.1.2" + }, + "version": "0.3.19" +} diff --git a/clients/managed/script/mutagen b/clients/managed/script/mutagen new file mode 100755 index 00000000..7d79abe0 --- /dev/null +++ b/clients/managed/script/mutagen @@ -0,0 +1,32 @@ +#!/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/managed/script/update-mutagen-assets.sh b/clients/managed/script/update-mutagen-assets.sh new file mode 100755 index 00000000..25ebacd5 --- /dev/null +++ b/clients/managed/script/update-mutagen-assets.sh @@ -0,0 +1,47 @@ +#!/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/managed/script/update-nuanced-lsp-archive.sh b/clients/managed/script/update-nuanced-lsp-archive.sh new file mode 100755 index 00000000..3c9515e6 --- /dev/null +++ b/clients/managed/script/update-nuanced-lsp-archive.sh @@ -0,0 +1,12 @@ +#!/usr/bin/env bash + +set -eu + +ARCHIVE_DIR="$(realpath "$(dirname "$0")/..")" + +if [ $# -ne 1 ]; then + echo "Usage: $0 REPO_DIR" + exit 1 +fi + +exec git -C "$1" archive -o "$ARCHIVE_DIR/nuanced-lsp-src.zip" HEAD diff --git a/clients/managed/src/service-setup.ts b/clients/managed/src/service-setup.ts new file mode 100755 index 00000000..7960a442 --- /dev/null +++ b/clients/managed/src/service-setup.ts @@ -0,0 +1,250 @@ +#!/usr/bin/env node + +import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; +import { SERVICE_DIGEST, BUILDER_DIGEST, LABEL_NUANCED_ROLE, BASE_DIGEST, NUANCED_ROLE_BASE, SOURCE_DIGEST, NUANCED_ROLE_BUILDER, NUANCED_ROLE_SOURCE, NUANCED_ROLE_SERVICE, SOURCE_ARCHIVE, SOURCE_DIR } from './util/constants'; +import { execOrThrow, findSnapshotByDigest, startInstance } from './util/morphcloud'; +import { NodeSSH } from 'node-ssh'; + +const VCPU_COUNT = 2; +const MEM_SIZE_MB = 16384; +const DISK_SIZE_MB = 16384; + +const VERBOSE = true; + +async function ensureBase(client: MorphCloudClient): Promise { + let baseSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); + if (baseSnapshot) { + console.log(`Found base snapshot: ${baseSnapshot.id}`); + return baseSnapshot; + } + + console.log('Creating minimal snapshot...'); + + baseSnapshot = await client.snapshots.create({ + imageId: "morphvm-minimal", + vcpus: VCPU_COUNT, + memory: MEM_SIZE_MB, + diskSize: DISK_SIZE_MB, + digest: BASE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BASE, + }, + }); + + console.log(`Created base snapshot: ${baseSnapshot.id}`); + + return baseSnapshot; +} + +async function ensureBuilder(client: MorphCloudClient, minimalSnapshot: Snapshot): Promise { + let builderSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); + if (builderSnapshot) { + console.log(`Found builder snapshot: ${builderSnapshot.id}`); + return builderSnapshot; + } + + console.log('Creating builder snapshot...'); + + let builderInstance: Instance | undefined; + try { + console.log('Starting builder instance...'); + builderInstance = await startInstance(client, minimalSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, + } + }); + console.log(`Started builder instance: ${builderInstance.id}`); + + console.log('Installing build tools...'); + console.log('- Installing system commands...'); + await execOrThrow(builderInstance, ` + apt-get update -qq \ + && apt-get install -qq -y curl git nodejs npm unzip pkg-config + `, { verbose: VERBOSE, prefix: " | " }); + + console.log('- Installing Docker...'); + await execOrThrow(builderInstance, ` + curl -sSf https://get.docker.com | sh -s -- \ + && systemctl enable docker.service \ + && systemctl enable containerd.service \ + && systemctl start docker.service + `, { verbose: VERBOSE, prefix: " | " }); + const dockerVersion = await execOrThrow(builderInstance, 'docker --version'); + console.log(` Installed ${dockerVersion.trim()}`); + + console.log('- Installing Rust...'); + await execOrThrow(builderInstance, ` + curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y + `, { verbose: VERBOSE, prefix: " | " }); + const rustcVersion = await execOrThrow(builderInstance, 'rustc --version'); + console.log(` Installed ${rustcVersion.trim()}`); + + console.log(`Installed build tools`); + + console.log('Creating builder snapshot...'); + builderSnapshot = await builderInstance.snapshot({ + digest: BUILDER_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, + } + }); + console.log(`Created builder snapshot: ${builderSnapshot.id}`); + + console.log('Cleaning up...'); + if (builderInstance) { + console.log('- builder instance...'); + await builderInstance.stop(); + } + console.log('Cleaned up'); + } catch (e) { + if (builderInstance) { + console.log(`Left builder instance running: ${builderInstance.id}`); + } + throw e; + } + + return builderSnapshot; +} + +async function ensureSource(client: MorphCloudClient, builderSnapshot: Snapshot): Promise { + let sourceSnapshot = await findSnapshotByDigest(client, SOURCE_DIGEST); + if (sourceSnapshot) { + console.log(`Found source snapshot: ${sourceSnapshot.id}`); + return sourceSnapshot; + } + + console.log('Creating source snapshot...'); + + let sourceInstance: Instance | undefined; + try { + console.log('Starting source instance...'); + sourceInstance = await startInstance(client, builderSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, + } + }); + console.log(`Started source instance: ${sourceInstance.id}`); + + console.log('Uploading repository...'); + let ssh: NodeSSH | undefined; + try { + const ssh = await sourceInstance.ssh(); + await ssh.putFile(SOURCE_ARCHIVE, SOURCE_ARCHIVE); + } finally { + if (ssh) { + ssh.dispose(); + } + } + await execOrThrow(sourceInstance, ` + unzip -q ${SOURCE_ARCHIVE} -d ${SOURCE_DIR} \ + && rm -f ${SOURCE_ARCHIVE} + `, { verbose: VERBOSE, prefix: "| " }); + console.log(`Uploaded repository`); + + console.log('Creating source snapshot...'); + sourceSnapshot = await sourceInstance.snapshot({ + digest: SOURCE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, + } + }); + console.log(`Created source snapshot: ${sourceSnapshot.id}`); + + console.log('Cleaning up...'); + if (sourceInstance) { + console.log('- source instance'); + await sourceInstance.stop(); + } + console.log('Cleaned up'); + } catch (e) { + if (sourceInstance) { + console.log(`Left source instance running: ${sourceInstance.id}`); + } + throw e; + } + + return sourceSnapshot; +} + +async function ensureService(client: MorphCloudClient, sourceSnapshot: Snapshot): Promise { + let serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); + if (serviceSnapshot) { + console.log(`Found service snapshot: ${serviceSnapshot.id}`); + return serviceSnapshot; + } + + console.log('Creating service snapshot...'); + + let serviceInstance: Instance | undefined; + try { + console.log('Starting service instance...'); + serviceInstance = await startInstance(client, sourceSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, + } + }); + console.log(`Started service instance: ${serviceInstance.id}`); + + console.log('Building Nuanced LSP...'); + const buildStartTime = Date.now(); + console.log("- Rust Docker images"); + await execOrThrow(serviceInstance, ` + cd ${SOURCE_DIR} \ + && scripts/build-rust-images.sh + `, { verbose: VERBOSE, prefix: " | " }); + console.log("- TypeScript CLI"); + await execOrThrow(serviceInstance, ` + cd ${SOURCE_DIR}/clients/typescript \ + && npm install \ + && npm run build \ + && npm install -g + `, { verbose: VERBOSE, prefix: " | " }); + const buildDuration = ((Date.now() - buildStartTime) / 1000).toFixed(2); + console.log(`Built Nuanced LSP in ${buildDuration}s`); + + console.log('Cleaning up repository...'); + await execOrThrow(serviceInstance, ` + rm -rf ${SOURCE_DIR} + `, { verbose: VERBOSE, prefix: "| " }); + console.log(`Cleaned up repository`); + + console.log('Creating service snapshot...'); + serviceSnapshot = await serviceInstance.snapshot({ + digest: SERVICE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, + } + }); + console.log(`Created service snapshot: ${serviceSnapshot.id}`); + + console.log('Cleaning up...'); + if (serviceInstance) { + console.log('- service instance...'); + await serviceInstance.stop(); + } + console.log(`Cleaned up`); + } catch (e) { + if (serviceInstance) { + console.log(`Left service instance running: ${serviceInstance.id}`); + } + throw e; + } + + return serviceSnapshot; +} + +async function main() { + const client = new MorphCloudClient(); + try { + const minimalSnapshot = await ensureBase(client); + const builderSnapshot = await ensureBuilder(client, minimalSnapshot); + const sourceSnapshot = await ensureSource(client, builderSnapshot); + const baseSnapshot = await ensureService(client, sourceSnapshot); + process.exit(0); + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/clients/managed/src/service-status.ts b/clients/managed/src/service-status.ts new file mode 100755 index 00000000..c066a108 --- /dev/null +++ b/clients/managed/src/service-status.ts @@ -0,0 +1,31 @@ +#!/usr/bin/env node + +import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; +import { SERVICE_DIGEST, BUILDER_DIGEST, LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, BASE_DIGEST, NUANCED_ROLE_WORKSPACE, SOURCE_DIGEST } from './util/constants'; +import { findSnapshotByDigest, formatInstanceStatus, listInstances } from './util/morphcloud'; + +async function main() { + const client = new MorphCloudClient(); + try { + console.log("System snapshots:"); + for (const digest of [ + BASE_DIGEST, + BUILDER_DIGEST, + SOURCE_DIGEST, + SERVICE_DIGEST, + ]) { + const snapshot = await findSnapshotByDigest(client, digest); + console.log(`- ${digest.padEnd(20)} : ${snapshot ? snapshot.id : ""}`); + } + console.log("Workspace instances:"); + for (const instance of await listInstances(client, { metadata: { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE } })) { + const digest = instance.metadata![LABEL_NUANCED_WORKSPACE_DIGEST]!; + console.log(`- ${digest.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`); + } + } catch (error) { + console.error('Error:', error); + process.exit(1); + } +} + +main().catch(console.error); diff --git a/clients/managed/src/util/config.ts b/clients/managed/src/util/config.ts new file mode 100644 index 00000000..fae768fe --- /dev/null +++ b/clients/managed/src/util/config.ts @@ -0,0 +1,30 @@ +import { join } from "path"; +import { homedir } from "os"; +import { mkdir } from "fs/promises"; +import { Instance } from "morphcloud"; +import { getLocallyUniqueDigest, getGloballyUniqueDigest } from "./digest"; + +export async function ensureConfigDirectory(): Promise { + const configDir = join(homedir(), ".nuanced"); + await mkdir(configDir, { recursive: true }); + return configDir; +} + +export async function ensureInstanceConfigDirectory(instance: Instance): Promise { + const configDir = await ensureConfigDirectory(); + const instanceDir = join(configDir, "instances", instance.id); + await mkdir(instanceDir, { recursive: true }); + return instanceDir; +} + +export async function ensureWorkspaceConfigDirectory(workspaceDigest: string): Promise { + const configDir = await ensureConfigDirectory(); + const workspaceDir = join(configDir, "workspaces", workspaceDigest); + await mkdir(workspaceDir, { recursive: true }); + return workspaceDir; +} + +export async function getWorkspaceProcessRcPath(workspaceDigest: string): Promise { + const workspaceDir = await ensureWorkspaceConfigDirectory(workspaceDigest); + return join(workspaceDir, "servers.json"); +} diff --git a/clients/managed/src/util/constants.ts b/clients/managed/src/util/constants.ts new file mode 100644 index 00000000..3cee2f89 --- /dev/null +++ b/clients/managed/src/util/constants.ts @@ -0,0 +1,20 @@ +import { ExecOptions, Instance, InstanceBootOptions, InstanceListOptions, InstanceStartOptions, InstanceStatus, MorphCloudClient, Snapshot } from 'morphcloud'; + +export const SOURCE_ARCHIVE = "nuanced-lsp-src.zip"; +export const SOURCE_DIR = "nuanced-lsp-src"; + +export const BASE_DIGEST = "nuanced-lsp-base"; +export const BUILDER_DIGEST = "nuanced-lsp-builder"; +export const SOURCE_DIGEST = "nuanced-lsp-source"; +export const SERVICE_DIGEST = "nuanced-lsp-service"; + +export const LABEL_NUANCED_ROLE = "nuanced.role" + +export const NUANCED_ROLE_BASE = "base" +export const NUANCED_ROLE_BUILDER = "builder" +export const NUANCED_ROLE_SOURCE = "source" +export const NUANCED_ROLE_SERVICE = "service" + +export const NUANCED_ROLE_WORKSPACE = "workspace" + +export const LABEL_NUANCED_WORKSPACE_DIGEST = "nuanced.workspace.digest" diff --git a/clients/managed/src/util/digest.ts b/clients/managed/src/util/digest.ts new file mode 100644 index 00000000..45fbaf80 --- /dev/null +++ b/clients/managed/src/util/digest.ts @@ -0,0 +1,14 @@ +import { createHash } from 'crypto'; +import { machineIdSync } from 'node-machine-id'; + +const MACHINE_ID = machineIdSync(); + +export function getLocallyUniqueDigest(value: string): string { + const hash = createHash('sha256').update(value).digest('hex'); + return hash.substring(0, 12); +} + +export function getGloballyUniqueDigest(value: string): string { + const hash = createHash('sha256').update(MACHINE_ID).update("-").update(value).digest('hex'); + return hash.substring(0, 12); +} diff --git a/clients/managed/src/util/morphcloud.ts b/clients/managed/src/util/morphcloud.ts new file mode 100644 index 00000000..7be1f322 --- /dev/null +++ b/clients/managed/src/util/morphcloud.ts @@ -0,0 +1,118 @@ +import { ExecOptions, Instance, InstanceBootOptions, InstanceListOptions, InstanceStartOptions, InstanceStatus, MorphCloudClient, Snapshot } from 'morphcloud'; + +export async function findSnapshotByDigest(client: MorphCloudClient, digest: string): Promise { + const snapshots = await client.snapshots.list({ + digest, + }); + const snapshot = snapshots[0]; + return snapshot; +} + +export async function bootInstance(client: MorphCloudClient, snapshot: Snapshot, opts?: { + vcpus?: number; + memory?: number; + diskSize?: number; + metadata?: Record; +}): Promise { + const bootOpts: InstanceBootOptions = { + ttlSeconds: 3600, + ...opts, + snapshotId: snapshot.id, + }; + const instance = await client.instances.boot(bootOpts); + await instance.waitUntilReady(30); + return instance; +} + +export async function startInstance(client: MorphCloudClient, snapshot: Snapshot, opts?: { + metadata?: Record; +}): Promise { + const startOpts: InstanceStartOptions = { + ttlSeconds: 3600, + ...opts, + snapshotId: snapshot.id, + }; + const instance = await client.instances.start(startOpts); + await instance.waitUntilReady(30); + return instance; +} + +export async function findInstance(client: MorphCloudClient, opts?: { + metadata?: Record; + ensureReady?: boolean; +}): Promise { + const listOpts: InstanceListOptions = { + ...opts, + }; + const instances = await client.instances.list(listOpts) + if (instances.length > 1) { + throw new Error("Multiple matching instances"); + } + const instance = instances[0]; + if (instance) { + switch (instance.status) { + case InstanceStatus.ERROR: + throw new Error(`instance ${instance.id} in error state`); + case InstanceStatus.PAUSED: + if (opts?.ensureReady) { + await instance.resume(); + await instance.waitUntilReady(30); + } + break; + case InstanceStatus.PENDING: + if (opts?.ensureReady) { + await instance.waitUntilReady(30); + } + break; + case InstanceStatus.SAVING: + if (opts?.ensureReady) { + await instance.waitUntilReady(30); + } + break; + } + } + return instance; +} + +export async function listInstances(client: MorphCloudClient, opts?: { + metadata?: Record; +}): Promise { + const listOpts: InstanceListOptions = { + ...opts, + }; + return await client.instances.list(listOpts); +} + +export function formatInstanceStatus(status: InstanceStatus): string { + switch (status) { + case InstanceStatus.ERROR: return "ERROR"; + case InstanceStatus.PENDING: return "PENDING"; + case InstanceStatus.READY: return "READY"; + case InstanceStatus.PAUSED: return "PAUSED"; + case InstanceStatus.SAVING: return "SAVING"; + } +} + +export async function execOrThrow(instance: Instance, cmd: string, opts?: { + verbose?: boolean, + prefix?: string, +}): Promise { + const { + verbose = false, + prefix = "", + } = opts || {}; + function logger(inner: (line: string) => void) { + return (content: string) => content.split('\n').forEach((line: string) => inner(`${prefix}${line}`)); + } + const execOpts: ExecOptions = {}; + if (verbose) { + execOpts.onStdout = logger(console.log); + execOpts.onStderr = logger(console.error); + } + try { + const response = await instance.exec(cmd, execOpts); + return response.stdout; + } catch (e) { + throw new Error(`Exec failed: ${e}`); + } +} diff --git a/clients/managed/src/util/mutagen.ts b/clients/managed/src/util/mutagen.ts new file mode 100644 index 00000000..d5363238 --- /dev/null +++ b/clients/managed/src/util/mutagen.ts @@ -0,0 +1,123 @@ +import { spawn } from "child-process-promise"; +import { Instance } from "morphcloud"; +import { join } from "path"; +import { platform, arch } from "os"; +import { mkdir, rm, writeFile } from "fs/promises"; +import { ensureConfigDirectory, ensureInstanceConfigDirectory } from "./config"; +import { getSshConfig, removeSshConfig } from "./ssh"; + +const MISSING_SESSION_ERROR = "unable to locate requested session"; + +export class MutagenClient { + constructor( + private readonly instance: Instance, + ) { } + + private syncName(): string { + return `nuanced-lsp-${this.instance.id.replace('_', '-')}`; + } + + // Status: Staging files on beta + // Status: Applying changes + // Status: Watching for changes + + async createSync(localDirectory: string): Promise { + try { + const sshConfig = await getSshConfig(this.instance); + await this.spawnMutagen([ + "sync", "create", + "--name", this.syncName(), + "--mode", "two-way-safe", + localDirectory, + `${sshConfig.user}@${sshConfig.host}:workspace`, + ], { env: { MUTAGEN_SSH_CONFIG_BETA: sshConfig.configPath } }); + } catch (e) { + throw new Error(`Failed to create sync: ${(e as any).stderr}`); + } + } + + async findSync(opts?: { ensureReady?: boolean }): Promise { + try { + await this.spawnMutagen(["sync", "list", this.syncName()]); + // TODO implement waiting on ready status + return true; + } catch (e) { + const stderr = (e as any).stderr.toLowerCase(); + if (stderr.includes(MISSING_SESSION_ERROR)) { + return false; + } else { + throw new Error(`Failed to find sync: ${(e as any).stderr}`); + } + } + } + + async flushSync(): Promise { + try { + await this.spawnMutagen(["sync", "flush", this.syncName()]); + } catch (e) { + throw new Error(`Failed to flush sync: ${(e as any).stderr}`); + } + } + + async pauseSync(): Promise { + try { + await this.spawnMutagen(["sync", "pause", this.syncName()]); + } catch (e) { + throw new Error(`Failed to pause sync: ${(e as any).stderr}`); + } + } + + async resumeSync(): Promise { + try { + const sshConfig = await getSshConfig(this.instance); + await this.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}`); + } + } + + async stopSync(): Promise { + try { + await this.spawnMutagen(["sync", "terminate", this.syncName()]); + } catch (e) { + const stderr = (e as any).stderr.toLowerCase(); + if (stderr.includes(MISSING_SESSION_ERROR)) { + return; + } else { + throw new Error(`Failed to stop sync: ${(e as any).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 = getMutagenBinaryPath(); + const spawnArgs = [...args]; + const spawnEnv = { + ...opts?.env, + MUTAGEN_DATA_DIRECTORY: mutagenDataDir, + }; + await spawn(spawnCommand, spawnArgs, { + capture: ["stderr"], + env: { + ...process.env, + ...spawnEnv, + }, + }); + } +} + +function getMutagenBinaryPath(): string { + const platformArch = `${platform()}_${arch()}`; + const binaryName = platform() === "win32" ? "mutagen.exe" : "mutagen"; + const binPath = join(__dirname, "..", "..", "bin", "mutagen", platformArch, binaryName); + + return binPath; +} diff --git a/clients/managed/src/util/process-rc.ts b/clients/managed/src/util/process-rc.ts new file mode 100644 index 00000000..d6708064 --- /dev/null +++ b/clients/managed/src/util/process-rc.ts @@ -0,0 +1,92 @@ +import * as fs from 'fs/promises'; +import * as lockfile from 'proper-lockfile'; + +export class ProcessRc { + private readonly filePath: string; + private readonly lockOptions: lockfile.LockOptions; + + constructor(filePath: string) { + this.filePath = filePath; + this.lockOptions = { + retries: { + retries: 5, + minTimeout: 100, + maxTimeout: 1000, + }, + }; + } + + async acquire(init?: () => Promise): Promise { + const release = await this.lockFile(); + try { + const pids = await this.readPids(); + const runningPids = await this.filterRunningPids(pids); + + if (runningPids.length === 0 && init) { + await init(); + } + + runningPids.push(process.pid); + await this.writePids(runningPids); + } finally { + await release(); + } + } + + async release(cleanup?: () => Promise): Promise { + const release = await this.lockFile(); + try { + const pids = await this.readPids(); + const runningPids = await this.filterRunningPids(pids); + const filteredPids = runningPids.filter(pid => pid !== process.pid); + await this.writePids(filteredPids); + + if (filteredPids.length === 0 && cleanup) { + await cleanup(); + } + } finally { + await release(); + } + } + + private async lockFile(): Promise<() => Promise> { + await this.ensureFileExists(); + return await lockfile.lock(this.filePath, this.lockOptions); + } + + private async ensureFileExists(): Promise { + try { + await fs.access(this.filePath); + } catch { + await fs.writeFile(this.filePath, JSON.stringify([])); + } + } + + private async readPids(): Promise { + const content = await fs.readFile(this.filePath, 'utf-8'); + return JSON.parse(content); + } + + private async writePids(pids: number[]): Promise { + await fs.writeFile(this.filePath, JSON.stringify(pids)); + } + + private async filterRunningPids(pids: number[]): Promise { + const runningPids: number[] = []; + for (const pid of pids) { + if (await this.isProcessRunning(pid)) { + runningPids.push(pid); + } + } + return runningPids; + } + + private async isProcessRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; + } + } +} diff --git a/clients/managed/src/util/sleep.ts b/clients/managed/src/util/sleep.ts new file mode 100644 index 00000000..fa765e62 --- /dev/null +++ b/clients/managed/src/util/sleep.ts @@ -0,0 +1,3 @@ +export async function sleep(ms: number) { + return new Promise(resolve => setTimeout(resolve, ms)); +} diff --git a/clients/managed/src/util/ssh.ts b/clients/managed/src/util/ssh.ts new file mode 100644 index 00000000..26b9c1c5 --- /dev/null +++ b/clients/managed/src/util/ssh.ts @@ -0,0 +1,83 @@ +import { rm, writeFile } from "fs/promises"; +import { Instance } from "morphcloud"; +import { NodeSSH } from "node-ssh"; +import { join } from "path"; +import { ClientChannel } from "ssh2"; +import stream from 'stream'; +import { ensureInstanceConfigDirectory } from "./config"; + +const MORPH_SSH_HOST = "ssh.cloud.morph.so"; + +export interface SshConfig { + user: string; + host: string; + configPath: string; +}; + +export async function getSshConfig(instance: Instance): Promise { + const configPath = await writeSshConfig(instance); + return { + user: instance.id, + host: MORPH_SSH_HOST, + configPath, + }; +} + +async function writeSshConfig(instance: Instance): Promise { + const instanceConfigDir = await ensureInstanceConfigDirectory(instance); + + const sshKey = await instance.sshKey(); + const sshKeyPath = join(instanceConfigDir, "ssh_key"); + await writeFile(sshKeyPath, sshKey.private_key, { mode: 0o600 }); + + const sshConfigPath = join(instanceConfigDir, "ssh_config"); + + const sshConfig = `# Generated file +HostName ${MORPH_SSH_HOST} +User ${instance.id} +IdentityFile ${sshKeyPath} +IdentitiesOnly yes +IdentityAgent none +StrictHostKeyChecking no +WarnWeakCrypto no +UserKnownHostsFile /dev/null + `; + + await writeFile(sshConfigPath, sshConfig); + return sshConfigPath; +} + +export async function removeSshConfig(instance: Instance): Promise { + const instanceConfigDir = await ensureInstanceConfigDirectory(instance); + try { + await rm(instanceConfigDir, { recursive: true, force: true }); + } catch(e) { + // Ignore errors during cleanup + } +} + +export function sshExec(ssh: NodeSSH, command: string, args: string[], opts?: { + stdin?: stream.Readable, + stdout?: stream.Writable, + stderr?: stream.Writable, +}): Promise { + return new Promise(async (resolve, reject) => { + try { + await ssh.exec(command, args, { + stdin: opts?.stdin, + stream: "both", + onChannel: (stream) => { + if (opts?.stdout) { + stream.stdout.pipe(opts.stdout); + } + if (opts?.stderr) { + stream.stderr.pipe(opts.stderr); + } + resolve(stream); + } + }); + } catch (e) { + reject(e); + } + }); +} diff --git a/clients/managed/src/workspace-lsp.ts b/clients/managed/src/workspace-lsp.ts new file mode 100755 index 00000000..db9f0deb --- /dev/null +++ b/clients/managed/src/workspace-lsp.ts @@ -0,0 +1,126 @@ +#!/usr/bin/env node + +import { Instance, MorphCloudClient } from 'morphcloud'; +import { SERVICE_DIGEST, NUANCED_ROLE_WORKSPACE, LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST } from './util/constants'; +import { findSnapshotByDigest, startInstance, findInstance, formatInstanceStatus } from './util/morphcloud'; +import { MutagenClient } from './util/mutagen'; +import { realpathSync } from 'fs'; +import { getGloballyUniqueDigest } from './util/digest'; +import { sshExec } from './util/ssh'; +import { sleep } from './util/sleep'; +import { getWorkspaceProcessRcPath } from './util/config'; +import { ProcessRc } from './util/process-rc'; + +async function main() { + const client = new MorphCloudClient(); + + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; + + const processRcPath = await getWorkspaceProcessRcPath(workspaceDigest); + const processRc = new ProcessRc(processRcPath); + + let workspaceInstance: Instance | undefined; + let mutagen: MutagenClient | undefined; + try { + await processRc.acquire(async () => { + workspaceInstance = await findInstance(client, { metadata, ensureReady: true }); + + if (!workspaceInstance) { + console.error("Creating workspace instance..."); + const serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); + if (!serviceSnapshot) { + throw new Error(`Missing service snapshot. Run setup.ts first.`); + } + workspaceInstance = await startInstance(client, serviceSnapshot, { metadata }); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + }); + + if (!workspaceInstance) { + workspaceInstance = await findInstance(client, { metadata, ensureReady: true }); + if (!workspaceInstance) { + throw new Error(`Workspace instance not found after acquire`); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + } + + mutagen = new MutagenClient(workspaceInstance); + + if (await mutagen.findSync({ ensureReady: true })) { + console.error("Resuming file sync..."); + try { + await mutagen.resumeSync(); + } catch (e) { + throw new Error(`Failed to resume file sync: ${e}`); + } + } else { + console.error("Creating file sync..."); + try { + await mutagen.createSync(workspaceDir); + await mutagen.flushSync(); + } catch (e) { + throw new Error(`Failed to setup file sync: ${e}`); + } + } + + console.error("Executing nuanced-lsp..."); + const ssh = await workspaceInstance.ssh(); + const cmd = "nuanced-lsp"; + const args = [ + "server", + "--proxy-image", "nuanced-lsp-proxy:latest", + "--watchdog-image", "nuanced-lsp-watchdog:latest", + "--wrapper-image", "nuanced-lsp-wrapper:latest", + "--shared", + "workspace", + ]; + console.error(`nuanced-lsp ${args.join(" ")}`); + const stream = await sshExec(ssh, cmd, args, { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }); + stream.on('close', async () => { + ssh.dispose(); + await processRc.release(async () => { + if (mutagen) { + await mutagen.pauseSync().catch((e) => { + console.error(`Error: Cannot pause sync ${workspaceDigest}: ${e}`); + }); + } + if (workspaceInstance) { + await workspaceInstance.pause().catch((e) => { + console.error(`Error: Cannot pause instance ${workspaceInstance!.id}: ${e}`); + }); + } + }); + process.exit(0); + }); + } catch (e) { + await processRc.release(async () => { + if (mutagen) { + await mutagen.stopSync().catch((e) => { + console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); + }); + } + if (workspaceInstance) { + await workspaceInstance.stop().catch((e) => { + console.error(`Error: Cannot stop instance ${workspaceInstance!.id}: ${e}`); + }); + } + }); + console.error(`Error: ${e}`); + process.exitCode = 1; + } +} + +main().catch(console.error); diff --git a/clients/managed/src/workspace-status.ts b/clients/managed/src/workspace-status.ts new file mode 100755 index 00000000..63574145 --- /dev/null +++ b/clients/managed/src/workspace-status.ts @@ -0,0 +1,33 @@ +#!/usr/bin/env node + +import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; +import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE } from './util/constants'; +import { findInstance } from './util/morphcloud'; +import { realpathSync } from 'fs'; +import { getGloballyUniqueDigest } from './util/digest'; + +async function main() { + const client = new MorphCloudClient(); + + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; + + let workspaceInstance: Instance | undefined; + try { + workspaceInstance = await findInstance(client, { metadata }); + console.log(`${workspaceDigest.padEnd(2)} : ${workspaceInstance?.id ?? ""}`); + } catch (error) { + console.error('Error:', error); + process.exitCode = 1; + } +} + +main().catch(console.error); diff --git a/clients/managed/src/workspace-stop.ts b/clients/managed/src/workspace-stop.ts new file mode 100755 index 00000000..e0d41ca0 --- /dev/null +++ b/clients/managed/src/workspace-stop.ts @@ -0,0 +1,44 @@ +#!/usr/bin/env node + +import { Instance, MorphCloudClient } from 'morphcloud'; +import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE } from './util/constants'; +import { findInstance } from './util/morphcloud'; +import { MutagenClient } from './util/mutagen'; +import { realpathSync } from 'fs'; +import { getGloballyUniqueDigest } from './util/digest'; + +async function main() { + const client = new MorphCloudClient(); + + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; + + let workspaceInstance: Instance | undefined; + try { + workspaceInstance = await findInstance(client, { metadata }); + + if (workspaceInstance) { + const mutagen = new MutagenClient(workspaceInstance); + await mutagen.stopSync().catch((_) => { + console.error(`Error: Cannot remove sync ${workspaceDigest}`); + }); + await workspaceInstance.stop().catch((e) => { + console.error(`Error: Cannot stop instance ${workspaceInstance!.id}`); + }); + } + } catch (error) { + console.error('Error:', error); + process.exitCode = 1; + } finally { + } +} + +main().catch(console.error); diff --git a/clients/managed/tsconfig.json b/clients/managed/tsconfig.json new file mode 100644 index 00000000..50572daa --- /dev/null +++ b/clients/managed/tsconfig.json @@ -0,0 +1,19 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "resolveJsonModule": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} From 63b559edaedb51255efa35f81162bba69ad19b0f Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 16 Dec 2025 16:14:48 +0100 Subject: [PATCH 02/14] Fix lint --- clients/managed/eslint.config.mjs | 1 + clients/managed/package.json | 4 +- clients/managed/src/service-setup.ts | 448 +++++++++++++----------- clients/managed/src/service-status.ts | 46 ++- clients/managed/src/util/config.ts | 39 ++- clients/managed/src/util/constants.ts | 16 +- clients/managed/src/util/digest.ts | 16 +- clients/managed/src/util/morphcloud.ts | 219 +++++++----- clients/managed/src/util/mutagen.ts | 204 ++++++----- clients/managed/src/util/process-rc.ts | 144 ++++---- clients/managed/src/util/sleep.ts | 2 +- clients/managed/src/util/ssh.ts | 107 +++--- clients/managed/src/workspace-lsp.ts | 242 +++++++------ clients/managed/src/workspace-status.ts | 54 +-- clients/managed/src/workspace-stop.ts | 71 ++-- 15 files changed, 892 insertions(+), 721 deletions(-) diff --git a/clients/managed/eslint.config.mjs b/clients/managed/eslint.config.mjs index 07c47d2c..223fdfc1 100644 --- a/clients/managed/eslint.config.mjs +++ b/clients/managed/eslint.config.mjs @@ -109,6 +109,7 @@ export default [ "coverage/", "dist/", "*.config.js", + "*.config.mjs", "node_modules/", "tests/workspaces/*/", ], diff --git a/clients/managed/package.json b/clients/managed/package.json index 763e234f..d126261c 100644 --- a/clients/managed/package.json +++ b/clients/managed/package.json @@ -38,7 +38,9 @@ "dependencies": { "child-process-promise": "^2.2.1", "node-machine-id": "^1.1.12", - "proper-lockfile": "^4.1.2" + "node-ssh": "^13.2.1", + "proper-lockfile": "^4.1.2", + "ssh2": "^1.17.0" }, "version": "0.3.19" } diff --git a/clients/managed/src/service-setup.ts b/clients/managed/src/service-setup.ts index 7960a442..60858e5c 100755 --- a/clients/managed/src/service-setup.ts +++ b/clients/managed/src/service-setup.ts @@ -1,9 +1,25 @@ #!/usr/bin/env node -import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; -import { SERVICE_DIGEST, BUILDER_DIGEST, LABEL_NUANCED_ROLE, BASE_DIGEST, NUANCED_ROLE_BASE, SOURCE_DIGEST, NUANCED_ROLE_BUILDER, NUANCED_ROLE_SOURCE, NUANCED_ROLE_SERVICE, SOURCE_ARCHIVE, SOURCE_DIR } from './util/constants'; -import { execOrThrow, findSnapshotByDigest, startInstance } from './util/morphcloud'; -import { NodeSSH } from 'node-ssh'; +import { Instance, MorphCloudClient, Snapshot } from "morphcloud"; +import { NodeSSH } from "node-ssh"; +import { + SERVICE_DIGEST, + BUILDER_DIGEST, + LABEL_NUANCED_ROLE, + BASE_DIGEST, + NUANCED_ROLE_BASE, + SOURCE_DIGEST, + NUANCED_ROLE_BUILDER, + NUANCED_ROLE_SOURCE, + NUANCED_ROLE_SERVICE, + SOURCE_ARCHIVE, + SOURCE_DIR, +} from "./util/constants"; +import { + execOrThrow, + findSnapshotByDigest, + startInstance, +} from "./util/morphcloud"; const VCPU_COUNT = 2; const MEM_SIZE_MB = 16384; @@ -12,239 +28,279 @@ const DISK_SIZE_MB = 16384; const VERBOSE = true; async function ensureBase(client: MorphCloudClient): Promise { - let baseSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); - if (baseSnapshot) { - console.log(`Found base snapshot: ${baseSnapshot.id}`); - return baseSnapshot; - } + let baseSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); + if (baseSnapshot) { + console.log(`Found base snapshot: ${baseSnapshot.id}`); + return baseSnapshot; + } - console.log('Creating minimal snapshot...'); - - baseSnapshot = await client.snapshots.create({ - imageId: "morphvm-minimal", - vcpus: VCPU_COUNT, - memory: MEM_SIZE_MB, - diskSize: DISK_SIZE_MB, - digest: BASE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BASE, - }, - }); + console.log("Creating minimal snapshot..."); - console.log(`Created base snapshot: ${baseSnapshot.id}`); + baseSnapshot = await client.snapshots.create({ + imageId: "morphvm-minimal", + vcpus: VCPU_COUNT, + memory: MEM_SIZE_MB, + diskSize: DISK_SIZE_MB, + digest: BASE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BASE, + }, + }); - return baseSnapshot; + console.log(`Created base snapshot: ${baseSnapshot.id}`); + + return baseSnapshot; } -async function ensureBuilder(client: MorphCloudClient, minimalSnapshot: Snapshot): Promise { - let builderSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); - if (builderSnapshot) { - console.log(`Found builder snapshot: ${builderSnapshot.id}`); - return builderSnapshot; - } +async function ensureBuilder( + client: MorphCloudClient, + minimalSnapshot: Snapshot, +): Promise { + let builderSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); + if (builderSnapshot) { + console.log(`Found builder snapshot: ${builderSnapshot.id}`); + return builderSnapshot; + } - console.log('Creating builder snapshot...'); + console.log("Creating builder snapshot..."); - let builderInstance: Instance | undefined; - try { - console.log('Starting builder instance...'); - builderInstance = await startInstance(client, minimalSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, - } - }); - console.log(`Started builder instance: ${builderInstance.id}`); - - console.log('Installing build tools...'); - console.log('- Installing system commands...'); - await execOrThrow(builderInstance, ` + let builderInstance: Instance | undefined; + try { + console.log("Starting builder instance..."); + builderInstance = await startInstance(client, minimalSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, + }, + }); + console.log(`Started builder instance: ${builderInstance.id}`); + + console.log("Installing build tools..."); + console.log("- Installing system commands..."); + await execOrThrow( + builderInstance, + ` apt-get update -qq \ && apt-get install -qq -y curl git nodejs npm unzip pkg-config - `, { verbose: VERBOSE, prefix: " | " }); - - console.log('- Installing Docker...'); - await execOrThrow(builderInstance, ` + `, + { verbose: VERBOSE, prefix: " | " }, + ); + + console.log("- Installing Docker..."); + await execOrThrow( + builderInstance, + ` curl -sSf https://get.docker.com | sh -s -- \ && systemctl enable docker.service \ && systemctl enable containerd.service \ && systemctl start docker.service - `, { verbose: VERBOSE, prefix: " | " }); - const dockerVersion = await execOrThrow(builderInstance, 'docker --version'); - console.log(` Installed ${dockerVersion.trim()}`); - - console.log('- Installing Rust...'); - await execOrThrow(builderInstance, ` + `, + { verbose: VERBOSE, prefix: " | " }, + ); + const dockerVersion = await execOrThrow( + builderInstance, + "docker --version", + ); + console.log(` Installed ${dockerVersion.trim()}`); + + console.log("- Installing Rust..."); + await execOrThrow( + builderInstance, + ` curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - `, { verbose: VERBOSE, prefix: " | " }); - const rustcVersion = await execOrThrow(builderInstance, 'rustc --version'); - console.log(` Installed ${rustcVersion.trim()}`); - - console.log(`Installed build tools`); - - console.log('Creating builder snapshot...'); - builderSnapshot = await builderInstance.snapshot({ - digest: BUILDER_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, - } - }); - console.log(`Created builder snapshot: ${builderSnapshot.id}`); - - console.log('Cleaning up...'); - if (builderInstance) { - console.log('- builder instance...'); - await builderInstance.stop(); - } - console.log('Cleaned up'); - } catch (e) { - if (builderInstance) { - console.log(`Left builder instance running: ${builderInstance.id}`); - } - throw e; + `, + { verbose: VERBOSE, prefix: " | " }, + ); + const rustcVersion = await execOrThrow(builderInstance, "rustc --version"); + console.log(` Installed ${rustcVersion.trim()}`); + + console.log("Installed build tools"); + + console.log("Creating builder snapshot..."); + builderSnapshot = await builderInstance.snapshot({ + digest: BUILDER_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, + }, + }); + console.log(`Created builder snapshot: ${builderSnapshot.id}`); + + console.log("Cleaning up..."); + if (builderInstance) { + console.log("- builder instance..."); + await builderInstance.stop(); } + console.log("Cleaned up"); + } catch (e) { + if (builderInstance) { + console.log(`Left builder instance running: ${builderInstance.id}`); + } + throw e; + } - return builderSnapshot; + return builderSnapshot; } -async function ensureSource(client: MorphCloudClient, builderSnapshot: Snapshot): Promise { - let sourceSnapshot = await findSnapshotByDigest(client, SOURCE_DIGEST); - if (sourceSnapshot) { - console.log(`Found source snapshot: ${sourceSnapshot.id}`); - return sourceSnapshot; - } +async function ensureSource( + client: MorphCloudClient, + builderSnapshot: Snapshot, +): Promise { + let sourceSnapshot = await findSnapshotByDigest(client, SOURCE_DIGEST); + if (sourceSnapshot) { + console.log(`Found source snapshot: ${sourceSnapshot.id}`); + return sourceSnapshot; + } - console.log('Creating source snapshot...'); + console.log("Creating source snapshot..."); - let sourceInstance: Instance | undefined; + let sourceInstance: Instance | undefined; + try { + console.log("Starting source instance..."); + sourceInstance = await startInstance(client, builderSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, + }, + }); + console.log(`Started source instance: ${sourceInstance.id}`); + + console.log("Uploading repository..."); + let ssh: NodeSSH | undefined; try { - console.log('Starting source instance...'); - sourceInstance = await startInstance(client, builderSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, - } - }); - console.log(`Started source instance: ${sourceInstance.id}`); - - console.log('Uploading repository...'); - let ssh: NodeSSH | undefined; - try { - const ssh = await sourceInstance.ssh(); - await ssh.putFile(SOURCE_ARCHIVE, SOURCE_ARCHIVE); - } finally { - if (ssh) { - ssh.dispose(); - } - } - await execOrThrow(sourceInstance, ` + const ssh = await sourceInstance.ssh(); + await ssh.putFile(SOURCE_ARCHIVE, SOURCE_ARCHIVE); + } finally { + if (ssh) { + ssh.dispose(); + } + } + await execOrThrow( + sourceInstance, + ` unzip -q ${SOURCE_ARCHIVE} -d ${SOURCE_DIR} \ && rm -f ${SOURCE_ARCHIVE} - `, { verbose: VERBOSE, prefix: "| " }); - console.log(`Uploaded repository`); - - console.log('Creating source snapshot...'); - sourceSnapshot = await sourceInstance.snapshot({ - digest: SOURCE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, - } - }); - console.log(`Created source snapshot: ${sourceSnapshot.id}`); - - console.log('Cleaning up...'); - if (sourceInstance) { - console.log('- source instance'); - await sourceInstance.stop(); - } - console.log('Cleaned up'); - } catch (e) { - if (sourceInstance) { - console.log(`Left source instance running: ${sourceInstance.id}`); - } - throw e; + `, + { verbose: VERBOSE, prefix: "| " }, + ); + console.log("Uploaded repository"); + + console.log("Creating source snapshot..."); + sourceSnapshot = await sourceInstance.snapshot({ + digest: SOURCE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, + }, + }); + console.log(`Created source snapshot: ${sourceSnapshot.id}`); + + console.log("Cleaning up..."); + if (sourceInstance) { + console.log("- source instance"); + await sourceInstance.stop(); } + console.log("Cleaned up"); + } catch (e) { + if (sourceInstance) { + console.log(`Left source instance running: ${sourceInstance.id}`); + } + throw e; + } - return sourceSnapshot; + return sourceSnapshot; } -async function ensureService(client: MorphCloudClient, sourceSnapshot: Snapshot): Promise { - let serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); - if (serviceSnapshot) { - console.log(`Found service snapshot: ${serviceSnapshot.id}`); - return serviceSnapshot; - } +async function ensureService( + client: MorphCloudClient, + sourceSnapshot: Snapshot, +): Promise { + let serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); + if (serviceSnapshot) { + console.log(`Found service snapshot: ${serviceSnapshot.id}`); + return serviceSnapshot; + } - console.log('Creating service snapshot...'); + console.log("Creating service snapshot..."); - let serviceInstance: Instance | undefined; - try { - console.log('Starting service instance...'); - serviceInstance = await startInstance(client, sourceSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, - } - }); - console.log(`Started service instance: ${serviceInstance.id}`); - - console.log('Building Nuanced LSP...'); - const buildStartTime = Date.now(); - console.log("- Rust Docker images"); - await execOrThrow(serviceInstance, ` + let serviceInstance: Instance | undefined; + try { + console.log("Starting service instance..."); + serviceInstance = await startInstance(client, sourceSnapshot, { + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, + }, + }); + console.log(`Started service instance: ${serviceInstance.id}`); + + console.log("Building Nuanced LSP..."); + const buildStartTime = Date.now(); + console.log("- Rust Docker images"); + await execOrThrow( + serviceInstance, + ` cd ${SOURCE_DIR} \ && scripts/build-rust-images.sh - `, { verbose: VERBOSE, prefix: " | " }); - console.log("- TypeScript CLI"); - await execOrThrow(serviceInstance, ` + `, + { verbose: VERBOSE, prefix: " | " }, + ); + console.log("- TypeScript CLI"); + await execOrThrow( + serviceInstance, + ` cd ${SOURCE_DIR}/clients/typescript \ && npm install \ && npm run build \ && npm install -g - `, { verbose: VERBOSE, prefix: " | " }); - const buildDuration = ((Date.now() - buildStartTime) / 1000).toFixed(2); - console.log(`Built Nuanced LSP in ${buildDuration}s`); - - console.log('Cleaning up repository...'); - await execOrThrow(serviceInstance, ` + `, + { verbose: VERBOSE, prefix: " | " }, + ); + const buildDuration = ((Date.now() - buildStartTime) / 1000).toFixed(2); + console.log(`Built Nuanced LSP in ${buildDuration}s`); + + console.log("Cleaning up repository..."); + await execOrThrow( + serviceInstance, + ` rm -rf ${SOURCE_DIR} - `, { verbose: VERBOSE, prefix: "| " }); - console.log(`Cleaned up repository`); - - console.log('Creating service snapshot...'); - serviceSnapshot = await serviceInstance.snapshot({ - digest: SERVICE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, - } - }); - console.log(`Created service snapshot: ${serviceSnapshot.id}`); - - console.log('Cleaning up...'); - if (serviceInstance) { - console.log('- service instance...'); - await serviceInstance.stop(); - } - console.log(`Cleaned up`); - } catch (e) { - if (serviceInstance) { - console.log(`Left service instance running: ${serviceInstance.id}`); - } - throw e; + `, + { verbose: VERBOSE, prefix: "| " }, + ); + console.log("Cleaned up repository"); + + console.log("Creating service snapshot..."); + serviceSnapshot = await serviceInstance.snapshot({ + digest: SERVICE_DIGEST, + metadata: { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, + }, + }); + console.log(`Created service snapshot: ${serviceSnapshot.id}`); + + console.log("Cleaning up..."); + if (serviceInstance) { + console.log("- service instance..."); + await serviceInstance.stop(); } + console.log("Cleaned up"); + } catch (e) { + if (serviceInstance) { + console.log(`Left service instance running: ${serviceInstance.id}`); + } + throw e; + } - return serviceSnapshot; + return serviceSnapshot; } async function main() { - const client = new MorphCloudClient(); - try { - const minimalSnapshot = await ensureBase(client); - const builderSnapshot = await ensureBuilder(client, minimalSnapshot); - const sourceSnapshot = await ensureSource(client, builderSnapshot); - const baseSnapshot = await ensureService(client, sourceSnapshot); - process.exit(0); - } catch (error) { - console.error('Error:', error); - process.exit(1); - } + const client = new MorphCloudClient(); + try { + const baseSnapshot = await ensureBase(client); + const builderSnapshot = await ensureBuilder(client, baseSnapshot); + const sourceSnapshot = await ensureSource(client, builderSnapshot); + const _serviceSnapshot = await ensureService(client, sourceSnapshot); + process.exit(0); + } catch (error) { + console.error("Error:", error); + process.exit(1); + } } main().catch(console.error); diff --git a/clients/managed/src/service-status.ts b/clients/managed/src/service-status.ts index c066a108..dd7cfb0c 100755 --- a/clients/managed/src/service-status.ts +++ b/clients/managed/src/service-status.ts @@ -1,31 +1,49 @@ #!/usr/bin/env node -import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; -import { SERVICE_DIGEST, BUILDER_DIGEST, LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, BASE_DIGEST, NUANCED_ROLE_WORKSPACE, SOURCE_DIGEST } from './util/constants'; -import { findSnapshotByDigest, formatInstanceStatus, listInstances } from './util/morphcloud'; +import { MorphCloudClient } from "morphcloud"; +import { + SERVICE_DIGEST, + BUILDER_DIGEST, + LABEL_NUANCED_ROLE, + LABEL_NUANCED_WORKSPACE_DIGEST, + BASE_DIGEST, + NUANCED_ROLE_WORKSPACE, + SOURCE_DIGEST, +} from "./util/constants"; +import { + findSnapshotByDigest, + formatInstanceStatus, + listInstances, +} from "./util/morphcloud"; async function main() { const client = new MorphCloudClient(); try { console.log("System snapshots:"); for (const digest of [ - BASE_DIGEST, - BUILDER_DIGEST, - SOURCE_DIGEST, - SERVICE_DIGEST, + BASE_DIGEST, + BUILDER_DIGEST, + SOURCE_DIGEST, + SERVICE_DIGEST, ]) { - const snapshot = await findSnapshotByDigest(client, digest); - console.log(`- ${digest.padEnd(20)} : ${snapshot ? snapshot.id : ""}`); + const snapshot = await findSnapshotByDigest(client, digest); + console.log( + `- ${digest.padEnd(20)} : ${snapshot ? snapshot.id : ""}`, + ); } console.log("Workspace instances:"); - for (const instance of await listInstances(client, { metadata: { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE } })) { - const digest = instance.metadata![LABEL_NUANCED_WORKSPACE_DIGEST]!; - console.log(`- ${digest.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`); + for (const instance of await listInstances(client, { + metadata: { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE }, + })) { + const digest = instance.metadata![LABEL_NUANCED_WORKSPACE_DIGEST]!; + console.log( + `- ${digest.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, + ); } } catch (error) { - console.error('Error:', error); + console.error("Error:", error); process.exit(1); - } + } } main().catch(console.error); diff --git a/clients/managed/src/util/config.ts b/clients/managed/src/util/config.ts index fae768fe..4aa4a261 100644 --- a/clients/managed/src/util/config.ts +++ b/clients/managed/src/util/config.ts @@ -2,29 +2,34 @@ import { join } from "path"; import { homedir } from "os"; import { mkdir } from "fs/promises"; import { Instance } from "morphcloud"; -import { getLocallyUniqueDigest, getGloballyUniqueDigest } from "./digest"; export async function ensureConfigDirectory(): Promise { - const configDir = join(homedir(), ".nuanced"); - await mkdir(configDir, { recursive: true }); - return configDir; + const configDir = join(homedir(), ".nuanced"); + await mkdir(configDir, { recursive: true }); + return configDir; } -export async function ensureInstanceConfigDirectory(instance: Instance): Promise { - const configDir = await ensureConfigDirectory(); - const instanceDir = join(configDir, "instances", instance.id); - await mkdir(instanceDir, { recursive: true }); - return instanceDir; +export async function ensureInstanceConfigDirectory( + instance: Instance, +): Promise { + const configDir = await ensureConfigDirectory(); + const instanceDir = join(configDir, "instances", instance.id); + await mkdir(instanceDir, { recursive: true }); + return instanceDir; } -export async function ensureWorkspaceConfigDirectory(workspaceDigest: string): Promise { - const configDir = await ensureConfigDirectory(); - const workspaceDir = join(configDir, "workspaces", workspaceDigest); - await mkdir(workspaceDir, { recursive: true }); - return workspaceDir; +export async function ensureWorkspaceConfigDirectory( + workspaceDigest: string, +): Promise { + const configDir = await ensureConfigDirectory(); + const workspaceDir = join(configDir, "workspaces", workspaceDigest); + await mkdir(workspaceDir, { recursive: true }); + return workspaceDir; } -export async function getWorkspaceProcessRcPath(workspaceDigest: string): Promise { - const workspaceDir = await ensureWorkspaceConfigDirectory(workspaceDigest); - return join(workspaceDir, "servers.json"); +export async function getWorkspaceProcessRcPath( + workspaceDigest: string, +): Promise { + const workspaceDir = await ensureWorkspaceConfigDirectory(workspaceDigest); + return join(workspaceDir, "servers.json"); } diff --git a/clients/managed/src/util/constants.ts b/clients/managed/src/util/constants.ts index 3cee2f89..eeb78aa2 100644 --- a/clients/managed/src/util/constants.ts +++ b/clients/managed/src/util/constants.ts @@ -1,5 +1,3 @@ -import { ExecOptions, Instance, InstanceBootOptions, InstanceListOptions, InstanceStartOptions, InstanceStatus, MorphCloudClient, Snapshot } from 'morphcloud'; - export const SOURCE_ARCHIVE = "nuanced-lsp-src.zip"; export const SOURCE_DIR = "nuanced-lsp-src"; @@ -8,13 +6,13 @@ export const BUILDER_DIGEST = "nuanced-lsp-builder"; export const SOURCE_DIGEST = "nuanced-lsp-source"; export const SERVICE_DIGEST = "nuanced-lsp-service"; -export const LABEL_NUANCED_ROLE = "nuanced.role" +export const LABEL_NUANCED_ROLE = "nuanced.role"; -export const NUANCED_ROLE_BASE = "base" -export const NUANCED_ROLE_BUILDER = "builder" -export const NUANCED_ROLE_SOURCE = "source" -export const NUANCED_ROLE_SERVICE = "service" +export const NUANCED_ROLE_BASE = "base"; +export const NUANCED_ROLE_BUILDER = "builder"; +export const NUANCED_ROLE_SOURCE = "source"; +export const NUANCED_ROLE_SERVICE = "service"; -export const NUANCED_ROLE_WORKSPACE = "workspace" +export const NUANCED_ROLE_WORKSPACE = "workspace"; -export const LABEL_NUANCED_WORKSPACE_DIGEST = "nuanced.workspace.digest" +export const LABEL_NUANCED_WORKSPACE_DIGEST = "nuanced.workspace.digest"; diff --git a/clients/managed/src/util/digest.ts b/clients/managed/src/util/digest.ts index 45fbaf80..ab9d020f 100644 --- a/clients/managed/src/util/digest.ts +++ b/clients/managed/src/util/digest.ts @@ -1,14 +1,18 @@ -import { createHash } from 'crypto'; -import { machineIdSync } from 'node-machine-id'; +import { createHash } from "crypto"; +import { machineIdSync } from "node-machine-id"; const MACHINE_ID = machineIdSync(); export function getLocallyUniqueDigest(value: string): string { - const hash = createHash('sha256').update(value).digest('hex'); - return hash.substring(0, 12); + const hash = createHash("sha256").update(value).digest("hex"); + return hash.substring(0, 12); } export function getGloballyUniqueDigest(value: string): string { - const hash = createHash('sha256').update(MACHINE_ID).update("-").update(value).digest('hex'); - return hash.substring(0, 12); + const hash = createHash("sha256") + .update(MACHINE_ID) + .update("-") + .update(value) + .digest("hex"); + return hash.substring(0, 12); } diff --git a/clients/managed/src/util/morphcloud.ts b/clients/managed/src/util/morphcloud.ts index 7be1f322..b5e14223 100644 --- a/clients/managed/src/util/morphcloud.ts +++ b/clients/managed/src/util/morphcloud.ts @@ -1,118 +1,151 @@ -import { ExecOptions, Instance, InstanceBootOptions, InstanceListOptions, InstanceStartOptions, InstanceStatus, MorphCloudClient, Snapshot } from 'morphcloud'; +import { + ExecOptions, + Instance, + InstanceBootOptions, + InstanceListOptions, + InstanceStartOptions, + InstanceStatus, + MorphCloudClient, + Snapshot, +} from "morphcloud"; -export async function findSnapshotByDigest(client: MorphCloudClient, digest: string): Promise { - const snapshots = await client.snapshots.list({ - digest, - }); - const snapshot = snapshots[0]; - return snapshot; +export async function findSnapshotByDigest( + client: MorphCloudClient, + digest: string, +): Promise { + const snapshots = await client.snapshots.list({ + digest, + }); + const snapshot = snapshots[0]; + return snapshot; } -export async function bootInstance(client: MorphCloudClient, snapshot: Snapshot, opts?: { +export async function bootInstance( + client: MorphCloudClient, + snapshot: Snapshot, + opts?: { vcpus?: number; memory?: number; diskSize?: number; metadata?: Record; -}): Promise { - const bootOpts: InstanceBootOptions = { - ttlSeconds: 3600, - ...opts, - snapshotId: snapshot.id, - }; - const instance = await client.instances.boot(bootOpts); - await instance.waitUntilReady(30); - return instance; + }, +): Promise { + const bootOpts: InstanceBootOptions = { + ttlSeconds: 3600, + ...opts, + snapshotId: snapshot.id, + }; + const instance = await client.instances.boot(bootOpts); + await instance.waitUntilReady(30); + return instance; } -export async function startInstance(client: MorphCloudClient, snapshot: Snapshot, opts?: { +export async function startInstance( + client: MorphCloudClient, + snapshot: Snapshot, + opts?: { metadata?: Record; -}): Promise { - const startOpts: InstanceStartOptions = { - ttlSeconds: 3600, - ...opts, - snapshotId: snapshot.id, - }; - const instance = await client.instances.start(startOpts); - await instance.waitUntilReady(30); - return instance; + }, +): Promise { + const startOpts: InstanceStartOptions = { + ttlSeconds: 3600, + ...opts, + snapshotId: snapshot.id, + }; + const instance = await client.instances.start(startOpts); + await instance.waitUntilReady(30); + return instance; } -export async function findInstance(client: MorphCloudClient, opts?: { +export async function findInstance( + client: MorphCloudClient, + opts?: { metadata?: Record; ensureReady?: boolean; -}): Promise { - const listOpts: InstanceListOptions = { - ...opts, - }; - const instances = await client.instances.list(listOpts) - if (instances.length > 1) { - throw new Error("Multiple matching instances"); - } - const instance = instances[0]; - if (instance) { - switch (instance.status) { - case InstanceStatus.ERROR: - throw new Error(`instance ${instance.id} in error state`); - case InstanceStatus.PAUSED: - if (opts?.ensureReady) { - await instance.resume(); - await instance.waitUntilReady(30); - } - break; - case InstanceStatus.PENDING: - if (opts?.ensureReady) { - await instance.waitUntilReady(30); - } - break; - case InstanceStatus.SAVING: - if (opts?.ensureReady) { - await instance.waitUntilReady(30); - } - break; + }, +): Promise { + const listOpts: InstanceListOptions = { + ...opts, + }; + const instances = await client.instances.list(listOpts); + if (instances.length > 1) { + throw new Error("Multiple matching instances"); + } + const instance = instances[0]; + if (instance) { + switch (instance.status) { + case InstanceStatus.ERROR: + throw new Error(`instance ${instance.id} in error state`); + case InstanceStatus.PAUSED: + if (opts?.ensureReady) { + await instance.resume(); + await instance.waitUntilReady(30); + } + break; + case InstanceStatus.PENDING: + if (opts?.ensureReady) { + await instance.waitUntilReady(30); } + break; + case InstanceStatus.SAVING: + if (opts?.ensureReady) { + await instance.waitUntilReady(30); + } + break; } - return instance; + } + return instance; } -export async function listInstances(client: MorphCloudClient, opts?: { +export async function listInstances( + client: MorphCloudClient, + opts?: { metadata?: Record; -}): Promise { - const listOpts: InstanceListOptions = { - ...opts, - }; - return await client.instances.list(listOpts); + }, +): Promise { + const listOpts: InstanceListOptions = { + ...opts, + }; + return await client.instances.list(listOpts); } export function formatInstanceStatus(status: InstanceStatus): string { - switch (status) { - case InstanceStatus.ERROR: return "ERROR"; - case InstanceStatus.PENDING: return "PENDING"; - case InstanceStatus.READY: return "READY"; - case InstanceStatus.PAUSED: return "PAUSED"; - case InstanceStatus.SAVING: return "SAVING"; - } + switch (status) { + case InstanceStatus.ERROR: + return "ERROR"; + case InstanceStatus.PENDING: + return "PENDING"; + case InstanceStatus.READY: + return "READY"; + case InstanceStatus.PAUSED: + return "PAUSED"; + case InstanceStatus.SAVING: + return "SAVING"; + } } -export async function execOrThrow(instance: Instance, cmd: string, opts?: { - verbose?: boolean, - prefix?: string, -}): Promise { - const { - verbose = false, - prefix = "", - } = opts || {}; - function logger(inner: (line: string) => void) { - return (content: string) => content.split('\n').forEach((line: string) => inner(`${prefix}${line}`)); - } - const execOpts: ExecOptions = {}; - if (verbose) { - execOpts.onStdout = logger(console.log); - execOpts.onStderr = logger(console.error); - } - try { - const response = await instance.exec(cmd, execOpts); - return response.stdout; - } catch (e) { - throw new Error(`Exec failed: ${e}`); - } +export async function execOrThrow( + instance: Instance, + cmd: string, + opts?: { + verbose?: boolean; + prefix?: string; + }, +): Promise { + const { verbose = false, prefix = "" } = opts || {}; + function logger(inner: (line: string) => void) { + return (content: string) => + content.split("\n").forEach((line: string) => inner(`${prefix}${line}`)); + } + const execOpts: ExecOptions = {}; + if (verbose) { + execOpts.onStdout = logger(console.log); + execOpts.onStderr = logger(console.error); + } + try { + const response = await instance.exec(cmd, execOpts); + return response.stdout; + } catch (e) { + throw new Error(`Exec failed: ${e}`); + } } diff --git a/clients/managed/src/util/mutagen.ts b/clients/managed/src/util/mutagen.ts index d5363238..55719d6f 100644 --- a/clients/managed/src/util/mutagen.ts +++ b/clients/managed/src/util/mutagen.ts @@ -1,123 +1,137 @@ -import { spawn } from "child-process-promise"; -import { Instance } from "morphcloud"; import { join } from "path"; import { platform, arch } from "os"; -import { mkdir, rm, writeFile } from "fs/promises"; -import { ensureConfigDirectory, ensureInstanceConfigDirectory } from "./config"; +import { mkdir } from "fs/promises"; +import { Instance } from "morphcloud"; +import { spawn } from "child-process-promise"; +import { ensureConfigDirectory } from "./config"; import { getSshConfig, removeSshConfig } from "./ssh"; const MISSING_SESSION_ERROR = "unable to locate requested session"; export class MutagenClient { - constructor( - private readonly instance: Instance, - ) { } + constructor(private readonly instance: Instance) {} - private syncName(): string { - return `nuanced-lsp-${this.instance.id.replace('_', '-')}`; - } + private syncName(): string { + return `nuanced-lsp-${this.instance.id.replace("_", "-")}`; + } - // Status: Staging files on beta - // Status: Applying changes - // Status: Watching for changes + // Status: Staging files on beta + // Status: Applying changes + // Status: Watching for changes - async createSync(localDirectory: string): Promise { - try { - const sshConfig = await getSshConfig(this.instance); - await this.spawnMutagen([ - "sync", "create", - "--name", this.syncName(), - "--mode", "two-way-safe", - localDirectory, - `${sshConfig.user}@${sshConfig.host}:workspace`, - ], { env: { MUTAGEN_SSH_CONFIG_BETA: sshConfig.configPath } }); - } catch (e) { - throw new Error(`Failed to create sync: ${(e as any).stderr}`); - } + async createSync(localDirectory: string): Promise { + try { + const sshConfig = await getSshConfig(this.instance); + await this.spawnMutagen( + [ + "sync", + "create", + "--name", + this.syncName(), + "--mode", + "two-way-safe", + localDirectory, + `${sshConfig.user}@${sshConfig.host}:workspace`, + ], + { env: { MUTAGEN_SSH_CONFIG_BETA: sshConfig.configPath } }, + ); + } catch (e) { + throw new Error(`Failed to create sync: ${(e as any).stderr}`); } + } - async findSync(opts?: { ensureReady?: boolean }): Promise { - try { - await this.spawnMutagen(["sync", "list", this.syncName()]); - // TODO implement waiting on ready status - return true; - } catch (e) { - const stderr = (e as any).stderr.toLowerCase(); - if (stderr.includes(MISSING_SESSION_ERROR)) { - return false; - } else { - throw new Error(`Failed to find sync: ${(e as any).stderr}`); - } - } + async findSync(_opts?: { ensureReady?: boolean }): Promise { + try { + await this.spawnMutagen(["sync", "list", this.syncName()]); + // TODO implement waiting on ready status + return true; + } catch (e) { + const stderr = (e as any).stderr.toLowerCase(); + if (stderr.includes(MISSING_SESSION_ERROR)) { + return false; + } else { + throw new Error(`Failed to find sync: ${(e as any).stderr}`); + } } + } - async flushSync(): Promise { - try { - await this.spawnMutagen(["sync", "flush", this.syncName()]); - } catch (e) { - throw new Error(`Failed to flush sync: ${(e as any).stderr}`); - } + async flushSync(): Promise { + try { + await this.spawnMutagen(["sync", "flush", this.syncName()]); + } catch (e) { + throw new Error(`Failed to flush sync: ${(e as any).stderr}`); } + } - async pauseSync(): Promise { - try { - await this.spawnMutagen(["sync", "pause", this.syncName()]); - } catch (e) { - throw new Error(`Failed to pause sync: ${(e as any).stderr}`); - } + async pauseSync(): Promise { + try { + await this.spawnMutagen(["sync", "pause", this.syncName()]); + } catch (e) { + throw new Error(`Failed to pause sync: ${(e as any).stderr}`); } + } - async resumeSync(): Promise { - try { - const sshConfig = await getSshConfig(this.instance); - await this.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}`); - } + async resumeSync(): Promise { + try { + const sshConfig = await getSshConfig(this.instance); + await this.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}`); } + } - async stopSync(): Promise { - try { - await this.spawnMutagen(["sync", "terminate", this.syncName()]); - } catch (e) { - const stderr = (e as any).stderr.toLowerCase(); - if (stderr.includes(MISSING_SESSION_ERROR)) { - return; - } else { - throw new Error(`Failed to stop sync: ${(e as any).stderr}`); - } - } finally { - await removeSshConfig(this.instance); - } + async stopSync(): Promise { + try { + await this.spawnMutagen(["sync", "terminate", this.syncName()]); + } catch (e) { + const stderr = (e as any).stderr.toLowerCase(); + if (stderr.includes(MISSING_SESSION_ERROR)) { + return; + } else { + throw new Error(`Failed to stop sync: ${(e as any).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 }); + private async spawnMutagen( + args: string[], + opts?: { env?: Record }, + ): Promise { + const mutagenDataDir = join(await ensureConfigDirectory(), "mutagen"); + await mkdir(mutagenDataDir, { recursive: true }); - const spawnCommand = getMutagenBinaryPath(); - const spawnArgs = [...args]; - const spawnEnv = { - ...opts?.env, - MUTAGEN_DATA_DIRECTORY: mutagenDataDir, - }; - await spawn(spawnCommand, spawnArgs, { - capture: ["stderr"], - env: { - ...process.env, - ...spawnEnv, - }, - }); - } + const spawnCommand = getMutagenBinaryPath(); + const spawnArgs = [...args]; + const spawnEnv = { + ...opts?.env, + MUTAGEN_DATA_DIRECTORY: mutagenDataDir, + }; + await spawn(spawnCommand, spawnArgs, { + capture: ["stderr"], + env: { + ...process.env, + ...spawnEnv, + }, + }); + } } function getMutagenBinaryPath(): string { - const platformArch = `${platform()}_${arch()}`; - const binaryName = platform() === "win32" ? "mutagen.exe" : "mutagen"; - const binPath = join(__dirname, "..", "..", "bin", "mutagen", platformArch, binaryName); + const platformArch = `${platform()}_${arch()}`; + const binaryName = platform() === "win32" ? "mutagen.exe" : "mutagen"; + const binPath = join( + __dirname, + "..", + "..", + "bin", + "mutagen", + platformArch, + binaryName, + ); - return binPath; + return binPath; } diff --git a/clients/managed/src/util/process-rc.ts b/clients/managed/src/util/process-rc.ts index d6708064..077e4403 100644 --- a/clients/managed/src/util/process-rc.ts +++ b/clients/managed/src/util/process-rc.ts @@ -1,92 +1,92 @@ -import * as fs from 'fs/promises'; -import * as lockfile from 'proper-lockfile'; +import * as fs from "fs/promises"; +import * as lockfile from "proper-lockfile"; export class ProcessRc { - private readonly filePath: string; - private readonly lockOptions: lockfile.LockOptions; + private readonly filePath: string; + private readonly lockOptions: lockfile.LockOptions; - constructor(filePath: string) { - this.filePath = filePath; - this.lockOptions = { - retries: { - retries: 5, - minTimeout: 100, - maxTimeout: 1000, - }, - }; - } + constructor(filePath: string) { + this.filePath = filePath; + this.lockOptions = { + retries: { + retries: 5, + minTimeout: 100, + maxTimeout: 1000, + }, + }; + } - async acquire(init?: () => Promise): Promise { - const release = await this.lockFile(); - try { - const pids = await this.readPids(); - const runningPids = await this.filterRunningPids(pids); + async acquire(init?: () => Promise): Promise { + const release = await this.lockFile(); + try { + const pids = await this.readPids(); + const runningPids = await this.filterRunningPids(pids); - if (runningPids.length === 0 && init) { - await init(); - } + if (runningPids.length === 0 && init) { + await init(); + } - runningPids.push(process.pid); - await this.writePids(runningPids); - } finally { - await release(); - } + runningPids.push(process.pid); + await this.writePids(runningPids); + } finally { + await release(); } + } - async release(cleanup?: () => Promise): Promise { - const release = await this.lockFile(); - try { - const pids = await this.readPids(); - const runningPids = await this.filterRunningPids(pids); - const filteredPids = runningPids.filter(pid => pid !== process.pid); - await this.writePids(filteredPids); + async release(cleanup?: () => Promise): Promise { + const release = await this.lockFile(); + try { + const pids = await this.readPids(); + const runningPids = await this.filterRunningPids(pids); + const filteredPids = runningPids.filter((pid) => pid !== process.pid); + await this.writePids(filteredPids); - if (filteredPids.length === 0 && cleanup) { - await cleanup(); - } - } finally { - await release(); - } + if (filteredPids.length === 0 && cleanup) { + await cleanup(); + } + } finally { + await release(); } + } - private async lockFile(): Promise<() => Promise> { - await this.ensureFileExists(); - return await lockfile.lock(this.filePath, this.lockOptions); - } + private async lockFile(): Promise<() => Promise> { + await this.ensureFileExists(); + return await lockfile.lock(this.filePath, this.lockOptions); + } - private async ensureFileExists(): Promise { - try { - await fs.access(this.filePath); - } catch { - await fs.writeFile(this.filePath, JSON.stringify([])); - } + private async ensureFileExists(): Promise { + try { + await fs.access(this.filePath); + } catch { + await fs.writeFile(this.filePath, JSON.stringify([])); } + } - private async readPids(): Promise { - const content = await fs.readFile(this.filePath, 'utf-8'); - return JSON.parse(content); - } + private async readPids(): Promise { + const content = await fs.readFile(this.filePath, "utf-8"); + return JSON.parse(content); + } - private async writePids(pids: number[]): Promise { - await fs.writeFile(this.filePath, JSON.stringify(pids)); - } + private async writePids(pids: number[]): Promise { + await fs.writeFile(this.filePath, JSON.stringify(pids)); + } - private async filterRunningPids(pids: number[]): Promise { - const runningPids: number[] = []; - for (const pid of pids) { - if (await this.isProcessRunning(pid)) { - runningPids.push(pid); - } - } - return runningPids; + private async filterRunningPids(pids: number[]): Promise { + const runningPids: number[] = []; + for (const pid of pids) { + if (await this.isProcessRunning(pid)) { + runningPids.push(pid); + } } + return runningPids; + } - private async isProcessRunning(pid: number): Promise { - try { - process.kill(pid, 0); - return true; - } catch { - return false; - } + private async isProcessRunning(pid: number): Promise { + try { + process.kill(pid, 0); + return true; + } catch { + return false; } + } } diff --git a/clients/managed/src/util/sleep.ts b/clients/managed/src/util/sleep.ts index fa765e62..69dc25d6 100644 --- a/clients/managed/src/util/sleep.ts +++ b/clients/managed/src/util/sleep.ts @@ -1,3 +1,3 @@ export async function sleep(ms: number) { - return new Promise(resolve => setTimeout(resolve, ms)); + return new Promise((resolve) => setTimeout(resolve, ms)); } diff --git a/clients/managed/src/util/ssh.ts b/clients/managed/src/util/ssh.ts index 26b9c1c5..47a719e7 100644 --- a/clients/managed/src/util/ssh.ts +++ b/clients/managed/src/util/ssh.ts @@ -1,38 +1,38 @@ import { rm, writeFile } from "fs/promises"; +import { join } from "path"; +import stream from "stream"; import { Instance } from "morphcloud"; import { NodeSSH } from "node-ssh"; -import { join } from "path"; import { ClientChannel } from "ssh2"; -import stream from 'stream'; import { ensureInstanceConfigDirectory } from "./config"; const MORPH_SSH_HOST = "ssh.cloud.morph.so"; export interface SshConfig { - user: string; - host: string; - configPath: string; -}; + user: string; + host: string; + configPath: string; +} export async function getSshConfig(instance: Instance): Promise { - const configPath = await writeSshConfig(instance); - return { - user: instance.id, - host: MORPH_SSH_HOST, - configPath, - }; + const configPath = await writeSshConfig(instance); + return { + user: instance.id, + host: MORPH_SSH_HOST, + configPath, + }; } -async function writeSshConfig(instance: Instance): Promise { - const instanceConfigDir = await ensureInstanceConfigDirectory(instance); +async function writeSshConfig(instance: Instance): Promise { + const instanceConfigDir = await ensureInstanceConfigDirectory(instance); - const sshKey = await instance.sshKey(); - const sshKeyPath = join(instanceConfigDir, "ssh_key"); - await writeFile(sshKeyPath, sshKey.private_key, { mode: 0o600 }); + const sshKey = await instance.sshKey(); + const sshKeyPath = join(instanceConfigDir, "ssh_key"); + await writeFile(sshKeyPath, sshKey.private_key, { mode: 0o600 }); - const sshConfigPath = join(instanceConfigDir, "ssh_config"); + const sshConfigPath = join(instanceConfigDir, "ssh_config"); - const sshConfig = `# Generated file + const sshConfig = `# Generated file HostName ${MORPH_SSH_HOST} User ${instance.id} IdentityFile ${sshKeyPath} @@ -43,41 +43,44 @@ WarnWeakCrypto no UserKnownHostsFile /dev/null `; - await writeFile(sshConfigPath, sshConfig); - return sshConfigPath; + await writeFile(sshConfigPath, sshConfig); + return sshConfigPath; } -export async function removeSshConfig(instance: Instance): Promise { - const instanceConfigDir = await ensureInstanceConfigDirectory(instance); - try { - await rm(instanceConfigDir, { recursive: true, force: true }); - } catch(e) { - // Ignore errors during cleanup - } +export async function removeSshConfig(instance: Instance): Promise { + const instanceConfigDir = await ensureInstanceConfigDirectory(instance); + try { + await rm(instanceConfigDir, { recursive: true, force: true }); + } catch { + // Ignore errors during cleanup + } } -export function sshExec(ssh: NodeSSH, command: string, args: string[], opts?: { - stdin?: stream.Readable, - stdout?: stream.Writable, - stderr?: stream.Writable, -}): Promise { - return new Promise(async (resolve, reject) => { - try { - await ssh.exec(command, args, { - stdin: opts?.stdin, - stream: "both", - onChannel: (stream) => { - if (opts?.stdout) { - stream.stdout.pipe(opts.stdout); - } - if (opts?.stderr) { - stream.stderr.pipe(opts.stderr); - } - resolve(stream); - } - }); - } catch (e) { - reject(e); - } - }); +export function sshExec( + ssh: NodeSSH, + command: string, + args: string[], + opts?: { + stdin?: stream.Readable; + stdout?: stream.Writable; + stderr?: stream.Writable; + }, +): Promise { + return new Promise((resolve, reject) => { + ssh + .exec(command, args, { + stdin: opts?.stdin, + stream: "both", + onChannel: (stream) => { + if (opts?.stdout) { + stream.stdout.pipe(opts.stdout); + } + if (opts?.stderr) { + stream.stderr.pipe(opts.stderr); + } + resolve(stream); + }, + }) + .catch(reject); + }); } diff --git a/clients/managed/src/workspace-lsp.ts b/clients/managed/src/workspace-lsp.ts index db9f0deb..90960747 100755 --- a/clients/managed/src/workspace-lsp.ts +++ b/clients/managed/src/workspace-lsp.ts @@ -1,126 +1,152 @@ #!/usr/bin/env node -import { Instance, MorphCloudClient } from 'morphcloud'; -import { SERVICE_DIGEST, NUANCED_ROLE_WORKSPACE, LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST } from './util/constants'; -import { findSnapshotByDigest, startInstance, findInstance, formatInstanceStatus } from './util/morphcloud'; -import { MutagenClient } from './util/mutagen'; -import { realpathSync } from 'fs'; -import { getGloballyUniqueDigest } from './util/digest'; -import { sshExec } from './util/ssh'; -import { sleep } from './util/sleep'; -import { getWorkspaceProcessRcPath } from './util/config'; -import { ProcessRc } from './util/process-rc'; +import { realpathSync } from "fs"; +import { Instance, MorphCloudClient } from "morphcloud"; +import { + SERVICE_DIGEST, + NUANCED_ROLE_WORKSPACE, + LABEL_NUANCED_ROLE, + LABEL_NUANCED_WORKSPACE_DIGEST, +} from "./util/constants"; +import { + findSnapshotByDigest, + startInstance, + findInstance, +} from "./util/morphcloud"; +import { MutagenClient } from "./util/mutagen"; +import { getGloballyUniqueDigest } from "./util/digest"; +import { sshExec } from "./util/ssh"; +import { getWorkspaceProcessRcPath } from "./util/config"; +import { ProcessRc } from "./util/process-rc"; async function main() { - const client = new MorphCloudClient(); + const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); - const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, - }; + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; - const processRcPath = await getWorkspaceProcessRcPath(workspaceDigest); - const processRc = new ProcessRc(processRcPath); + const processRcPath = await getWorkspaceProcessRcPath(workspaceDigest); + const processRc = new ProcessRc(processRcPath); - let workspaceInstance: Instance | undefined; - let mutagen: MutagenClient | undefined; - try { - await processRc.acquire(async () => { - workspaceInstance = await findInstance(client, { metadata, ensureReady: true }); + let workspaceInstance: Instance | undefined; + let mutagen: MutagenClient | undefined; + try { + await processRc.acquire(async () => { + workspaceInstance = await findInstance(client, { + metadata, + ensureReady: true, + }); - if (!workspaceInstance) { - console.error("Creating workspace instance..."); - const serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); - if (!serviceSnapshot) { - throw new Error(`Missing service snapshot. Run setup.ts first.`); - } - workspaceInstance = await startInstance(client, serviceSnapshot, { metadata }); - } - console.error(`Workspace instance: ${workspaceInstance.id}`); + if (!workspaceInstance) { + console.error("Creating workspace instance..."); + const serviceSnapshot = await findSnapshotByDigest( + client, + SERVICE_DIGEST, + ); + if (!serviceSnapshot) { + throw new Error("Missing service snapshot. Run setup.ts first."); + } + workspaceInstance = await startInstance(client, serviceSnapshot, { + metadata, }); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + }); - if (!workspaceInstance) { - workspaceInstance = await findInstance(client, { metadata, ensureReady: true }); - if (!workspaceInstance) { - throw new Error(`Workspace instance not found after acquire`); - } - console.error(`Workspace instance: ${workspaceInstance.id}`); - } + if (!workspaceInstance) { + workspaceInstance = await findInstance(client, { + metadata, + ensureReady: true, + }); + if (!workspaceInstance) { + throw new Error("Workspace instance not found after acquire"); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + } - mutagen = new MutagenClient(workspaceInstance); + mutagen = new MutagenClient(workspaceInstance); - if (await mutagen.findSync({ ensureReady: true })) { - console.error("Resuming file sync..."); - try { - await mutagen.resumeSync(); - } catch (e) { - throw new Error(`Failed to resume file sync: ${e}`); - } - } else { - console.error("Creating file sync..."); - try { - await mutagen.createSync(workspaceDir); - await mutagen.flushSync(); - } catch (e) { - throw new Error(`Failed to setup file sync: ${e}`); - } - } + if (await mutagen.findSync({ ensureReady: true })) { + console.error("Resuming file sync..."); + try { + await mutagen.resumeSync(); + } catch (e) { + throw new Error(`Failed to resume file sync: ${e}`); + } + } else { + console.error("Creating file sync..."); + try { + await mutagen.createSync(workspaceDir); + await mutagen.flushSync(); + } catch (e) { + throw new Error(`Failed to setup file sync: ${e}`); + } + } - console.error("Executing nuanced-lsp..."); - const ssh = await workspaceInstance.ssh(); - const cmd = "nuanced-lsp"; - const args = [ - "server", - "--proxy-image", "nuanced-lsp-proxy:latest", - "--watchdog-image", "nuanced-lsp-watchdog:latest", - "--wrapper-image", "nuanced-lsp-wrapper:latest", - "--shared", - "workspace", - ]; - console.error(`nuanced-lsp ${args.join(" ")}`); - const stream = await sshExec(ssh, cmd, args, { - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - }); - stream.on('close', async () => { - ssh.dispose(); - await processRc.release(async () => { - if (mutagen) { - await mutagen.pauseSync().catch((e) => { - console.error(`Error: Cannot pause sync ${workspaceDigest}: ${e}`); - }); - } - if (workspaceInstance) { - await workspaceInstance.pause().catch((e) => { - console.error(`Error: Cannot pause instance ${workspaceInstance!.id}: ${e}`); - }); - } - }); - process.exit(0); + console.error("Executing nuanced-lsp..."); + const ssh = await workspaceInstance.ssh(); + const cmd = "nuanced-lsp"; + const args = [ + "server", + "--proxy-image", + "nuanced-lsp-proxy:latest", + "--watchdog-image", + "nuanced-lsp-watchdog:latest", + "--wrapper-image", + "nuanced-lsp-wrapper:latest", + "--shared", + "workspace", + ]; + console.error(`nuanced-lsp ${args.join(" ")}`); + const stream = await sshExec(ssh, cmd, args, { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }); + stream.on("close", async () => { + ssh.dispose(); + await processRc.release(async () => { + if (mutagen) { + await mutagen.pauseSync().catch((e) => { + console.error(`Error: Cannot pause sync ${workspaceDigest}: ${e}`); + }); + } + if (workspaceInstance) { + await workspaceInstance.pause().catch((e) => { + console.error( + `Error: Cannot pause instance ${workspaceInstance!.id}: ${e}`, + ); + }); + } + }); + process.exit(0); + }); + } catch (e) { + await processRc.release(async () => { + if (mutagen) { + await mutagen.stopSync().catch((e) => { + console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); }); - } catch (e) { - await processRc.release(async () => { - if (mutagen) { - await mutagen.stopSync().catch((e) => { - console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); - }); - } - if (workspaceInstance) { - await workspaceInstance.stop().catch((e) => { - console.error(`Error: Cannot stop instance ${workspaceInstance!.id}: ${e}`); - }); - } + } + if (workspaceInstance) { + await workspaceInstance.stop().catch((e) => { + console.error( + `Error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, + ); }); - console.error(`Error: ${e}`); - process.exitCode = 1; - } + } + }); + console.error(`Error: ${e}`); + process.exitCode = 1; + } } main().catch(console.error); diff --git a/clients/managed/src/workspace-status.ts b/clients/managed/src/workspace-status.ts index 63574145..0cc36056 100755 --- a/clients/managed/src/workspace-status.ts +++ b/clients/managed/src/workspace-status.ts @@ -1,33 +1,39 @@ #!/usr/bin/env node -import { Instance, MorphCloudClient, Snapshot } from 'morphcloud'; -import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE } from './util/constants'; -import { findInstance } from './util/morphcloud'; -import { realpathSync } from 'fs'; -import { getGloballyUniqueDigest } from './util/digest'; +import { realpathSync } from "fs"; +import { Instance, MorphCloudClient } from "morphcloud"; +import { + LABEL_NUANCED_ROLE, + LABEL_NUANCED_WORKSPACE_DIGEST, + NUANCED_ROLE_WORKSPACE, +} from "./util/constants"; +import { findInstance } from "./util/morphcloud"; +import { getGloballyUniqueDigest } from "./util/digest"; async function main() { - const client = new MorphCloudClient(); + const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); - const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, - }; + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; - let workspaceInstance: Instance | undefined; - try { - workspaceInstance = await findInstance(client, { metadata }); - console.log(`${workspaceDigest.padEnd(2)} : ${workspaceInstance?.id ?? ""}`); - } catch (error) { - console.error('Error:', error); - process.exitCode = 1; - } + let workspaceInstance: Instance | undefined; + try { + workspaceInstance = await findInstance(client, { metadata }); + console.log( + `${workspaceDigest.padEnd(2)} : ${workspaceInstance?.id ?? ""}`, + ); + } catch (error) { + console.error("Error:", error); + process.exitCode = 1; + } } main().catch(console.error); diff --git a/clients/managed/src/workspace-stop.ts b/clients/managed/src/workspace-stop.ts index e0d41ca0..3e52c5fd 100755 --- a/clients/managed/src/workspace-stop.ts +++ b/clients/managed/src/workspace-stop.ts @@ -1,44 +1,49 @@ #!/usr/bin/env node -import { Instance, MorphCloudClient } from 'morphcloud'; -import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE } from './util/constants'; -import { findInstance } from './util/morphcloud'; -import { MutagenClient } from './util/mutagen'; -import { realpathSync } from 'fs'; -import { getGloballyUniqueDigest } from './util/digest'; +import { realpathSync } from "fs"; +import { Instance, MorphCloudClient } from "morphcloud"; +import { + LABEL_NUANCED_ROLE, + LABEL_NUANCED_WORKSPACE_DIGEST, + NUANCED_ROLE_WORKSPACE, +} from "./util/constants"; +import { findInstance } from "./util/morphcloud"; +import { MutagenClient } from "./util/mutagen"; +import { getGloballyUniqueDigest } from "./util/digest"; async function main() { - const client = new MorphCloudClient(); + const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); - const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, - }; + if (process.argv.length !== 3) { + console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); + process.exit(1); + } + const workspaceDir = realpathSync(process.argv[2]); + const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const metadata = { + [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, + [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + }; - let workspaceInstance: Instance | undefined; - try { - workspaceInstance = await findInstance(client, { metadata }); + let workspaceInstance: Instance | undefined; + try { + workspaceInstance = await findInstance(client, { metadata }); - if (workspaceInstance) { - const mutagen = new MutagenClient(workspaceInstance); - await mutagen.stopSync().catch((_) => { - console.error(`Error: Cannot remove sync ${workspaceDigest}`); - }); - await workspaceInstance.stop().catch((e) => { - console.error(`Error: Cannot stop instance ${workspaceInstance!.id}`); - }); - } - } catch (error) { - console.error('Error:', error); - process.exitCode = 1; - } finally { + if (workspaceInstance) { + const mutagen = new MutagenClient(workspaceInstance); + await mutagen.stopSync().catch((e) => { + console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); + }); + await workspaceInstance.stop().catch((e) => { + console.error( + `Error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, + ); + }); } + } catch (error) { + console.error("Error:", error); + process.exitCode = 1; + } } main().catch(console.error); From 952e14a77c066fd97808d2d10c1c1053b070e41c Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Tue, 16 Dec 2025 17:23:58 +0100 Subject: [PATCH 03/14] Turn into proper CLI --- clients/managed/package.json | 13 ++-- clients/managed/src/cli.ts | 61 +++++++++++++++++++ .../src/{ => commands}/service-setup.ts | 14 ++--- .../src/{ => commands}/service-status.ts | 13 ++-- .../src/{ => commands}/workspace-lsp.ts | 40 ++++++------ .../src/{ => commands}/workspace-status.ts | 23 +++---- .../src/{ => commands}/workspace-stop.ts | 25 +++----- 7 files changed, 110 insertions(+), 79 deletions(-) create mode 100644 clients/managed/src/cli.ts rename clients/managed/src/{ => commands}/service-setup.ts (97%) rename clients/managed/src/{ => commands}/service-status.ts (85%) rename clients/managed/src/{ => commands}/workspace-lsp.ts (80%) rename clients/managed/src/{ => commands}/workspace-status.ts (54%) rename clients/managed/src/{ => commands}/workspace-stop.ts (61%) diff --git a/clients/managed/package.json b/clients/managed/package.json index d126261c..9e344ba2 100644 --- a/clients/managed/package.json +++ b/clients/managed/package.json @@ -1,16 +1,19 @@ { "name": "@nuanced-dev/managed-lsp", "description": "", + "bin": { + "nuanced-lsp-managed": "dist/cli.js" + }, "files": [ "bin", "dist" ], "scripts": { - "build": "tsc && chmod +x dist/*.js", - "setup": "tsx src/service-setup.ts", - "lsp": "tsx src/workspace-lsp.ts", + "build": "tsc && chmod +x dist/cli.js", + "clean": "rm -rf dist", "lint": "eslint", - "lint:fix": "eslint --fix" + "lint:fix": "eslint --fix", + "run": "tsx src/cli.ts" }, "keywords": [], "author": "", @@ -20,6 +23,7 @@ "@eslint/js": "^9.37.0", "@stylistic/eslint-plugin": "^5.4.0", "@types/child-process-promise": "^2.2.6", + "@types/commander": "^2.12.0", "@types/node": "^24.10.0", "@types/node-ssh": "^7.0.6", "@types/proper-lockfile": "^4.1.4", @@ -37,6 +41,7 @@ }, "dependencies": { "child-process-promise": "^2.2.1", + "commander": "^14.0.2", "node-machine-id": "^1.1.12", "node-ssh": "^13.2.1", "proper-lockfile": "^4.1.2", diff --git a/clients/managed/src/cli.ts b/clients/managed/src/cli.ts new file mode 100644 index 00000000..b1fa7c12 --- /dev/null +++ b/clients/managed/src/cli.ts @@ -0,0 +1,61 @@ +#!/usr/bin/env node + +import { Command } from "commander"; + +const program = new Command(); + +program + .name("managed-lsp") + .description("Managed LSP CLI for containerized LSP services") + .version("0.3.19"); + +const service = program.command("service").description("Service management"); + +service + .command("setup") + .description("Set up service snapshots") + .action(async () => { + const { serviceSetup } = await import("./commands/service-setup"); + serviceSetup().catch(console.error); + }); + +service + .command("status") + .description("Show service status") + .action(async () => { + const { serviceStatus } = await import("./commands/service-status"); + serviceStatus().catch(console.error); + }); + +const workspace = program + .command("workspace") + .description("Workspace management"); + +workspace + .command("lsp") + .description("Run LSP server for a workspace") + .argument("", "Workspace directory") + .action(async (directory: string) => { + const { workspaceLsp } = await import("./commands/workspace-lsp"); + workspaceLsp(directory).catch(console.error); + }); + +workspace + .command("status") + .description("Show workspace status") + .argument("", "Workspace directory") + .action(async (directory: string) => { + const { workspaceStatus } = await import("./commands/workspace-status"); + workspaceStatus(directory).catch(console.error); + }); + +workspace + .command("stop") + .description("Stop a workspace") + .argument("", "Workspace directory") + .action(async (directory: string) => { + const { workspaceStop } = await import("./commands/workspace-stop"); + workspaceStop(directory).catch(console.error); + }); + +program.parse(); diff --git a/clients/managed/src/service-setup.ts b/clients/managed/src/commands/service-setup.ts similarity index 97% rename from clients/managed/src/service-setup.ts rename to clients/managed/src/commands/service-setup.ts index 60858e5c..f746cddc 100755 --- a/clients/managed/src/service-setup.ts +++ b/clients/managed/src/commands/service-setup.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { Instance, MorphCloudClient, Snapshot } from "morphcloud"; import { NodeSSH } from "node-ssh"; import { @@ -14,12 +12,12 @@ import { NUANCED_ROLE_SERVICE, SOURCE_ARCHIVE, SOURCE_DIR, -} from "./util/constants"; +} from "../util/constants"; import { execOrThrow, findSnapshotByDigest, startInstance, -} from "./util/morphcloud"; +} from "../util/morphcloud"; const VCPU_COUNT = 2; const MEM_SIZE_MB = 16384; @@ -289,18 +287,14 @@ async function ensureService( return serviceSnapshot; } -async function main() { +export async function serviceSetup() { const client = new MorphCloudClient(); try { const baseSnapshot = await ensureBase(client); const builderSnapshot = await ensureBuilder(client, baseSnapshot); const sourceSnapshot = await ensureSource(client, builderSnapshot); const _serviceSnapshot = await ensureService(client, sourceSnapshot); - process.exit(0); } catch (error) { - console.error("Error:", error); - process.exit(1); + throw new Error(`Service setup failed: ${error}`); } } - -main().catch(console.error); diff --git a/clients/managed/src/service-status.ts b/clients/managed/src/commands/service-status.ts similarity index 85% rename from clients/managed/src/service-status.ts rename to clients/managed/src/commands/service-status.ts index dd7cfb0c..f6c90b30 100755 --- a/clients/managed/src/service-status.ts +++ b/clients/managed/src/commands/service-status.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { MorphCloudClient } from "morphcloud"; import { SERVICE_DIGEST, @@ -9,14 +7,14 @@ import { BASE_DIGEST, NUANCED_ROLE_WORKSPACE, SOURCE_DIGEST, -} from "./util/constants"; +} from "../util/constants"; import { findSnapshotByDigest, formatInstanceStatus, listInstances, -} from "./util/morphcloud"; +} from "../util/morphcloud"; -async function main() { +export async function serviceStatus() { const client = new MorphCloudClient(); try { console.log("System snapshots:"); @@ -41,9 +39,6 @@ async function main() { ); } } catch (error) { - console.error("Error:", error); - process.exit(1); + throw new Error(`Service status failed: ${error}`); } } - -main().catch(console.error); diff --git a/clients/managed/src/workspace-lsp.ts b/clients/managed/src/commands/workspace-lsp.ts similarity index 80% rename from clients/managed/src/workspace-lsp.ts rename to clients/managed/src/commands/workspace-lsp.ts index 90960747..afff7198 100755 --- a/clients/managed/src/workspace-lsp.ts +++ b/clients/managed/src/commands/workspace-lsp.ts @@ -1,5 +1,3 @@ -#!/usr/bin/env node - import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; import { @@ -7,27 +5,23 @@ import { NUANCED_ROLE_WORKSPACE, LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, -} from "./util/constants"; +} from "../util/constants"; import { findSnapshotByDigest, startInstance, findInstance, -} from "./util/morphcloud"; -import { MutagenClient } from "./util/mutagen"; -import { getGloballyUniqueDigest } from "./util/digest"; -import { sshExec } from "./util/ssh"; -import { getWorkspaceProcessRcPath } from "./util/config"; -import { ProcessRc } from "./util/process-rc"; +} from "../util/morphcloud"; +import { MutagenClient } from "../util/mutagen"; +import { getGloballyUniqueDigest } from "../util/digest"; +import { sshExec } from "../util/ssh"; +import { getWorkspaceProcessRcPath } from "../util/config"; +import { ProcessRc } from "../util/process-rc"; -async function main() { +export async function workspaceLsp(workspaceDir: string) { const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const workspaceRealDir = realpathSync(workspaceDir); + const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); const metadata = { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, @@ -84,7 +78,7 @@ async function main() { } else { console.error("Creating file sync..."); try { - await mutagen.createSync(workspaceDir); + await mutagen.createSync(workspaceRealDir); await mutagen.flushSync(); } catch (e) { throw new Error(`Failed to setup file sync: ${e}`); @@ -133,20 +127,20 @@ async function main() { await processRc.release(async () => { if (mutagen) { await mutagen.stopSync().catch((e) => { - console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); + console.error( + `Workspace LSP error: Cannot remove sync ${workspaceDigest}: ${e}`, + ); }); } if (workspaceInstance) { await workspaceInstance.stop().catch((e) => { console.error( - `Error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, + `Workspace LSP error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, ); }); } }); - console.error(`Error: ${e}`); - process.exitCode = 1; + console.error(`Workspace LSP error: ${e}`); + process.exit(1); } } - -main().catch(console.error); diff --git a/clients/managed/src/workspace-status.ts b/clients/managed/src/commands/workspace-status.ts similarity index 54% rename from clients/managed/src/workspace-status.ts rename to clients/managed/src/commands/workspace-status.ts index 0cc36056..ae668052 100755 --- a/clients/managed/src/workspace-status.ts +++ b/clients/managed/src/commands/workspace-status.ts @@ -1,24 +1,18 @@ -#!/usr/bin/env node - import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE, -} from "./util/constants"; -import { findInstance } from "./util/morphcloud"; -import { getGloballyUniqueDigest } from "./util/digest"; +} from "../util/constants"; +import { findInstance } from "../util/morphcloud"; +import { getGloballyUniqueDigest } from "../util/digest"; -async function main() { +export async function workspaceStatus(workspaceDir: string) { const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const workspaceRealDir = realpathSync(workspaceDir); + const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); const metadata = { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, @@ -31,9 +25,6 @@ async function main() { `${workspaceDigest.padEnd(2)} : ${workspaceInstance?.id ?? ""}`, ); } catch (error) { - console.error("Error:", error); - process.exitCode = 1; + throw new Error(`Workspace status failed: ${error}`); } } - -main().catch(console.error); diff --git a/clients/managed/src/workspace-stop.ts b/clients/managed/src/commands/workspace-stop.ts similarity index 61% rename from clients/managed/src/workspace-stop.ts rename to clients/managed/src/commands/workspace-stop.ts index 3e52c5fd..f47cc2f5 100755 --- a/clients/managed/src/workspace-stop.ts +++ b/clients/managed/src/commands/workspace-stop.ts @@ -1,25 +1,19 @@ -#!/usr/bin/env node - import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_ROLE, LABEL_NUANCED_WORKSPACE_DIGEST, NUANCED_ROLE_WORKSPACE, -} from "./util/constants"; -import { findInstance } from "./util/morphcloud"; -import { MutagenClient } from "./util/mutagen"; -import { getGloballyUniqueDigest } from "./util/digest"; +} from "../util/constants"; +import { findInstance } from "../util/morphcloud"; +import { MutagenClient } from "../util/mutagen"; +import { getGloballyUniqueDigest } from "../util/digest"; -async function main() { +export async function workspaceStop(workspaceDir: string) { const client = new MorphCloudClient(); - if (process.argv.length !== 3) { - console.error(`Usage: ${process.argv[1]} WORKSPACE_DIR`); - process.exit(1); - } - const workspaceDir = realpathSync(process.argv[2]); - const workspaceDigest = getGloballyUniqueDigest(workspaceDir); + const workspaceRealDir = realpathSync(workspaceDir); + const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); const metadata = { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, @@ -41,9 +35,6 @@ async function main() { }); } } catch (error) { - console.error("Error:", error); - process.exitCode = 1; + throw new Error(`Workspace stop failed: ${error}`); } } - -main().catch(console.error); From c23817e155ed90b273b6f0f1dc34225ba5093bcf Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 12:23:25 +0100 Subject: [PATCH 04/14] Restructure package --- DEVELOPMENT.md | 1 + clients/managed/README.md | 269 ------------------ .../{managed => morphcloud}/.gitattributes | 0 clients/{managed => morphcloud}/.gitignore | 0 clients/morphcloud/DEVELOPMENT.md | 36 +++ clients/morphcloud/LICENSE | 22 ++ clients/morphcloud/README.md | 24 ++ .../bin/mutagen/darwin_arm64/mutagen | 0 .../darwin_arm64/mutagen-agents.tar.gz | 0 .../bin/mutagen/darwin_x64/mutagen | 0 .../mutagen/darwin_x64/mutagen-agents.tar.gz | 0 .../bin/mutagen/linux_arm64/mutagen | 0 .../mutagen/linux_arm64/mutagen-agents.tar.gz | 0 .../bin/mutagen/linux_x64/mutagen | 0 .../mutagen/linux_x64/mutagen-agents.tar.gz | 0 .../mutagen/windows_x64/mutagen-agents.tar.gz | 0 .../bin/mutagen/windows_x64/mutagen.exe | 0 .../{managed => morphcloud}/eslint.config.mjs | 0 clients/{managed => morphcloud}/package.json | 43 +-- .../script => morphcloud/scripts}/mutagen | 0 .../scripts}/update-mutagen-assets.sh | 0 .../scripts}/update-nuanced-lsp-archive.sh | 0 clients/{managed => morphcloud}/src/cli.ts | 0 .../src/commands/service-setup.ts | 0 .../src/commands/service-status.ts | 0 .../src/commands/workspace-lsp.ts | 0 .../src/commands/workspace-status.ts | 0 .../src/commands/workspace-stop.ts | 0 .../src/util/config.ts | 0 .../src/util/constants.ts | 0 .../src/util/digest.ts | 0 .../src/util/morphcloud.ts | 0 .../src/util/mutagen.ts | 0 .../src/util/process-rc.ts | 0 .../{managed => morphcloud}/src/util/sleep.ts | 0 .../{managed => morphcloud}/src/util/ssh.ts | 0 clients/{managed => morphcloud}/tsconfig.json | 0 clients/typescript/README.md | 2 +- clients/vscode/LICENSE | 22 ++ clients/vscode/README.md | 6 +- 40 files changed, 137 insertions(+), 288 deletions(-) delete mode 100644 clients/managed/README.md rename clients/{managed => morphcloud}/.gitattributes (100%) rename clients/{managed => morphcloud}/.gitignore (100%) create mode 100644 clients/morphcloud/DEVELOPMENT.md create mode 100644 clients/morphcloud/LICENSE create mode 100644 clients/morphcloud/README.md rename clients/{managed => morphcloud}/bin/mutagen/darwin_arm64/mutagen (100%) rename clients/{managed => morphcloud}/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz (100%) rename clients/{managed => morphcloud}/bin/mutagen/darwin_x64/mutagen (100%) rename clients/{managed => morphcloud}/bin/mutagen/darwin_x64/mutagen-agents.tar.gz (100%) rename clients/{managed => morphcloud}/bin/mutagen/linux_arm64/mutagen (100%) rename clients/{managed => morphcloud}/bin/mutagen/linux_arm64/mutagen-agents.tar.gz (100%) rename clients/{managed => morphcloud}/bin/mutagen/linux_x64/mutagen (100%) rename clients/{managed => morphcloud}/bin/mutagen/linux_x64/mutagen-agents.tar.gz (100%) rename clients/{managed => morphcloud}/bin/mutagen/windows_x64/mutagen-agents.tar.gz (100%) rename clients/{managed => morphcloud}/bin/mutagen/windows_x64/mutagen.exe (100%) rename clients/{managed => morphcloud}/eslint.config.mjs (100%) rename clients/{managed => morphcloud}/package.json (68%) rename clients/{managed/script => morphcloud/scripts}/mutagen (100%) rename clients/{managed/script => morphcloud/scripts}/update-mutagen-assets.sh (100%) rename clients/{managed/script => morphcloud/scripts}/update-nuanced-lsp-archive.sh (100%) rename clients/{managed => morphcloud}/src/cli.ts (100%) rename clients/{managed => morphcloud}/src/commands/service-setup.ts (100%) rename clients/{managed => morphcloud}/src/commands/service-status.ts (100%) rename clients/{managed => morphcloud}/src/commands/workspace-lsp.ts (100%) rename clients/{managed => morphcloud}/src/commands/workspace-status.ts (100%) rename clients/{managed => morphcloud}/src/commands/workspace-stop.ts (100%) rename clients/{managed => morphcloud}/src/util/config.ts (100%) rename clients/{managed => morphcloud}/src/util/constants.ts (100%) rename clients/{managed => morphcloud}/src/util/digest.ts (100%) rename clients/{managed => morphcloud}/src/util/morphcloud.ts (100%) rename clients/{managed => morphcloud}/src/util/mutagen.ts (100%) rename clients/{managed => morphcloud}/src/util/process-rc.ts (100%) rename clients/{managed => morphcloud}/src/util/sleep.ts (100%) rename clients/{managed => morphcloud}/src/util/ssh.ts (100%) rename clients/{managed => morphcloud}/tsconfig.json (100%) create mode 100644 clients/vscode/LICENSE diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index eaadad7e..90d92fcc 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -20,6 +20,7 @@ See [architecture](docs/architecture.md) documentation for more details. | Path | Description | |-------------------|-------------------------------------------------------------------| +| `clients` | Clients, each in their own subdirectory | | `crates/common` | Shared logic between the proxy and wrapper | | `crates/proxy` | Proxy service code | | `crates/wrapper` | Wrapper service code | diff --git a/clients/managed/README.md b/clients/managed/README.md deleted file mode 100644 index 385fe3c5..00000000 --- a/clients/managed/README.md +++ /dev/null @@ -1,269 +0,0 @@ -# Morph Cloud Examples - -A collection of TypeScript examples demonstrating [Morph Cloud](https://cloud.morph.so)'s snapshot and instance management capabilities. - -## Overview - -This project contains three example scripts that showcase Morph Cloud's ability to capture and restore complete computational states: - -1. **Counter Example** (`index.ts`) - Demonstrates how background processes persist across snapshots -2. **Clone & Build** (`clone-and-build.ts`) - Shows how build artifacts are preserved in snapshots -3. **Cleanup** (`cleanup.ts`) - Utility to delete all instances and snapshots - -## Prerequisites - -Before running these examples, you'll need: - -1. **Morph Cloud API Key** - - Sign up at [cloud.morph.so](https://cloud.morph.so) - - Generate an API key from your dashboard - -2. **GitHub Personal Access Token** (for clone-and-build example only) - - Create a token at [github.com/settings/tokens](https://github.com/settings/tokens) - - Requires `repo` scope for private repositories - -## Installation - -```bash -# Clone this repository -git clone https://github.com/nuanced-dev/morph-example.git -cd morph-example - -# Install dependencies -npm install - -# Build the TypeScript project -npm run build -``` - -## Configuration - -Set your environment variables: - -```bash -# Required for all examples -export MORPH_API_KEY='your_morph_api_key_here' - -# Required only for clone-and-build example -export GITHUB_TOKEN='your_github_token_here' -``` - -## Examples - -### 1. Counter Example - -**What it does:** -- Creates a snapshot and starts an instance -- Starts a background counter that increments every 5 seconds -- Creates a snapshot of the running instance (with the counter running) -- Starts a new instance from that snapshot -- Verifies the counter continues from where it left off - -**What it demonstrates:** -- Background processes survive snapshots -- Process state is perfectly preserved -- New instances resume execution seamlessly - -**How to run:** -```bash -npm run counter -``` - -**Expected output:** -``` -Created snapshot: snapshot_xxxxx -Started instance: morphvm_xxxxx -Initial counter: 1 -Counter after waiting: 3 -Created snapshot: snapshot_yyyyy -Started new instance: morphvm_yyyyy -Counter in new instance: 5 -โœจ Success! The background process continued exactly where it left off. -``` - ---- - -### 2. Clone & Build Example - -**What it does:** -1. Creates a base snapshot with a minimal VM image -2. Installs development tools (Git, GitHub CLI, Bun, Python) -3. Clones a private GitHub repository -4. Runs the full build process (install dependencies + compile) -5. Creates a snapshot of the built project -6. Starts a new instance from the snapshot -7. Reruns the build to verify everything is cached - -**What it demonstrates:** -- Complete development environments can be snapshotted -- Build artifacts and dependencies are preserved -- Subsequent builds are nearly instant (no reinstall needed) -- Starting from a snapshot is much faster than rebuilding - -**Configuration:** - -Edit `src/clone-and-build.ts` to customize: - -```typescript -const CONFIG = { - githubToken: process.env.GITHUB_TOKEN, - repo: 'your-org/your-repo', // Change this to your repo - clonePath: '/root/project', - buildCommands: [ - 'bun install', // Change to npm/yarn if needed - 'bun run build' - ], - verifyCommand: 'bun run build --ignore-scripts', -}; -``` - -**How to run:** -```bash -export GITHUB_TOKEN='your_token' -export MORPH_API_KEY='your_key' -npm run clone-and-build -``` - -**Expected output:** -``` -๐Ÿ“ฆ Step 1: Creating base snapshot... -โœ… Created base snapshot: snapshot_xxxxx - -๐Ÿ”ง Step 2: Starting instance from base snapshot... -โœ… Started instance: morphvm_xxxxx - -๐Ÿ”จ Step 3: Installing git, GitHub CLI, and build tools... -โœ… Installed Python 3.x -โœ… Installed gh version 2.83.0 -โœ… Installed Bun 1.3.1 - -๐Ÿ“ฅ Step 4: Cloning private repository... -โœ… Successfully cloned repository - -๐Ÿ”จ Step 5: Building the repository... -โœ… First build completed in 69.08s - -๐Ÿ“ธ Step 6: Creating snapshot of built repository... -โœ… Created built snapshot: snapshot_yyyyy - -๐Ÿ”„ Step 7: Starting new instance from built snapshot... -โœ… Started new instance: morphvm_zzzzz - -๐Ÿ” Step 8: Verifying build state is preserved... -โœ… Verification build completed in 1.23s - -๐Ÿ“Š Results Summary: -โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• - First build time: 69.08s - Verification build time: 1.23s - Time saved: 67.85s - Speedup: 56.1x faster -โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ•โ• -``` - ---- - -### 3. Cleanup Utility - -**What it does:** -- Lists all running instances and stops them -- Lists all snapshots and deletes them -- Provides progress feedback with error handling - -**When to use:** -- After testing/experimenting with Morph Cloud -- To clean up resources and avoid charges -- When you want to start fresh - -**How to run:** -```bash -npm run cleanup -``` - -**Expected output:** -``` -๐Ÿงน Starting cleanup of all instances and snapshots... - -๐Ÿ“‹ Fetching all instances... -Found 3 instance(s): - - ๐Ÿ—‘๏ธ Deleting instance morphvm_xxxxx (status: ready)... - โœ… Deleted instance morphvm_xxxxx - ๐Ÿ—‘๏ธ Deleting instance morphvm_yyyyy (status: ready)... - โœ… Deleted instance morphvm_yyyyy - ๐Ÿ—‘๏ธ Deleting instance morphvm_zzzzz (status: paused)... - โœ… Deleted instance morphvm_zzzzz - -๐Ÿ“‹ Fetching all snapshots... -Found 5 snapshot(s): - - ๐Ÿ—‘๏ธ Deleting snapshot snapshot_xxxxx (status: ready)... - โœ… Deleted snapshot snapshot_xxxxx - ... - -โœจ Cleanup complete! -``` - -## Available Scripts - -```bash -# Build the TypeScript project -npm run build - -# Run the counter example -npm run counter - -# Run the clone-and-build example -npm run clone-and-build - -# Clean up all instances and snapshots -npm run cleanup -``` - -## Project Structure - -``` -morph-example/ -โ”œโ”€โ”€ src/ -โ”‚ โ”œโ”€โ”€ counter.ts # Counter example -โ”‚ โ”œโ”€โ”€ clone-and-build.ts # Clone & build example -โ”‚ โ””โ”€โ”€ cleanup.ts # Cleanup utility -โ”œโ”€โ”€ dist/ # Compiled JavaScript (generated) -โ”œโ”€โ”€ package.json -โ”œโ”€โ”€ tsconfig.json -โ””โ”€โ”€ README.md -``` - -## Troubleshooting - -### "API key not found" error -Make sure you've set the `MORPH_API_KEY` environment variable: -```bash -export MORPH_API_KEY='your_key_here' -``` - -### "Failed to clone repository" error -1. Verify your `GITHUB_TOKEN` is set -2. Ensure the token has `repo` scope -3. Check that the repo URL in `CONFIG` is correct - -### Build errors with native modules -The clone-and-build script installs Python and build tools automatically. If you still encounter issues: -- Check that `python3` and `build-essential` are installed -- Verify the `PYTHON` environment variable is set to `/usr/bin/python3` - -### Slow verification build -If the verification build is still slow (>5s), prebuild scripts may be regenerating files. Make sure `verifyCommand` uses `--ignore-scripts`: -```typescript -verifyCommand: 'bun run build --ignore-scripts' -``` - -## Learn More - -- [Morph Cloud Documentation](https://cloud.morph.so/docs) -- [Morph Cloud API Reference](https://cloud.morph.so/docs/api-reference) -- [morphcloud npm package](https://www.npmjs.com/package/morphcloud) - -## License - -ISC diff --git a/clients/managed/.gitattributes b/clients/morphcloud/.gitattributes similarity index 100% rename from clients/managed/.gitattributes rename to clients/morphcloud/.gitattributes diff --git a/clients/managed/.gitignore b/clients/morphcloud/.gitignore similarity index 100% rename from clients/managed/.gitignore rename to clients/morphcloud/.gitignore diff --git a/clients/morphcloud/DEVELOPMENT.md b/clients/morphcloud/DEVELOPMENT.md new file mode 100644 index 00000000..8095e3cf --- /dev/null +++ b/clients/morphcloud/DEVELOPMENT.md @@ -0,0 +1,36 @@ +# Development + +## Repo layout + +| Path | Description | +|-------------------|----------------------------------------------| +| `src/` | TypeScript client (CLI + library) | +| `scripts/` | Project-level scripts | +| `tests/` | Unified test suite | + +## Requirements + +- Recent Node.js version installed +- A [Morph Cloud](https://cloud.morph.so) account, and an API token in the `MORPH_API_KEY` environment variable. + +## Local development + +**Building:** + +Build the source: + +```bash +npm run build +``` + +Lint and format the source: + +```bash +npm run lint:fix +``` + +Run the CLI from source: + +```bash +npm run dev +``` diff --git a/clients/morphcloud/LICENSE b/clients/morphcloud/LICENSE new file mode 100644 index 00000000..a4ddfe3e --- /dev/null +++ b/clients/morphcloud/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) Agentic Corporation +Copyright (c) Nuanced + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md new file mode 100644 index 00000000..c4cacbb8 --- /dev/null +++ b/clients/morphcloud/README.md @@ -0,0 +1,24 @@ +# Nuanced LSP Morph Cloud Client + +This is an **experimental** client to run Nuanced LSP remotely on [Morph Cloud](https://cloud.morph.so). + +**Current features:** + +- TBD + +**Open issues:** + +- TBD + +## Requirements + +- Recent Node.js version installed +- A [Morph Cloud](https://cloud.morph.so) account, and an API token in the `MORPH_API_KEY` environment variable. + +## Quick start + +TBD + +## License + +This work is licensed under the terms of the MIT license. For a copy, see [LICENSE](LICENSE) or . diff --git a/clients/managed/bin/mutagen/darwin_arm64/mutagen b/clients/morphcloud/bin/mutagen/darwin_arm64/mutagen similarity index 100% rename from clients/managed/bin/mutagen/darwin_arm64/mutagen rename to clients/morphcloud/bin/mutagen/darwin_arm64/mutagen diff --git a/clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz b/clients/morphcloud/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz similarity index 100% rename from clients/managed/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz rename to clients/morphcloud/bin/mutagen/darwin_arm64/mutagen-agents.tar.gz diff --git a/clients/managed/bin/mutagen/darwin_x64/mutagen b/clients/morphcloud/bin/mutagen/darwin_x64/mutagen similarity index 100% rename from clients/managed/bin/mutagen/darwin_x64/mutagen rename to clients/morphcloud/bin/mutagen/darwin_x64/mutagen diff --git a/clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz b/clients/morphcloud/bin/mutagen/darwin_x64/mutagen-agents.tar.gz similarity index 100% rename from clients/managed/bin/mutagen/darwin_x64/mutagen-agents.tar.gz rename to clients/morphcloud/bin/mutagen/darwin_x64/mutagen-agents.tar.gz diff --git a/clients/managed/bin/mutagen/linux_arm64/mutagen b/clients/morphcloud/bin/mutagen/linux_arm64/mutagen similarity index 100% rename from clients/managed/bin/mutagen/linux_arm64/mutagen rename to clients/morphcloud/bin/mutagen/linux_arm64/mutagen diff --git a/clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz b/clients/morphcloud/bin/mutagen/linux_arm64/mutagen-agents.tar.gz similarity index 100% rename from clients/managed/bin/mutagen/linux_arm64/mutagen-agents.tar.gz rename to clients/morphcloud/bin/mutagen/linux_arm64/mutagen-agents.tar.gz diff --git a/clients/managed/bin/mutagen/linux_x64/mutagen b/clients/morphcloud/bin/mutagen/linux_x64/mutagen similarity index 100% rename from clients/managed/bin/mutagen/linux_x64/mutagen rename to clients/morphcloud/bin/mutagen/linux_x64/mutagen diff --git a/clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz b/clients/morphcloud/bin/mutagen/linux_x64/mutagen-agents.tar.gz similarity index 100% rename from clients/managed/bin/mutagen/linux_x64/mutagen-agents.tar.gz rename to clients/morphcloud/bin/mutagen/linux_x64/mutagen-agents.tar.gz diff --git a/clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz b/clients/morphcloud/bin/mutagen/windows_x64/mutagen-agents.tar.gz similarity index 100% rename from clients/managed/bin/mutagen/windows_x64/mutagen-agents.tar.gz rename to clients/morphcloud/bin/mutagen/windows_x64/mutagen-agents.tar.gz diff --git a/clients/managed/bin/mutagen/windows_x64/mutagen.exe b/clients/morphcloud/bin/mutagen/windows_x64/mutagen.exe similarity index 100% rename from clients/managed/bin/mutagen/windows_x64/mutagen.exe rename to clients/morphcloud/bin/mutagen/windows_x64/mutagen.exe diff --git a/clients/managed/eslint.config.mjs b/clients/morphcloud/eslint.config.mjs similarity index 100% rename from clients/managed/eslint.config.mjs rename to clients/morphcloud/eslint.config.mjs diff --git a/clients/managed/package.json b/clients/morphcloud/package.json similarity index 68% rename from clients/managed/package.json rename to clients/morphcloud/package.json index 9e344ba2..6129a660 100644 --- a/clients/managed/package.json +++ b/clients/morphcloud/package.json @@ -1,8 +1,16 @@ { - "name": "@nuanced-dev/managed-lsp", - "description": "", + "name": "@nuanced-dev/lsp-morphcloud", + "version": "0.1.0", + "description": "CLI to run Nuanced LSP on Morphcloud", + "license": "MIT", + "author": "Nuanced", + "repository": { + "type": "git", + "url": "git+https://github.com/nuanced-dev/lsp.git" + }, + "type": "module", "bin": { - "nuanced-lsp-managed": "dist/cli.js" + "nuanced-lsp-morphcloud": "dist/cli.js" }, "files": [ "bin", @@ -11,14 +19,18 @@ "scripts": { "build": "tsc && chmod +x dist/cli.js", "clean": "rm -rf dist", + "dev": "tsx src/cli.ts", "lint": "eslint", - "lint:fix": "eslint --fix", - "run": "tsx src/cli.ts" + "lint:fix": "eslint --fix" + }, + "dependencies": { + "child-process-promise": "^2.2.1", + "commander": "^14.0.2", + "node-machine-id": "^1.1.12", + "node-ssh": "^13.2.1", + "proper-lockfile": "^4.1.2", + "ssh2": "^1.17.0" }, - "keywords": [], - "author": "", - "license": "ISC", - "type": "commonjs", "devDependencies": { "@eslint/js": "^9.37.0", "@stylistic/eslint-plugin": "^5.4.0", @@ -39,13 +51,10 @@ "typescript": "^5.9.3", "typescript-eslint": "^8.45.0" }, - "dependencies": { - "child-process-promise": "^2.2.1", - "commander": "^14.0.2", - "node-machine-id": "^1.1.12", - "node-ssh": "^13.2.1", - "proper-lockfile": "^4.1.2", - "ssh2": "^1.17.0" + "engines": { + "node": ">=18" }, - "version": "0.3.19" + "publishConfig": { + "access": "public" + } } diff --git a/clients/managed/script/mutagen b/clients/morphcloud/scripts/mutagen similarity index 100% rename from clients/managed/script/mutagen rename to clients/morphcloud/scripts/mutagen diff --git a/clients/managed/script/update-mutagen-assets.sh b/clients/morphcloud/scripts/update-mutagen-assets.sh similarity index 100% rename from clients/managed/script/update-mutagen-assets.sh rename to clients/morphcloud/scripts/update-mutagen-assets.sh diff --git a/clients/managed/script/update-nuanced-lsp-archive.sh b/clients/morphcloud/scripts/update-nuanced-lsp-archive.sh similarity index 100% rename from clients/managed/script/update-nuanced-lsp-archive.sh rename to clients/morphcloud/scripts/update-nuanced-lsp-archive.sh diff --git a/clients/managed/src/cli.ts b/clients/morphcloud/src/cli.ts similarity index 100% rename from clients/managed/src/cli.ts rename to clients/morphcloud/src/cli.ts diff --git a/clients/managed/src/commands/service-setup.ts b/clients/morphcloud/src/commands/service-setup.ts similarity index 100% rename from clients/managed/src/commands/service-setup.ts rename to clients/morphcloud/src/commands/service-setup.ts diff --git a/clients/managed/src/commands/service-status.ts b/clients/morphcloud/src/commands/service-status.ts similarity index 100% rename from clients/managed/src/commands/service-status.ts rename to clients/morphcloud/src/commands/service-status.ts diff --git a/clients/managed/src/commands/workspace-lsp.ts b/clients/morphcloud/src/commands/workspace-lsp.ts similarity index 100% rename from clients/managed/src/commands/workspace-lsp.ts rename to clients/morphcloud/src/commands/workspace-lsp.ts diff --git a/clients/managed/src/commands/workspace-status.ts b/clients/morphcloud/src/commands/workspace-status.ts similarity index 100% rename from clients/managed/src/commands/workspace-status.ts rename to clients/morphcloud/src/commands/workspace-status.ts diff --git a/clients/managed/src/commands/workspace-stop.ts b/clients/morphcloud/src/commands/workspace-stop.ts similarity index 100% rename from clients/managed/src/commands/workspace-stop.ts rename to clients/morphcloud/src/commands/workspace-stop.ts diff --git a/clients/managed/src/util/config.ts b/clients/morphcloud/src/util/config.ts similarity index 100% rename from clients/managed/src/util/config.ts rename to clients/morphcloud/src/util/config.ts diff --git a/clients/managed/src/util/constants.ts b/clients/morphcloud/src/util/constants.ts similarity index 100% rename from clients/managed/src/util/constants.ts rename to clients/morphcloud/src/util/constants.ts diff --git a/clients/managed/src/util/digest.ts b/clients/morphcloud/src/util/digest.ts similarity index 100% rename from clients/managed/src/util/digest.ts rename to clients/morphcloud/src/util/digest.ts diff --git a/clients/managed/src/util/morphcloud.ts b/clients/morphcloud/src/util/morphcloud.ts similarity index 100% rename from clients/managed/src/util/morphcloud.ts rename to clients/morphcloud/src/util/morphcloud.ts diff --git a/clients/managed/src/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts similarity index 100% rename from clients/managed/src/util/mutagen.ts rename to clients/morphcloud/src/util/mutagen.ts diff --git a/clients/managed/src/util/process-rc.ts b/clients/morphcloud/src/util/process-rc.ts similarity index 100% rename from clients/managed/src/util/process-rc.ts rename to clients/morphcloud/src/util/process-rc.ts diff --git a/clients/managed/src/util/sleep.ts b/clients/morphcloud/src/util/sleep.ts similarity index 100% rename from clients/managed/src/util/sleep.ts rename to clients/morphcloud/src/util/sleep.ts diff --git a/clients/managed/src/util/ssh.ts b/clients/morphcloud/src/util/ssh.ts similarity index 100% rename from clients/managed/src/util/ssh.ts rename to clients/morphcloud/src/util/ssh.ts diff --git a/clients/managed/tsconfig.json b/clients/morphcloud/tsconfig.json similarity index 100% rename from clients/managed/tsconfig.json rename to clients/morphcloud/tsconfig.json diff --git a/clients/typescript/README.md b/clients/typescript/README.md index dcef3639..150c7180 100644 --- a/clients/typescript/README.md +++ b/clients/typescript/README.md @@ -1,4 +1,4 @@ -# Nuanced LSP +# Nuanced LSP TypeScript Client This is the TypeScript library and CLI for the Nuanced LSP containerized code navigation service. diff --git a/clients/vscode/LICENSE b/clients/vscode/LICENSE new file mode 100644 index 00000000..a4ddfe3e --- /dev/null +++ b/clients/vscode/LICENSE @@ -0,0 +1,22 @@ +MIT License + +Copyright (c) Agentic Corporation +Copyright (c) Nuanced + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/clients/vscode/README.md b/clients/vscode/README.md index c44f9a33..91e41a63 100644 --- a/clients/vscode/README.md +++ b/clients/vscode/README.md @@ -1,6 +1,6 @@ # Nuanced LSP VSCode Extension -VSCode extension for Nuanced LSP server providing advanced language features. +VSCode extension for Nuanced LSP server. _This is not meant to be a full-featured language plugin! Merely a way to showcase the LSP capabilities and do interactive debugging._ @@ -40,3 +40,7 @@ This will create a `.vsix` file that can be installed in VSCode via: ```bash code --install-extension nuanced-lsp-vscode-0.1.0.vsix ``` + +## License + +This work is licensed under the terms of the MIT license. For a copy, see [LICENSE](LICENSE) or . From 23c2809e5b28e8f579232181cee32482c1972fa9 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 13:10:18 +0100 Subject: [PATCH 05/14] Add rpc method to error message --- crates/proxy/src/handlers/lsp_ws.rs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/crates/proxy/src/handlers/lsp_ws.rs b/crates/proxy/src/handlers/lsp_ws.rs index d7c61081..dffb7f9e 100644 --- a/crates/proxy/src/handlers/lsp_ws.rs +++ b/crates/proxy/src/handlers/lsp_ws.rs @@ -142,8 +142,10 @@ async fn handle_client_text_message( } // Extract document URI to determine routing - let document_uri = - extract_document_uri(&json_rpc_msg).ok_or("Could not extract document URI from message")?; + let document_uri = extract_document_uri(&json_rpc_msg).ok_or(format!( + "Could not extract document URI from message with method: {}", + json_rpc_msg.method.as_ref().map_or("???", |m| m), + ))?; // Convert file:// URI to path let file_path = uri_to_file_path(&document_uri)?; From 6f0a63bdd8f87b4a709b1a254ecf620d9acfe58b Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 13:17:42 +0100 Subject: [PATCH 06/14] Remove special logic for reusing existing container for LSP server, and use regular up and down commands --- clients/typescript/CHANGELOG.md | 9 ++++ clients/typescript/package.json | 2 +- clients/typescript/src/__generated/version.ts | 2 +- clients/typescript/src/cli.ts | 41 ++++--------------- clients/typescript/src/server.ts | 32 ++------------- clients/typescript/src/types.ts | 5 +-- 6 files changed, 24 insertions(+), 67 deletions(-) diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 98607e41..55d90982 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -5,6 +5,15 @@ 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.5.1] - 2025-01-21 + +### Changed + +- Shared mode for the `server` command has been replaced by a flag to + specify a container name. If the container name is set, the server + uses the preexisting container with that name. The regular `up` and + `down` commands can be used to manage that container. + ## [0.5.0] - 2025-01-20 ### Added diff --git a/clients/typescript/package.json b/clients/typescript/package.json index 9eb11574..e04033d0 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@nuanced-dev/lsp", - "version": "0.5.0", + "version": "0.5.1", "description": "TypeScript library and CLI for Nuanced LSP", "license": "MIT", "author": "Nuanced", diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index ae8863ac..d77abebc 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,3 +1,3 @@ // AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs -export const VERSION = "0.5.0"; +export const VERSION = "0.5.1"; diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index 48b4f418..e97fa720 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -1,5 +1,5 @@ #!/usr/bin/env node -import { Command, Option } from "commander"; +import { Command } from "commander"; import { isErr } from "./types.js"; import type { BaseCommandOptions, @@ -56,15 +56,6 @@ async function generateRandomContainerName(): Promise { return `nuanced-lsp-${id}`; } -async function generateSharedContainerName(workspace: string): Promise { - const { createHash } = await import("node:crypto"); - const { resolve } = await import("node:path"); - const absolutePath = resolve(workspace); - const hash = createHash("sha256").update(absolutePath).digest("hex"); - const shortHash = hash.slice(0, 12); - return `nuanced-lsp-${shortHash}`; -} - // Lazy-load client (avoids startup cost if user only runs --help, etc.) async function lspClient(opts: { containerName?: string; @@ -271,24 +262,12 @@ async function serverCommand( workspace: string, opts: ServerCommandOptions, ): Promise { - let containerName: string; - if (opts.shared) { - containerName = await generateSharedContainerName(workspace); - } else { - containerName = await generateRandomContainerName(); - } - const client = await lspClient({ - containerName, ...opts, - lspPort: opts.hostPort, + containerName: opts.containerName ?? (await generateRandomContainerName()), + lspPort: opts.hostPort ?? 0, }); - if (opts.shared && opts.sharedMode === "down") { - await client.down(); - return; - } - try { // Run the LSP server stdio loop const { runLspServer } = await import("./server.js"); @@ -738,9 +717,13 @@ program ), ) .argument("", "Host workspace directory to mount") + .option( + "--container-name ", + "Name of an already running container to use (default: start new container with random name).", + ) .option( "--host-port ", - `Host port to map to ${DEFAULT_HOST_PORT}. Note: Use port 0 for a dynamically assigned host port from Docker.`, + `Host port to map to ${DEFAULT_HOST_PORT} (default: port 0 for a dynamically assigned host port from Docker).`, (v: string) => parseInt(v, 10), ) .option( @@ -774,14 +757,6 @@ program (v: string, prev: string[] | undefined) => (prev ? prev.concat(v) : [v]), ) .option("--env-file ", "Path to an env file") - .option("--shared", "Share container across multiple server instances") - .addOption( - new Option("--shared-mode ", "Mode for shared container").choices([ - "up", - "down", - "use", - ]), - ) .action(serverCommand); program diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 8789d40d..579ef6bc 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -48,12 +48,8 @@ class LspServer { async run(): Promise { try { await this.startServer(); - if (this.opts.shared && this.opts.sharedMode === "up") { - await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); - return; - } - this.setupSignalHandlers(); await this.sendLogMessage(MessageType.Info, "Nuanced LSP started"); + this.setupSignalHandlers(); await this.processMessages(); } catch (err) { await this.sendLogMessage( @@ -63,33 +59,13 @@ class LspServer { throw err; } finally { await this.stopServer(); - if (!this.opts.shared) { - await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); - } else { - await this.sendLogMessage( - MessageType.Info, - "Nuanced LSP disconnected (shared container left running)", - ); - } + await this.sendLogMessage(MessageType.Info, "Nuanced LSP stopped"); process.exit(0); } } private async startServer(): Promise { - let mustStartServer = true; - - if (this.opts.shared) { - const statusRes = await this.client.status(); - if (statusRes.ok) { - await this.sendLogMessage( - MessageType.Info, - `Using existing shared container '${this.client.containerName}'`, - ); - mustStartServer = false; - } - } - - if (mustStartServer) { + if (!this.opts.containerName) { const res = await this.client.up(this.workspace, { containerRegistry: this.opts.containerRegistry, languageImageVersion: this.opts.languageImageVersion, @@ -206,7 +182,7 @@ class LspServer { this.ws = null; } - if (!this.opts.shared) { + if (!this.opts.containerName) { await this.client.down(); } } diff --git a/clients/typescript/src/types.ts b/clients/typescript/src/types.ts index 9f3e1685..1257e25d 100644 --- a/clients/typescript/src/types.ts +++ b/clients/typescript/src/types.ts @@ -487,6 +487,7 @@ export type FindReferencesOptions = Named< >; export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ + containerName: z.string().optional(), hostPort: z.number().int().min(0).optional(), containerRegistry: z.string().optional(), languageImageVersion: z.string().optional(), @@ -498,10 +499,6 @@ export const ServerCommandOptionsSchema = BaseCommandOptionsSchema.extend({ debug: z.boolean().optional(), env: z.array(z.string()).optional(), envFile: z.string().optional(), - shared: z.boolean().optional(), - sharedMode: z - .union([z.literal("up"), z.literal("down"), z.literal("use")]) - .optional(), }); export type ServerCommandOptions = Named< typeof ServerCommandOptionsSchema, From bfb159fe18c29e7f954533de24abaf57d4208700 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 14:35:15 +0100 Subject: [PATCH 07/14] Disallow workspace when server is called with container name --- clients/typescript/CHANGELOG.md | 8 ++++++++ clients/typescript/package.json | 2 +- clients/typescript/src/cli.ts | 22 ++++++++++++++++++---- clients/typescript/src/server.ts | 12 +++++++++--- 4 files changed, 36 insertions(+), 8 deletions(-) diff --git a/clients/typescript/CHANGELOG.md b/clients/typescript/CHANGELOG.md index 55d90982..512df47b 100644 --- a/clients/typescript/CHANGELOG.md +++ b/clients/typescript/CHANGELOG.md @@ -5,6 +5,14 @@ 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.5.2] - 2025-01-21 + +### Fixed + +- Check that the workspace is not specified if `server` is given a + container name. + + ## [0.5.1] - 2025-01-21 ### Changed diff --git a/clients/typescript/package.json b/clients/typescript/package.json index e04033d0..fa6a6b9b 100644 --- a/clients/typescript/package.json +++ b/clients/typescript/package.json @@ -1,6 +1,6 @@ { "name": "@nuanced-dev/lsp", - "version": "0.5.1", + "version": "0.5.2", "description": "TypeScript library and CLI for Nuanced LSP", "license": "MIT", "author": "Nuanced", diff --git a/clients/typescript/src/cli.ts b/clients/typescript/src/cli.ts index e97fa720..396cb186 100644 --- a/clients/typescript/src/cli.ts +++ b/clients/typescript/src/cli.ts @@ -259,9 +259,20 @@ async function upCommand( } async function serverCommand( - workspace: string, + workspace: string | undefined, opts: ServerCommandOptions, ): Promise { + // Validate that exactly one of workspace or containerName is provided + if ( + (opts.containerName && workspace) || + (!opts.containerName && !workspace) + ) { + log.err( + "Must specify either workspace or --container-name. Provide a workspace to start a new container, or --container-name to use an existing one.", + ); + process.exit(1); + } + const client = await lspClient({ ...opts, containerName: opts.containerName ?? (await generateRandomContainerName()), @@ -271,7 +282,7 @@ async function serverCommand( try { // Run the LSP server stdio loop const { runLspServer } = await import("./server.js"); - await runLspServer(client, workspace, opts, process.stdin, process.stdout); + await runLspServer(client, workspace!, opts, process.stdin, process.stdout); } catch { // Fatal errors are already logged via window/logMessage process.exitCode = 1; @@ -716,10 +727,13 @@ program "Start a stdio LSP server that forwards requests to the Nuanced LSP container.", ), ) - .argument("", "Host workspace directory to mount") + .argument( + "[workspace]", + "Host workspace directory to mount (required when starting a new container, omit when using --container-name)", + ) .option( "--container-name ", - "Name of an already running container to use (default: start new container with random name).", + "Name of an already running container to use. When specified, workspace argument must be omitted.", ) .option( "--host-port ", diff --git a/clients/typescript/src/server.ts b/clients/typescript/src/server.ts index 579ef6bc..19bf5b87 100644 --- a/clients/typescript/src/server.ts +++ b/clients/typescript/src/server.ts @@ -21,7 +21,7 @@ enum MessageType { class LspServer { private readonly client: NuancedLspClient; - private readonly workspace: string; + private readonly workspace: string | undefined; private readonly opts: ServerCommandOptions; private readonly input: Readable; private readonly output: Writable; @@ -32,7 +32,7 @@ class LspServer { constructor( client: NuancedLspClient, - workspace: string, + workspace: string | undefined, opts: ServerCommandOptions, input: Readable, output: Writable, @@ -66,6 +66,12 @@ class LspServer { private async startServer(): Promise { if (!this.opts.containerName) { + if (!this.workspace) { + throw new Error( + "Workspace is required when not using an existing container", + ); + } + const res = await this.client.up(this.workspace, { containerRegistry: this.opts.containerRegistry, languageImageVersion: this.opts.languageImageVersion, @@ -303,7 +309,7 @@ class LspServer { */ export async function runLspServer( client: NuancedLspClient, - workspace: string, + workspace: string | undefined, opts: ServerCommandOptions, input: Readable = process.stdin, output: Writable = process.stdout, From b2bf4970edbc788992731919fb63789ff8acf7a6 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 14:48:40 +0100 Subject: [PATCH 08/14] Morphcloud client overhaul --- clients/morphcloud/docs/architecture.md | 0 clients/morphcloud/src/cli.ts | 27 +- .../morphcloud/src/commands/service-create.ts | 105 ++++++ .../morphcloud/src/commands/service-delete.ts | 25 ++ .../morphcloud/src/commands/service-setup.ts | 300 ------------------ .../morphcloud/src/commands/service-status.ts | 36 +-- ...{workspace-stop.ts => workspace-delete.ts} | 21 +- .../morphcloud/src/commands/workspace-lsp.ts | 146 --------- .../src/commands/workspace-server.ts | 171 ++++++++++ .../src/commands/workspace-status.ts | 19 +- clients/morphcloud/src/util/config.ts | 13 +- clients/morphcloud/src/util/constants.ts | 20 +- clients/morphcloud/src/util/digest.ts | 18 -- clients/morphcloud/src/util/morphcloud.ts | 16 +- 14 files changed, 381 insertions(+), 536 deletions(-) create mode 100644 clients/morphcloud/docs/architecture.md create mode 100755 clients/morphcloud/src/commands/service-create.ts create mode 100644 clients/morphcloud/src/commands/service-delete.ts delete mode 100755 clients/morphcloud/src/commands/service-setup.ts rename clients/morphcloud/src/commands/{workspace-stop.ts => workspace-delete.ts} (62%) delete mode 100755 clients/morphcloud/src/commands/workspace-lsp.ts create mode 100755 clients/morphcloud/src/commands/workspace-server.ts delete mode 100644 clients/morphcloud/src/util/digest.ts diff --git a/clients/morphcloud/docs/architecture.md b/clients/morphcloud/docs/architecture.md new file mode 100644 index 00000000..e69de29b diff --git a/clients/morphcloud/src/cli.ts b/clients/morphcloud/src/cli.ts index b1fa7c12..2ea62332 100644 --- a/clients/morphcloud/src/cli.ts +++ b/clients/morphcloud/src/cli.ts @@ -12,10 +12,11 @@ program const service = program.command("service").description("Service management"); service - .command("setup") - .description("Set up service snapshots") + .command("create") + .description("Create service snapshot") .action(async () => { - const { serviceSetup } = await import("./commands/service-setup"); + const { serviceCreate: serviceSetup } = + await import("./commands/service-create"); serviceSetup().catch(console.error); }); @@ -27,16 +28,25 @@ service serviceStatus().catch(console.error); }); +service + .command("delete") + .description("Delete service snapshot") + .action(async () => { + const { serviceDelete } = await import("./commands/service-delete"); + serviceDelete().catch(console.error); + }); + const workspace = program .command("workspace") .description("Workspace management"); workspace - .command("lsp") + .command("server") .description("Run LSP server for a workspace") .argument("", "Workspace directory") .action(async (directory: string) => { - const { workspaceLsp } = await import("./commands/workspace-lsp"); + const { workspaceServer: workspaceLsp } = + await import("./commands/workspace-server"); workspaceLsp(directory).catch(console.error); }); @@ -50,11 +60,12 @@ workspace }); workspace - .command("stop") - .description("Stop a workspace") + .command("delete") + .description("Delete a workspace") .argument("", "Workspace directory") .action(async (directory: string) => { - const { workspaceStop } = await import("./commands/workspace-stop"); + const { workspaceDelete: workspaceStop } = + await import("./commands/workspace-delete"); workspaceStop(directory).catch(console.error); }); diff --git a/clients/morphcloud/src/commands/service-create.ts b/clients/morphcloud/src/commands/service-create.ts new file mode 100755 index 00000000..ba46279b --- /dev/null +++ b/clients/morphcloud/src/commands/service-create.ts @@ -0,0 +1,105 @@ +import { Instance, MorphCloudClient } from "morphcloud"; +import { + LABEL_NUANCED_LSP_ROLE, + NUANCED_LSP_ROLE_SERVICE, +} from "../util/constants"; +import { execOrThrow, findSnapshot, startInstance } from "../util/morphcloud"; + +const VCPU_COUNT = 2; +const MEM_SIZE_MB = 16384; +const DISK_SIZE_MB = 16384; + +const VERBOSE = true; + +export async function serviceCreate() { + const client = new MorphCloudClient(); + let serviceInstance: Instance | undefined; + try { + const metadata = { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }; + + let serviceSnapshot = await findSnapshot(client, { + metadata, + }); + if (serviceSnapshot) { + console.log(`Found service snapshot: ${serviceSnapshot.id}`); + return serviceSnapshot; + } + + console.log("Creating service snapshot..."); + + console.log("Creating minimal snapshot..."); + const minimalSnapshot = await client.snapshots.create({ + imageId: "morphvm-minimal", + vcpus: VCPU_COUNT, + memory: MEM_SIZE_MB, + diskSize: DISK_SIZE_MB, + }); + console.log(`Created minimal snapshot: ${minimalSnapshot.id}`); + + console.log("Starting service instance..."); + serviceInstance = await startInstance(client, minimalSnapshot, { + metadata, + }); + console.log(`Started service instance: ${serviceInstance.id}`); + + console.log("Installing packages..."); + console.log("- Installing system commands..."); + await execOrThrow( + serviceInstance, + ` + apt-get update -qq \ + && apt-get install -qq -y curl nodejs npm + `, + { verbose: VERBOSE, prefix: " | " }, + ); + + console.log("- Installing Docker..."); + await execOrThrow( + serviceInstance, + ` + curl -sSf https://get.docker.com | sh -s -- \ + && systemctl enable docker.service \ + && systemctl enable containerd.service \ + && systemctl start docker.service + `, + { verbose: VERBOSE, prefix: " | " }, + ); + const dockerVersion = await execOrThrow( + serviceInstance, + "docker --version", + ); + console.log(` Installed ${dockerVersion.trim()}`); + + console.log("- Installing Nuanced LSP..."); + await execOrThrow(serviceInstance, "npm install -g @nuanced-dev/lsp", { + verbose: VERBOSE, + prefix: " | ", + }); + console.log("Installed packages"); + + console.log("Pulling service images..."); + await execOrThrow(serviceInstance, "nuanced-lsp pull --all-services", { + verbose: VERBOSE, + prefix: "| ", + }); + console.log("Pulled service images"); + + console.log("Creating service snapshot..."); + serviceSnapshot = await serviceInstance.snapshot({ + metadata, + }); + console.log(`Created service snapshot: ${serviceSnapshot.id}`); + + console.log("Cleaning up..."); + if (serviceInstance) { + console.log("- service instance..."); + await serviceInstance.stop(); + } + console.log("Cleaned up"); + } catch (error) { + if (serviceInstance) { + console.log(`Left service instance running: ${serviceInstance.id}`); + } + throw new Error(`Service setup failed: ${error}`); + } +} diff --git a/clients/morphcloud/src/commands/service-delete.ts b/clients/morphcloud/src/commands/service-delete.ts new file mode 100644 index 00000000..af484bea --- /dev/null +++ b/clients/morphcloud/src/commands/service-delete.ts @@ -0,0 +1,25 @@ +import { MorphCloudClient } from "morphcloud"; +import { + LABEL_NUANCED_LSP_ROLE, + NUANCED_LSP_ROLE_SERVICE, +} from "../util/constants"; +import { findSnapshot } from "../util/morphcloud"; + +export async function serviceDelete() { + const client = new MorphCloudClient(); + + try { + const metadata = { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }; + const serviceSnapshot = await findSnapshot(client, { metadata }); + + if (serviceSnapshot) { + console.log(`Deleting service snapshot: ${serviceSnapshot.id}`); + await serviceSnapshot.delete(); + console.log("Service snapshot deleted"); + } else { + console.log("No service snapshot found"); + } + } catch (error) { + throw new Error(`Service delete failed: ${error}`); + } +} diff --git a/clients/morphcloud/src/commands/service-setup.ts b/clients/morphcloud/src/commands/service-setup.ts deleted file mode 100755 index f746cddc..00000000 --- a/clients/morphcloud/src/commands/service-setup.ts +++ /dev/null @@ -1,300 +0,0 @@ -import { Instance, MorphCloudClient, Snapshot } from "morphcloud"; -import { NodeSSH } from "node-ssh"; -import { - SERVICE_DIGEST, - BUILDER_DIGEST, - LABEL_NUANCED_ROLE, - BASE_DIGEST, - NUANCED_ROLE_BASE, - SOURCE_DIGEST, - NUANCED_ROLE_BUILDER, - NUANCED_ROLE_SOURCE, - NUANCED_ROLE_SERVICE, - SOURCE_ARCHIVE, - SOURCE_DIR, -} from "../util/constants"; -import { - execOrThrow, - findSnapshotByDigest, - startInstance, -} from "../util/morphcloud"; - -const VCPU_COUNT = 2; -const MEM_SIZE_MB = 16384; -const DISK_SIZE_MB = 16384; - -const VERBOSE = true; - -async function ensureBase(client: MorphCloudClient): Promise { - let baseSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); - if (baseSnapshot) { - console.log(`Found base snapshot: ${baseSnapshot.id}`); - return baseSnapshot; - } - - console.log("Creating minimal snapshot..."); - - baseSnapshot = await client.snapshots.create({ - imageId: "morphvm-minimal", - vcpus: VCPU_COUNT, - memory: MEM_SIZE_MB, - diskSize: DISK_SIZE_MB, - digest: BASE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BASE, - }, - }); - - console.log(`Created base snapshot: ${baseSnapshot.id}`); - - return baseSnapshot; -} - -async function ensureBuilder( - client: MorphCloudClient, - minimalSnapshot: Snapshot, -): Promise { - let builderSnapshot = await findSnapshotByDigest(client, BUILDER_DIGEST); - if (builderSnapshot) { - console.log(`Found builder snapshot: ${builderSnapshot.id}`); - return builderSnapshot; - } - - console.log("Creating builder snapshot..."); - - let builderInstance: Instance | undefined; - try { - console.log("Starting builder instance..."); - builderInstance = await startInstance(client, minimalSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, - }, - }); - console.log(`Started builder instance: ${builderInstance.id}`); - - console.log("Installing build tools..."); - console.log("- Installing system commands..."); - await execOrThrow( - builderInstance, - ` - apt-get update -qq \ - && apt-get install -qq -y curl git nodejs npm unzip pkg-config - `, - { verbose: VERBOSE, prefix: " | " }, - ); - - console.log("- Installing Docker..."); - await execOrThrow( - builderInstance, - ` - curl -sSf https://get.docker.com | sh -s -- \ - && systemctl enable docker.service \ - && systemctl enable containerd.service \ - && systemctl start docker.service - `, - { verbose: VERBOSE, prefix: " | " }, - ); - const dockerVersion = await execOrThrow( - builderInstance, - "docker --version", - ); - console.log(` Installed ${dockerVersion.trim()}`); - - console.log("- Installing Rust..."); - await execOrThrow( - builderInstance, - ` - curl --proto "=https" --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y - `, - { verbose: VERBOSE, prefix: " | " }, - ); - const rustcVersion = await execOrThrow(builderInstance, "rustc --version"); - console.log(` Installed ${rustcVersion.trim()}`); - - console.log("Installed build tools"); - - console.log("Creating builder snapshot..."); - builderSnapshot = await builderInstance.snapshot({ - digest: BUILDER_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_BUILDER, - }, - }); - console.log(`Created builder snapshot: ${builderSnapshot.id}`); - - console.log("Cleaning up..."); - if (builderInstance) { - console.log("- builder instance..."); - await builderInstance.stop(); - } - console.log("Cleaned up"); - } catch (e) { - if (builderInstance) { - console.log(`Left builder instance running: ${builderInstance.id}`); - } - throw e; - } - - return builderSnapshot; -} - -async function ensureSource( - client: MorphCloudClient, - builderSnapshot: Snapshot, -): Promise { - let sourceSnapshot = await findSnapshotByDigest(client, SOURCE_DIGEST); - if (sourceSnapshot) { - console.log(`Found source snapshot: ${sourceSnapshot.id}`); - return sourceSnapshot; - } - - console.log("Creating source snapshot..."); - - let sourceInstance: Instance | undefined; - try { - console.log("Starting source instance..."); - sourceInstance = await startInstance(client, builderSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, - }, - }); - console.log(`Started source instance: ${sourceInstance.id}`); - - console.log("Uploading repository..."); - let ssh: NodeSSH | undefined; - try { - const ssh = await sourceInstance.ssh(); - await ssh.putFile(SOURCE_ARCHIVE, SOURCE_ARCHIVE); - } finally { - if (ssh) { - ssh.dispose(); - } - } - await execOrThrow( - sourceInstance, - ` - unzip -q ${SOURCE_ARCHIVE} -d ${SOURCE_DIR} \ - && rm -f ${SOURCE_ARCHIVE} - `, - { verbose: VERBOSE, prefix: "| " }, - ); - console.log("Uploaded repository"); - - console.log("Creating source snapshot..."); - sourceSnapshot = await sourceInstance.snapshot({ - digest: SOURCE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SOURCE, - }, - }); - console.log(`Created source snapshot: ${sourceSnapshot.id}`); - - console.log("Cleaning up..."); - if (sourceInstance) { - console.log("- source instance"); - await sourceInstance.stop(); - } - console.log("Cleaned up"); - } catch (e) { - if (sourceInstance) { - console.log(`Left source instance running: ${sourceInstance.id}`); - } - throw e; - } - - return sourceSnapshot; -} - -async function ensureService( - client: MorphCloudClient, - sourceSnapshot: Snapshot, -): Promise { - let serviceSnapshot = await findSnapshotByDigest(client, SERVICE_DIGEST); - if (serviceSnapshot) { - console.log(`Found service snapshot: ${serviceSnapshot.id}`); - return serviceSnapshot; - } - - console.log("Creating service snapshot..."); - - let serviceInstance: Instance | undefined; - try { - console.log("Starting service instance..."); - serviceInstance = await startInstance(client, sourceSnapshot, { - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, - }, - }); - console.log(`Started service instance: ${serviceInstance.id}`); - - console.log("Building Nuanced LSP..."); - const buildStartTime = Date.now(); - console.log("- Rust Docker images"); - await execOrThrow( - serviceInstance, - ` - cd ${SOURCE_DIR} \ - && scripts/build-rust-images.sh - `, - { verbose: VERBOSE, prefix: " | " }, - ); - console.log("- TypeScript CLI"); - await execOrThrow( - serviceInstance, - ` - cd ${SOURCE_DIR}/clients/typescript \ - && npm install \ - && npm run build \ - && npm install -g - `, - { verbose: VERBOSE, prefix: " | " }, - ); - const buildDuration = ((Date.now() - buildStartTime) / 1000).toFixed(2); - console.log(`Built Nuanced LSP in ${buildDuration}s`); - - console.log("Cleaning up repository..."); - await execOrThrow( - serviceInstance, - ` - rm -rf ${SOURCE_DIR} - `, - { verbose: VERBOSE, prefix: "| " }, - ); - console.log("Cleaned up repository"); - - console.log("Creating service snapshot..."); - serviceSnapshot = await serviceInstance.snapshot({ - digest: SERVICE_DIGEST, - metadata: { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_SERVICE, - }, - }); - console.log(`Created service snapshot: ${serviceSnapshot.id}`); - - console.log("Cleaning up..."); - if (serviceInstance) { - console.log("- service instance..."); - await serviceInstance.stop(); - } - console.log("Cleaned up"); - } catch (e) { - if (serviceInstance) { - console.log(`Left service instance running: ${serviceInstance.id}`); - } - throw e; - } - - return serviceSnapshot; -} - -export async function serviceSetup() { - const client = new MorphCloudClient(); - try { - const baseSnapshot = await ensureBase(client); - const builderSnapshot = await ensureBuilder(client, baseSnapshot); - const sourceSnapshot = await ensureSource(client, builderSnapshot); - const _serviceSnapshot = await ensureService(client, sourceSnapshot); - } catch (error) { - throw new Error(`Service setup failed: ${error}`); - } -} diff --git a/clients/morphcloud/src/commands/service-status.ts b/clients/morphcloud/src/commands/service-status.ts index f6c90b30..6a295477 100755 --- a/clients/morphcloud/src/commands/service-status.ts +++ b/clients/morphcloud/src/commands/service-status.ts @@ -1,15 +1,12 @@ import { MorphCloudClient } from "morphcloud"; import { - SERVICE_DIGEST, - BUILDER_DIGEST, - LABEL_NUANCED_ROLE, - LABEL_NUANCED_WORKSPACE_DIGEST, - BASE_DIGEST, - NUANCED_ROLE_WORKSPACE, - SOURCE_DIGEST, + LABEL_NUANCED_LSP_ROLE, + LABEL_NUANCED_LSP_WORKSPACE_PATH, + NUANCED_LSP_ROLE_SERVICE, + NUANCED_LSP_ROLE_WORKSPACE, } from "../util/constants"; import { - findSnapshotByDigest, + findSnapshot, formatInstanceStatus, listInstances, } from "../util/morphcloud"; @@ -18,24 +15,19 @@ export async function serviceStatus() { const client = new MorphCloudClient(); try { console.log("System snapshots:"); - for (const digest of [ - BASE_DIGEST, - BUILDER_DIGEST, - SOURCE_DIGEST, - SERVICE_DIGEST, - ]) { - const snapshot = await findSnapshotByDigest(client, digest); - console.log( - `- ${digest.padEnd(20)} : ${snapshot ? snapshot.id : ""}`, - ); - } + const serviceSnapshot = await findSnapshot(client, { + metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_SERVICE }, + }); + console.log( + `- service${" ".repeat(13)} : ${serviceSnapshot ? serviceSnapshot.id : ""}`, + ); console.log("Workspace instances:"); for (const instance of await listInstances(client, { - metadata: { [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE }, + metadata: { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE }, })) { - const digest = instance.metadata![LABEL_NUANCED_WORKSPACE_DIGEST]!; + const path = instance.metadata![LABEL_NUANCED_LSP_WORKSPACE_PATH]!; console.log( - `- ${digest.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, + `- ${path.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, ); } } catch (error) { diff --git a/clients/morphcloud/src/commands/workspace-stop.ts b/clients/morphcloud/src/commands/workspace-delete.ts similarity index 62% rename from clients/morphcloud/src/commands/workspace-stop.ts rename to clients/morphcloud/src/commands/workspace-delete.ts index f47cc2f5..096d6f83 100755 --- a/clients/morphcloud/src/commands/workspace-stop.ts +++ b/clients/morphcloud/src/commands/workspace-delete.ts @@ -1,22 +1,25 @@ import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; +import { machineIdSync } from "node-machine-id"; import { - LABEL_NUANCED_ROLE, - LABEL_NUANCED_WORKSPACE_DIGEST, - NUANCED_ROLE_WORKSPACE, + LABEL_NUANCED_LSP_ROLE, + LABEL_NUANCED_LSP_WORKSPACE_PATH, + LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, + NUANCED_LSP_ROLE_WORKSPACE, } from "../util/constants"; import { findInstance } from "../util/morphcloud"; import { MutagenClient } from "../util/mutagen"; -import { getGloballyUniqueDigest } from "../util/digest"; -export async function workspaceStop(workspaceDir: string) { +const MACHINE_ID = machineIdSync(); + +export async function workspaceDelete(workspaceDir: string) { const client = new MorphCloudClient(); const workspaceRealDir = realpathSync(workspaceDir); - const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE, + [LABEL_NUANCED_LSP_WORKSPACE_PATH]: workspaceRealDir, + [LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID]: MACHINE_ID, }; let workspaceInstance: Instance | undefined; @@ -26,7 +29,7 @@ export async function workspaceStop(workspaceDir: string) { if (workspaceInstance) { const mutagen = new MutagenClient(workspaceInstance); await mutagen.stopSync().catch((e) => { - console.error(`Error: Cannot remove sync ${workspaceDigest}: ${e}`); + console.error(`Error: Cannot remove sync ${workspaceRealDir}: ${e}`); }); await workspaceInstance.stop().catch((e) => { console.error( diff --git a/clients/morphcloud/src/commands/workspace-lsp.ts b/clients/morphcloud/src/commands/workspace-lsp.ts deleted file mode 100755 index afff7198..00000000 --- a/clients/morphcloud/src/commands/workspace-lsp.ts +++ /dev/null @@ -1,146 +0,0 @@ -import { realpathSync } from "fs"; -import { Instance, MorphCloudClient } from "morphcloud"; -import { - SERVICE_DIGEST, - NUANCED_ROLE_WORKSPACE, - LABEL_NUANCED_ROLE, - LABEL_NUANCED_WORKSPACE_DIGEST, -} from "../util/constants"; -import { - findSnapshotByDigest, - startInstance, - findInstance, -} from "../util/morphcloud"; -import { MutagenClient } from "../util/mutagen"; -import { getGloballyUniqueDigest } from "../util/digest"; -import { sshExec } from "../util/ssh"; -import { getWorkspaceProcessRcPath } from "../util/config"; -import { ProcessRc } from "../util/process-rc"; - -export async function workspaceLsp(workspaceDir: string) { - const client = new MorphCloudClient(); - - const workspaceRealDir = realpathSync(workspaceDir); - const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); - const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, - }; - - const processRcPath = await getWorkspaceProcessRcPath(workspaceDigest); - const processRc = new ProcessRc(processRcPath); - - let workspaceInstance: Instance | undefined; - let mutagen: MutagenClient | undefined; - try { - await processRc.acquire(async () => { - workspaceInstance = await findInstance(client, { - metadata, - ensureReady: true, - }); - - if (!workspaceInstance) { - console.error("Creating workspace instance..."); - const serviceSnapshot = await findSnapshotByDigest( - client, - SERVICE_DIGEST, - ); - if (!serviceSnapshot) { - throw new Error("Missing service snapshot. Run setup.ts first."); - } - workspaceInstance = await startInstance(client, serviceSnapshot, { - metadata, - }); - } - console.error(`Workspace instance: ${workspaceInstance.id}`); - }); - - if (!workspaceInstance) { - workspaceInstance = await findInstance(client, { - metadata, - ensureReady: true, - }); - if (!workspaceInstance) { - throw new Error("Workspace instance not found after acquire"); - } - console.error(`Workspace instance: ${workspaceInstance.id}`); - } - - mutagen = new MutagenClient(workspaceInstance); - - if (await mutagen.findSync({ ensureReady: true })) { - console.error("Resuming file sync..."); - try { - await mutagen.resumeSync(); - } catch (e) { - throw new Error(`Failed to resume file sync: ${e}`); - } - } else { - console.error("Creating file sync..."); - try { - await mutagen.createSync(workspaceRealDir); - await mutagen.flushSync(); - } catch (e) { - throw new Error(`Failed to setup file sync: ${e}`); - } - } - - console.error("Executing nuanced-lsp..."); - const ssh = await workspaceInstance.ssh(); - const cmd = "nuanced-lsp"; - const args = [ - "server", - "--proxy-image", - "nuanced-lsp-proxy:latest", - "--watchdog-image", - "nuanced-lsp-watchdog:latest", - "--wrapper-image", - "nuanced-lsp-wrapper:latest", - "--shared", - "workspace", - ]; - console.error(`nuanced-lsp ${args.join(" ")}`); - const stream = await sshExec(ssh, cmd, args, { - stdin: process.stdin, - stdout: process.stdout, - stderr: process.stderr, - }); - stream.on("close", async () => { - ssh.dispose(); - await processRc.release(async () => { - if (mutagen) { - await mutagen.pauseSync().catch((e) => { - console.error(`Error: Cannot pause sync ${workspaceDigest}: ${e}`); - }); - } - if (workspaceInstance) { - await workspaceInstance.pause().catch((e) => { - console.error( - `Error: Cannot pause instance ${workspaceInstance!.id}: ${e}`, - ); - }); - } - }); - process.exit(0); - }); - } catch (e) { - await processRc.release(async () => { - if (mutagen) { - await mutagen.stopSync().catch((e) => { - console.error( - `Workspace LSP error: Cannot remove sync ${workspaceDigest}: ${e}`, - ); - }); - } - if (workspaceInstance) { - await workspaceInstance.stop().catch((e) => { - console.error( - `Workspace LSP error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, - ); - }); - } - }); - console.error(`Workspace LSP error: ${e}`); - process.exit(1); - } -} diff --git a/clients/morphcloud/src/commands/workspace-server.ts b/clients/morphcloud/src/commands/workspace-server.ts new file mode 100755 index 00000000..7b86119d --- /dev/null +++ b/clients/morphcloud/src/commands/workspace-server.ts @@ -0,0 +1,171 @@ +import { realpathSync } from "fs"; +import { Instance, MorphCloudClient } from "morphcloud"; +import { machineIdSync } from "node-machine-id"; +import { + NUANCED_LSP_ROLE_SERVICE, + NUANCED_LSP_ROLE_WORKSPACE, + LABEL_NUANCED_LSP_ROLE, + LABEL_NUANCED_LSP_WORKSPACE_PATH, + LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, +} from "../util/constants"; +import { + findSnapshot, + startInstance, + findInstance, + execOrThrow, +} from "../util/morphcloud"; +import { MutagenClient } from "../util/mutagen"; +import { sshExec } from "../util/ssh"; +import { getWorkspaceProcessRcPath } from "../util/config"; +import { ProcessRc } from "../util/process-rc"; + +const MACHINE_ID = machineIdSync(); + +async function ensureWorkspaceInstance( + client: MorphCloudClient, + metadata: Record, + workspaceRealDir: string, +): Promise<{ instance: Instance; mutagen: MutagenClient }> { + let workspaceInstance = await findInstance(client, { + metadata, + ensureReady: true, + }); + + 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}`); + } + } + + return { instance: workspaceInstance, mutagen }; +} + +export async function workspaceServer(workspaceDir: string) { + const client = new MorphCloudClient(); + + const workspaceRealDir = realpathSync(workspaceDir); + const metadata = { + [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE, + [LABEL_NUANCED_LSP_WORKSPACE_PATH]: workspaceRealDir, + [LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID]: MACHINE_ID, + }; + + const processRcPath = await getWorkspaceProcessRcPath(workspaceRealDir); + const processRc = new ProcessRc(processRcPath); + + let workspaceInstance: Instance | undefined; + let mutagen: MutagenClient | undefined; + try { + await processRc.acquire(async () => { + const result = await ensureWorkspaceInstance( + client, + metadata, + workspaceRealDir, + ); + workspaceInstance = result.instance; + mutagen = result.mutagen; + }); + + if (!workspaceInstance) { + workspaceInstance = await findInstance(client, { + metadata, + ensureReady: true, + }); + if (!workspaceInstance) { + throw new Error("Workspace instance not found after acquire"); + } + console.error(`Workspace instance: ${workspaceInstance.id}`); + } + + console.error("Executing nuanced-lsp server..."); + const ssh = await workspaceInstance.ssh(); + const cmd = "nuanced-lsp"; + const args = ["server", "--container-name", "nuanced-lsp"]; + const stream = await sshExec(ssh, cmd, args, { + stdin: process.stdin, + stdout: process.stdout, + stderr: process.stderr, + }); + stream.on("close", async () => { + ssh.dispose(); + await processRc.release(async () => { + if (mutagen) { + await mutagen.pauseSync().catch((e) => { + console.error(`Error: Cannot pause sync ${workspaceRealDir}: ${e}`); + }); + } + if (workspaceInstance) { + await workspaceInstance.pause().catch((e) => { + console.error( + `Error: Cannot pause instance ${workspaceInstance!.id}: ${e}`, + ); + }); + } + }); + process.exit(0); + }); + } catch (e) { + await processRc.release(async () => { + if (mutagen) { + await mutagen.stopSync().catch((e) => { + console.error( + `Workspace LSP error: Cannot remove sync ${workspaceRealDir}: ${e}`, + ); + }); + } + if (workspaceInstance) { + await workspaceInstance.stop().catch((e) => { + console.error( + `Workspace LSP error: Cannot stop instance ${workspaceInstance!.id}: ${e}`, + ); + }); + } + }); + console.error(`Workspace LSP error: ${e}`); + process.exit(1); + } +} diff --git a/clients/morphcloud/src/commands/workspace-status.ts b/clients/morphcloud/src/commands/workspace-status.ts index ae668052..9735ae2f 100755 --- a/clients/morphcloud/src/commands/workspace-status.ts +++ b/clients/morphcloud/src/commands/workspace-status.ts @@ -1,28 +1,31 @@ import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; +import { machineIdSync } from "node-machine-id"; import { - LABEL_NUANCED_ROLE, - LABEL_NUANCED_WORKSPACE_DIGEST, - NUANCED_ROLE_WORKSPACE, + LABEL_NUANCED_LSP_ROLE, + LABEL_NUANCED_LSP_WORKSPACE_PATH, + LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, + NUANCED_LSP_ROLE_WORKSPACE, } from "../util/constants"; import { findInstance } from "../util/morphcloud"; -import { getGloballyUniqueDigest } from "../util/digest"; + +const MACHINE_ID = machineIdSync(); export async function workspaceStatus(workspaceDir: string) { const client = new MorphCloudClient(); const workspaceRealDir = realpathSync(workspaceDir); - const workspaceDigest = getGloballyUniqueDigest(workspaceRealDir); const metadata = { - [LABEL_NUANCED_ROLE]: NUANCED_ROLE_WORKSPACE, - [LABEL_NUANCED_WORKSPACE_DIGEST]: workspaceDigest, + [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE, + [LABEL_NUANCED_LSP_WORKSPACE_PATH]: workspaceRealDir, + [LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID]: MACHINE_ID, }; let workspaceInstance: Instance | undefined; try { workspaceInstance = await findInstance(client, { metadata }); console.log( - `${workspaceDigest.padEnd(2)} : ${workspaceInstance?.id ?? ""}`, + `${workspaceRealDir.padEnd(2)} : ${workspaceInstance?.id ?? ""}`, ); } catch (error) { throw new Error(`Workspace status failed: ${error}`); diff --git a/clients/morphcloud/src/util/config.ts b/clients/morphcloud/src/util/config.ts index 4aa4a261..94644baa 100644 --- a/clients/morphcloud/src/util/config.ts +++ b/clients/morphcloud/src/util/config.ts @@ -1,6 +1,7 @@ import { join } from "path"; import { homedir } from "os"; import { mkdir } from "fs/promises"; +import { createHash } from "crypto"; import { Instance } from "morphcloud"; export async function ensureConfigDirectory(): Promise { @@ -19,17 +20,21 @@ export async function ensureInstanceConfigDirectory( } export async function ensureWorkspaceConfigDirectory( - workspaceDigest: string, + workspacePath: string, ): Promise { const configDir = await ensureConfigDirectory(); - const workspaceDir = join(configDir, "workspaces", workspaceDigest); + const hash = createHash("sha256") + .update(workspacePath) + .digest("hex") + .substring(0, 12); + const workspaceDir = join(configDir, "workspaces", hash); await mkdir(workspaceDir, { recursive: true }); return workspaceDir; } export async function getWorkspaceProcessRcPath( - workspaceDigest: string, + workspacePath: string, ): Promise { - const workspaceDir = await ensureWorkspaceConfigDirectory(workspaceDigest); + const workspaceDir = await ensureWorkspaceConfigDirectory(workspacePath); return join(workspaceDir, "servers.json"); } diff --git a/clients/morphcloud/src/util/constants.ts b/clients/morphcloud/src/util/constants.ts index eeb78aa2..3a0deea1 100644 --- a/clients/morphcloud/src/util/constants.ts +++ b/clients/morphcloud/src/util/constants.ts @@ -1,18 +1,10 @@ -export const SOURCE_ARCHIVE = "nuanced-lsp-src.zip"; -export const SOURCE_DIR = "nuanced-lsp-src"; +export const LABEL_NUANCED_LSP_ROLE = "nuanced-lsp.role"; -export const BASE_DIGEST = "nuanced-lsp-base"; -export const BUILDER_DIGEST = "nuanced-lsp-builder"; -export const SOURCE_DIGEST = "nuanced-lsp-source"; -export const SERVICE_DIGEST = "nuanced-lsp-service"; +export const NUANCED_LSP_ROLE_SERVICE = "service"; -export const LABEL_NUANCED_ROLE = "nuanced.role"; +export const NUANCED_LSP_ROLE_WORKSPACE = "workspace"; -export const NUANCED_ROLE_BASE = "base"; -export const NUANCED_ROLE_BUILDER = "builder"; -export const NUANCED_ROLE_SOURCE = "source"; -export const NUANCED_ROLE_SERVICE = "service"; +export const LABEL_NUANCED_LSP_WORKSPACE_PATH = "nuanced-lsp.workspace.path"; -export const NUANCED_ROLE_WORKSPACE = "workspace"; - -export const LABEL_NUANCED_WORKSPACE_DIGEST = "nuanced.workspace.digest"; +export const LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID = + "nuanced-lsp.workspace.machine_id"; diff --git a/clients/morphcloud/src/util/digest.ts b/clients/morphcloud/src/util/digest.ts deleted file mode 100644 index ab9d020f..00000000 --- a/clients/morphcloud/src/util/digest.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { createHash } from "crypto"; -import { machineIdSync } from "node-machine-id"; - -const MACHINE_ID = machineIdSync(); - -export function getLocallyUniqueDigest(value: string): string { - const hash = createHash("sha256").update(value).digest("hex"); - return hash.substring(0, 12); -} - -export function getGloballyUniqueDigest(value: string): string { - const hash = createHash("sha256") - .update(MACHINE_ID) - .update("-") - .update(value) - .digest("hex"); - return hash.substring(0, 12); -} diff --git a/clients/morphcloud/src/util/morphcloud.ts b/clients/morphcloud/src/util/morphcloud.ts index b5e14223..5456cb2d 100644 --- a/clients/morphcloud/src/util/morphcloud.ts +++ b/clients/morphcloud/src/util/morphcloud.ts @@ -9,15 +9,17 @@ import { Snapshot, } from "morphcloud"; -export async function findSnapshotByDigest( +export async function findSnapshot( client: MorphCloudClient, - digest: string, + opts?: { + metadata?: Record; + }, ): Promise { - const snapshots = await client.snapshots.list({ - digest, - }); - const snapshot = snapshots[0]; - return snapshot; + const snapshots = await client.snapshots.list(opts); + if (snapshots.length > 1) { + throw new Error("Multiple matching snapshots"); + } + return snapshots[0]; } export async function bootInstance( From 92ab95b37bcae4af13a4be70c928cd8ac8c321ae Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 14:57:32 +0100 Subject: [PATCH 09/14] Update README --- clients/morphcloud/README.md | 54 +++++++++++++++++++++++++++++++++--- 1 file changed, 50 insertions(+), 4 deletions(-) diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md index c4cacbb8..593fe940 100644 --- a/clients/morphcloud/README.md +++ b/clients/morphcloud/README.md @@ -4,11 +4,17 @@ This is an **experimental** client to run Nuanced LSP remotely on [Morph Cloud]( **Current features:** -- TBD +- Run Nuanced LSP instances for a workspace remotely in the Morph Cloud +- Sync files from the local workspace to the remote instance +- Resume a previous LSP instance for the same workspace for fast startup -**Open issues:** +**Known issues:** -- TBD +- File syncs are sometimes not properly resumed, in which case the workspace instance needs to be recreated + +**Future possibilities:** + +- Use instances from other branches of the same repo to improve cold start time. For example, if an instance is available for the main branch, feature branches could start from that and reuse existing state as much as possible. ## Requirements @@ -17,7 +23,47 @@ This is an **experimental** client to run Nuanced LSP remotely on [Morph Cloud]( ## Quick start -TBD +**Create the service snapshot:** + +```bash +nuanced-lsp-morphcloud service create +``` + +This creates the base snapshot from which the workspace instances are spawned. + +**Check service status:** + +```bash +nuanced-lsp-morphcloud service status +``` + +**Delete service snapshot:** + +```bash +nuanced-lsp-morphcloud service delete +``` + +This can be useful if you want to recreate the service snapshot. The old snapshot must be removed first. + +**Start a remove LSP server for a workspace:** + +```bash +nuanced-lsp-morphcloud workspace server /path/to/workspace +``` + +**Check workspace status:** + +```bash +nuanced-lsp-morphcloud workspace status /path/to/workspace +``` + +**Remove a workspace status:** + +```bash +nuanced-lsp-morphcloud workspace delete /path/to/workspace +``` + +This can be useful if you want to recreate the workspace instance. ## License From 62b4acfaf3bb665e77df752d50ba910536537f68 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 14:58:07 +0100 Subject: [PATCH 10/14] Add changelog --- clients/morphcloud/CHANGELOG.md | 10 ++++++++++ 1 file changed, 10 insertions(+) create mode 100644 clients/morphcloud/CHANGELOG.md diff --git a/clients/morphcloud/CHANGELOG.md b/clients/morphcloud/CHANGELOG.md new file mode 100644 index 00000000..328283cd --- /dev/null +++ b/clients/morphcloud/CHANGELOG.md @@ -0,0 +1,10 @@ +# Changelog + +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.0] - 2025-01-21 + +Initial release. From e35a42564aee42e7569721fa5f7bcfe5752d8442 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 15:03:08 +0100 Subject: [PATCH 11/14] Add release automation --- .../workflows/morphcloud-client-release.yml | 77 ++++++++++ .github/workflows/test.yml | 27 ++++ clients/morphcloud/DEVELOPMENT.md | 20 +++ clients/morphcloud/README.md | 4 +- clients/morphcloud/scripts/release.sh | 144 ++++++++++++++++++ 5 files changed, 271 insertions(+), 1 deletion(-) create mode 100644 .github/workflows/morphcloud-client-release.yml create mode 100755 clients/morphcloud/scripts/release.sh diff --git a/.github/workflows/morphcloud-client-release.yml b/.github/workflows/morphcloud-client-release.yml new file mode 100644 index 00000000..06599c1d --- /dev/null +++ b/.github/workflows/morphcloud-client-release.yml @@ -0,0 +1,77 @@ +name: Release Morph Cloud Client + +on: + push: + tags: + - 'morphcloud-client-v*' + +concurrency: + group: ${{ github.workflow }}-${{ github.ref }} + cancel-in-progress: true + +permissions: + contents: write + id-token: write # allows trusted publishing to NPM + +defaults: + run: + working-directory: clients/morphcloud + +jobs: + publish: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js 24.x + uses: actions/setup-node@v6 + with: + node-version: 24.x + + - name: Validate tag version + id: version + run: | + TAG_VERSION="${GITHUB_REF#refs/tags/typescript-client-v}" + PACKAGE_VERSION=$(node -p "require('./package.json').version") + + echo "Tag version: $TAG_VERSION" + echo "Current package version: $PACKAGE_VERSION" + + if [ "$TAG_VERSION" != "$PACKAGE_VERSION" ]; then + echo "Error: Tag version ($TAG_VERSION) does not match package.json version ($PACKAGE_VERSION)" + exit 1 + fi + + echo "โœ“ Tag version matches package.json version" + echo "VERSION=$PACKAGE_VERSION" >> $GITHUB_OUTPUT + + - name: Install dependencies + working-directory: clients/typescript + run: npm install + + - name: Build + run: npm run build + + - name: Publish to npm + run: npm publish + + - name: Create GitHub Release + uses: softprops/action-gh-release@v2 + with: + tag_name: typescript-client-v${{ steps.version.outputs.VERSION }} + name: TypeScript Client v${{ steps.version.outputs.VERSION }} + body: | + Published package [@nuanced-dev/lsp-morphcloud](https://www.npmjs.com/package/@nuanced-dev/lsp-morphcloud) to NPM. + + ### Installation + + ```bash + npm install -g @nuanced-dev/lsp-morphcloud@${{ steps.version.outputs.VERSION }} + ``` + + ### Changes + + See [CHANGELOG.md](${{ github.server_url }}/${{ github.repository }}/blob/${{ github.ref_name }}/clients/morphcloud/CHANGELOG.md) for details. + draft: false + prerelease: false diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index a6a1d958..1229ba89 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -330,3 +330,30 @@ jobs: - name: Lint run: npm run lint + + morphcloud-client-check: + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + node-version: [18.x, 20.x, 22.x, 24.x] + defaults: + run: + working-directory: clients/morphcloud + steps: + - name: Checkout + uses: actions/checkout@v6 + + - name: Setup Node.js ${{ matrix.node-version }} + uses: actions/setup-node@v6 + with: + node-version: ${{ matrix.node-version }} + + - name: Install dependencies + run: npm install + + - name: Build + run: npm run build + + - name: Lint + run: npm run lint diff --git a/clients/morphcloud/DEVELOPMENT.md b/clients/morphcloud/DEVELOPMENT.md index 8095e3cf..559b578e 100644 --- a/clients/morphcloud/DEVELOPMENT.md +++ b/clients/morphcloud/DEVELOPMENT.md @@ -34,3 +34,23 @@ Run the CLI from source: ```bash npm run dev ``` + +## Release + +Follow these steps to release a new version: + +1. Open a branch for the new release. + +1. Update the package version in `package.json` to the desired new version. + +1. Update the [changelog](CHANGELOG.md) to include an entry for the new version. + +1. Run the release script: + + ```bash + scripts/release.sh + ``` + + The release script pushes a tag to GitHub that will trigger the release workflow. The release workflow publishes the NPM package and creates a GitHub release for the new version. + +1. If releases are successful, merge the release branch. diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md index 593fe940..602ed369 100644 --- a/clients/morphcloud/README.md +++ b/clients/morphcloud/README.md @@ -1,6 +1,8 @@ # Nuanced LSP Morph Cloud Client -This is an **experimental** client to run Nuanced LSP remotely on [Morph Cloud](https://cloud.morph.so). +This is a client to run Nuanced LSP remotely on [Morph Cloud](https://cloud.morph.so). + +_Note that this client is experimental and not considered stable!_ **Current features:** diff --git a/clients/morphcloud/scripts/release.sh b/clients/morphcloud/scripts/release.sh new file mode 100755 index 00000000..357fe56f --- /dev/null +++ b/clients/morphcloud/scripts/release.sh @@ -0,0 +1,144 @@ +#!/usr/bin/env bash + +set -euo pipefail + +SCRIPT_DIR="$(cd "$(dirname "$(realpath "${BASH_SOURCE[0]}")")" && pwd)" +CLIENT_DIR="$(dirname "$SCRIPT_DIR")" + +source "$CLIENT_DIR/../../scripts/include/colors.sh" +source "$CLIENT_DIR/../../scripts/include/lib.sh" + +PACKAGE_VERSION=$(node -p "require('$CLIENT_DIR/package.json').version") +export PACKAGE_VERSION + +usage() { + echo "Usage: $0 [--dry-run|-N] [--force|-f]" +} + +help() { + echo "Release Morph Cloud client by pushing git tag" + echo "" + echo "Usage: $0 [OPTIONS...]" + echo "" + echo "Options:" + echo " --dry-run, -N Run all checks but only print the tag that would be pushed" + echo " --force, -f Force push tag even if it already exists" + echo " --help, -h Show this help message" + echo "" + echo "Pre-release checks:" + echo " - Git working directory must be clean" + echo " - Changelog entry must exist for the version being released" + echo " - Git tag must not already exist" + echo "" + echo "This will push tag: morphcloud-client-v${PACKAGE_VERSION}" +} + +# Defaults +DRY_RUN=false +FORCE=false + +# Parse arguments +for arg in "$@"; do + case $arg in + --help|-h) + help + exit 0 + ;; + --dry-run|-N) + DRY_RUN=true + ;; + --force|-f) + FORCE=true + ;; + *) + echo -e "${YELLOW}Unknown argument: $arg${NC}" + usage + exit 1 + ;; + esac +done + +echo -e "${BLUE}=========================================${NC}" +echo -e "${BLUE} Release Morph Cloud Client${NC}" +echo -e "${BLUE} Version: v$PACKAGE_VERSION${NC}" +echo -e "${BLUE}=========================================${NC}" +echo + +# Pre-release checks +echo -e "${YELLOW}Running pre-release checks...${NC}" + +# Check working directory is clean +if ! is_git_working_directory_clean "$CLIENT_DIR"; then + echo -e "${RED}Error: Git working directory is not clean${NC}" + echo -e "${YELLOW}Please commit or stash your changes before releasing${NC}" + exit 1 +fi +echo -e "${GREEN}โœ“ Git working directory is clean${NC}" + +# Check version is semver +if ! is_semver "$PACKAGE_VERSION"; then + echo -e "${RED}Error: Package version must be in MAJOR.MINOR.PATCH format${NC}" + echo -e "${YELLOW}Current version: $PACKAGE_VERSION${NC}" + exit 1 +fi +echo -e "${GREEN}โœ“ Package version is valid semver: $PACKAGE_VERSION${NC}" + +# Check changelog entry +if ! has_changelog_entry "$PACKAGE_VERSION" "$CLIENT_DIR/CHANGELOG.md"; then + echo -e "${RED}Error: Changelog entry missing for version $PACKAGE_VERSION${NC}" + echo -e "${YELLOW}Please add a changelog entry in clients/morphcloud/CHANGELOG.md with format:${NC}" + echo -e "${YELLOW} ## [$PACKAGE_VERSION] - YYYY-MM-DD${NC}" + exit 1 +fi +echo -e "${GREEN}โœ“ Changelog entry exists for version $PACKAGE_VERSION${NC}" + +# Check tag doesn't exist (unless --force is set) +TAG="morphcloud-client-v$PACKAGE_VERSION" +if git_tag_exists "$TAG"; then + if [ "$FORCE" = false ]; then + echo -e "${RED}Error: Git tag $TAG already exists${NC}" + echo -e "${YELLOW}Use --force to override and push anyway${NC}" + exit 1 + else + echo -e "${YELLOW}โš  Git tag $TAG already exists (will force push)${NC}" + fi +else + echo -e "${GREEN}โœ“ Git tag $TAG does not exist${NC}" +fi + +echo -e "${GREEN}All pre-release checks passed${NC}" +echo + +# Push tag +if [ "$DRY_RUN" = true ]; then + echo -e "${YELLOW}DRY RUN: Would create and push tag: $TAG${NC}" + echo + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN} Dry Run Complete${NC}" + echo -e "${GREEN}=========================================${NC}" + echo + echo -e "${BLUE}Would push tag: $TAG${NC}" +else + echo -e "${YELLOW}Pushing git tag...${NC}" + echo -e "${BLUE}Creating and pushing tag: $TAG${NC}" + if [ "$FORCE" = true ]; then + git tag --force "$TAG" + git push --force origin "$TAG" + else + git tag "$TAG" + git push origin "$TAG" + fi + echo -e "${GREEN}โœ“ Tag $TAG pushed${NC}" + + echo + echo -e "${GREEN}=========================================${NC}" + echo -e "${GREEN} Release Complete${NC}" + echo -e "${GREEN}=========================================${NC}" + echo + echo -e "${BLUE}GitHub Actions workflow will now:${NC}" + echo -e "${BLUE} 1. Run tests${NC}" + echo -e "${BLUE} 2. Build the package${NC}" + echo -e "${BLUE} 3. Publish to npm${NC}" + echo -e "${BLUE} 4. Create GitHub release for $TAG${NC}" + echo +fi From 6628ea6cc4584db050f85ea65947b98c8c3d4841 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 15:21:38 +0100 Subject: [PATCH 12/14] Make esm package --- clients/morphcloud/README.md | 1 + clients/morphcloud/src/cli.ts | 12 ++++---- .../morphcloud/src/commands/service-create.ts | 8 ++++-- .../morphcloud/src/commands/service-delete.ts | 4 +-- .../morphcloud/src/commands/service-status.ts | 13 ++++++--- .../src/commands/workspace-delete.ts | 6 ++-- .../src/commands/workspace-server.ts | 15 ++++++---- .../src/commands/workspace-status.ts | 4 +-- clients/morphcloud/src/util/constants.ts | 7 +++-- clients/morphcloud/src/util/mutagen.ts | 4 +-- clients/morphcloud/src/util/ssh.ts | 2 +- clients/morphcloud/tsconfig.json | 28 +++++++++++-------- clients/typescript/tsconfig.json | 1 + 13 files changed, 62 insertions(+), 43 deletions(-) diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md index 602ed369..32669cf7 100644 --- a/clients/morphcloud/README.md +++ b/clients/morphcloud/README.md @@ -17,6 +17,7 @@ _Note that this client is experimental and not considered stable!_ **Future possibilities:** - Use instances from other branches of the same repo to improve cold start time. For example, if an instance is available for the main branch, feature branches could start from that and reuse existing state as much as possible. +- Support creating multiple, named, service snapshots with different resource or languages preloaded. ## Requirements diff --git a/clients/morphcloud/src/cli.ts b/clients/morphcloud/src/cli.ts index 2ea62332..8a140d94 100644 --- a/clients/morphcloud/src/cli.ts +++ b/clients/morphcloud/src/cli.ts @@ -16,7 +16,7 @@ service .description("Create service snapshot") .action(async () => { const { serviceCreate: serviceSetup } = - await import("./commands/service-create"); + await import("./commands/service-create.js"); serviceSetup().catch(console.error); }); @@ -24,7 +24,7 @@ service .command("status") .description("Show service status") .action(async () => { - const { serviceStatus } = await import("./commands/service-status"); + const { serviceStatus } = await import("./commands/service-status.js"); serviceStatus().catch(console.error); }); @@ -32,7 +32,7 @@ service .command("delete") .description("Delete service snapshot") .action(async () => { - const { serviceDelete } = await import("./commands/service-delete"); + const { serviceDelete } = await import("./commands/service-delete.js"); serviceDelete().catch(console.error); }); @@ -46,7 +46,7 @@ workspace .argument("", "Workspace directory") .action(async (directory: string) => { const { workspaceServer: workspaceLsp } = - await import("./commands/workspace-server"); + await import("./commands/workspace-server.js"); workspaceLsp(directory).catch(console.error); }); @@ -55,7 +55,7 @@ workspace .description("Show workspace status") .argument("", "Workspace directory") .action(async (directory: string) => { - const { workspaceStatus } = await import("./commands/workspace-status"); + const { workspaceStatus } = await import("./commands/workspace-status.js"); workspaceStatus(directory).catch(console.error); }); @@ -65,7 +65,7 @@ workspace .argument("", "Workspace directory") .action(async (directory: string) => { const { workspaceDelete: workspaceStop } = - await import("./commands/workspace-delete"); + await import("./commands/workspace-delete.js"); workspaceStop(directory).catch(console.error); }); diff --git a/clients/morphcloud/src/commands/service-create.ts b/clients/morphcloud/src/commands/service-create.ts index ba46279b..46f418a9 100755 --- a/clients/morphcloud/src/commands/service-create.ts +++ b/clients/morphcloud/src/commands/service-create.ts @@ -2,8 +2,12 @@ import { Instance, MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_LSP_ROLE, NUANCED_LSP_ROLE_SERVICE, -} from "../util/constants"; -import { execOrThrow, findSnapshot, startInstance } from "../util/morphcloud"; +} from "../util/constants.js"; +import { + execOrThrow, + findSnapshot, + startInstance, +} from "../util/morphcloud.js"; const VCPU_COUNT = 2; const MEM_SIZE_MB = 16384; diff --git a/clients/morphcloud/src/commands/service-delete.ts b/clients/morphcloud/src/commands/service-delete.ts index af484bea..fdb751a8 100644 --- a/clients/morphcloud/src/commands/service-delete.ts +++ b/clients/morphcloud/src/commands/service-delete.ts @@ -2,8 +2,8 @@ import { MorphCloudClient } from "morphcloud"; import { LABEL_NUANCED_LSP_ROLE, NUANCED_LSP_ROLE_SERVICE, -} from "../util/constants"; -import { findSnapshot } from "../util/morphcloud"; +} from "../util/constants.js"; +import { findSnapshot } from "../util/morphcloud.js"; export async function serviceDelete() { const client = new MorphCloudClient(); diff --git a/clients/morphcloud/src/commands/service-status.ts b/clients/morphcloud/src/commands/service-status.ts index 6a295477..afdad05d 100755 --- a/clients/morphcloud/src/commands/service-status.ts +++ b/clients/morphcloud/src/commands/service-status.ts @@ -2,32 +2,37 @@ 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"; +} from "../util/constants.js"; import { findSnapshot, formatInstanceStatus, listInstances, -} from "../util/morphcloud"; +} from "../util/morphcloud.js"; export async function serviceStatus() { const client = new MorphCloudClient(); try { - console.log("System snapshots:"); + 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 : ""}`, ); + 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( - `- ${path.padEnd(20)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, + `- ${label.padEnd(40)} : ${instance.id} (${formatInstanceStatus(instance.status)})`, ); } } catch (error) { diff --git a/clients/morphcloud/src/commands/workspace-delete.ts b/clients/morphcloud/src/commands/workspace-delete.ts index 096d6f83..225dbefc 100755 --- a/clients/morphcloud/src/commands/workspace-delete.ts +++ b/clients/morphcloud/src/commands/workspace-delete.ts @@ -6,9 +6,9 @@ import { LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, NUANCED_LSP_ROLE_WORKSPACE, -} from "../util/constants"; -import { findInstance } from "../util/morphcloud"; -import { MutagenClient } from "../util/mutagen"; +} from "../util/constants.js"; +import { findInstance } from "../util/morphcloud.js"; +import { MutagenClient } from "../util/mutagen.js"; const MACHINE_ID = machineIdSync(); diff --git a/clients/morphcloud/src/commands/workspace-server.ts b/clients/morphcloud/src/commands/workspace-server.ts index 7b86119d..5cc91ac6 100755 --- a/clients/morphcloud/src/commands/workspace-server.ts +++ b/clients/morphcloud/src/commands/workspace-server.ts @@ -1,4 +1,5 @@ import { realpathSync } from "fs"; +import { hostname } from "os"; import { Instance, MorphCloudClient } from "morphcloud"; import { machineIdSync } from "node-machine-id"; import { @@ -7,17 +8,18 @@ import { LABEL_NUANCED_LSP_ROLE, LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, -} from "../util/constants"; + LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME, +} from "../util/constants.js"; import { findSnapshot, startInstance, findInstance, execOrThrow, -} from "../util/morphcloud"; -import { MutagenClient } from "../util/mutagen"; -import { sshExec } from "../util/ssh"; -import { getWorkspaceProcessRcPath } from "../util/config"; -import { ProcessRc } from "../util/process-rc"; +} from "../util/morphcloud.js"; +import { MutagenClient } from "../util/mutagen.js"; +import { sshExec } from "../util/ssh.js"; +import { getWorkspaceProcessRcPath } from "../util/config.js"; +import { ProcessRc } from "../util/process-rc.js"; const MACHINE_ID = machineIdSync(); @@ -92,6 +94,7 @@ export async function workspaceServer(workspaceDir: string) { [LABEL_NUANCED_LSP_ROLE]: NUANCED_LSP_ROLE_WORKSPACE, [LABEL_NUANCED_LSP_WORKSPACE_PATH]: workspaceRealDir, [LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID]: MACHINE_ID, + [LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME]: hostname(), }; const processRcPath = await getWorkspaceProcessRcPath(workspaceRealDir); diff --git a/clients/morphcloud/src/commands/workspace-status.ts b/clients/morphcloud/src/commands/workspace-status.ts index 9735ae2f..fb7c0974 100755 --- a/clients/morphcloud/src/commands/workspace-status.ts +++ b/clients/morphcloud/src/commands/workspace-status.ts @@ -6,8 +6,8 @@ import { LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, NUANCED_LSP_ROLE_WORKSPACE, -} from "../util/constants"; -import { findInstance } from "../util/morphcloud"; +} from "../util/constants.js"; +import { findInstance } from "../util/morphcloud.js"; const MACHINE_ID = machineIdSync(); diff --git a/clients/morphcloud/src/util/constants.ts b/clients/morphcloud/src/util/constants.ts index 3a0deea1..94ca882d 100644 --- a/clients/morphcloud/src/util/constants.ts +++ b/clients/morphcloud/src/util/constants.ts @@ -1,10 +1,11 @@ export const LABEL_NUANCED_LSP_ROLE = "nuanced-lsp.role"; - export const NUANCED_LSP_ROLE_SERVICE = "service"; - export const NUANCED_LSP_ROLE_WORKSPACE = "workspace"; -export const LABEL_NUANCED_LSP_WORKSPACE_PATH = "nuanced-lsp.workspace.path"; +export const LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME = + "nuanced-lsp.workspace.hostname"; export const LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID = "nuanced-lsp.workspace.machine_id"; + +export const LABEL_NUANCED_LSP_WORKSPACE_PATH = "nuanced-lsp.workspace.path"; diff --git a/clients/morphcloud/src/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts index 55719d6f..14564114 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -3,8 +3,8 @@ import { platform, arch } from "os"; import { mkdir } from "fs/promises"; import { Instance } from "morphcloud"; import { spawn } from "child-process-promise"; -import { ensureConfigDirectory } from "./config"; -import { getSshConfig, removeSshConfig } from "./ssh"; +import { ensureConfigDirectory } from "./config.js"; +import { getSshConfig, removeSshConfig } from "./ssh.js"; const MISSING_SESSION_ERROR = "unable to locate requested session"; diff --git a/clients/morphcloud/src/util/ssh.ts b/clients/morphcloud/src/util/ssh.ts index 47a719e7..3f0fdefa 100644 --- a/clients/morphcloud/src/util/ssh.ts +++ b/clients/morphcloud/src/util/ssh.ts @@ -4,7 +4,7 @@ import stream from "stream"; import { Instance } from "morphcloud"; import { NodeSSH } from "node-ssh"; import { ClientChannel } from "ssh2"; -import { ensureInstanceConfigDirectory } from "./config"; +import { ensureInstanceConfigDirectory } from "./config.js"; const MORPH_SSH_HOST = "ssh.cloud.morph.so"; diff --git a/clients/morphcloud/tsconfig.json b/clients/morphcloud/tsconfig.json index 50572daa..7f02279f 100644 --- a/clients/morphcloud/tsconfig.json +++ b/clients/morphcloud/tsconfig.json @@ -1,19 +1,23 @@ { "compilerOptions": { "target": "ES2020", - "module": "commonjs", - "lib": ["ES2020"], - "outDir": "./dist", - "rootDir": "./src", - "strict": true, + "module": "NodeNext", + "moduleResolution": "NodeNext", + + "outDir": "dist", + "rootDir": "src", + + "resolveJsonModule": true, "esModuleInterop": true, - "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, - "resolveJsonModule": true, - "declaration": true, - "declarationMap": true, - "sourceMap": true + "strict": true, + "skipLibCheck": true, + + "declaration": false, + "declarationMap": false, + + "types": ["node"] }, - "include": ["src/**/*"], - "exclude": ["node_modules", "dist"] + "include": ["src/**/*"] } diff --git a/clients/typescript/tsconfig.json b/clients/typescript/tsconfig.json index f5ea9862..86a2db5d 100644 --- a/clients/typescript/tsconfig.json +++ b/clients/typescript/tsconfig.json @@ -17,6 +17,7 @@ "declaration": true, "declarationMap": true, "emitDeclarationOnly": false, + "types": ["node"] }, "include": ["src/**/*"] From be91f8669d38421cd6644074ab956b6ca8b2a938 Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 15:23:53 +0100 Subject: [PATCH 13/14] Update generated file --- clients/typescript/src/__generated/version.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clients/typescript/src/__generated/version.ts b/clients/typescript/src/__generated/version.ts index d77abebc..cb73720f 100644 --- a/clients/typescript/src/__generated/version.ts +++ b/clients/typescript/src/__generated/version.ts @@ -1,3 +1,3 @@ // AUTO-GENERATED FILE. Do not edit. // Generated by scripts/generate-version.mjs -export const VERSION = "0.5.1"; +export const VERSION = "0.5.2"; From 6b7c06b3871e890b09c08c100b993834ff381dea Mon Sep 17 00:00:00 2001 From: Hendrik van Antwerpen Date: Wed, 21 Jan 2026 15:43:38 +0100 Subject: [PATCH 14/14] More cleanup after testing --- clients/morphcloud/DEVELOPMENT.md | 17 +++++++++++++++++ clients/morphcloud/README.md | 9 +++++---- .../morphcloud/scripts/{mutagen => mutagen.sh} | 0 .../scripts/update-nuanced-lsp-archive.sh | 12 ------------ .../morphcloud/src/commands/service-create.ts | 7 +++++++ .../morphcloud/src/commands/workspace-delete.ts | 4 +--- .../morphcloud/src/commands/workspace-server.ts | 4 +--- .../morphcloud/src/commands/workspace-status.ts | 4 +--- clients/morphcloud/src/util/constants.ts | 4 ++++ clients/morphcloud/src/util/mutagen.ts | 2 +- 10 files changed, 37 insertions(+), 26 deletions(-) rename clients/morphcloud/scripts/{mutagen => mutagen.sh} (100%) delete mode 100755 clients/morphcloud/scripts/update-nuanced-lsp-archive.sh diff --git a/clients/morphcloud/DEVELOPMENT.md b/clients/morphcloud/DEVELOPMENT.md index 559b578e..0af4397a 100644 --- a/clients/morphcloud/DEVELOPMENT.md +++ b/clients/morphcloud/DEVELOPMENT.md @@ -35,6 +35,23 @@ Run the CLI from source: npm run dev ``` +## Mutagen + +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: + +```bash +scripts/mutagen.sh +``` + +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` + ## Release Follow these steps to release a new version: diff --git a/clients/morphcloud/README.md b/clients/morphcloud/README.md index 32669cf7..25703fea 100644 --- a/clients/morphcloud/README.md +++ b/clients/morphcloud/README.md @@ -6,13 +6,14 @@ _Note that this client is experimental and not considered stable!_ **Current features:** -- Run Nuanced LSP instances for a workspace remotely in the Morph Cloud -- Sync files from the local workspace to the remote instance -- Resume a previous LSP instance for the same workspace for fast startup +- Run Nuanced LSP instances for a workspace remotely in the Morph Cloud. +- Sync files from the local workspace to the remote instance. +- Resume a previous LSP instance for the same workspace for fast startup. **Known issues:** -- File syncs are sometimes not properly resumed, in which case the workspace instance needs to be recreated +- 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. **Future possibilities:** diff --git a/clients/morphcloud/scripts/mutagen b/clients/morphcloud/scripts/mutagen.sh similarity index 100% rename from clients/morphcloud/scripts/mutagen rename to clients/morphcloud/scripts/mutagen.sh diff --git a/clients/morphcloud/scripts/update-nuanced-lsp-archive.sh b/clients/morphcloud/scripts/update-nuanced-lsp-archive.sh deleted file mode 100755 index 3c9515e6..00000000 --- a/clients/morphcloud/scripts/update-nuanced-lsp-archive.sh +++ /dev/null @@ -1,12 +0,0 @@ -#!/usr/bin/env bash - -set -eu - -ARCHIVE_DIR="$(realpath "$(dirname "$0")/..")" - -if [ $# -ne 1 ]; then - echo "Usage: $0 REPO_DIR" - exit 1 -fi - -exec git -C "$1" archive -o "$ARCHIVE_DIR/nuanced-lsp-src.zip" HEAD diff --git a/clients/morphcloud/src/commands/service-create.ts b/clients/morphcloud/src/commands/service-create.ts index 46f418a9..c99d2aa9 100755 --- a/clients/morphcloud/src/commands/service-create.ts +++ b/clients/morphcloud/src/commands/service-create.ts @@ -47,6 +47,7 @@ export async function serviceCreate() { console.log(`Started service instance: ${serviceInstance.id}`); console.log("Installing packages..."); + console.log("- Installing system commands..."); await execOrThrow( serviceInstance, @@ -79,6 +80,12 @@ export async function serviceCreate() { verbose: VERBOSE, prefix: " | ", }); + const nuancedVersion = await execOrThrow( + serviceInstance, + "nuanced-lsp --version", + ); + console.log(` Installed ${nuancedVersion.trim()}`); + console.log("Installed packages"); console.log("Pulling service images..."); diff --git a/clients/morphcloud/src/commands/workspace-delete.ts b/clients/morphcloud/src/commands/workspace-delete.ts index 225dbefc..f7de110c 100755 --- a/clients/morphcloud/src/commands/workspace-delete.ts +++ b/clients/morphcloud/src/commands/workspace-delete.ts @@ -1,17 +1,15 @@ import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; -import { machineIdSync } from "node-machine-id"; import { LABEL_NUANCED_LSP_ROLE, LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, NUANCED_LSP_ROLE_WORKSPACE, + MACHINE_ID, } from "../util/constants.js"; import { findInstance } from "../util/morphcloud.js"; import { MutagenClient } from "../util/mutagen.js"; -const MACHINE_ID = machineIdSync(); - export async function workspaceDelete(workspaceDir: string) { const client = new MorphCloudClient(); diff --git a/clients/morphcloud/src/commands/workspace-server.ts b/clients/morphcloud/src/commands/workspace-server.ts index 5cc91ac6..8dff3a25 100755 --- a/clients/morphcloud/src/commands/workspace-server.ts +++ b/clients/morphcloud/src/commands/workspace-server.ts @@ -1,7 +1,6 @@ import { realpathSync } from "fs"; import { hostname } from "os"; import { Instance, MorphCloudClient } from "morphcloud"; -import { machineIdSync } from "node-machine-id"; import { NUANCED_LSP_ROLE_SERVICE, NUANCED_LSP_ROLE_WORKSPACE, @@ -9,6 +8,7 @@ import { LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, LABEL_NUANCED_LSP_WORKSPACE_HOSTNAME, + MACHINE_ID, } from "../util/constants.js"; import { findSnapshot, @@ -21,8 +21,6 @@ import { sshExec } from "../util/ssh.js"; import { getWorkspaceProcessRcPath } from "../util/config.js"; import { ProcessRc } from "../util/process-rc.js"; -const MACHINE_ID = machineIdSync(); - async function ensureWorkspaceInstance( client: MorphCloudClient, metadata: Record, diff --git a/clients/morphcloud/src/commands/workspace-status.ts b/clients/morphcloud/src/commands/workspace-status.ts index fb7c0974..0c709a6c 100755 --- a/clients/morphcloud/src/commands/workspace-status.ts +++ b/clients/morphcloud/src/commands/workspace-status.ts @@ -1,16 +1,14 @@ import { realpathSync } from "fs"; import { Instance, MorphCloudClient } from "morphcloud"; -import { machineIdSync } from "node-machine-id"; import { LABEL_NUANCED_LSP_ROLE, LABEL_NUANCED_LSP_WORKSPACE_PATH, LABEL_NUANCED_LSP_WORKSPACE_MACHINE_ID, NUANCED_LSP_ROLE_WORKSPACE, + MACHINE_ID, } from "../util/constants.js"; import { findInstance } from "../util/morphcloud.js"; -const MACHINE_ID = machineIdSync(); - export async function workspaceStatus(workspaceDir: string) { const client = new MorphCloudClient(); diff --git a/clients/morphcloud/src/util/constants.ts b/clients/morphcloud/src/util/constants.ts index 94ca882d..9e409b4e 100644 --- a/clients/morphcloud/src/util/constants.ts +++ b/clients/morphcloud/src/util/constants.ts @@ -1,3 +1,7 @@ +import nodeMachineId from "node-machine-id"; + +export const MACHINE_ID = nodeMachineId.machineIdSync(); + export const LABEL_NUANCED_LSP_ROLE = "nuanced-lsp.role"; export const NUANCED_LSP_ROLE_SERVICE = "service"; export const NUANCED_LSP_ROLE_WORKSPACE = "workspace"; diff --git a/clients/morphcloud/src/util/mutagen.ts b/clients/morphcloud/src/util/mutagen.ts index 14564114..be9ea305 100644 --- a/clients/morphcloud/src/util/mutagen.ts +++ b/clients/morphcloud/src/util/mutagen.ts @@ -124,7 +124,7 @@ function getMutagenBinaryPath(): string { const platformArch = `${platform()}_${arch()}`; const binaryName = platform() === "win32" ? "mutagen.exe" : "mutagen"; const binPath = join( - __dirname, + import.meta.dirname, "..", "..", "bin",