feat(commands): add /update command with package-manager auto-detection (#1687)

Adds a `/update` slash command that updates OpenClaude to the latest
published version, routing by how the running install is actually
managed so it updates the installation the user is running.

`globalPackageManager.ts` detects the owning package manager (npm,
yarn, pnpm, bun) for npm-style installs and maps it to the correct
global-install command. `installGlobalPackage()` and `getLatestVersion()`
now consume it, so the legacy `openclaude update` CLI and the background
auto-updater gain yarn/pnpm support; `getLatestVersion()` also falls
back to a direct npm-registry HTTP lookup when npm isn't on the PATH.

`updateStrategy.ts` factors the install-type routing and the
third-party-build guard out of `src/cli/update.ts` (now shared by both
entrypoints). `/update` uses it to refuse development/third-party builds,
point package-manager/native/local installs at their safe update paths,
and only do a global npm install when that's what's actually running —
instead of always installing a stray global package.

Co-authored-by: OpenClaude <openclaude@gitlawb.com>
This commit is contained in:
Kevin Codex
2026-06-18 09:50:12 +08:00
committed by GitHub
co-authored by OpenClaude
parent cc385a6490
commit c4aa756689
9 changed files with 1062 additions and 12 deletions
+2 -5
View File
@@ -1,5 +1,4 @@
import chalk from 'chalk'
import { getAPIProvider } from 'src/utils/model/providers.js'
import { logEvent } from 'src/services/analytics/index.js'
import {
getLatestVersion,
@@ -27,6 +26,7 @@ import { getPackageManager } from 'src/utils/nativeInstaller/packageManagers.js'
import { writeToStdout } from 'src/utils/process.js'
import { gte } from 'src/utils/semver.js'
import { getInitialSettings } from 'src/utils/settings/settings.js'
import { isThirdPartyBuildBlocked } from 'src/utils/updateStrategy.js'
export async function update() {
// Block updates for third-party providers using upstream Anthropic builds.
@@ -34,10 +34,7 @@ export async function update() {
// which would silently replace the OpenClaude build with the upstream
// Claude Code binary. However, builds with a custom PACKAGE_URL (like
// OpenClaude's @gitlawb/openclaude) are safe to self-update.
if (
getAPIProvider() !== 'firstParty' &&
MACRO.PACKAGE_URL === '@anthropic-ai/claude-code'
) {
if (isThirdPartyBuildBlocked()) {
writeToStdout(
chalk.yellow(
`Auto-update is not available for third-party provider builds.\n`,
+2
View File
@@ -149,6 +149,7 @@ import heapDump from './commands/heapdump/index.js'
import mockLimits from './commands/mock-limits/index.js'
import bridgeKick from './commands/bridge-kick.js'
import version from './commands/version.js'
import update from './commands/update/index.js'
import wiki from './commands/wiki/index.js'
import summary from './commands/summary/index.js'
import {
@@ -341,6 +342,7 @@ const COMMANDS = memoize((): Command[] => [
rewind,
securityReview,
terminalSetup,
update,
upgrade,
extraUsage,
extraUsageNonInteractive,
+11
View File
@@ -0,0 +1,11 @@
import type { Command } from '../../types/command.js'
const update = {
type: 'local-jsx',
name: 'update',
description: 'Update OpenClaude to the latest version',
argumentHint: '[latest|stable|<version>] [--force]',
load: () => import('./update.js'),
} satisfies Command
export default update
+352
View File
@@ -0,0 +1,352 @@
import React, { useEffect, useRef, useState } from 'react'
import type { CommandResultDisplay } from '../../commands.js'
import { StatusIcon } from '../../components/design-system/StatusIcon.js'
import { Box, render, Text } from '../../ink.js'
import {
getLatestVersion,
installGlobalPackage,
} from '../../utils/autoUpdater.js'
import type { ReleaseChannel } from '../../utils/config.js'
import { logForDebugging } from '../../utils/debug.js'
import { errorMessage } from '../../utils/errors.js'
import { detectGlobalPackageManager } from '../../utils/globalPackageManager.js'
import { installOrUpdateClaudePackage } from '../../utils/localInstaller.js'
import { installLatest as installLatestNative } from '../../utils/nativeInstaller/index.js'
import type { PackageManager } from '../../utils/nativeInstaller/packageManagers.js'
import { resolveUpdateStrategy } from '../../utils/updateStrategy.js'
const PACKAGE_URL = MACRO.PACKAGE_URL
const CURRENT_VERSION = MACRO.DISPLAY_VERSION
interface UpdateProps {
onDone: (
result: string,
options?: { display?: CommandResultDisplay },
) => void
force: boolean
target: string
}
type UpdateState =
| { type: 'checking' }
| { type: 'blocked'; reason: 'third-party-build' | 'development' }
| { type: 'package-manager'; manager: PackageManager }
| { type: 'no-package-manager' }
| { type: 'up-to-date'; version: string }
| { type: 'updating'; version: string; via: string }
| { type: 'success'; version: string; via: string }
| { type: 'error'; message: string }
// Manager-specific upgrade command, mirroring src/cli/update.ts.
function packageManagerHint(manager: PackageManager): string | null {
switch (manager) {
case 'homebrew':
return 'brew upgrade claude-code'
case 'winget':
return 'winget upgrade Anthropic.ClaudeCode'
case 'apk':
return 'apk upgrade claude-code'
default:
return null
}
}
function Update({ onDone, force, target }: UpdateProps): React.ReactNode {
const [state, setState] = useState<UpdateState>({ type: 'checking' })
// Terminal states are entered once, but guard against a double-schedule —
// matching the onDone-guard pattern used elsewhere (e.g. REPL's doneWasCalled).
const doneScheduled = useRef(false)
useEffect(() => {
async function run() {
try {
// Route by how the running install is actually managed, so we never
// shadow a native/package-manager/local install with a stray global
// npm package (and so third-party upstream builds aren't replaced).
const strategy = await resolveUpdateStrategy()
logForDebugging(
`Update: strategy=${JSON.stringify(strategy)} (force=${force}, target=${target})`,
)
if (strategy.action === 'blocked') {
setState({ type: 'blocked', reason: strategy.reason })
return
}
if (strategy.action === 'package-manager') {
setState({ type: 'package-manager', manager: strategy.manager })
return
}
const isChannel = target === 'latest' || target === 'stable'
const channel: ReleaseChannel = target === 'stable' ? 'stable' : 'latest'
if (strategy.action === 'native') {
setState({
type: 'updating',
version: isChannel ? channel : target,
via: 'native build',
})
const result = await installLatestNative(
isChannel ? channel : target,
force,
)
if (result.lockFailed) {
setState({
type: 'error',
message:
'Another install is in progress. Try again in a moment.',
})
return
}
if (!result.latestVersion) {
setState({ type: 'error', message: 'Failed to check for updates.' })
return
}
if (result.latestVersion === CURRENT_VERSION) {
setState({ type: 'up-to-date', version: CURRENT_VERSION })
return
}
setState({
type: 'success',
version: result.latestVersion,
via: 'native build',
})
return
}
// strategy.action === 'npm' — update the local or global npm install.
const via =
strategy.method === 'global'
? await detectGlobalPackageManager()
: 'local install'
if (strategy.method === 'global' && !via) {
setState({ type: 'no-package-manager' })
return
}
const resolved = isChannel ? await getLatestVersion(channel) : target
if (
!force &&
isChannel &&
resolved &&
resolved.trim() === CURRENT_VERSION.trim()
) {
setState({ type: 'up-to-date', version: resolved })
return
}
const display = resolved || target
setState({ type: 'updating', version: display, via: via as string })
// Reuse the shared installers: each holds the update lock, checks
// permissions, cleans up old aliases, and records installMethod.
const status =
strategy.method === 'local'
? await installOrUpdateClaudePackage(
channel,
isChannel ? null : target,
)
: await installGlobalPackage(target === 'latest' ? null : target)
switch (status) {
case 'success':
setState({ type: 'success', version: display, via: via as string })
break
case 'no_permissions':
setState({
type: 'error',
message:
'Insufficient permissions for the install. Re-run with the right permissions (e.g. sudo) or fix your install directory ownership.',
})
break
case 'in_progress':
setState({
type: 'error',
message:
'Another update is already in progress. Try again in a moment.',
})
break
default:
setState({
type: 'error',
message: 'Install failed. Run with --debug for details.',
})
}
} catch (error) {
logForDebugging(`Update command failed: ${error}`, { level: 'error' })
setState({ type: 'error', message: errorMessage(error) })
}
}
void run()
}, [force, target])
useEffect(() => {
if (doneScheduled.current || state.type === 'checking' || state.type === 'updating') {
return
}
doneScheduled.current = true
const { message, delay } = terminalDoneMessage(state)
setTimeout(onDone, delay, message, { display: 'system' as const })
}, [state, onDone])
return (
<Box flexDirection="column" marginTop={1}>
{state.type === 'checking' && (
<Text color="claude">
Detecting installation type and checking for updates...
</Text>
)}
{state.type === 'blocked' && (
<Box flexDirection="column" gap={1}>
<Box>
<StatusIcon status="warning" withSpace />
<Text color="warning">
{state.reason === 'development'
? 'Auto-update is unavailable for a development build.'
: 'Auto-update is unavailable for third-party provider builds.'}
</Text>
</Box>
{state.reason === 'development' && (
<Box marginLeft={2}>
<Text dimColor>
Update from source: git pull && bun install && bun run build
</Text>
</Box>
)}
<Box marginLeft={2}>
<Text dimColor>
Or reinstall: npm install -g {PACKAGE_URL}@latest
</Text>
</Box>
</Box>
)}
{state.type === 'package-manager' && (
<Box flexDirection="column" gap={1}>
<Box>
<StatusIcon status="warning" withSpace />
<Text color="warning">
OpenClaude is managed by a package manager ({state.manager}).
</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor>
{packageManagerHint(state.manager)
? `To update, run: ${packageManagerHint(state.manager)}`
: 'Please use your package manager to update.'}
</Text>
</Box>
</Box>
)}
{state.type === 'no-package-manager' && (
<Box flexDirection="column" gap={1}>
<Box>
<StatusIcon status="error" withSpace />
<Text color="error">No supported package manager found</Text>
</Box>
<Text dimColor>
Install npm, pnpm, yarn, or bun, then run /update again.
</Text>
</Box>
)}
{state.type === 'up-to-date' && (
<Box>
<StatusIcon status="success" withSpace />
<Text color="success">
Already on the latest version ({state.version}).
</Text>
</Box>
)}
{state.type === 'updating' && (
<Text color="claude">
Updating OpenClaude to {state.version} via {state.via} (this may take a
moment)...
</Text>
)}
{state.type === 'success' && (
<Box flexDirection="column" gap={1}>
<Box>
<StatusIcon status="success" withSpace />
<Text color="success" bold>
OpenClaude updated to {state.version} via {state.via}!
</Text>
</Box>
<Box marginLeft={2}>
<Text dimColor>
Restart OpenClaude for the new version to take effect.
</Text>
</Box>
</Box>
)}
{state.type === 'error' && (
<Box flexDirection="column" gap={1}>
<Box>
<StatusIcon status="error" withSpace />
<Text color="error">Update failed</Text>
</Box>
<Text color="error">{state.message}</Text>
<Box marginTop={1}>
<Text dimColor>
You can update manually, e.g. npm install -g {PACKAGE_URL}@latest
</Text>
</Box>
</Box>
)}
</Box>
)
}
// The user-visible system message + dwell time for each terminal state.
function terminalDoneMessage(state: UpdateState): {
message: string
delay: number
} {
switch (state.type) {
case 'success':
return { message: 'OpenClaude updated successfully', delay: 3000 }
case 'up-to-date':
return { message: 'OpenClaude is already up to date', delay: 1500 }
case 'blocked':
return { message: 'Auto-update is unavailable for this build', delay: 3000 }
case 'package-manager':
return {
message: 'OpenClaude is managed by a package manager',
delay: 3000,
}
case 'no-package-manager':
return { message: 'No supported package manager found', delay: 3000 }
case 'error':
return { message: 'OpenClaude update failed', delay: 4000 }
default:
return { message: '', delay: 0 }
}
}
export async function call(
onDone: (result: string, options?: { display?: CommandResultDisplay }) => void,
_context: unknown,
args: string,
): Promise<React.ReactNode> {
const tokens = (args ?? '').trim().split(/\s+/).filter(Boolean)
const force = tokens.includes('--force')
const nonFlag = tokens.filter(token => !token.startsWith('--'))
const target = nonFlag[0] || 'latest'
const { unmount } = await render(
<Update
onDone={(result, options) => {
unmount()
onDone(result, options)
}}
force={force}
target={target}
/>,
)
return null
}
+49 -7
View File
@@ -16,6 +16,10 @@ import { env } from './env.js'
import { getClaudeConfigHomeDir } from './envUtils.js'
import { ClaudeError, getErrnoCode, isENOENT } from './errors.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import {
detectGlobalPackageManager,
getGlobalInstallArgs,
} from './globalPackageManager.js'
import { getFsImplementation } from './fsOperations.js'
import { gracefulShutdownSync } from './gracefulShutdown.js'
import { logError } from './log.js'
@@ -367,11 +371,37 @@ export async function getLatestVersion(
if (result.stdout) {
logForDebugging(`npm stdout: ${result.stdout.trim()}`)
}
return null
// npm may be unavailable (bun/pnpm/yarn-only installs) or transiently
// failing — fall back to a direct registry request so update checks still
// work without npm on the PATH.
return getLatestVersionFromRegistryHttp(npmTag)
}
return result.stdout.trim()
}
/**
* Look up a dist-tag's version directly from the public npm registry over HTTP.
* Used as a fallback when `npm view` is unavailable or fails.
*/
async function getLatestVersionFromRegistryHttp(
tag: string,
): Promise<string | null> {
try {
const response = await axios.get(
`https://registry.npmjs.org/${MACRO.PACKAGE_URL}`,
{ timeout: 10_000 },
)
const distTags = (
response.data as { 'dist-tags'?: Record<string, string> }
)?.['dist-tags']
const version = distTags?.[tag]
return typeof version === 'string' ? version : null
} catch (error) {
logForDebugging(`Registry HTTP lookup for ${MACRO.PACKAGE_URL} failed: ${error}`)
return null
}
}
export type NpmDistTags = {
latest: string | null
stable: string | null
@@ -504,8 +534,16 @@ export async function installGlobalPackage(
try {
await removeClaudeAliasesFromShellConfigs()
// Resolve the package manager that owns this install (npm/yarn/pnpm/bun),
// falling back to npm/bun by runtime when detection is inconclusive. This is
// the single source of truth for how we drive a global install.
const packageManager =
(await detectGlobalPackageManager()) ??
(env.isRunningWithBun() ? 'bun' : 'npm')
// Check if we're using npm from Windows path in WSL
if (!env.isRunningWithBun() && env.isNpmFromWindowsPath()) {
if (packageManager === 'npm' && env.isNpmFromWindowsPath()) {
logError(new Error('Windows NPM detected in WSL environment'))
logEvent('tengu_auto_updater_windows_npm_in_wsl', {
currentVersion:
@@ -526,9 +564,14 @@ To fix this issue:
return 'install_failed'
}
const { hasPermissions } = await checkGlobalInstallPermissions()
if (!hasPermissions) {
return 'no_permissions'
// The permission probe inspects the npm/bun global prefix; only meaningful
// for those managers. pnpm/yarn manage their own global store, so we let the
// install command itself surface any permission error there.
if (packageManager === 'npm' || packageManager === 'bun') {
const { hasPermissions } = await checkGlobalInstallPermissions()
if (!hasPermissions) {
return 'no_permissions'
}
}
// Use specific version if provided, otherwise use latest
@@ -538,10 +581,9 @@ To fix this issue:
// Run from home directory to avoid reading project-level .npmrc/.bunfig.toml
// which could be maliciously crafted to redirect to an attacker's registry
const packageManager = env.isRunningWithBun() ? 'bun' : 'npm'
const installResult = await execFileNoThrowWithCwd(
packageManager,
['install', '-g', packageSpec],
getGlobalInstallArgs(packageManager, packageSpec),
{ cwd: homedir() },
)
if (installResult.code !== 0) {
+124
View File
@@ -0,0 +1,124 @@
import { describe, expect, test } from 'bun:test'
import {
type GlobalPackageManager,
getGlobalInstallArgs,
pickFallbackPackageManager,
selectOwningPackageManager,
} from './globalPackageManager.js'
describe('getGlobalInstallArgs', () => {
const spec = '@gitlawb/openclaude@latest'
const cases: Array<[GlobalPackageManager, string[]]> = [
['npm', ['install', '-g', spec]],
['pnpm', ['add', '-g', spec]],
['bun', ['add', '-g', spec]],
['yarn', ['global', 'add', spec]],
]
for (const [pm, expected] of cases) {
test(`${pm} builds the right global install argv`, () => {
expect(getGlobalInstallArgs(pm, spec)).toEqual(expected)
})
}
test('passes an explicit version spec through unchanged', () => {
expect(getGlobalInstallArgs('npm', '@gitlawb/openclaude@1.2.3')).toEqual([
'install',
'-g',
'@gitlawb/openclaude@1.2.3',
])
})
})
describe('selectOwningPackageManager', () => {
test('returns null when no candidate root contains the binary', () => {
expect(
selectOwningPackageManager('/home/u/.bun/install/global/node_modules/x', [
{ pm: 'npm', root: '/usr/local/lib/node_modules' },
{ pm: 'pnpm', root: '/home/u/.local/share/pnpm/global/5/node_modules' },
]),
).toBeNull()
})
test('returns null for empty candidate list', () => {
expect(selectOwningPackageManager('/anything', [])).toBeNull()
})
test('matches the package manager whose root contains the binary', () => {
expect(
selectOwningPackageManager(
'/home/u/.local/share/pnpm/global/5/node_modules/@gitlawb/openclaude/cli.js',
[
{ pm: 'npm', root: '/usr/local/lib/node_modules' },
{
pm: 'pnpm',
root: '/home/u/.local/share/pnpm/global/5/node_modules',
},
],
),
).toBe('pnpm')
})
test('most specific (longest) root wins when roots are nested', () => {
// npm's root is a parent of bun's here; bun must win because its root is
// the more specific match.
expect(
selectOwningPackageManager('/opt/pm/node_modules/bun/global/openclaude', [
{ pm: 'npm', root: '/opt/pm/node_modules' },
{ pm: 'bun', root: '/opt/pm/node_modules/bun/global' },
]),
).toBe('bun')
})
test('treats an exact path match as owned', () => {
expect(
selectOwningPackageManager('/usr/local/lib/node_modules', [
{ pm: 'npm', root: '/usr/local/lib/node_modules' },
]),
).toBe('npm')
})
test('does not match a sibling directory sharing a prefix', () => {
// "/a/node_modules-other" must not be considered under "/a/node_modules".
expect(
selectOwningPackageManager('/a/node_modules-other/openclaude/cli.js', [
{ pm: 'npm', root: '/a/node_modules' },
]),
).toBeNull()
})
test('ignores candidates with an empty root', () => {
expect(
selectOwningPackageManager('/usr/lib/node_modules/openclaude', [
{ pm: 'yarn', root: '' },
{ pm: 'npm', root: '/usr/lib/node_modules' },
]),
).toBe('npm')
})
})
describe('pickFallbackPackageManager', () => {
test('returns null when nothing is available', () => {
expect(pickFallbackPackageManager([], false)).toBeNull()
expect(pickFallbackPackageManager([], true)).toBeNull()
})
test('prefers bun when running under the Bun runtime and bun is available', () => {
expect(pickFallbackPackageManager(['npm', 'bun'], true)).toBe('bun')
})
test('does not force bun when not running under Bun', () => {
expect(pickFallbackPackageManager(['npm', 'bun'], false)).toBe('npm')
})
test('falls back to priority order when bun is unavailable under Bun', () => {
expect(pickFallbackPackageManager(['pnpm', 'yarn'], true)).toBe('pnpm')
})
test('honours FALLBACK_PRIORITY (npm > bun > pnpm > yarn)', () => {
expect(pickFallbackPackageManager(['yarn', 'pnpm', 'bun'], false)).toBe(
'bun',
)
expect(pickFallbackPackageManager(['yarn', 'pnpm'], false)).toBe('pnpm')
expect(pickFallbackPackageManager(['yarn'], false)).toBe('yarn')
})
})
+212
View File
@@ -0,0 +1,212 @@
import memoize from 'lodash-es/memoize.js'
import { realpath } from 'node:fs/promises'
import { homedir } from 'node:os'
import { join, resolve, sep } from 'node:path'
import { logForDebugging } from './debug.js'
import { execFileNoThrowWithCwd } from './execFileNoThrow.js'
import { which } from './which.js'
export type GlobalPackageManager = 'npm' | 'yarn' | 'pnpm' | 'bun'
// All package managers we know how to drive a global install with.
const ALL_PACKAGE_MANAGERS: GlobalPackageManager[] = [
'npm',
'yarn',
'pnpm',
'bun',
]
// Order used when path-based detection is inconclusive. npm first because it is
// the documented install method; bun next because we ship a Bun-built binary.
const FALLBACK_PRIORITY: GlobalPackageManager[] = ['npm', 'bun', 'pnpm', 'yarn']
/**
* Build the argv (after the binary name) to globally install `spec`
* (e.g. "@gitlawb/openclaude@latest") with the given package manager.
*/
export function getGlobalInstallArgs(
pm: GlobalPackageManager,
spec: string,
): string[] {
switch (pm) {
case 'npm':
return ['install', '-g', spec]
case 'pnpm':
return ['add', '-g', spec]
case 'bun':
return ['add', '-g', spec]
case 'yarn':
// Classic yarn syntax; yarn berry aliases `global add` to the same effect
// for the documented openclaude install path.
return ['global', 'add', spec]
}
}
/** True when `child` is the same path as, or nested inside, `parent`. */
function isUnder(child: string, parent: string): boolean {
const p = resolve(parent)
const c = resolve(child)
const withSep = p.endsWith(sep) ? p : p + sep
return c === p || c.startsWith(withSep)
}
/**
* Pure decision: given the running binary's path and each candidate package
* manager's global root, return the PM whose root contains the binary. When
* several roots match (one nested inside another) the most specific — longest —
* root wins, so npm's broad global dir never shadows pnpm/bun/yarn. Returns null
* when no root contains the path.
*/
export function selectOwningPackageManager(
selfPath: string,
candidates: ReadonlyArray<{ pm: GlobalPackageManager; root: string }>,
): GlobalPackageManager | null {
let best: { pm: GlobalPackageManager; rootLen: number } | null = null
for (const { pm, root } of candidates) {
if (!root) continue
const rootLen = resolve(root).length
if (isUnder(selfPath, root) && (!best || rootLen > best.rootLen)) {
best = { pm, rootLen }
}
}
return best?.pm ?? null
}
/**
* Pure fallback selection when path-based detection is inconclusive. Prefers bun
* when we're executing under the Bun runtime, otherwise the first available PM
* by FALLBACK_PRIORITY. Returns null only when nothing is available.
*/
export function pickFallbackPackageManager(
available: ReadonlyArray<GlobalPackageManager>,
runningUnderBun: boolean,
): GlobalPackageManager | null {
if (available.length === 0) {
return null
}
if (runningUnderBun && available.includes('bun')) {
return 'bun'
}
for (const pm of FALLBACK_PRIORITY) {
if (available.includes(pm)) {
return pm
}
}
return available[0] ?? null
}
async function isAvailable(pm: GlobalPackageManager): Promise<boolean> {
return Boolean(await which(pm))
}
/**
* Resolve a package manager's global `node_modules` directory, where globally
* installed packages live. Returns null when the command fails or is unknown.
*/
async function getGlobalRoot(
pm: GlobalPackageManager,
): Promise<string | null> {
// Run from $HOME so a project-local .npmrc / .bunfig.toml can't redirect us.
const opts = { cwd: homedir(), timeout: 15_000 }
try {
switch (pm) {
case 'npm': {
const r = await execFileNoThrowWithCwd('npm', ['root', '-g'], opts)
return r.code === 0 && r.stdout.trim() ? r.stdout.trim() : null
}
case 'pnpm': {
const r = await execFileNoThrowWithCwd('pnpm', ['root', '-g'], opts)
return r.code === 0 && r.stdout.trim() ? r.stdout.trim() : null
}
case 'yarn': {
// Classic yarn only; Yarn Berry (v2+) removed `yarn global`, so this
// returns null for Berry installs and detection falls back via
// pickFallbackPackageManager — acceptable degradation, as Berry has no
// classic global install for us to own anyway.
const r = await execFileNoThrowWithCwd('yarn', ['global', 'dir'], opts)
return r.code === 0 && r.stdout.trim()
? join(r.stdout.trim(), 'node_modules')
: null
}
case 'bun': {
// Bun has no `root -g`; its global packages live under BUN_INSTALL.
const bunInstall = process.env.BUN_INSTALL || join(homedir(), '.bun')
return join(bunInstall, 'install', 'global', 'node_modules')
}
}
} catch (error) {
logForDebugging(`getGlobalRoot(${pm}) failed: ${error}`)
return null
}
}
async function resolveRealPath(target: string): Promise<string> {
try {
return await realpath(target)
} catch {
return target
}
}
/**
* Detect which package manager owns the currently running OpenClaude install.
*
* Strategy:
* 1. Resolve the real path of the running binary (following the bin symlink
* into the package manager's global node_modules).
* 2. Match it against each available PM's global root via
* {@link selectOwningPackageManager}.
* 3. If nothing matches, fall back via {@link pickFallbackPackageManager}.
*
* Memoized — detection spawns several subprocesses and the answer is stable for
* the life of the process. Returns null only when none of npm/yarn/pnpm/bun are
* installed.
*/
export const detectGlobalPackageManager = memoize(
async (): Promise<GlobalPackageManager | null> => {
// Availability probes and the self-path lookup are independent subprocess
// calls — run them concurrently so detection cost is bounded by the slowest
// probe rather than the sum of all of them.
const [availabilityFlags, selfPath] = await Promise.all([
Promise.all(ALL_PACKAGE_MANAGERS.map(isAvailable)),
process.argv[1]
? resolveRealPath(process.argv[1])
: Promise.resolve(null),
])
const available = ALL_PACKAGE_MANAGERS.filter((_, i) => availabilityFlags[i])
if (available.length === 0) {
return null
}
if (selfPath) {
// Resolve each available manager's global root concurrently too; the most
// specific match wins regardless of order (see selectOwningPackageManager).
const candidates = (
await Promise.all(
available.map(async pm => {
const root = await getGlobalRoot(pm)
return root ? { pm, root: await resolveRealPath(root) } : null
}),
)
).filter(
(candidate): candidate is { pm: GlobalPackageManager; root: string } =>
candidate !== null,
)
const owner = selectOwningPackageManager(selfPath, candidates)
if (owner) {
logForDebugging(`Detected install owner package manager: ${owner}`)
return owner
}
}
const fallback = pickFallbackPackageManager(
available,
typeof Bun !== 'undefined',
)
if (fallback) {
logForDebugging(`Falling back to package manager: ${fallback}`)
}
return fallback
},
)
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, test } from 'bun:test'
import type { DiagnosticInfo, InstallationType } from './doctorDiagnostic.js'
import type { PackageManager } from './nativeInstaller/packageManagers.js'
import type { LegacyAPIProvider } from './model/providers.js'
import {
isThirdPartyBuildBlockedFor,
planUpdate,
resolveUpdateStrategy,
type UpdateStrategyDeps,
} from './updateStrategy.js'
describe('isThirdPartyBuildBlockedFor', () => {
const UPSTREAM = '@anthropic-ai/claude-code'
const OPENCLAUDE = '@gitlawb/openclaude'
test('blocks a third-party provider running the upstream build', () => {
for (const provider of [
'bedrock',
'vertex',
'openai',
'gemini',
] as LegacyAPIProvider[]) {
expect(isThirdPartyBuildBlockedFor(provider, UPSTREAM)).toBe(true)
}
})
test('allows the first-party provider on the upstream build', () => {
expect(isThirdPartyBuildBlockedFor('firstParty', UPSTREAM)).toBe(false)
})
test('allows a custom-PACKAGE_URL build (OpenClaude) on any provider', () => {
expect(isThirdPartyBuildBlockedFor('bedrock', OPENCLAUDE)).toBe(false)
expect(isThirdPartyBuildBlockedFor('firstParty', OPENCLAUDE)).toBe(false)
})
})
describe('planUpdate', () => {
const base = {
thirdPartyBlocked: false,
packageManager: 'unknown' as PackageManager,
localInstallExists: false,
}
test('third-party build is blocked regardless of installation type', () => {
for (const installationType of [
'npm-global',
'native',
'npm-local',
] as InstallationType[]) {
expect(
planUpdate({ ...base, thirdPartyBlocked: true, installationType }),
).toEqual({ action: 'blocked', reason: 'third-party-build' })
}
})
test('development build is blocked', () => {
expect(
planUpdate({ ...base, installationType: 'development' }),
).toEqual({ action: 'blocked', reason: 'development' })
})
test('package-manager install routes to manual update with the manager', () => {
expect(
planUpdate({
...base,
installationType: 'package-manager',
packageManager: 'homebrew',
}),
).toEqual({ action: 'package-manager', manager: 'homebrew' })
})
test('native install routes to the native updater', () => {
expect(planUpdate({ ...base, installationType: 'native' })).toEqual({
action: 'native',
})
})
test('npm-local and npm-global route to their npm method', () => {
expect(planUpdate({ ...base, installationType: 'npm-local' })).toEqual({
action: 'npm',
method: 'local',
})
expect(planUpdate({ ...base, installationType: 'npm-global' })).toEqual({
action: 'npm',
method: 'global',
})
})
test('unknown install falls back to file detection (local)', () => {
expect(
planUpdate({
...base,
installationType: 'unknown',
localInstallExists: true,
}),
).toEqual({ action: 'npm', method: 'local' })
})
test('unknown install falls back to file detection (global)', () => {
expect(
planUpdate({
...base,
installationType: 'unknown',
localInstallExists: false,
}),
).toEqual({ action: 'npm', method: 'global' })
})
})
describe('resolveUpdateStrategy', () => {
function makeDeps(
overrides: Partial<UpdateStrategyDeps> & {
installationType?: InstallationType
} = {},
): { deps: UpdateStrategyDeps; calls: Record<string, number> } {
const calls = { diagnostic: 0, packageManager: 0, localInstall: 0 }
const deps: UpdateStrategyDeps = {
isThirdPartyBlocked: overrides.isThirdPartyBlocked ?? (() => false),
getDiagnostic:
overrides.getDiagnostic ??
(async () => {
calls.diagnostic++
return {
installationType: overrides.installationType ?? 'npm-global',
} as DiagnosticInfo
}),
getPackageManager:
overrides.getPackageManager ??
(async () => {
calls.packageManager++
return 'homebrew' as PackageManager
}),
localInstallationExists:
overrides.localInstallationExists ??
(async () => {
calls.localInstall++
return true
}),
}
return { deps, calls }
}
test('short-circuits on third-party block without probing the diagnostic', async () => {
const { deps, calls } = makeDeps({ isThirdPartyBlocked: () => true })
expect(await resolveUpdateStrategy(deps)).toEqual({
action: 'blocked',
reason: 'third-party-build',
})
expect(calls.diagnostic).toBe(0)
})
test('only probes the package manager for package-manager installs', async () => {
const { deps, calls } = makeDeps({ installationType: 'package-manager' })
expect(await resolveUpdateStrategy(deps)).toEqual({
action: 'package-manager',
manager: 'homebrew',
})
expect(calls.packageManager).toBe(1)
expect(calls.localInstall).toBe(0)
})
test('only probes local-install existence for unknown installs', async () => {
const { deps, calls } = makeDeps({ installationType: 'unknown' })
expect(await resolveUpdateStrategy(deps)).toEqual({
action: 'npm',
method: 'local',
})
expect(calls.localInstall).toBe(1)
expect(calls.packageManager).toBe(0)
})
test('routes a global npm install without extra probes', async () => {
const { deps, calls } = makeDeps({ installationType: 'npm-global' })
expect(await resolveUpdateStrategy(deps)).toEqual({
action: 'npm',
method: 'global',
})
expect(calls.packageManager).toBe(0)
expect(calls.localInstall).toBe(0)
})
})
+129
View File
@@ -0,0 +1,129 @@
import type { DiagnosticInfo, InstallationType } from './doctorDiagnostic.js'
import { getDoctorDiagnostic } from './doctorDiagnostic.js'
import { localInstallationExists } from './localInstaller.js'
import { type LegacyAPIProvider, getAPIProvider } from './model/providers.js'
import type { PackageManager } from './nativeInstaller/packageManagers.js'
import { getPackageManager } from './nativeInstaller/packageManagers.js'
/**
* How the *currently running* OpenClaude installation should be updated.
*
* - `blocked` — must not self-update (third-party upstream build, or a
* development build); the caller should show guidance.
* - `package-manager` — owned by a system package manager (homebrew/winget/…);
* the user must update through that manager.
* - `native` — update via the native installer.
* - `npm` — update the npm install (`local` or `global`).
*/
export type UpdateStrategy =
| { action: 'blocked'; reason: 'third-party-build' | 'development' }
| { action: 'package-manager'; manager: PackageManager }
| { action: 'native' }
| { action: 'npm'; method: 'local' | 'global' }
/**
* True when this build must NOT self-update: a third-party provider session
* running on the upstream `@anthropic-ai/claude-code` package. Self-updating
* there pulls from the first-party distribution and would silently replace the
* build the user is running. Custom-PACKAGE_URL builds (OpenClaude's
* `@gitlawb/openclaude`) are safe to self-update.
*
* Shared by the `openclaude update` CLI and the `/update` slash command so both
* honour the same guard.
*/
export function isThirdPartyBuildBlocked(): boolean {
return isThirdPartyBuildBlockedFor(getAPIProvider(), MACRO.PACKAGE_URL)
}
/**
* Pure form of {@link isThirdPartyBuildBlocked}, taking the provider and build
* package URL as inputs so the guard logic can be regression-tested without
* `getAPIProvider()` / the build-time `MACRO` global.
*/
export function isThirdPartyBuildBlockedFor(
apiProvider: LegacyAPIProvider,
packageUrl: string,
): boolean {
return apiProvider !== 'firstParty' && packageUrl === '@anthropic-ai/claude-code'
}
/**
* Injectable dependencies — lets callers (and tests) substitute the
* environment probes without module mocking.
*/
export type UpdateStrategyDeps = {
isThirdPartyBlocked: () => boolean
getDiagnostic: () => Promise<DiagnosticInfo>
getPackageManager: () => Promise<PackageManager>
localInstallationExists: () => Promise<boolean>
}
const defaultDeps: UpdateStrategyDeps = {
isThirdPartyBlocked: isThirdPartyBuildBlocked,
getDiagnostic: getDoctorDiagnostic,
getPackageManager,
localInstallationExists,
}
/**
* Pure routing decision from a known installation type. Kept separate so the
* branch logic can be unit-tested without spawning the diagnostic probes.
* `packageManager`/`localInstallExists` are only consulted by the branches that
* need them.
*/
export function planUpdate(input: {
thirdPartyBlocked: boolean
installationType: InstallationType
packageManager: PackageManager
localInstallExists: boolean
}): UpdateStrategy {
if (input.thirdPartyBlocked) {
return { action: 'blocked', reason: 'third-party-build' }
}
switch (input.installationType) {
case 'development':
return { action: 'blocked', reason: 'development' }
case 'package-manager':
return { action: 'package-manager', manager: input.packageManager }
case 'native':
return { action: 'native' }
case 'npm-local':
return { action: 'npm', method: 'local' }
case 'npm-global':
return { action: 'npm', method: 'global' }
case 'unknown':
// Fall back to file detection, matching cli/update.ts's unknown branch.
return { action: 'npm', method: input.localInstallExists ? 'local' : 'global' }
}
}
/**
* Decide how to update the currently running installation. Mirrors the routing
* in `src/cli/update.ts` so the CLI and the `/update` slash command update the
* installation the user is actually running, instead of blindly installing a
* global npm package.
*
* Short-circuits the third-party guard before any probing, and only runs the
* package-manager / local-detection probes for the branches that need them.
*/
export async function resolveUpdateStrategy(
deps: UpdateStrategyDeps = defaultDeps,
): Promise<UpdateStrategy> {
if (deps.isThirdPartyBlocked()) {
return { action: 'blocked', reason: 'third-party-build' }
}
const { installationType } = await deps.getDiagnostic()
return planUpdate({
thirdPartyBlocked: false,
installationType,
packageManager:
installationType === 'package-manager'
? await deps.getPackageManager()
: 'unknown',
localInstallExists:
installationType === 'unknown'
? await deps.localInstallationExists()
: false,
})
}