fix: OpenClaude native launcher after Linux install (#1798)

* fix linux openclaude native install launcher

Create the package-aware OpenClaude launcher for native installs while keeping the downloaded native payload name unchanged.

Repair the native launcher after npm cleanup with a relink-only helper so npm uninstall cannot remove ~/.local/bin/openclaude.

Update protocol registration and cleanup fallbacks to use the OpenClaude command name, with regression coverage for install surfaces.

* test linux install repair flow
This commit is contained in:
JATMN
2026-06-28 06:16:04 +08:00
committed by GitHub
parent 13cf30afa4
commit ab8645da9f
5 changed files with 263 additions and 35 deletions
+21 -13
View File
@@ -9,7 +9,7 @@ import { Box, render, Text } from '../ink.js';
import { logForDebugging } from '../utils/debug.js';
import { env } from '../utils/env.js';
import { errorMessage } from '../utils/errors.js';
import { checkInstall, cleanupNpmInstallations, cleanupShellAliases, installLatest } from '../utils/nativeInstaller/index.js';
import { checkInstall, cleanupNpmInstallations, cleanupShellAliases, installLatest, repairNativeLauncher } from '../utils/nativeInstaller/index.js';
import { getInitialSettings, updateSettingsForSource } from '../utils/settings/settings.js';
interface InstallProps {
onDone: (result: string, options?: {
@@ -86,7 +86,7 @@ function SetupNotes(t0) {
function _temp(message, index) {
return <Box key={index} marginLeft={2}><Text dimColor={true}> {message}</Text></Box>;
}
function Install({
export function Install({
onDone,
force,
target
@@ -126,17 +126,10 @@ function Install({
logForDebugging('Install: Already up to date');
}
// Set up launcher and shell integration
setState({
type: 'setting-up'
});
const setupMessages = await checkInstall(true);
logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
if (setupMessages.length > 0) {
setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
}
// Now that native installation succeeded, clean up old npm installations
// Now that native installation succeeded, clean up old npm installations.
// npm uninstall owns its bin entries and can remove ~/.local/bin/openclaude
// when the npm prefix overlaps the native launcher directory, so repair the
// native launcher after cleanup before checking the final install state.
logForDebugging('Install: Cleaning up npm installations after successful install');
const {
removed,
@@ -151,6 +144,21 @@ function Install({
// Continue despite cleanup errors - native install already succeeded
}
setState({
type: 'setting-up'
});
if (!result.latestVersion) {
throw new Error('Could not repair native launcher - installed version is unknown.');
}
logForDebugging(`Install: Repairing native launcher after npm cleanup`);
await repairNativeLauncher(result.latestVersion);
// Set up launcher and shell integration against the final post-cleanup state
const setupMessages = await checkInstall(true);
logForDebugging(`Install: Setup launcher completed with ${setupMessages.length} messages`);
if (setupMessages.length > 0) {
setupMessages.forEach(msg => logForDebugging(`Install: Setup message: ${msg.message}`));
}
// Clean up old shell aliases
const aliasMessages = await cleanupShellAliases();
if (aliasMessages.length > 0) {
+9 -3
View File
@@ -233,13 +233,19 @@ export async function registerProtocolHandler(
}
/**
* Resolve the claude binary path for protocol registration. Prefers the
* native installer's stable symlink (~/.local/bin/claude) which survives
* Resolve the CLI binary path for protocol registration. Prefers the
* native installer's stable launcher in ~/.local/bin, which survives
* auto-updates; falls back to process.execPath when the symlink is absent
* (dev builds, non-native installs).
*/
export function getProtocolBinaryName(platform = process.platform): string {
const baseName =
MACRO.PACKAGE_URL === '@anthropic-ai/claude-code' ? 'claude' : 'openclaude'
return platform === 'win32' ? `${baseName}.exe` : baseName
}
async function resolveClaudePath(): Promise<string> {
const binaryName = process.platform === 'win32' ? 'claude.exe' : 'claude'
const binaryName = getProtocolBinaryName()
const stablePath = path.join(getUserBinDir(), binaryName)
try {
await fs.realpath(stablePath)
+1
View File
@@ -14,5 +14,6 @@ export {
installLatest,
lockCurrentVersion,
removeInstalledSymlink,
repairNativeLauncher,
type SetupMessage,
} from './installer.js'
+50 -15
View File
@@ -112,9 +112,15 @@ export function getBinaryName(platform: string): string {
return platform.startsWith('win32') ? 'claude.exe' : 'claude'
}
export function getExecutableName(platform: string): string {
const baseName =
MACRO.PACKAGE_URL === '@anthropic-ai/claude-code' ? 'claude' : 'openclaude'
return platform.startsWith('win32') ? `${baseName}.exe` : baseName
}
function getBaseDirectories() {
const platform = getPlatform()
const executableName = getBinaryName(platform)
const executableName = getExecutableName(platform)
return {
// Data directories (permanent storage)
@@ -465,7 +471,7 @@ async function performVersionUpdate(
logForDebugging(`Version ${version} already installed, updating symlink`)
}
// Create direct symlink from ~/.local/bin/claude to the version binary
// Create direct symlink from the CLI launcher to the version binary.
await removeDirectoryIfEmpty(executablePath)
await updateSymlink(executablePath, installPath)
@@ -492,6 +498,25 @@ async function versionIsAvailable(version: string): Promise<boolean> {
return isPossibleClaudeBinary(installPath)
}
export async function repairNativeLauncher(version: string): Promise<void> {
const dirs = getBaseDirectories()
const installPath = join(dirs.versions, version)
if (!(await isPossibleClaudeBinary(installPath))) {
throw new Error(`Cannot repair native launcher: installed version not found at ${installPath}`)
}
await removeDirectoryIfEmpty(dirs.executable)
await updateSymlink(dirs.executable, installPath)
if (!(await isPossibleClaudeBinary(dirs.executable))) {
throw new Error(
`Failed to repair executable at ${dirs.executable}. ` +
`Check write permissions to ${dirs.executable}.`,
)
}
}
async function updateLatest(
channelOrVersion: string,
forceReinstall: boolean = false,
@@ -845,7 +870,7 @@ export async function checkInstall(
})
}
// Check if claude executable exists and is valid.
// Check if the CLI executable exists and is valid.
// On non-Windows, call readlink directly and route errno — ENOENT means
// the executable is missing, EINVAL means it exists but isn't a symlink.
// This avoids an access()→readlink() TOCTOU where deletion between the
@@ -856,7 +881,7 @@ export async function checkInstall(
// On Windows it's a copied executable, not a symlink
if (!(await isPossibleClaudeBinary(dirs.executable))) {
messages.push({
message: `installMethod is native, but claude command is missing or invalid at ${dirs.executable}`,
message: `installMethod is native, but command is missing or invalid at ${dirs.executable}`,
userActionRequired: true,
type: 'error',
})
@@ -867,7 +892,7 @@ export async function checkInstall(
const absoluteTarget = resolve(dirname(dirs.executable), target)
if (!(await isPossibleClaudeBinary(absoluteTarget))) {
messages.push({
message: `Claude symlink points to missing or invalid binary: ${target}`,
message: `CLI symlink points to missing or invalid binary: ${target}`,
userActionRequired: true,
type: 'error',
})
@@ -875,7 +900,7 @@ export async function checkInstall(
} catch (e) {
if (isENOENT(e)) {
messages.push({
message: `installMethod is native, but claude command not found at ${dirs.executable}`,
message: `installMethod is native, but command not found at ${dirs.executable}`,
userActionRequired: true,
type: 'error',
})
@@ -883,7 +908,7 @@ export async function checkInstall(
// EINVAL (not a symlink) or other — check as regular binary
if (!(await isPossibleClaudeBinary(dirs.executable))) {
messages.push({
message: `${dirs.executable} exists but is not a valid Claude binary`,
message: `${dirs.executable} exists but is not a valid CLI binary`,
userActionRequired: true,
type: 'error',
})
@@ -1191,11 +1216,18 @@ export async function cleanupOldVersions(): Promise<void> {
// Clean up old renamed executables on Windows (no longer running at startup)
if (getPlatform().startsWith('win32')) {
const executableDir = dirname(dirs.executable)
const oldExecutablePrefixes = new Set(['claude.exe', getExecutableName(getPlatform())])
try {
const files = await readdir(executableDir)
let cleanedCount = 0
for (const file of files) {
if (!/^claude\.exe\.old\.\d+$/.test(file)) continue
const oldExecutablePrefix = Array.from(oldExecutablePrefixes).find(prefix =>
file.startsWith(`${prefix}.old.`),
)
if (!oldExecutablePrefix) continue
if (!/^\d+$/.test(file.slice(oldExecutablePrefix.length + '.old.'.length))) {
continue
}
try {
await unlink(join(executableDir, file))
cleanedCount++
@@ -1458,7 +1490,7 @@ async function isNpmSymlink(executablePath: string): Promise<boolean> {
}
/**
* Remove the claude symlink from the executable directory
* Remove the CLI symlink from the executable directory
* This is used when switching away from native installation
* Will only remove if it's a native binary symlink, not npm-managed JS files
*/
@@ -1476,12 +1508,12 @@ export async function removeInstalledSymlink(): Promise<void> {
// It's a native binary symlink, safe to remove
await unlink(dirs.executable)
logForDebugging(`Removed claude symlink at ${dirs.executable}`)
logForDebugging(`Removed CLI symlink at ${dirs.executable}`)
} catch (error) {
if (isENOENT(error)) {
return
}
logError(new Error(`Failed to remove claude symlink: ${error}`))
logError(new Error(`Failed to remove CLI symlink: ${error}`))
}
}
@@ -1556,11 +1588,14 @@ async function manualRemoveNpmPackage(
}
}
const binName =
packageName === '@anthropic-ai/claude-code' ? 'claude' : 'openclaude'
if (getPlatform().startsWith('win32')) {
// Windows - only remove executables, not the package directory
const binCmd = join(globalPrefix, 'claude.cmd')
const binPs1 = join(globalPrefix, 'claude.ps1')
const binExe = join(globalPrefix, 'claude')
const binCmd = join(globalPrefix, `${binName}.cmd`)
const binPs1 = join(globalPrefix, `${binName}.ps1`)
const binExe = join(globalPrefix, binName)
if (await tryRemove(binCmd, 'bin script')) {
manuallyRemoved = true
@@ -1575,7 +1610,7 @@ async function manualRemoveNpmPackage(
}
} else {
// Unix/Mac - only remove symlink, not the package directory
const binSymlink = join(globalPrefix, 'bin', 'claude')
const binSymlink = join(globalPrefix, 'bin', binName)
if (await tryRemove(binSymlink, 'bin symlink')) {
manuallyRemoved = true
+182 -4
View File
@@ -1,7 +1,9 @@
import { afterEach, beforeEach, expect, mock, test } from 'bun:test'
import { PassThrough } from 'node:stream'
import * as fsPromises from 'fs/promises'
import { homedir } from 'os'
import { join } from 'path'
import { createElement } from 'react'
import {
acquireSharedMutationLock,
releaseSharedMutationLock,
@@ -29,15 +31,44 @@ const realExecFileNoThrowModule = { ...realExecFileNoThrow }
// at module load and gate it on this flag so the persisted mock transparently
// falls through to the real implementation whenever the flag is off.
let simulateNpmUninstallFailure = false
let simulateNpmUninstallEnotempty = false
let fakeNpmPrefix: string | undefined
mock.module('./execFileNoThrow.js', () => ({
...realExecFileNoThrowModule,
execFileNoThrowWithCwd: (
...args: Parameters<typeof realExecFileNoThrow.execFileNoThrowWithCwd>
) =>
simulateNpmUninstallFailure
? Promise.resolve({ stdout: '', stderr: 'npm ERR! code E404', code: 1 })
: realExecFileNoThrowModule.execFileNoThrowWithCwd(...args),
) => {
const [command, commandArgs] = args
if (command === 'npm' && Array.isArray(commandArgs)) {
if (
fakeNpmPrefix &&
commandArgs[0] === 'config' &&
commandArgs[1] === 'get' &&
commandArgs[2] === 'prefix'
) {
return Promise.resolve({ stdout: fakeNpmPrefix, stderr: '', code: 0 })
}
if (simulateNpmUninstallEnotempty && commandArgs[0] === 'uninstall') {
return Promise.resolve({
stdout: '',
stderr: 'npm error code ENOTEMPTY',
code: 1,
})
}
if (simulateNpmUninstallFailure && commandArgs[0] === 'uninstall') {
return Promise.resolve({
stdout: '',
stderr: 'npm ERR! code E404',
code: 1,
})
}
}
return realExecFileNoThrowModule.execFileNoThrowWithCwd(...args)
},
}))
beforeEach(async () => {
@@ -53,6 +84,8 @@ afterEach(() => {
;(globalThis as Record<string, unknown>).MACRO = originalMacro
}
simulateNpmUninstallFailure = false
simulateNpmUninstallEnotempty = false
fakeNpmPrefix = undefined
mock.restore()
mock.module('../utils/env.js', () => realEnv)
mock.module('./envUtils.js', () => realEnvUtils)
@@ -69,6 +102,9 @@ async function importFreshInstaller() {
return import(`./nativeInstaller/installer.ts?ts=${Date.now()}-${Math.random()}`)
}
async function importFreshProtocolRegistration() {
return import(`./deepLink/registerProtocol.ts?ts=${Date.now()}-${Math.random()}`)
}
async function mockEnvPlatform(platform: 'darwin' | 'win32') {
const actualEnvModule = await import(`./env.js?ts=${Date.now()}-${Math.random()}`)
mock.module('../utils/env.js', () => ({
@@ -98,6 +134,120 @@ test('install command displays openclaude.exe path on Windows', async () => {
)
})
test('native installer uses openclaude launcher for OpenClaude package', async () => {
;(globalThis as Record<string, unknown>).MACRO = {
PACKAGE_URL: '@gitlawb/openclaude',
}
const { getBinaryName, getExecutableName } = await importFreshInstaller()
expect(getBinaryName('linux-x64')).toBe('claude')
expect(getExecutableName('linux-x64')).toBe('openclaude')
expect(getExecutableName('win32-x64')).toBe('openclaude.exe')
})
test('native installer preserves claude launcher for Anthropic package', async () => {
;(globalThis as Record<string, unknown>).MACRO = {
PACKAGE_URL: '@anthropic-ai/claude-code',
}
const { getExecutableName } = await importFreshInstaller()
expect(getExecutableName('linux-x64')).toBe('claude')
expect(getExecutableName('win32-x64')).toBe('claude.exe')
})
test('deep-link protocol resolver uses openclaude launcher for OpenClaude package', async () => {
;(globalThis as Record<string, unknown>).MACRO = {
PACKAGE_URL: '@gitlawb/openclaude',
}
const { getProtocolBinaryName } = await importFreshProtocolRegistration()
expect(getProtocolBinaryName('linux')).toBe('openclaude')
expect(getProtocolBinaryName('win32')).toBe('openclaude.exe')
})
test('install command repairs launcher after npm cleanup before final check', async () => {
const calls: string[] = []
let repairCompleted = false
const stdout = new PassThrough()
const stdin = new PassThrough() as PassThrough & {
isTTY: boolean
setRawMode: (mode: boolean) => void
ref: () => void
unref: () => void
}
stdin.isTTY = true
stdin.setRawMode = () => {}
stdin.ref = () => {}
stdin.unref = () => {}
mock.module('../utils/nativeInstaller/index.js', () => ({
installLatest: async () => {
calls.push('installLatest')
return { latestVersion: '1.2.3', wasUpdated: true, lockFailed: false }
},
cleanupNpmInstallations: async () => {
calls.push('cleanupNpmInstallations')
return { removed: 1, errors: [], warnings: [] }
},
repairNativeLauncher: async (version: string) => {
calls.push('repairNativeLauncher:' + version)
await Bun.sleep(1)
repairCompleted = true
},
checkInstall: async (setup: boolean) => {
calls.push('checkInstall:' + setup + ':' + repairCompleted)
return []
},
cleanupShellAliases: async () => {
calls.push('cleanupShellAliases')
return []
},
}))
const [{ Install }, { render }] = await Promise.all([
importFreshInstallCommand(),
import(`../ink.js?ts=${Date.now()}-${Math.random()}`),
])
const done = new Promise<void>((resolve, reject) => {
void render(
createElement(Install, {
target: '1.2.3',
onDone: (result: string) => {
try {
expect(result).toBe('OpenClaude installation completed successfully')
resolve()
} catch (error) {
reject(error)
}
},
}),
{
stdout: stdout as unknown as NodeJS.WriteStream,
stdin: stdin as unknown as NodeJS.ReadStream,
patchConsole: false,
},
).catch(reject)
})
try {
await done
} finally {
stdin.end()
stdout.end()
}
expect(calls).toEqual([
'installLatest',
'cleanupNpmInstallations',
'repairNativeLauncher:1.2.3',
'checkInstall:true:true',
'cleanupShellAliases',
])
})
test('cleanupNpmInstallations removes both openclaude and legacy claude local install dirs', async () => {
const removedPaths: string[] = []
;(globalThis as Record<string, unknown>).MACRO = {
@@ -125,3 +275,31 @@ test('cleanupNpmInstallations removes both openclaude and legacy claude local in
expect(removedPaths).toContain(join(homedir(), '.openclaude', 'local'))
expect(removedPaths).toContain(join(homedir(), '.claude', 'local'))
})
test('cleanupNpmInstallations manual fallback removes openclaude npm shim', async () => {
await mockEnvPlatform('darwin')
const testHome = join(process.cwd(), 'work', 'openclaude-install-home-test')
const npmPrefix = join(testHome, '.npm-global')
const shimPath = join(npmPrefix, 'bin', 'openclaude')
;(globalThis as Record<string, unknown>).MACRO = {
PACKAGE_URL: '@gitlawb/openclaude',
}
process.env.HOME = testHome
process.env.USERPROFILE = testHome
process.env.CLAUDE_CONFIG_DIR = join(testHome, '.openclaude')
fakeNpmPrefix = npmPrefix
simulateNpmUninstallEnotempty = true
await fsPromises.mkdir(join(npmPrefix, 'bin'), { recursive: true })
await fsPromises.writeFile(shimPath, 'stale npm shim')
try {
const { cleanupNpmInstallations } = await importFreshInstaller()
await cleanupNpmInstallations()
await expect(fsPromises.stat(shimPath)).rejects.toThrow()
} finally {
await fsPromises.rm(testHome, { recursive: true, force: true })
}
})