Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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
24 changes: 20 additions & 4 deletions bin/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,9 @@

import { createRequire } from "module"
import { startProxyServer } from "../src/proxy/server"
import { exec as execCallback } from "child_process"
import { exec as execCallback, execFile as execFileCallback } from "child_process"
import { promisify } from "util"
import { resolveClaudeExecutableAsync } from "../src/proxy/models"

const require = createRequire(import.meta.url)
const { version } = require("../package.json")
Expand Down Expand Up @@ -99,6 +100,7 @@ if (args[0] === "refresh-token") {
}

const exec = promisify(execCallback)
const execFile = promisify(execFileCallback)

// Prevent SDK subprocess crashes from killing the proxy
process.on("uncaughtException", (err) => {
Expand Down Expand Up @@ -134,9 +136,22 @@ try {
console.error(`[meridian] Failed to parse MERIDIAN_PROFILES: ${e instanceof Error ? e.message : e}`)
}

/**
* Run the CLI default action (start the proxy server).
*
* @param start Server bootstrap (overridable for tests).
* @param runAuthCheck Pre-flight `claude auth status` runner. Default
* resolves the bundled/platform-package binary via
* `resolveClaudeExecutableAsync` and runs `<resolved> auth status` via
* `execFile` — does NOT depend on `claude` being on PATH (#478). Tests
* override this to simulate ENOENT / non-zero exit / malformed JSON.
*/
export async function runCli(
start = startProxyServer,
runExec: typeof exec = exec
runAuthCheck: () => Promise<{ stdout: string }> = async () => {
const claudePath = await resolveClaudeExecutableAsync()
return execFile(claudePath, ["auth", "status"], { timeout: 5000 })
}
) {
// Plugin check — warn if OpenCode config exists but meridian plugin is missing
try {
Expand All @@ -152,9 +167,10 @@ export async function runCli(
}
} catch { /* non-fatal */ }

// Pre-flight auth check
// Pre-flight auth check — runs the resolved Claude binary's auth-status
// subcommand. Independent of whether `claude` is on PATH (#478).
try {
const { stdout } = await runExec("claude auth status", { timeout: 5000 })
const { stdout } = await runAuthCheck()
const auth = JSON.parse(stdout)
if (!auth.loggedIn) {
console.error("\x1b[31m✗ Not logged in to Claude.\x1b[0m Run: claude login")
Expand Down
93 changes: 92 additions & 1 deletion src/__tests__/claude-executable-resolver.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
*/
import { describe, it, expect } from "bun:test"
import { join, dirname } from "path"
import { resolveClaudeExecutable, resolveClaudeExecutableWithSource } from "../proxy/models"
import { resolveClaudeExecutable, resolveClaudeExecutableWithSource, resolveClaudeExecutableSync } from "../proxy/models"

// `path.join` produces backslashed paths on Windows and slash-separated paths
// on POSIX. Tests use `J(...)` and `BIN(pkgJson, ...rest)` everywhere a path
Expand Down Expand Up @@ -485,3 +485,94 @@ describe("resolveClaudeExecutableWithSource", () => {
expect(await resolveClaudeExecutableWithSource(deps)).toBeNull()
})
})

// ---------------------------------------------------------------------------
// resolveClaudeExecutableSync — synchronous subset used by CLI commands
// (`meridian profile list`, etc.) that can't await. Skips the async PATH
// lookup and the legacy SDK cli.js fallback. Closes the diagnostic gap
// from #478 where Stefan's auth-status checks failed because they spawned
// `claude` via shell PATH instead of routing through the resolver.
// ---------------------------------------------------------------------------

describe("resolveClaudeExecutableSync", () => {
it("reports source 'env' when MERIDIAN_CLAUDE_PATH wins", () => {
const deps = makeDeps({
envGet: (n) => (n === "MERIDIAN_CLAUDE_PATH" ? "/custom/claude" : undefined),
existsSync: (p) => p === "/custom/claude",
})
expect(resolveClaudeExecutableSync(deps)).toEqual({
path: "/custom/claude",
source: "env",
})
})

it("reports source 'bundled' when claude-code/bin/claude.exe wins", () => {
const pkgJson = "/m/cc/package.json"
const expectedBin = BIN(pkgJson, "bin", "claude.exe")
const deps = makeDeps({
resolvePackage: (s) => {
if (s === "@anthropic-ai/claude-code/package.json") return pkgJson
throw new Error("not configured")
},
existsSync: (p) => p === expectedBin,
statSync: () => ({ size: 200_000_000 }),
})
expect(resolveClaudeExecutableSync(deps)).toEqual({
path: expectedBin,
source: "bundled",
})
})

it("reports source 'platform-package' when peer pkg wins after bundled stub", () => {
const platformPkg = "/m/cc-d-a/package.json"
const platformBin = BIN(platformPkg, "claude")
const deps = makeDeps({
platform: "darwin",
arch: "arm64",
resolvePackage: (s) => {
if (s === "@anthropic-ai/claude-code-darwin-arm64/package.json") return platformPkg
throw new Error("not configured")
},
existsSync: (p) => p === platformBin,
})
expect(resolveClaudeExecutableSync(deps)).toEqual({
path: platformBin,
source: "platform-package",
})
})

it("returns null when env, bundled, and platform-pkg all miss (PATH lookup not attempted)", () => {
// Stefan's case: no MERIDIAN_CLAUDE_PATH, no bundled binary in this
// test, no platform peer package, no `claude` on PATH. The async
// resolver's PATH-lookup step is intentionally skipped here — sync
// exec of `which`/`where` is platform-fragile, and the audit showed
// bundled/platform-pkg covers every supported install layout.
const deps = makeDeps({
envGet: () => undefined,
resolvePackage: () => { throw new Error("nope") },
existsSync: () => false,
})
expect(resolveClaudeExecutableSync(deps)).toBeNull()
})

it("does NOT consult exec/PATH (purely synchronous deps)", () => {
// The sync resolver should not even *try* to call exec — that's the
// whole reason it exists. Pin the contract: pass an exec that throws
// and assert resolution still works via bundled.
const pkgJson = "/m/cc/package.json"
const expectedBin = BIN(pkgJson, "bin", "claude.exe")
const deps = makeDeps({
resolvePackage: (s) => {
if (s === "@anthropic-ai/claude-code/package.json") return pkgJson
throw new Error("not configured")
},
existsSync: (p) => p === expectedBin,
statSync: () => ({ size: 200_000_000 }),
exec: async () => { throw new Error("sync resolver must not call exec") },
})
expect(resolveClaudeExecutableSync(deps)).toEqual({
path: expectedBin,
source: "bundled",
})
})
})
7 changes: 6 additions & 1 deletion src/__tests__/proxy-async-ops.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,7 +82,12 @@ describe("proxy async ops", () => {
const { EventEmitter } = await import("events")
return { server: new EventEmitter(), config: {}, close: async () => {} } as any
},
(() => {
// Simulate the auth-status check throwing — same scenario as before
// (binary missing / spawn error). The injection point moved from
// `runExec` to `runAuthCheck` after #478, but the assertion is the
// same: when the check fails, the warning fires and the proxy still
// starts. Test name kept stable.
(async () => {
throw new Error("spawn ENOENT")
}) as any
)
Expand Down
45 changes: 43 additions & 2 deletions src/proxy/models.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,14 @@
* Model mapping and Claude executable resolution.
*/

import { exec as execCallback } from "child_process"
import { exec as execCallback, execFile as execFileCallback } from "child_process"
import { existsSync, statSync } from "fs"
import { fileURLToPath } from "url"
import { join, dirname } from "path"
import { promisify } from "util"

const exec = promisify(execCallback)
const execFile = promisify(execFileCallback)

/**
* Files smaller than this are treated as the placeholder stub that
Expand Down Expand Up @@ -231,7 +232,17 @@ export async function getClaudeAuthStatusAsync(profileId?: string, envOverrides?

c_promise = (async () => {
try {
const { stdout } = await exec("claude auth status", {
// Route through the resolver instead of relying on `claude` being
// on PATH. Stefan's case (#478): bunx-installed meridian under
// systemd, no global claude binary — `exec("claude auth status")`
// fails before we ever spawn the SDK subprocess. The resolved
// executable comes from the same lookup chain that powers the SDK
// call (env > bundled > platform-package > PATH > legacy-cli-js),
// so this path works in every install layout the SDK already
// supports. execFile (vs exec) avoids any quoting issues with
// spaces in the resolved path.
const claudePath = await resolveClaudeExecutableAsync()
const { stdout } = await execFile(claudePath, ["auth", "status"], {
timeout: 5000,
...(envOverrides ? { env: { ...process.env, ...envOverrides } } : {}),
})
Expand Down Expand Up @@ -476,6 +487,36 @@ export async function resolveClaudeExecutable(deps: ResolverDeps = DEFAULT_DEPS)
return info?.path ?? null
}

/**
* Synchronous subset of the resolver. Used by CLI commands
* (`meridian profile list`, `profileAdd`, etc.) that can't await before
* spawning `claude auth status`.
*
* Skips two steps that the async resolver runs:
* - `path-lookup` — running `which`/`where` synchronously is awkward
* and platform-fragile; the audit showed bundled + platform-package
* covers every supported install layout (npm-global, npx/bunx
* download, Docker, NixOS).
* - `legacy-cli-js` — only matters for stale Bun installs of SDK < 0.2.98.
*
* Closes the diagnostic gap from #478: `getAuthStatus` in profileCli.ts
* and `getClaudeAuthStatusAsync` in this file previously called
* `claude auth status` via shell, which fails when `claude` isn't on
* PATH (Stefan's case — bunx-installed meridian under systemd, no
* global claude). Both call sites now route through resolved paths.
*/
export function resolveClaudeExecutableSync(
deps: ResolverDeps = DEFAULT_DEPS,
): ClaudeExecutableInfo | null {
const env = tryEnvOverride(deps)
if (env) return { path: env, source: "env" }
const bundled = tryBundledBinary(deps)
if (bundled) return { path: bundled, source: "bundled" }
const platformPkg = tryPlatformPackage(deps)
if (platformPkg) return { path: platformPkg, source: "platform-package" }
return null
}

/**
* Returns the cached resolved-executable info — `null` if
* `resolveClaudeExecutableAsync` hasn't run yet. Used by `/health` and the
Expand Down
37 changes: 32 additions & 5 deletions src/proxy/profileCli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,10 @@

import { mkdirSync, existsSync, rmSync, readFileSync, writeFileSync } from "node:fs"
import { join } from "node:path"
import { execSync, spawnSync } from "node:child_process"
import { execFileSync, spawnSync } from "node:child_process"
import { homedir } from "node:os"
import type { ProfileConfig } from "./profiles"
import { resolveClaudeExecutableSync } from "./models"
import { setSetting } from "./settings"

const PROFILES_DIR = join(homedir(), ".config", "meridian", "profiles")
Expand Down Expand Up @@ -43,8 +44,20 @@ function saveProfileConfig(profiles: ProfileConfig[]): void {
}

function getAuthStatus(configDir: string): { loggedIn: boolean; email?: string; subscriptionType?: string } {
// Route through the synchronous resolver instead of relying on `claude`
// being on PATH (#478). The CLI command runs in whatever environment
// the user invokes it — under systemd or bunx-without-global-claude,
// PATH won't have a claude binary even when meridian's own bundled or
// platform-package binary is right there in node_modules.
const resolved = resolveClaudeExecutableSync()
if (!resolved) {
console.warn(`[meridian] Could not resolve a Claude executable for auth check (set MERIDIAN_CLAUDE_PATH or install @anthropic-ai/claude-code)`)
return { loggedIn: false }
}
try {
const result = execSync("claude auth status", {
// execFileSync (vs execSync) avoids quoting issues with spaces in the
// resolved path and bypasses the shell entirely — no PATH lookup.
const result = execFileSync(resolved.path, ["auth", "status"], {
timeout: 5000,
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
stdio: ["pipe", "pipe", "pipe"],
Expand Down Expand Up @@ -119,8 +132,15 @@ export function profileAdd(id: string): void {
console.log(" Press Ctrl+C to cancel, or wait for the browser to open...")
console.log()

// Run claude auth login with the profile's config dir
const result = spawnSync("claude", ["auth", "login"], {
// Run claude auth login with the profile's config dir. Route through
// the sync resolver so we don't depend on `claude` being on PATH (#478).
const resolvedAuth = resolveClaudeExecutableSync()
if (!resolvedAuth) {
console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m")
console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude")
process.exit(1)
}
const result = spawnSync(resolvedAuth.path, ["auth", "login"], {
env: { ...process.env, CLAUDE_CONFIG_DIR: configDir },
stdio: "inherit",
})
Expand Down Expand Up @@ -290,7 +310,14 @@ export function profileLogin(id: string): void {
console.log("\x1b[33m⚠ Make sure you're signed into the correct Claude account in your browser.\x1b[0m")
console.log()

const result = spawnSync("claude", ["auth", "login"], {
// Route through the sync resolver — see profileAdd above (#478).
const resolvedLogin = resolveClaudeExecutableSync()
if (!resolvedLogin) {
console.error("\x1b[31m✗ Could not find a Claude executable to run auth login.\x1b[0m")
console.error(" Install via: npm install -g @anthropic-ai/claude-code, or set MERIDIAN_CLAUDE_PATH=/path/to/claude")
process.exit(1)
}
const result = spawnSync(resolvedLogin.path, ["auth", "login"], {
env: { ...process.env, CLAUDE_CONFIG_DIR: profile.claudeConfigDir },
stdio: "inherit",
})
Expand Down
Loading