diff --git a/packages/ui/src/components/chat/message/MessageBody.tsx b/packages/ui/src/components/chat/message/MessageBody.tsx index 27989d74..d7ca5298 100644 --- a/packages/ui/src/components/chat/message/MessageBody.tsx +++ b/packages/ui/src/components/chat/message/MessageBody.tsx @@ -14,7 +14,7 @@ import { isEmptyTextPart, extractTextContent } from './partUtils'; import { FadeInOnReveal } from './FadeInOnReveal'; import { Button } from '@/components/ui/button'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; -import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react'; +import { RiCheckLine, RiFileCopyLine, RiChatNewLine, RiArrowGoBackLine, RiGitBranchLine, RiHourglassLine, RiTimeLine, RiVolumeUpLine, RiStopLine, RiShare2Line, RiLoader4Line } from '@remixicon/react'; import { ArrowsMerge } from '@/components/icons/ArrowsMerge'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; @@ -1365,7 +1365,10 @@ const AssistantMessageBody: React.FC> = ({ ) : null} {footerTimestamp ? ( - {footerTimestamp} + + + {footerTimestamp} + ) : null} @@ -1388,7 +1391,10 @@ const AssistantMessageBody: React.FC> = ({ ) : null} {footerTimestamp ? ( - {footerTimestamp} + + + {footerTimestamp} + ) : null} diff --git a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx index 84684d63..baa40aae 100644 --- a/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ReasoningPart.tsx @@ -6,6 +6,7 @@ import { cn } from '@/lib/utils'; import { formatTimestampForDisplay } from '../timeFormat'; import type { ContentChangeReason } from '@/hooks/useChatScrollManager'; import { ScrollableOverlay } from '@/components/ui/ScrollableOverlay'; +import { useUIStore } from '@/stores/useUIStore'; type PartWithText = Part & { text?: string; content?: string; time?: { start?: number; end?: number } }; @@ -98,6 +99,7 @@ export const ReasoningTimelineBlock: React.FC = ({ time, }) => { const [isExpanded, setIsExpanded] = React.useState(false); + const isMobile = useUIStore((state) => state.isMobile); const summary = React.useMemo(() => getReasoningSummary(text), [text]); const { label, Icon } = variantConfig[variant]; @@ -157,23 +159,35 @@ export const ReasoningTimelineBlock: React.FC = ({ {(summary || typeof timeStart === 'number' || endedTimestampText) ? (
- {summary ? {summary} : null} + {summary ? {summary} : null} {typeof timeStart === 'number' ? ( - - + + + + + {!isMobile && endedTimestampText ? ( + + {endedTimestampText} + + ) : null} ) : null} - {endedTimestampText ? ( - + {typeof timeStart !== 'number' && !isMobile && endedTimestampText ? ( + {endedTimestampText} ) : null} diff --git a/packages/ui/src/components/chat/message/parts/ToolPart.tsx b/packages/ui/src/components/chat/message/parts/ToolPart.tsx index 84507850..630b921f 100644 --- a/packages/ui/src/components/chat/message/parts/ToolPart.tsx +++ b/packages/ui/src/components/chat/message/parts/ToolPart.tsx @@ -157,12 +157,7 @@ const parseDiffStats = (metadata?: Record): { added: number; re return { added, removed }; }; -const getRelativePath = (absolutePath: string, currentDirectory: string, isMobile: boolean): string => { - - if (isMobile) { - return absolutePath.split('/').pop() || absolutePath; - } - +const getRelativePath = (absolutePath: string, currentDirectory: string): string => { if (absolutePath.startsWith(currentDirectory)) { const relativePath = absolutePath.substring(currentDirectory.length); @@ -227,9 +222,9 @@ const parseQuestionOutput = (output: string): Array<{ question: string; answer: return pairs.length > 0 ? pairs : null; }; -const formatStructuredOutputDescription = (input: Record | undefined, output: unknown, isMobile: boolean): string => { +const formatStructuredOutputDescription = (input: Record | undefined, output: unknown): string => { if (typeof output === 'string' && output.trim().length > 0) { - const maxLength = isMobile ? 50 : 100; + const maxLength = 100; const text = output.trim(); return text.length > maxLength ? `${text.substring(0, maxLength)}...` : text; } @@ -271,31 +266,64 @@ const formatStructuredOutputDescription = (input: Record | unde return 'Result'; } - const maxLength = isMobile ? 50 : 100; + const maxLength = 100; const truncated = preview.length > maxLength ? `${preview.substring(0, maxLength)}...` : preview; return truncated; }; -const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: boolean, currentDirectory: string): string => { +const getToolDescriptionPath = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string | null => { + const stateWithData = state as ToolStateWithMetadata; + const metadata = stateWithData.metadata; + const input = stateWithData.input; + + if (part.tool === 'apply_patch') { + const files = Array.isArray(metadata?.files) ? metadata?.files : []; + const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; + const filePath = firstFile?.relativePath || firstFile?.filePath; + if (files.length > 1) return null; + if (typeof filePath === 'string') { + return getRelativePath(filePath, currentDirectory); + } + return null; + } + + if ((part.tool === 'edit' || part.tool === 'multiedit') && input) { + const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; + if (typeof filePath === 'string') { + return getRelativePath(filePath, currentDirectory); + } + } + + if (['write', 'create', 'file_write', 'read', 'view', 'file_read', 'cat'].includes(part.tool) && input) { + const filePath = input?.filePath || input?.file_path || input?.path; + if (typeof filePath === 'string') { + return getRelativePath(filePath, currentDirectory); + } + } + + return null; +}; + +const getToolDescription = (part: ToolPartType, state: ToolStateUnion, currentDirectory: string): string => { const stateWithData = state as ToolStateWithMetadata; const metadata = stateWithData.metadata; const input = stateWithData.input; const tool = part.tool.toLowerCase(); if (tool === 'structuredoutput' || tool === 'structured_output') { - return formatStructuredOutputDescription(input, stateWithData.output, isMobile); + return formatStructuredOutputDescription(input, stateWithData.output); + } + + const filePathLabel = getToolDescriptionPath(part, state, currentDirectory); + if (filePathLabel) { + return filePathLabel; } if (part.tool === 'apply_patch') { const files = Array.isArray(metadata?.files) ? metadata?.files : []; - const firstFile = files[0] as { relativePath?: string; filePath?: string } | undefined; - const filePath = firstFile?.relativePath || firstFile?.filePath; if (files.length > 1) { return `${files.length} files`; } - if (typeof filePath === 'string') { - return getRelativePath(filePath, currentDirectory, isMobile); - } return 'Patch'; } @@ -305,27 +333,13 @@ const getToolDescription = (part: ToolPartType, state: ToolStateUnion, isMobile: return `Asked ${count} question${count !== 1 ? 's' : ''}`; } - if ((part.tool === 'edit' || part.tool === 'multiedit') && input) { - const filePath = input?.filePath || input?.file_path || input?.path || metadata?.filePath || metadata?.file_path || metadata?.path; - if (typeof filePath === 'string') { - return getRelativePath(filePath, currentDirectory, isMobile); - } - } - - if ((part.tool === 'read' || part.tool === 'write') && input) { - const filePath = input?.filePath || input?.file_path || input?.path; - if (typeof filePath === 'string') { - return getRelativePath(filePath, currentDirectory, isMobile); - } - } - if (part.tool === 'bash' && input?.command && typeof input.command === 'string') { const firstLine = input.command.split('\n')[0]; - return isMobile ? firstLine.substring(0, 50) : firstLine.substring(0, 100); + return firstLine.substring(0, 100); } if (part.tool === 'task' && input?.description && typeof input.description === 'string') { - return isMobile ? input.description.substring(0, 40) : input.description.substring(0, 80); + return input.description.substring(0, 80); } if (part.tool === 'skill' && input?.name && typeof input.name === 'string') { @@ -451,6 +465,32 @@ const getTaskSummaryLabel = (entry: TaskToolSummaryEntry): string => { return 'tool'; }; +const FILE_PATH_LABEL_TOOLS = new Set([ + 'read', + 'view', + 'file_read', + 'cat', + 'write', + 'create', + 'file_write', + 'edit', + 'multiedit', + 'apply_patch', +]); + +const shouldRenderGitPathLabel = (toolName: string, label: string): boolean => { + if (!FILE_PATH_LABEL_TOOLS.has(toolName.toLowerCase())) { + return false; + } + + const trimmed = label.trim(); + if (!trimmed || trimmed === 'Patch' || /^\d+\s+files$/.test(trimmed)) { + return false; + } + + return trimmed.includes('/') || trimmed.includes('\\'); +}; + const stripTaskMetadataFromOutput = (output: string): string => { // Strip only a trailing ... block. return output.replace(/\n*[\s\S]*?<\/task_metadata>\s*$/i, '').trimEnd(); @@ -550,13 +590,14 @@ const parseTaskMetadataBlock = (output: string | undefined): { const TaskToolSummary: React.FC<{ entries: TaskToolSummaryEntry[]; isExpanded: boolean; + isMobile: boolean; hasPrevTool: boolean; hasNextTool: boolean; output?: string; sessionId?: string; onShowPopup?: (content: ToolPopupContent) => void; input?: Record; -}> = ({ entries, isExpanded, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => { +}> = ({ entries, isExpanded, isMobile, hasPrevTool, hasNextTool, output, sessionId, onShowPopup, input }) => { const setCurrentSession = useSessionStore((state) => state.setCurrentSession); const displayEntries = React.useMemo(() => { const nonPending = entries.filter((entry) => entry.state?.status !== 'pending'); @@ -611,13 +652,18 @@ const TaskToolSummary: React.FC<{ const displayName = getToolMetadata(toolName).displayName; return ( -
+
{getToolIcon(toolName)} {displayName} - {label} + {status !== 'error' && shouldRenderGitPathLabel(toolName, label) ? ( + renderPathLikeGitChanges(label) + ) : ( + {label} + )}
); })} @@ -699,17 +745,25 @@ type DiffPatchEntry = { patch: string; }; -const renderPathLikeGitChanges = (path: string) => { +const renderPathLikeGitChanges = (path: string, grow = true) => { const lastSlash = path.lastIndexOf('/'); if (lastSlash === -1) { - return {path}; + return ( + + {path} + + ); } const dir = path.slice(0, lastSlash); const name = path.slice(lastSlash + 1); return ( - + {dir} @@ -725,7 +779,6 @@ const getDiffPatchEntries = ( metadata: Record | undefined, fallbackDiff: string, currentDirectory: string, - isMobile: boolean, ): DiffPatchEntry[] => { const files = Array.isArray(metadata?.files) ? metadata.files : []; @@ -748,7 +801,7 @@ const getDiffPatchEntries = ( : `File ${index + 1}`; const title = typeof rawPath === 'string' - ? getRelativePath(rawPath, currentDirectory, isMobile) + ? getRelativePath(rawPath, currentDirectory) : `File ${index + 1}`; return { @@ -824,8 +877,9 @@ const WriteInputPreview: React.FC = React.memo(({ return (
-
- {`${displayPath} (${headerLineLabel})`} +
+ {renderPathLikeGitChanges(displayPath)} + ({headerLineLabel})
; syntaxTheme: { [key: string]: React.CSSProperties }; toolName: string; + currentDirectory: string; pierreTheme: { light: string; dark: string }; pierreThemeType: 'light' | 'dark'; renderScrollableBlock: ( @@ -866,6 +921,7 @@ const ReadToolVirtualized: React.FC = React.memo(({ input, syntaxTheme, toolName, + currentDirectory, pierreTheme, pierreThemeType, renderScrollableBlock, @@ -877,7 +933,7 @@ const ReadToolVirtualized: React.FC = React.memo(({ return detectLanguageFromOutput(contentForLanguage, toolName, input as Record); }, [parsedReadOutput, toolName, input]); - const filePath = + const rawFilePath = typeof input?.filePath === 'string' ? input.filePath : typeof input?.file_path === 'string' @@ -885,6 +941,7 @@ const ReadToolVirtualized: React.FC = React.memo(({ : typeof input?.path === 'string' ? input.path : 'read-output'; + const displayPath = getRelativePath(rawFilePath, currentDirectory); const codeLines: CodeLine[] = React.useMemo(() => parsedReadOutput.lines.map((line) => ({ text: line.text, @@ -894,21 +951,29 @@ const ReadToolVirtualized: React.FC = React.memo(({ if (parsedReadOutput.type === 'file') { const fileContent = parsedReadOutput.lines.map((line) => line.text).join('\n'); + const lineCount = Math.max(parsedReadOutput.lines.length, 1); + const headerLineLabel = lineCount === 1 ? 'line 1' : `lines 1-${lineCount}`; return renderScrollableBlock( - , +
+
+ {renderPathLikeGitChanges(displayPath)} + ({headerLineLabel}) +
+ +
, { className: 'p-1' } ) as React.ReactElement; } @@ -951,8 +1016,8 @@ const ImagePreview: React.FC = React.memo(({ content, filePat return (
-
- {displayPath} +
+ {renderPathLikeGitChanges(displayPath)}
= React.memo(({ const diffContent = typeof metadata?.diff === 'string' ? (metadata.diff as string) : null; const diffEntries = React.useMemo( - () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory, isMobile) : []), - [currentDirectory, diffContent, isMobile, metadata] + () => (diffContent ? getDiffPatchEntries(metadata, diffContent, currentDirectory) : []), + [currentDirectory, diffContent, metadata] ); const writeFilePath = part.tool === 'write' ? typeof input?.filePath === 'string' @@ -1022,7 +1087,7 @@ const ToolExpandedContent: React.FC = React.memo(({ const shouldShowWriteInputPreview = part.tool === 'write' && !!writeInputContent; const isWriteImageFile = writeFilePath ? isImageFile(writeFilePath) : false; const writeDisplayPath = shouldShowWriteInputPreview - ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory, isMobile) : 'New file') + ? (writeFilePath ? getRelativePath(writeFilePath, currentDirectory) : 'New file') : null; const inputTextContent = React.useMemo(() => { @@ -1224,6 +1289,7 @@ const ToolExpandedContent: React.FC = React.memo(({ input={input} syntaxTheme={syntaxTheme} toolName={part.tool} + currentDirectory={currentDirectory} pierreTheme={pierreTheme} pierreThemeType={pierreThemeType} renderScrollableBlock={renderScrollableBlock} @@ -1543,7 +1609,8 @@ const ToolPart: React.FC = ({ }, [isTaskTool, onContentChange, taskSummaryEntries.length]); const diffStats = (part.tool === 'edit' || part.tool === 'multiedit' || part.tool === 'apply_patch') ? parseDiffStats(metadata) : null; - const description = getToolDescription(part, state, isMobile, currentDirectory); + const descriptionPath = getToolDescriptionPath(part, state, currentDirectory); + const description = getToolDescription(part, state, currentDirectory); const displayName = getToolMetadata(part.tool).displayName; // Get justification text (tool title/description) when setting is enabled @@ -1621,18 +1688,16 @@ const ToolPart: React.FC = ({ >
{} - +
= ({
- {justificationText && ( - - {justificationText} +
+ {justificationText && ( + + {justificationText} + + )} + {!justificationText && description && ( + descriptionPath && description === descriptionPath ? ( + renderPathLikeGitChanges(descriptionPath, false) + ) : ( + + {description} + + ) + )} + {diffStats && ( + + +{diffStats.added} + {' '} + -{diffStats.removed} + + )} +
+ {typeof effectiveTimeStart === 'number' ? ( + + + + + {!isMobile && endedTimestampText ? ( + + {endedTimestampText} + + ) : null} - )} - {!justificationText && description && ( - - {description} - - )} - {diffStats && ( - - +{diffStats.added} - {' '} - -{diffStats.removed} - - )} - {typeof effectiveTimeStart === 'number' && ( - - - - )} - {endedTimestampText ? ( - + ) : null} + {typeof effectiveTimeStart !== 'number' && !isMobile && endedTimestampText ? ( + {endedTimestampText} ) : null} @@ -1703,6 +1785,7 @@ const ToolPart: React.FC = ({ { } } -async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promise { +async function isServerReachable(): Promise { + try { + const response = await fetch('/health', { + method: 'GET', + headers: { Accept: 'application/json' }, + }); + return response.ok; + } catch { + return false; + } +} + +async function waitForUpdateApplied(previousVersion?: string, maxAttempts = 40, intervalMs = 2000): Promise { for (let i = 0; i < maxAttempts; i++) { try { const response = await fetch('/api/openchamber/update-check', { @@ -148,6 +160,16 @@ async function waitForUpdateApplied(maxAttempts = 40, intervalMs = 2000): Promis if (data && data.available === false) { return true; } + if ( + data && + typeof data.currentVersion === 'string' && + typeof previousVersion === 'string' && + data.currentVersion !== previousVersion + ) { + return true; + } + } else if ((response.status === 401 || response.status === 403) && await isServerReachable()) { + return true; } } catch { // Server may be restarting @@ -240,7 +262,7 @@ export const UpdateDialog: React.FC = ({ setWebUpdateState('reconnecting'); - const applied = await waitForUpdateApplied(); + const applied = await waitForUpdateApplied(info?.currentVersion); if (applied) { window.location.reload(); @@ -248,7 +270,7 @@ export const UpdateDialog: React.FC = ({ setWebUpdateState('error'); setWebError('Update did not apply. Refresh and try again, or run: openchamber update'); } - }, []); + }, [info?.currentVersion]); const isWebUpdating = webUpdateState !== 'idle' && webUpdateState !== 'error'; diff --git a/packages/web/bin/cli.js b/packages/web/bin/cli.js index 518ea960..3414aea9 100755 --- a/packages/web/bin/cli.js +++ b/packages/web/bin/cli.js @@ -24,6 +24,10 @@ function getBunBinary() { const BUN_BIN = getBunBinary(); +function importFromFilePath(filePath) { + return import(pathToFileURL(filePath).href); +} + function isBunRuntime() { return typeof globalThis.Bun !== 'undefined'; } @@ -625,7 +629,7 @@ const commands = { return; } - const { startWebUiServer } = await import(pathToFileURL(serverPath).href); + const { startWebUiServer } = await importFromFilePath(serverPath); await startWebUiServer({ port: options.port, attachSignals: true, @@ -926,7 +930,7 @@ const commands = { executeUpdate, detectPackageManager, getCurrentVersion, - } = await import(pathToFileURL(packageManagerPath).href); + } = await importFromFilePath(packageManagerPath); // Check for running instances before update let runningInstances = []; diff --git a/packages/web/server/index.js b/packages/web/server/index.js index 72e0af74..bca93e74 100644 --- a/packages/web/server/index.js +++ b/packages/web/server/index.js @@ -3367,27 +3367,30 @@ const ENV_CONFIGURED_OPENCODE_PORT = (() => { const ENV_CONFIGURED_OPENCODE_HOST = (() => { const raw = process.env.OPENCODE_HOST?.trim(); if (!raw) return null; + + const warnInvalidHost = (reason) => { + console.warn(`[config] Ignoring OPENCODE_HOST=${JSON.stringify(raw)}: ${reason}`); + }; + let url; try { url = new URL(raw); } catch { - console.error(`[fatal] OPENCODE_HOST is not a valid URL: ${JSON.stringify(raw)}`); - process.exit(1); + warnInvalidHost('not a valid URL'); + return null; } if (url.protocol !== 'http:' && url.protocol !== 'https:') { - console.error(`[fatal] OPENCODE_HOST must use http or https scheme, got: ${JSON.stringify(url.protocol)}`); - process.exit(1); + warnInvalidHost(`must use http or https scheme (got ${JSON.stringify(url.protocol)})`); + return null; } const port = parseInt(url.port, 10); if (!Number.isFinite(port) || port <= 0) { - console.error(`[fatal] OPENCODE_HOST must include an explicit port (e.g. http://hostname:4096), got: ${JSON.stringify(raw)}`); - process.exit(1); + warnInvalidHost('must include an explicit port (example: http://hostname:4096)'); + return null; } if (url.pathname !== '/' || url.search || url.hash) { - console.error( - `[fatal] OPENCODE_HOST must not include a path, query, or hash; got: ${JSON.stringify(raw)}` - ); - process.exit(1); + warnInvalidHost('must not include path, query, or hash'); + return null; } return { origin: url.origin, port }; })(); @@ -7240,19 +7243,39 @@ async function main(options = {}) { const isWindows = process.platform === 'win32'; - // Build restart command with stored options - let restartCmd = `openchamber serve --port ${storedOptions.port} --daemon`; + const quotePosix = (value) => `'${String(value).replace(/'/g, "'\\''")}'`; + const quoteCmd = (value) => { + const stringValue = String(value); + return `"${stringValue.replace(/"/g, '""')}"`; + }; + + // Build restart command using explicit runtime + CLI path. + // Avoids relying on `openchamber` being in PATH for service environments. + const cliPath = path.resolve(__dirname, '..', 'bin', 'cli.js'); + const restartParts = [ + isWindows ? quoteCmd(process.execPath) : quotePosix(process.execPath), + isWindows ? quoteCmd(cliPath) : quotePosix(cliPath), + 'serve', + '--port', + String(storedOptions.port), + '--daemon', + ]; + let restartCmdPrimary = restartParts.join(' '); + let restartCmdFallback = `openchamber serve --port ${storedOptions.port} --daemon`; if (storedOptions.uiPassword) { if (isWindows) { // Escape for cmd.exe quoted argument const escapedPw = storedOptions.uiPassword.replace(/"/g, '""'); - restartCmd += ` --ui-password "${escapedPw}"`; + restartCmdPrimary += ` --ui-password "${escapedPw}"`; + restartCmdFallback += ` --ui-password "${escapedPw}"`; } else { // Escape for POSIX single-quoted argument const escapedPw = storedOptions.uiPassword.replace(/'/g, "'\\''"); - restartCmd += ` --ui-password '${escapedPw}'`; + restartCmdPrimary += ` --ui-password '${escapedPw}'`; + restartCmdFallback += ` --ui-password '${escapedPw}'`; } } + const restartCmd = `(${restartCmdPrimary}) || (${restartCmdFallback})`; // Respond immediately - update will happen after response res.json({ @@ -7298,14 +7321,32 @@ async function main(options = {}) { fi `; - // Spawn detached shell to run update after we exit + // Spawn detached shell to run update after we exit. + // Capture output to disk so restart failures are diagnosable. + const updateLogPath = path.join(OPENCHAMBER_DATA_DIR, 'update-install.log'); + let logFd = null; + try { + fs.mkdirSync(path.dirname(updateLogPath), { recursive: true }); + logFd = fs.openSync(updateLogPath, 'a'); + } catch (logError) { + console.warn('Failed to open update log file, continuing without log capture:', logError); + } + const child = spawnChild(shell, [shellFlag, script], { detached: true, - stdio: 'ignore', + stdio: logFd !== null ? ['ignore', logFd, logFd] : 'ignore', env: process.env, }); child.unref(); + if (logFd !== null) { + try { + fs.closeSync(logFd); + } catch { + // ignore + } + } + console.log('Update process spawned, shutting down server...'); // Give child process time to start, then exit diff --git a/packages/web/server/lib/package-manager.js b/packages/web/server/lib/package-manager.js index f25f1098..e5c8b584 100644 --- a/packages/web/server/lib/package-manager.js +++ b/packages/web/server/lib/package-manager.js @@ -19,7 +19,21 @@ const CHANGELOG_URL = 'https://raw.githubusercontent.com/btriapitsyn/openchamber * 4. Fall back to npm */ export function detectPackageManager() { - // Strategy 1: Check user agent (most reliable during install) + const forcedPm = process.env.OPENCHAMBER_PACKAGE_MANAGER?.trim(); + if (forcedPm && ['npm', 'pnpm', 'yarn', 'bun'].includes(forcedPm)) { + const forcedPmCommand = resolvePackageManagerCommand(forcedPm); + if (isCommandAvailable(forcedPmCommand)) { + return forcedPm; + } + } + + // Strategy 1: Detect from runtime executable path (reliable for server-side updates) + const runtimePm = detectPackageManagerFromRuntimePath(process.execPath); + if (runtimePm && isCommandAvailable(resolvePackageManagerCommand(runtimePm))) { + return runtimePm; + } + + // Strategy 2: Check user agent (most reliable during install) const userAgent = process.env.npm_config_user_agent || ''; let hintedPm = null; if (userAgent.startsWith('pnpm')) hintedPm = 'pnpm'; @@ -27,7 +41,7 @@ export function detectPackageManager() { else if (userAgent.startsWith('bun')) hintedPm = 'bun'; else if (userAgent.startsWith('npm')) hintedPm = 'npm'; - // Strategy 2: Check execpath + // Strategy 3: Check execpath const execPath = process.env.npm_execpath || ''; if (!hintedPm) { if (execPath.includes('pnpm')) hintedPm = 'pnpm'; @@ -36,34 +50,41 @@ export function detectPackageManager() { else if (execPath.includes('npm')) hintedPm = 'npm'; } - // Strategy 3: Analyze package location for PM-specific patterns + // Strategy 4: Detect from invoked binary path (works for bun global symlink installs) + const invokedPm = detectPackageManagerFromInvocationPath(process.argv?.[1]); + if (invokedPm && isCommandAvailable(resolvePackageManagerCommand(invokedPm))) { + return invokedPm; + } if (!hintedPm) { - try { - const pkgPath = path.resolve(__dirname, '..', '..'); - if (pkgPath.includes('.pnpm')) hintedPm = 'pnpm'; - else if (pkgPath.includes('/.yarn/') || pkgPath.includes('\\.yarn\\')) hintedPm = 'yarn'; - else if (pkgPath.includes('/.bun/') || pkgPath.includes('\\.bun\\')) hintedPm = 'bun'; - } catch { - // Ignore path resolution errors + hintedPm = invokedPm; + } + + // Strategy 5: Analyze package location for PM-specific patterns + try { + const pkgPath = path.resolve(__dirname, '..', '..'); + const pmFromPath = detectPackageManagerFromInstallPath(pkgPath); + if (pmFromPath && isCommandAvailable(resolvePackageManagerCommand(pmFromPath))) { + return pmFromPath; } + if (!hintedPm) { + hintedPm = pmFromPath; + } + } catch { + // Ignore path resolution errors } // Validate the hinted PM actually owns the global install. // This avoids false positives (for example running via bunx while installed with npm). - if (hintedPm && isCommandAvailable(hintedPm) && isPackageInstalledWith(hintedPm)) { + if (hintedPm && isCommandAvailable(resolvePackageManagerCommand(hintedPm)) && isPackageInstalledWith(hintedPm)) { return hintedPm; } - if (isCommandAvailable('npm') && isPackageInstalledWith('npm')) { - return 'npm'; - } - - // Strategy 4: Check which PM binaries are available and preferred + // Strategy 6: Check which PM binaries are available and preferred const pmChecks = [ - { name: 'npm', check: () => isCommandAvailable('npm') }, - { name: 'pnpm', check: () => isCommandAvailable('pnpm') }, - { name: 'yarn', check: () => isCommandAvailable('yarn') }, - { name: 'bun', check: () => isCommandAvailable('bun') }, + { name: 'pnpm', check: () => isCommandAvailable(resolvePackageManagerCommand('pnpm')) }, + { name: 'yarn', check: () => isCommandAvailable(resolvePackageManagerCommand('yarn')) }, + { name: 'bun', check: () => isCommandAvailable(resolvePackageManagerCommand('bun')) }, + { name: 'npm', check: () => isCommandAvailable(resolvePackageManagerCommand('npm')) }, ]; for (const { name, check } of pmChecks) { @@ -78,6 +99,74 @@ export function detectPackageManager() { return 'npm'; } +function detectPackageManagerFromInstallPath(pkgPath) { + if (!pkgPath) return null; + const normalized = pkgPath.replace(/\\/g, '/').toLowerCase(); + if (normalized.includes('/.pnpm/') || normalized.includes('/pnpm/')) return 'pnpm'; + if (normalized.includes('/.yarn/')) return 'yarn'; + if (normalized.includes('/.bun/') || normalized.includes('/bun/install/')) return 'bun'; + if (normalized.includes('/node_modules/')) return 'npm'; + return null; +} + +function detectPackageManagerFromRuntimePath(runtimePath) { + if (!runtimePath || typeof runtimePath !== 'string') return null; + const normalized = runtimePath.replace(/\\/g, '/').toLowerCase(); + if (normalized.includes('/.bun/bin/bun') || normalized.endsWith('/bun') || normalized.endsWith('/bun.exe')) { + return 'bun'; + } + if (normalized.includes('/pnpm/')) return 'pnpm'; + if (normalized.includes('/yarn/')) return 'yarn'; + if (normalized.includes('/node') || normalized.endsWith('/node.exe')) return 'npm'; + return null; +} + +function detectPackageManagerFromInvocationPath(invokedPath) { + if (!invokedPath || typeof invokedPath !== 'string') return null; + const normalized = invokedPath.replace(/\\/g, '/').toLowerCase(); + if (normalized.includes('/.bun/bin/')) return 'bun'; + if (normalized.includes('/.pnpm/')) return 'pnpm'; + if (normalized.includes('/.yarn/')) return 'yarn'; + return null; +} + +function getPackageManagerCommandCandidates(pm) { + const candidates = []; + if (pm === 'bun') { + const bunExecutable = process.platform === 'win32' ? 'bun.exe' : 'bun'; + if (process.env.BUN_INSTALL) { + candidates.push(path.join(process.env.BUN_INSTALL, 'bin', bunExecutable)); + } + if (process.env.HOME) { + candidates.push(path.join(process.env.HOME, '.bun', 'bin', bunExecutable)); + } + if (process.env.USERPROFILE) { + candidates.push(path.join(process.env.USERPROFILE, '.bun', 'bin', bunExecutable)); + } + } + candidates.push(pm); + return [...new Set(candidates.filter(Boolean))]; +} + +function resolvePackageManagerCommand(pm) { + const candidates = getPackageManagerCommandCandidates(pm); + for (const candidate of candidates) { + if (isCommandAvailable(candidate)) { + return candidate; + } + } + return pm; +} + +function quoteCommand(command) { + if (!command) return command; + if (!/\s/.test(command)) return command; + if (process.platform === 'win32') { + return `"${command.replace(/"/g, '""')}"`; + } + return `'${command.replace(/'/g, "'\\''")}'`; +} + function isCommandAvailable(command) { try { const result = spawnSync(command, ['--version'], { @@ -93,6 +182,7 @@ function isCommandAvailable(command) { function isPackageInstalledWith(pm) { try { + const pmCommand = resolvePackageManagerCommand(pm); let args; switch (pm) { case 'pnpm': @@ -108,7 +198,7 @@ function isPackageInstalledWith(pm) { args = ['list', '-g', '--depth=0', PACKAGE_NAME]; } - const result = spawnSync(pm, args, { + const result = spawnSync(pmCommand, args, { encoding: 'utf8', stdio: ['ignore', 'pipe', 'pipe'], timeout: 10000, @@ -125,15 +215,16 @@ function isPackageInstalledWith(pm) { * Get the update command for the detected package manager */ export function getUpdateCommand(pm = detectPackageManager()) { + const pmCommand = quoteCommand(resolvePackageManagerCommand(pm)); switch (pm) { case 'pnpm': - return `pnpm add -g ${PACKAGE_NAME}@latest`; + return `${pmCommand} add -g ${PACKAGE_NAME}@latest`; case 'yarn': - return `yarn global add ${PACKAGE_NAME}@latest`; + return `${pmCommand} global add ${PACKAGE_NAME}@latest`; case 'bun': - return `bun add -g ${PACKAGE_NAME}@latest`; + return `${pmCommand} add -g ${PACKAGE_NAME}@latest`; default: - return `npm install -g ${PACKAGE_NAME}@latest`; + return `${pmCommand} install -g ${PACKAGE_NAME}@latest`; } } @@ -259,8 +350,7 @@ export function executeUpdate(pm = detectPackageManager()) { console.log(`Updating ${PACKAGE_NAME} using ${pm}...`); console.log(`Running: ${command}`); - const [cmd, ...args] = command.split(' '); - const result = spawnSync(cmd, args, { + const result = spawnSync(command, { stdio: 'inherit', shell: true, });