mirror of
https://github.com/Gitlawb/openclaude.git
synced 2026-08-24 10:14:19 -05:00
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:
co-authored by
OpenClaude
parent
cc385a6490
commit
c4aa756689
+2
-5
@@ -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`,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
@@ -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
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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
|
||||
},
|
||||
)
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user