diff --git a/editors/vscode/README.md b/editors/vscode/README.md index cb775548a..b2acbe5d3 100644 --- a/editors/vscode/README.md +++ b/editors/vscode/README.md @@ -9,12 +9,18 @@ runs `mdsmith fix` on the whole buffer. ## Prerequisites -- The `mdsmith` binary on `$PATH`, or a path you supply via - the `mdsmith.path` setting. Install with - `go install github.com/jeduden/mdsmith/cmd/mdsmith@latest` - or download from the - [releases page](https://github.com/jeduden/mdsmith/releases). - VS Code 1.85 or later. +- **The `mdsmith` binary** — the extension includes a bundled binary + for the host platform (typically Linux from CI builds). If you're on + the same platform as the build host, no separate install is required. + + For other platforms or if the bundled binary is unavailable, install + `mdsmith` manually: + - `npm install -g @mdsmith/cli` + - `go install github.com/jeduden/mdsmith/cmd/mdsmith@latest` + - Download from the + [releases page](https://github.com/jeduden/mdsmith/releases) + - Then optionally configure `mdsmith.path` to point to the binary. ## Install @@ -24,13 +30,13 @@ code --install-extension mdsmith-.vsix ## Settings -| Setting | Default | Purpose | -|------------------------|-------------|----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------| -| `mdsmith.path` | `"mdsmith"` | Binary path; resolved against the extension-host PATH (use an absolute path if `which mdsmith` works in your terminal but the extension reports `spawn ENOENT` — `~/.bashrc`/`~/.zshrc` are not sourced) | -| `mdsmith.config` | `""` | Override `-c` config path | -| `mdsmith.run` | `"onSave"` | When to lint: `onType`, `onSave`, or `off` | -| `mdsmith.fixOnSave` | `false` | Wires `source.fixAll.mdsmith` on save | -| `mdsmith.trace.server` | `"off"` | LSP trace verbosity | +| Setting | Default | Purpose | +|------------------------|-------------|-------------------------------------------------------------------------------------------------------------------------------------------------------------------------| +| `mdsmith.path` | `"mdsmith"` | Binary path; defaults to bundled binary in dist/bin/. Falls back to PATH resolution if bundled binary unavailable. Set absolute path if needed (e.g. `/go/bin/mdsmith`) | +| `mdsmith.config` | `""` | Override `-c` config path | +| `mdsmith.run` | `"onSave"` | When to lint: `onType`, `onSave`, or `off` | +| `mdsmith.fixOnSave` | `false` | Wires `source.fixAll.mdsmith` on save | +| `mdsmith.trace.server` | `"off"` | LSP trace verbosity | See the [full guide](https://github.com/jeduden/mdsmith/blob/main/docs/guides/editors/vscode.md) diff --git a/editors/vscode/build.ts b/editors/vscode/build.ts index 145016dc5..26c4ac969 100644 --- a/editors/vscode/build.ts +++ b/editors/vscode/build.ts @@ -2,8 +2,11 @@ // Bundles src/extension.ts into dist/extension.js as a single CJS // file consumed by VS Code, marking `vscode` as external because // the host supplies it at runtime. +// Also copies platform binaries from @mdsmith/* packages into dist/bin/ +// when available. Note: npm platform packages have os/cpu constraints, +// so only the host platform binary will be bundled. -import { copyFileSync, existsSync } from "node:fs"; +import { copyFileSync, existsSync, mkdirSync } from "node:fs"; import { join } from "node:path"; const args = Bun.argv.slice(2); @@ -22,6 +25,50 @@ if (existsSync(repoLicense)) { copyFileSync(repoLicense, stagedLicense); } +// Copy platform binaries from @mdsmith/* packages into dist/bin/ +// when available. Note: The @mdsmith/* platform packages have os/cpu +// constraints, so npm only installs the package matching the build +// host's platform. This means only the host platform binary will be +// bundled (typically linux-x64 in CI). Other platforms fall back to +// PATH resolution. +function copyPlatformBinaries() { + const distBin = join(import.meta.dir, "dist", "bin"); + mkdirSync(distBin, { recursive: true }); + + // Platform packages that @mdsmith/cli declares as optionalDependencies. + // Only the host platform package will actually be installed due to + // os/cpu constraints. + const platforms = [ + { pkg: "@mdsmith/linux-x64", binary: "mdsmith" }, + { pkg: "@mdsmith/linux-arm64", binary: "mdsmith" }, + { pkg: "@mdsmith/darwin-x64", binary: "mdsmith" }, + { pkg: "@mdsmith/darwin-arm64", binary: "mdsmith" }, + { pkg: "@mdsmith/win32-x64", binary: "mdsmith.exe" }, + ]; + + let copied = 0; + for (const { pkg, binary } of platforms) { + const srcBin = join(import.meta.dir, "node_modules", pkg, "bin", binary); + if (existsSync(srcBin)) { + const destBin = join(distBin, `${pkg.replace("@mdsmith/", "")}-${binary}`); + copyFileSync(srcBin, destBin); + copied++; + } + } + + if (copied > 0) { + console.log(`copied ${copied} platform binary/binaries → dist/bin/`); + } else { + console.warn( + "warning: no platform binaries found in node_modules/@mdsmith/; " + + "extension will fall back to PATH resolution. Run `bun install` " + + "to bundle binaries." + ); + } +} + +copyPlatformBinaries(); + const config: Parameters[0] = { entrypoints: ["src/extension.ts"], outdir: "dist", diff --git a/editors/vscode/package.json b/editors/vscode/package.json index 5a168d099..e7f65f13b 100644 --- a/editors/vscode/package.json +++ b/editors/vscode/package.json @@ -47,7 +47,7 @@ "mdsmith.path": { "type": "string", "default": "mdsmith", - "description": "Path to the mdsmith binary. A bare name is resolved against VS Code's extension-host PATH, which is the container/login-shell environment — interactive-only files like ~/.bashrc and ~/.zshrc are NOT sourced. If `which mdsmith` works in a terminal but the extension reports `spawn ENOENT`, set this to an absolute path (e.g. /go/bin/mdsmith) or symlink the binary into /usr/local/bin." + "description": "Path to the mdsmith binary. Defaults to the bundled binary in dist/bin/ when available (host platform only, typically linux-x64 from CI). Falls back to resolving 'mdsmith' against PATH if bundled binary unavailable or on non-host platforms. Set to an absolute path if you installed mdsmith elsewhere (e.g. /go/bin/mdsmith)." }, "mdsmith.config": { "type": "string", @@ -84,6 +84,9 @@ "dependencies": { "vscode-languageclient": "^9.0.1" }, + "optionalDependencies": { + "@mdsmith/cli": "0.0.0-dev" + }, "devDependencies": { "@types/bun": "^1.1.0", "@types/node": "^20.11.0", diff --git a/editors/vscode/src/binary.test.ts b/editors/vscode/src/binary.test.ts new file mode 100644 index 000000000..fd1b9b5e7 --- /dev/null +++ b/editors/vscode/src/binary.test.ts @@ -0,0 +1,72 @@ +// Unit tests for binary resolution logic. +// The extension bundles cross-platform mdsmith binaries into dist/bin/ +// during the build step; these tests verify the resolution logic and +// fallback behavior. + +import { describe, expect, mock, test } from "bun:test"; +import { join } from "node:path"; +import { resolveBinary } from "./binary"; + +describe("resolveBinary", () => { + test("returns custom path unchanged when user specifies non-default", () => { + const fileExists = mock(() => false); + const result = resolveBinary("/custom/path/to/mdsmith", "/ext", "linux", "x64", fileExists); + expect(result).toBe("/custom/path/to/mdsmith"); + // Should not even attempt to check for bundled binary + expect(fileExists).not.toHaveBeenCalled(); + }); + + test("returns bundled binary when default path and bundled exists (Linux x64)", () => { + const extensionPath = "/ext"; + const bundledPath = join(extensionPath, "dist", "bin", "linux-x64-mdsmith"); + + // Mock: bundled binary exists + const fileExists = mock((path) => path === bundledPath); + + const result = resolveBinary("mdsmith", extensionPath, "linux", "x64", fileExists); + expect(result).toBe(bundledPath); + expect(fileExists).toHaveBeenCalledWith(bundledPath); + }); + + test("returns bundled binary when default path and bundled exists (macOS arm64)", () => { + const extensionPath = "/ext"; + const bundledPath = join(extensionPath, "dist", "bin", "darwin-arm64-mdsmith"); + + // Mock: bundled binary exists + const fileExists = mock((path) => path === bundledPath); + + const result = resolveBinary("mdsmith", extensionPath, "darwin", "arm64", fileExists); + expect(result).toBe(bundledPath); + expect(fileExists).toHaveBeenCalledWith(bundledPath); + }); + + test("returns bundled binary when default path and bundled exists (Windows x64)", () => { + const extensionPath = "/ext"; + const bundledPath = join(extensionPath, "dist", "bin", "win32-x64-mdsmith.exe"); + + // Mock: bundled binary exists + const fileExists = mock((path) => path === bundledPath); + + const result = resolveBinary("mdsmith", extensionPath, "win32", "x64", fileExists); + expect(result).toBe(bundledPath); + expect(fileExists).toHaveBeenCalledWith(bundledPath); + }); + + test("falls back to default path when bundled binary does not exist", () => { + // Mock: no bundled binary + const fileExists = mock(() => false); + + const result = resolveBinary("mdsmith", "/ext", "linux", "x64", fileExists); + expect(result).toBe("mdsmith"); + // Should have checked for bundled binary + expect(fileExists).toHaveBeenCalled(); + }); + + test("returns custom bare name unchanged", () => { + const fileExists = mock(() => false); + const result = resolveBinary("my-mdsmith-fork", "/ext", "linux", "x64", fileExists); + expect(result).toBe("my-mdsmith-fork"); + // Should not check for bundled binary when not the default + expect(fileExists).not.toHaveBeenCalled(); + }); +}); diff --git a/editors/vscode/src/binary.ts b/editors/vscode/src/binary.ts new file mode 100644 index 000000000..d8ef7fe98 --- /dev/null +++ b/editors/vscode/src/binary.ts @@ -0,0 +1,67 @@ +// Binary resolution logic for the mdsmith extension. +// The extension bundles platform binaries from npm packages into dist/bin/ +// during the build step when available. Due to npm os/cpu constraints on +// the @mdsmith/* packages, only the host platform binary is bundled (typically +// linux-x64 in CI). This module resolves the bundled binary when present, +// falling back to PATH for other platforms or when bundling failed. + +import { existsSync } from "node:fs"; +import { join } from "node:path"; + +// resolveBinary returns the path to the mdsmith binary. When the +// configured path is the bare string "mdsmith", it first checks for +// a bundled binary in dist/bin/ (copied there by build.ts from the +// @mdsmith/* npm packages). Platform-specific binaries are named like +// "linux-x64-mdsmith", "win32-x64-mdsmith.exe", etc. If the bundled +// binary exists, return its absolute path. Otherwise return the +// configured path unchanged so the LanguageClient resolves it against +// PATH (fallback for dev builds, non-host platforms, or when optional +// deps failed to install). +// +// Platform bundling limitation: The @mdsmith/* platform packages have +// os/cpu constraints, so npm only installs the package matching the +// build host. This means only one platform binary is bundled per .vsix +// (typically linux-x64 from CI). Other platforms fall back to PATH and +// require manual mdsmith installation. +// +// The extensionPath should be the vscode.ExtensionContext.extensionPath +// (the directory containing package.json and dist/). +// +// The optional platform, arch, and fileExists parameters are for testing; +// in production they default to process.platform, process.arch, and fs.existsSync. +export function resolveBinary( + configuredPath: string, + extensionPath: string, + platform: string = process.platform, + arch: string = process.arch, + fileExists: (path: string) => boolean = existsSync +): string { + // If the user specified a custom path (not the bare "mdsmith"), + // honor it exactly — they know what they want. + if (configuredPath !== "mdsmith") { + return configuredPath; + } + + // The user left the default "mdsmith". Check for bundled binaries + // in dist/bin/. The build script copies them there with names like + // "linux-x64-mdsmith", "win32-x64-mdsmith.exe". + + // Map Node's process.platform and process.arch to our package names + const platformArch = `${platform}-${arch}`; + const binaryName = platform === "win32" ? "mdsmith.exe" : "mdsmith"; + const bundledBinary = join( + extensionPath, + "dist", + "bin", + `${platformArch}-${binaryName}` + ); + + if (fileExists(bundledBinary)) { + return bundledBinary; + } + + // The bundled binary does not exist (build step didn't run, or + // optional dependencies weren't installed). Fall back to the bare + // "mdsmith" string so the LanguageClient resolves it against PATH. + return configuredPath; +} diff --git a/editors/vscode/src/extension.ts b/editors/vscode/src/extension.ts index 0dac06d0d..05f4d707c 100644 --- a/editors/vscode/src/extension.ts +++ b/editors/vscode/src/extension.ts @@ -18,6 +18,7 @@ import { collectFixAllEdits, startupErrorMessage } from "./wiring"; +import { resolveBinary } from "./binary"; let client: LanguageClient | undefined; // Track the .mdsmith.yml file watcher across the activate / @@ -74,7 +75,8 @@ export async function activate(context: vscode.ExtensionContext): Promise // must remain usable so the user can retry. async function startServer(context: vscode.ExtensionContext): Promise { const cfg = vscode.workspace.getConfiguration("mdsmith"); - const binary = cfg.get("path", "mdsmith"); + const configuredPath = cfg.get("path", "mdsmith"); + const binary = resolveBinary(configuredPath, context.extensionPath); const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath; const serverOptions: ServerOptions = buildServerOptions(