Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
40 changes: 1 addition & 39 deletions main/src/container-engine.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { exec } from 'node:child_process'
import { promisify } from 'node:util'
import { platform } from 'node:os'
import log from './logger'
import { createEnhancedPath } from './utils/enhanced-path'

const execAsync = promisify(exec)

Expand All @@ -11,45 +12,6 @@ interface ContainerEngineStatus {
available: boolean
}

// Common installation paths by platform
const getCommonPaths = (): string[] => {
const currentPlatform = platform()

switch (currentPlatform) {
case 'darwin':
return [
'/Applications/Docker.app/Contents/Resources/bin',
'/opt/homebrew/bin',
'/usr/local/bin',
'~/.rd/bin',
]
case 'linux':
return ['/usr/local/bin', '/opt/homebrew/bin', '/snap/bin', '~/.rd/bin']
case 'win32':
return [
'C:\\Program Files\\Docker\\Docker\\resources\\bin',
'C:\\Program Files\\RedHat\\Podman',
]
default:
return []
}
}

const expandPath = (path: string): string =>
path.startsWith('~')
? path.replace('~', process.env.HOME || process.env.USERPROFILE || '')
: path

const createEnhancedPath = (): string => {
const commonPaths = getCommonPaths().map(expandPath)
const currentPath = process.env.PATH || ''
const separator = platform() === 'win32' ? ';' : ':'

return [...commonPaths, ...currentPath.split(separator)]
.filter(Boolean)
.join(separator)
}

const tryCommand = async (command: string): Promise<boolean> => {
try {
await execAsync(command)
Expand Down
39 changes: 39 additions & 0 deletions main/src/tests/toolhive-manager.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { EventEmitter } from 'node:events'
import { vol } from 'memfs'
import { spawn } from 'node:child_process'
import net from 'node:net'
import { platform } from 'node:os'
import { app } from 'electron'
import {
startToolhive,
Expand Down Expand Up @@ -32,6 +33,18 @@ vi.mock('node:child_process', async (importOriginal) => {
},
}
})
vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
const platformMock = vi.fn(actual.platform)
return {
...actual,
platform: platformMock,
default: {
...actual,
platform: platformMock,
},
}
})
vi.mock('node:fs')
vi.mock('node:net', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:net')>()
Expand Down Expand Up @@ -454,6 +467,32 @@ describe('toolhive-manager', () => {
expect(spawnArgs.some((a) => a.startsWith('--sentry-'))).toBe(false)
})

it('spawns with an enhanced PATH that includes common container-tooling dirs', async () => {
vi.mocked(platform).mockReturnValue('darwin')
vi.stubEnv('PATH', '/usr/bin:/bin')

const startPromise = startToolhive()
await vi.advanceTimersByTimeAsync(50)
await startPromise

Comment thread
samuv marked this conversation as resolved.
const spawnOptions = mockSpawn.mock.calls[0]![2] as {
env: Record<string, string>
}
const spawnedPath = spawnOptions.env.PATH
expect(spawnedPath).toBeDefined()
const entries = spawnedPath!.split(':')

expect(entries).toContain(
'/Applications/Docker.app/Contents/Resources/bin'
)
expect(entries).toContain('/opt/homebrew/bin')
expect(entries).toContain('/usr/local/bin')
expect(entries).toContain('/usr/bin')
expect(entries).toContain('/bin')

expect(spawnOptions.env.TOOLHIVE_SKIP_DESKTOP_CHECK).toBe('true')
})

it('omits sentry flags when telemetry is disabled', async () => {
vi.stubEnv('VITE_SENTRY_THV_DSN', 'https://test@sentry.io/123')
vi.mocked(readSetting).mockReturnValue('false')
Expand Down
2 changes: 2 additions & 0 deletions main/src/toolhive-manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ import log from './logger'
import * as Sentry from '@sentry/electron/main'
import { getQuittingState } from './app-state'
import { readSetting } from './db/readers/settings-reader'
import { createEnhancedPath } from './utils/enhanced-path'
import {
ALREADY_RUNNING,
REGISTRY_AUTH_REQUIRED,
Expand Down Expand Up @@ -182,6 +183,7 @@ export async function startToolhive(): Promise<void> {
windowsHide: true,
env: {
...process.env,
PATH: createEnhancedPath(),
TOOLHIVE_SKIP_DESKTOP_CHECK: 'true',
},
Comment thread
samuv marked this conversation as resolved.
})
Expand Down
128 changes: 128 additions & 0 deletions main/src/utils/__tests__/enhanced-path.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,128 @@
import { describe, it, expect, vi, afterEach, beforeEach } from 'vitest'
import { platform } from 'node:os'

import { createEnhancedPath } from '../enhanced-path'

vi.mock('node:os', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:os')>()
const platformMock = vi.fn(actual.platform)
return {
...actual,
platform: platformMock,
default: {
...actual,
platform: platformMock,
},
}
})

const mockPlatform = vi.mocked(platform)

describe('createEnhancedPath', () => {
beforeEach(() => {
mockPlatform.mockReset()
})

afterEach(() => {
vi.unstubAllEnvs()
})

it('prepends macOS container-tooling paths on darwin', () => {
mockPlatform.mockReturnValue('darwin')
vi.stubEnv('PATH', '/usr/bin:/bin')
vi.stubEnv('HOME', '/Users/test')

const result = createEnhancedPath()
const entries = result.split(':')

expect(entries.slice(0, 4)).toEqual([
'/Applications/Docker.app/Contents/Resources/bin',
'/opt/homebrew/bin',
'/usr/local/bin',
'/Users/test/.rd/bin',
])
expect(entries).toContain('/usr/bin')
expect(entries).toContain('/bin')
})

it('expands ~ using HOME', () => {
mockPlatform.mockReturnValue('darwin')
vi.stubEnv('PATH', '')
vi.stubEnv('HOME', '/Users/alice')

const result = createEnhancedPath()

expect(result).toContain('/Users/alice/.rd/bin')
expect(result).not.toContain('~')
})

it('keeps ~ verbatim when HOME and USERPROFILE are both unset', () => {
mockPlatform.mockReturnValue('darwin')
vi.stubEnv('PATH', '')
vi.stubEnv('HOME', '')
vi.stubEnv('USERPROFILE', '')

const result = createEnhancedPath()
const entries = result.split(':')

expect(entries).toContain('~/.rd/bin')
expect(entries).not.toContain('/.rd/bin')
})

it('uses semicolon as separator on win32', () => {
mockPlatform.mockReturnValue('win32')
vi.stubEnv('PATH', 'C:\\Windows\\System32;C:\\Windows')

const result = createEnhancedPath()
const entries = result.split(';')

expect(entries[0]).toBe('C:\\Program Files\\Docker\\Docker\\resources\\bin')
expect(entries[1]).toBe('C:\\Program Files\\RedHat\\Podman')
expect(entries).toContain('C:\\Windows\\System32')
expect(entries).toContain('C:\\Windows')
})

it('prepends linux container-tooling paths', () => {
mockPlatform.mockReturnValue('linux')
vi.stubEnv('PATH', '/usr/bin:/bin')
vi.stubEnv('HOME', '/home/test')

const result = createEnhancedPath()
const entries = result.split(':')

expect(entries.slice(0, 4)).toEqual([
'/usr/local/bin',
'/opt/homebrew/bin',
'/snap/bin',
'/home/test/.rd/bin',
])
})

it('preserves existing PATH entries after the prepended paths', () => {
mockPlatform.mockReturnValue('darwin')
vi.stubEnv('PATH', '/existing/bin:/another/bin')
vi.stubEnv('HOME', '/Users/test')

const result = createEnhancedPath()
const entries = result.split(':')

const existingIndex = entries.indexOf('/existing/bin')
const anotherIndex = entries.indexOf('/another/bin')

expect(existingIndex).toBeGreaterThan(-1)
expect(anotherIndex).toBeGreaterThan(existingIndex)
})

it('handles empty PATH gracefully', () => {
mockPlatform.mockReturnValue('darwin')
vi.stubEnv('PATH', '')
vi.stubEnv('HOME', '/Users/test')

const result = createEnhancedPath()

expect(result).not.toBe('')
expect(result).toContain('/usr/local/bin')
// No trailing empty segments from the split/join
expect(result.endsWith(':')).toBe(false)
})
})
40 changes: 40 additions & 0 deletions main/src/utils/enhanced-path.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { platform } from 'node:os'

const getCommonPaths = (): string[] => {
const currentPlatform = platform()

switch (currentPlatform) {
case 'darwin':
return [
'/Applications/Docker.app/Contents/Resources/bin',
'/opt/homebrew/bin',
'/usr/local/bin',
'~/.rd/bin',
]
case 'linux':
return ['/usr/local/bin', '/opt/homebrew/bin', '/snap/bin', '~/.rd/bin']
case 'win32':
return [
'C:\\Program Files\\Docker\\Docker\\resources\\bin',
'C:\\Program Files\\RedHat\\Podman',
]
default:
return []
}
}

const expandPath = (path: string): string => {
if (!path.startsWith('~')) return path
const homeDir = process.env.HOME || process.env.USERPROFILE
return homeDir ? path.replace('~', homeDir) : path
}

export const createEnhancedPath = (): string => {
const commonPaths = getCommonPaths().map(expandPath)
const currentPath = process.env.PATH || ''
const separator = platform() === 'win32' ? ';' : ':'

return [...commonPaths, ...currentPath.split(separator)]
.filter(Boolean)
.join(separator)
}
Loading