diff --git a/src/util.spec.ts b/src/util.spec.ts index 5bccc66..9556ab2 100644 --- a/src/util.spec.ts +++ b/src/util.spec.ts @@ -22,10 +22,18 @@ import * as fs from 'node:fs'; import * as path from 'node:path'; import * as extensionApi from '@podman-desktop/api'; -import { afterEach, beforeEach, expect, test, vi } from 'vitest'; +import { afterEach, beforeEach, describe, expect, test, vi } from 'vitest'; import type { MinikubeInstaller } from './minikube-installer'; -import { detectMinikube, getMinikubeHome, getMinikubePath, installBinaryToSystem } from './util'; +import { + deleteFile, + deleteFileAsAdmin, + detectMinikube, + getMinikubeHome, + getMinikubePath, + installBinaryToSystem, + whereBinary, +} from './util'; vi.mock('@podman-desktop/api', async () => { return { @@ -61,6 +69,9 @@ vi.mock('node:child_process'); vi.mock('node:fs', async () => { return { existsSync: vi.fn(), + promises: { + unlink: vi.fn(), + }, }; }); @@ -72,52 +83,60 @@ beforeEach(() => { vi.mocked(extensionApi.configuration.getConfiguration).mockReturnValue({ get: configGetMock, } as unknown as extensionApi.Configuration); + + vi.mocked(extensionApi.env).isMac = false; + vi.mocked(extensionApi.env).isWindows = false; + vi.mocked(extensionApi.env).isLinux = false; }); afterEach(() => { process.env = originalProcessEnv; }); -test('getMinikubePath on macOS', async () => { - vi.mocked(extensionApi.env).isMac = true; +describe('getMinikubePath', () => { + test('getMinikubePath on macOS', async () => { + vi.mocked(extensionApi.env).isMac = true; - const computedPath = getMinikubePath(); - expect(computedPath).toEqual('/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin'); -}); + const computedPath = getMinikubePath(); + expect(computedPath).toEqual('/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin'); + }); -test('getMinikubePath on macOS with existing PATH', async () => { - const existingPATH = '/my-existing-path'; - process.env.PATH = existingPATH; - vi.mocked(extensionApi.env).isMac = true; + test('getMinikubePath on macOS with existing PATH', async () => { + const existingPATH = '/my-existing-path'; + process.env.PATH = existingPATH; + vi.mocked(extensionApi.env).isMac = true; - const computedPath = getMinikubePath(); - expect(computedPath).toEqual(`${existingPATH}:/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin`); + const computedPath = getMinikubePath(); + expect(computedPath).toEqual(`${existingPATH}:/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin`); + }); }); -test('getMinikubeHome with empty configuration property', async () => { - const existingEnvHome = '/my-existing-minikube-home'; - const existingConfigHome = ''; - process.env.MINIKUBE_HOME = existingEnvHome; +describe('getMinikubeHome', () => { + test('getMinikubeHome with empty configuration property', async () => { + const existingEnvHome = '/my-existing-minikube-home'; + const existingConfigHome = ''; + process.env.MINIKUBE_HOME = existingEnvHome; - configGetMock.mockReturnValue(existingConfigHome); + configGetMock.mockReturnValue(existingConfigHome); - const computedHome = getMinikubeHome(); + const computedHome = getMinikubeHome(); - expect(computedHome).toEqual(existingEnvHome); - expect(computedHome).not.toEqual(existingConfigHome); -}); + expect(computedHome).toEqual(existingEnvHome); + expect(computedHome).not.toEqual(existingConfigHome); + }); -test('getMinikubeHome with empty configuration property', async () => { - const existingEnvHome = '/my-existing-minikube-home'; - const existingConfigHome = '/my-another-existing-minikube-home'; - process.env.MINIKUBE_HOME = existingEnvHome; + test('getMinikubeHome with empty configuration property', async () => { + const existingEnvHome = '/my-existing-minikube-home'; + const existingConfigHome = '/my-another-existing-minikube-home'; + process.env.MINIKUBE_HOME = existingEnvHome; - configGetMock.mockReturnValue(existingConfigHome); + configGetMock.mockReturnValue(existingConfigHome); - const computedHome = getMinikubeHome(); + const computedHome = getMinikubeHome(); - expect(computedHome).not.toEqual(existingEnvHome); - expect(computedHome).toEqual(existingConfigHome); + expect(computedHome).not.toEqual(existingEnvHome); + expect(computedHome).toEqual(existingConfigHome); + }); }); test('detectMinikube', async () => { @@ -143,83 +162,220 @@ test('detectMinikube', async () => { expect(execMock.mock.calls[0][1]).toEqual(['version']); }); -test('error: expect installBinaryToSystem to fail with a non existing binary', async () => { - // Mock the platform to be linux - Object.defineProperty(process, 'platform', { - value: 'linux', - }); - - vi.spyOn(extensionApi.process, 'exec').mockImplementation( - () => - new Promise((_, reject) => { - const error: extensionApi.RunError = { - name: '', - message: 'Command failed', - exitCode: 1603, - command: 'command', - stdout: 'stdout', - stderr: 'stderr', - cancelled: false, - killed: false, - }; - - reject(error); - }), - ); - - // Expect await installBinaryToSystem to throw an error - await expect(installBinaryToSystem('test', 'tmpBinary')).rejects.toThrowError(); +describe('installBinaryToSystem', () => { + test('error: expect installBinaryToSystem to fail with a non existing binary', async () => { + // Mock the platform to be linux + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + vi.mocked(extensionApi.env).isLinux = true; + + vi.spyOn(extensionApi.process, 'exec').mockImplementation( + () => + new Promise((_, reject) => { + const error: extensionApi.RunError = { + name: '', + message: 'Command failed', + exitCode: 1603, + command: 'command', + stdout: 'stdout', + stderr: 'stderr', + cancelled: false, + killed: false, + }; + + reject(error); + }), + ); + + // Expect await installBinaryToSystem to throw an error + await expect(installBinaryToSystem('test', 'tmpBinary')).rejects.toThrowError(); + }); + + test('success: installBinaryToSystem on mac with /usr/local/bin already created', async () => { + // Mock the platform to be darwin + Object.defineProperty(process, 'platform', { + value: 'darwin', + }); + vi.mocked(extensionApi.env).isMac = true; + + // Mock existsSync to be true since within the function it's doing: !fs.existsSync(localBinDir) + vi.spyOn(fs, 'existsSync').mockImplementation(() => { + return true; + }); + + const destination = `${path.sep}usr${path.sep}local${path.sep}bin${path.sep}tmpBinary`; + + // Run installBinaryToSystem which will trigger the spyOn mock + const result = await installBinaryToSystem('test', 'tmpBinary'); + expect(result).toBe(destination); + + // check called with admin being true + expect(extensionApi.process.exec).toBeCalledWith('chmod', expect.arrayContaining(['+x', 'test'])); + expect(extensionApi.process.exec).toHaveBeenNthCalledWith(2, 'cp', ['test', destination], { isAdmin: true }); + }); + + test('expect: installBinaryToSystem on linux with /usr/local/bin NOT created yet (expect mkdir -p command)', async () => { + // Mock the platform to be darwin + Object.defineProperty(process, 'platform', { + value: 'linux', + }); + vi.mocked(extensionApi.env).isLinux = true; + + // Mock existsSync to be false since within the function it's doing: !fs.existsSync(localBinDir) + vi.spyOn(fs, 'existsSync').mockImplementation(() => { + return false; + }); + + // Run installBinaryToSystem which will trigger the spyOn mock + await installBinaryToSystem('test', 'tmpBinary'); + + expect(extensionApi.process.exec).toBeCalledWith('chmod', expect.arrayContaining(['+x', 'test'])); + + // check called with admin being true + expect(extensionApi.process.exec).toBeCalledWith( + 'mkdir', + ['-p', '/usr/local/bin'], + expect.objectContaining({ isAdmin: true }), + ); + expect(extensionApi.process.exec).toBeCalledWith( + 'cp', + ['test', `${path.sep}usr${path.sep}local${path.sep}bin${path.sep}tmpBinary`], + expect.objectContaining({ isAdmin: true }), + ); + }); }); -test('success: installBinaryToSystem on mac with /usr/local/bin already created', async () => { - // Mock the platform to be darwin - Object.defineProperty(process, 'platform', { - value: 'darwin', +describe('whereBinary', () => { + test.each(['isLinux', 'isMac'] as ('isLinux' | 'isMac')[])('%s should use which', async platform => { + vi.mocked(extensionApi.env)[platform] = true; + vi.mocked(extensionApi.process.exec).mockResolvedValue({ + stdout: '/usr/bin/minikube', + stderr: '', + command: '', + }); + + const result = await whereBinary('minikube'); + + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('which', ['minikube']); + expect(result).toBe('/usr/bin/minikube'); }); - // Mock existsSync to be true since within the function it's doing: !fs.existsSync(localBinDir) - vi.spyOn(fs, 'existsSync').mockImplementation(() => { - return true; + test('isWindow should use where.exe', async () => { + vi.mocked(extensionApi.env).isWindows = true; + vi.mocked(extensionApi.process.exec).mockResolvedValue({ + stdout: 'C:/dummy/windows/path/minikube.exe', + stderr: '', + command: '', + }); + + const result = await whereBinary('minikube'); + + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('where.exe', ['minikube']); + expect(result).toBe('C:/dummy/windows/path/minikube.exe'); }); - // Run installBinaryToSystem which will trigger the spyOn mock - await installBinaryToSystem('test', 'tmpBinary'); + test('error on linux should propagate', async () => { + vi.mocked(extensionApi.env).isLinux = true; + vi.mocked(extensionApi.process.exec).mockRejectedValue(new Error('something wrong')); + + await expect(async () => { + await whereBinary('minikube'); + }).rejects.toThrowError(`binary minikube not found`); + }); - // check called with admin being true - expect(extensionApi.process.exec).toBeCalledWith('chmod', expect.arrayContaining(['+x', 'test'])); - expect(extensionApi.process.exec).toHaveBeenNthCalledWith( - 2, - 'cp', - ['test', `${path.sep}usr${path.sep}local${path.sep}bin${path.sep}tmpBinary`], - { isAdmin: true }, - ); + test('error on window should propagate', async () => { + vi.mocked(extensionApi.env).isWindows = true; + vi.mocked(extensionApi.process.exec).mockRejectedValue(new Error('something wrong')); + + await expect(async () => { + await whereBinary('minikube'); + }).rejects.toThrowError(`binary minikube not found`); + }); +}); + +describe('deleteFile', () => { + test('file that does not exists should do nothing', async () => { + vi.mocked(fs.existsSync).mockReturnValue(false); + await deleteFile('/dummy/minikube'); + + expect(fs.promises.unlink).not.toHaveBeenCalled(); + }); + + test('file that exist should unlink', async () => { + vi.mocked(fs.existsSync).mockReturnValue(true); + await deleteFile('/dummy/minikube'); + + expect(fs.promises.unlink).toHaveBeenCalledWith('/dummy/minikube'); + }); + + test('unknown error on unlink should propagate', async () => { + vi.mocked(extensionApi.env).isWindows = true; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.unlink).mockRejectedValue(new Error('weird error')); + + await expect(async () => { + await deleteFile('C:/dummy/minikube.exe'); + }).rejects.toThrowError('weird error'); + + expect(fs.promises.unlink).toHaveBeenCalledWith('C:/dummy/minikube.exe'); + expect(extensionApi.process.exec).not.toHaveBeenCalled(); + }); + + test('access error on unlink should try to delete with admin privilege', async () => { + vi.mocked(extensionApi.env).isWindows = true; + vi.mocked(fs.existsSync).mockReturnValue(true); + vi.mocked(fs.promises.unlink).mockRejectedValue({ code: 'EACCES' }); + + await deleteFile('C:/dummy/minikube.exe'); + + expect(fs.promises.unlink).toHaveBeenCalledWith('C:/dummy/minikube.exe'); + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('del', ['C:/dummy/minikube.exe'], { + isAdmin: true, + }); + }); }); -test('expect: installBinaryToSystem on linux with /usr/local/bin NOT created yet (expect mkdir -p command)', async () => { - // Mock the platform to be darwin - Object.defineProperty(process, 'platform', { - value: 'linux', +describe('deleteFileAsAdmin', () => { + test('error should propagate', async () => { + vi.mocked(extensionApi.env).isLinux = true; + vi.mocked(extensionApi.process.exec).mockRejectedValue(new Error('something wrong')); + + await expect(async () => { + await deleteFileAsAdmin('/dummy/minikube'); + }).rejects.toThrowError('something wrong'); }); - // Mock existsSync to be false since within the function it's doing: !fs.existsSync(localBinDir) - vi.spyOn(fs, 'existsSync').mockImplementation(() => { - return false; + test('linux should use rm', async () => { + vi.mocked(extensionApi.env).isLinux = true; + + await deleteFileAsAdmin('/dummy/minikube'); + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('rm', ['/dummy/minikube'], { + isAdmin: true, + }); }); - // Run installBinaryToSystem which will trigger the spyOn mock - await installBinaryToSystem('test', 'tmpBinary'); + test('window should use rm', async () => { + vi.mocked(extensionApi.env).isWindows = true; - expect(extensionApi.process.exec).toBeCalledWith('chmod', expect.arrayContaining(['+x', 'test'])); + await deleteFileAsAdmin('C:/dummy/minikube.exe'); + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('del', ['C:/dummy/minikube.exe'], { + isAdmin: true, + }); + }); - // check called with admin being true - expect(extensionApi.process.exec).toBeCalledWith( - 'mkdir', - ['-p', '/usr/local/bin'], - expect.objectContaining({ isAdmin: true }), - ); - expect(extensionApi.process.exec).toBeCalledWith( - 'cp', - ['test', `${path.sep}usr${path.sep}local${path.sep}bin${path.sep}tmpBinary`], - expect.objectContaining({ isAdmin: true }), - ); + test('mac should use rm', async () => { + vi.mocked(extensionApi.env).isMac = true; + + await deleteFileAsAdmin('/dummy/minikube'); + expect(extensionApi.process.exec).toHaveBeenCalledOnce(); + expect(extensionApi.process.exec).toHaveBeenCalledWith('rm', ['/dummy/minikube'], { + isAdmin: true, + }); + }); }); diff --git a/src/util.ts b/src/util.ts index 8ec2ad8..4b7b781 100644 --- a/src/util.ts +++ b/src/util.ts @@ -24,17 +24,6 @@ import * as extensionApi from '@podman-desktop/api'; import type { MinikubeInstaller } from './minikube-installer'; -export interface SpawnResult { - stdOut: string; - stdErr: string; - error: undefined | string; -} - -export interface RunOptions { - env?: NodeJS.ProcessEnv; - logger?: extensionApi.Logger; -} - const macosExtraPath = '/usr/local/bin:/opt/homebrew/bin:/opt/local/bin:/opt/podman/bin'; export function getMinikubePath(): string { @@ -95,12 +84,21 @@ export async function detectMinikube(pathAddition: string, installer: MinikubeIn return undefined; } +export function getBinarySystemPath(binaryName: string): string { + if (extensionApi.env.isWindows) { + return path.join(os.homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', `${binaryName}.exe`); + } else { + return path.join('/usr/local/bin', binaryName); + } +} + // Takes a binary path (e.g. /tmp/minikube) and installs it to the system. Renames it based on binaryName // supports Windows, Linux and macOS // If using Windows or Mac, we will use sudo-prompt in order to elevate the privileges // If using Linux, we'll use pkexec and polkit support to ask for privileges. // When running in a flatpak, we'll use flatpak-spawn to execute the command on the host -export async function installBinaryToSystem(binaryPath: string, binaryName: string): Promise { +// @return the system-wide path where it is installed +export async function installBinaryToSystem(binaryPath: string, binaryName: string): Promise { const system = process.platform; // Before copying the file, make sure it's executable (chmod +x) for Linux and Mac @@ -115,15 +113,13 @@ export async function installBinaryToSystem(binaryPath: string, binaryName: stri // Create the appropriate destination path (Windows uses AppData/Local, Linux and Mac use /usr/local/bin) // and the appropriate command to move the binary to the destination path - let destinationPath: string; + const destinationPath: string = getBinarySystemPath(binaryName); let command: string; let args: string[]; if (system === 'win32') { - destinationPath = path.join(os.homedir(), 'AppData', 'Local', 'Microsoft', 'WindowsApps', `${binaryName}.exe`); command = 'copy'; args = [`"${binaryPath}"`, `"${destinationPath}"`]; } else { - destinationPath = path.join('/usr/local/bin', binaryName); command = 'cp'; args = [binaryPath, destinationPath]; } @@ -139,8 +135,68 @@ export async function installBinaryToSystem(binaryPath: string, binaryName: stri // Use admin prileges / ask for password for copying to /usr/local/bin await extensionApi.process.exec(command, args, { isAdmin: true }); console.log(`Successfully installed '${binaryName}' binary.`); + return destinationPath; } catch (error) { console.error(`Failed to install '${binaryName}' binary: ${error}`); throw error; } } + +/** + * Given an executable name will find where it is installed on the system + * @param executable + */ +export async function whereBinary(executable: string): Promise { + // grab full path for Linux and mac + if (extensionApi.env.isLinux || extensionApi.env.isMac) { + try { + const { stdout: fullPath } = await extensionApi.process.exec('which', [executable]); + return fullPath; + } catch (err) { + console.warn('Error getting full path', err); + } + } else if (extensionApi.env.isWindows) { + // grab full path for Windows + try { + const { stdout: fullPath } = await extensionApi.process.exec('where.exe', [executable]); + // remove all line break/carriage return characters from full path + return fullPath.replace(/(\r\n|\n|\r)/gm, ''); + } catch (err) { + console.warn('Error getting full path', err); + } + } + + throw new Error(`binary ${executable} not found.`); +} + +export async function deleteFile(filePath: string): Promise { + if (filePath && fs.existsSync(filePath)) { + try { + await fs.promises.unlink(filePath); + } catch (error: unknown) { + if ( + error && + typeof error === 'object' && + 'code' in error && + (error.code === 'EACCES' || error.code === 'EPERM') + ) { + await deleteFileAsAdmin(filePath); + } else { + throw error; + } + } + } +} + +export async function deleteFileAsAdmin(filePath: string): Promise { + const args: string[] = [filePath]; + const command = extensionApi.env.isWindows ? 'del' : 'rm'; + + try { + // Use admin prileges + await extensionApi.process.exec(command, args, { isAdmin: true }); + } catch (error) { + console.error(`Failed to uninstall '${filePath}': ${error}`); + throw error; + } +}