mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
+21
-13
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -14,5 +14,6 @@ export {
|
||||
installLatest,
|
||||
lockCurrentVersion,
|
||||
removeInstalledSymlink,
|
||||
repairNativeLauncher,
|
||||
type SetupMessage,
|
||||
} from './installer.js'
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 })
|
||||
}
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user