Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 21 additions & 12 deletions editors/vscode/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,12 +9,21 @@ 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 bundles pre-built binaries
for all platforms (Linux, macOS, Windows) from npm. The build step
copies platform binaries from the `@mdsmith/*` npm packages into
`dist/bin/`, so they ship in the .vsix and work on all platforms
from a single install. No separate binary install is required in
most cases.
Comment thread
Claude marked this conversation as resolved.
Outdated
Comment thread
Claude marked this conversation as resolved.
Outdated

If the bundled binary is unavailable or you prefer a custom build,
you can 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 configure `mdsmith.path` to point to the binary.

## Install

Expand All @@ -24,13 +33,13 @@ code --install-extension mdsmith-<version>.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 |
Comment thread
Claude marked this conversation as resolved.
| `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)
Expand Down
45 changes: 44 additions & 1 deletion editors/vscode/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,10 @@
// 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/
// so they can be bundled in the .vsix (even with --no-dependencies).

import { copyFileSync, existsSync } from "node:fs";
import { copyFileSync, existsSync, mkdirSync, readdirSync } from "node:fs";
Comment thread
Claude marked this conversation as resolved.
Outdated
import { join } from "node:path";

const args = Bun.argv.slice(2);
Expand All @@ -22,6 +24,47 @@ if (existsSync(repoLicense)) {
copyFileSync(repoLicense, stagedLicense);
}

// Copy platform binaries from @mdsmith/* packages into dist/bin/
// so they ship in the .vsix even with vsce package --no-dependencies.
// The npm packages install as optional dependencies; when present,
// bundle them. When absent (offline install, proxy), the extension
// falls 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
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" },
];
Comment thread
Claude marked this conversation as resolved.
Comment thread
Claude marked this conversation as resolved.

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 `npm install` " +
"to bundle binaries."
Comment thread
Claude marked this conversation as resolved.
Outdated
);
}
}

copyPlatformBinaries();

const config: Parameters<typeof Bun.build>[0] = {
entrypoints: ["src/extension.ts"],
outdir: "dist",
Expand Down
5 changes: 4 additions & 1 deletion editors/vscode/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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/ (copied from @mdsmith/* npm packages during build). Works on Linux, macOS, and Windows from a single .vsix. If the bundled binary is unavailable, falls back to resolving 'mdsmith' against PATH. Set to an absolute path if you installed mdsmith elsewhere (e.g. /go/bin/mdsmith)."
Comment thread
Claude marked this conversation as resolved.
Outdated
Comment thread
Claude marked this conversation as resolved.
Outdated
},
"mdsmith.config": {
"type": "string",
Expand Down Expand Up @@ -84,6 +84,9 @@
"dependencies": {
"vscode-languageclient": "^9.0.1"
},
"optionalDependencies": {
"@mdsmith/cli": "0.0.0-dev"
},
Comment thread
Claude marked this conversation as resolved.
Comment thread
Claude marked this conversation as resolved.
Comment thread
Claude marked this conversation as resolved.
Comment thread
Claude marked this conversation as resolved.
Comment thread
Claude marked this conversation as resolved.
"devDependencies": {
"@types/bun": "^1.1.0",
"@types/node": "^20.11.0",
Expand Down
72 changes: 72 additions & 0 deletions editors/vscode/src/binary.test.ts
Original file line number Diff line number Diff line change
@@ -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);
Comment thread
Claude marked this conversation as resolved.

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);

Comment thread
Claude marked this conversation as resolved.
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();
});
});
65 changes: 65 additions & 0 deletions editors/vscode/src/binary.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
// Binary resolution logic for the mdsmith extension.
// The extension bundles cross-platform mdsmith binaries from npm packages
// into dist/bin/ during the build step. This module resolves the correct
// platform binary when the user leaves the default "mdsmith" path, falling
// back to PATH if bundling failed or binaries are unavailable.

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
// bundled binaries 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 or when optional deps failed to install).
//
// Cross-platform bundling: The build script copies binaries from ALL
// @mdsmith/* platform packages (linux-x64, darwin-arm64, win32-x64, etc.)
// into dist/bin/. This works even with `vsce package --no-dependencies`
// because dist/ is included in the .vsix. At runtime, this function
// selects the binary matching the user's OS+arch.
Comment thread
Claude marked this conversation as resolved.
Outdated
Comment thread
Claude marked this conversation as resolved.
Outdated
//
// 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;
}
4 changes: 3 additions & 1 deletion editors/vscode/src/extension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 /
Expand Down Expand Up @@ -74,7 +75,8 @@ export async function activate(context: vscode.ExtensionContext): Promise<void>
// must remain usable so the user can retry.
async function startServer(context: vscode.ExtensionContext): Promise<void> {
const cfg = vscode.workspace.getConfiguration("mdsmith");
const binary = cfg.get<string>("path", "mdsmith");
const configuredPath = cfg.get<string>("path", "mdsmith");
const binary = resolveBinary(configuredPath, context.extensionPath);
const workspaceRoot = vscode.workspace.workspaceFolders?.[0]?.uri.fsPath;

const serverOptions: ServerOptions = buildServerOptions(
Expand Down
Loading