diff --git a/docs/vm_launcher.md b/docs/vm_launcher.md new file mode 100644 index 00000000..7042c44e --- /dev/null +++ b/docs/vm_launcher.md @@ -0,0 +1,21 @@ +# Virtual Machine Launcher + +Virtual Machine support is **experimental** and is only meant to run *one VM at a time* within the BootC extension. + +We launch the virtual machine by using QEMU. + +There are some caveats however: +- The virtual machine is booted as a snapshot and writes data to a /tmp file. The .raw file will remain unmodified. All changes are discarded on shut down. +- VM is shutdown when changing to another page. +- Port 22 is forwarded to 2222 locally for SSH testing. The VM may be accessed by using ssh localhost -p 2222 on an external terminal. +- VM uses 4GB of RAM by default. + +## Installation + +### macOS + +Install QEMU on macOS by running the following with `brew`: + +```sh +brew install qemu +``` \ No newline at end of file diff --git a/package.json b/package.json index 0c70aefb..145f7421 100644 --- a/package.json +++ b/package.json @@ -65,6 +65,7 @@ "vitest": "^2.0.2" }, "dependencies": { + "@xterm/addon-attach": "^0.11.0", "js-yaml": "^4.1.0" }, "packageManager": "pnpm@9.10.0+sha512.73a29afa36a0d092ece5271de5177ecbf8318d454ecd701343131b8ebc0c1a91c487da46ab77c8e596d6acf1461e3594ced4becedf8921b074fbd8653ed7051c" diff --git a/packages/backend/package.json b/packages/backend/package.json index 3c2bec24..f1e7e43f 100644 --- a/packages/backend/package.json +++ b/packages/backend/package.json @@ -102,6 +102,7 @@ "@vitest/coverage-v8": "^2.0.2", "@xterm/xterm": "^5.5.0", "@xterm/addon-fit": "^0.10.0", + "@xterm/addon-attach": "^0.11.0", "eslint": "^8.57.1", "eslint-import-resolver-custom-alias": "^1.3.2", "eslint-import-resolver-typescript": "^3.6.3", diff --git a/packages/backend/src/api-impl.ts b/packages/backend/src/api-impl.ts index 4dae6874..df003267 100644 --- a/packages/backend/src/api-impl.ts +++ b/packages/backend/src/api-impl.ts @@ -25,10 +25,11 @@ import { History } from './history'; import * as containerUtils from './container-utils'; import { Messages } from '/@shared/src/messages/Messages'; import { telemetryLogger } from './extension'; -import { checkPrereqs, isLinux, getUidGid } from './machine-utils'; +import { checkPrereqs, isLinux, isMac, getUidGid } from './machine-utils'; import * as fs from 'node:fs'; import path from 'node:path'; import { getContainerEngine } from './container-utils'; +import { checkVMLaunchPrereqs, launchVM, stopVM } from './launch-vm'; export class BootcApiImpl implements BootcApi { private history: History; @@ -46,6 +47,10 @@ export class BootcApiImpl implements BootcApi { return checkPrereqs(await getContainerEngine()); } + async checkVMLaunchPrereqs(folder: string, architecture: string): Promise { + return checkVMLaunchPrereqs(folder, architecture); + } + async buildExists(folder: string, types: BuildType[]): Promise { return buildExists(folder, types); } @@ -54,6 +59,31 @@ export class BootcApiImpl implements BootcApi { return buildDiskImage(build, this.history, overwrite); } + async launchVM(folder: string, architecture: string): Promise { + try { + await launchVM(folder, architecture); + // Notify it has successfully launched + await this.notify(Messages.MSG_VM_LAUNCH_ERROR, { success: 'Launched!', error: '' }); + } catch (e) { + // Make sure that we are able to display the "stderr" information if it exists as that actually shows + // the error when running the command. + let errorMessage: string; + if (e instanceof Error) { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + errorMessage = `${e.message} ${'stderr' in e ? (e as any).stderr : ''}`; + } else { + errorMessage = String(e); + } + await this.notify(Messages.MSG_VM_LAUNCH_ERROR, { success: '', error: errorMessage }); + } + return Promise.resolve(); + } + + // Stop VM by pid file on the system + async stopVM(): Promise { + return stopVM(); + } + async deleteBuilds(builds: BootcBuildInfo[]): Promise { const response = await podmanDesktopApi.window.showWarningMessage( `Are you sure you want to remove the selected disk images from the build history? This will remove the history of the build as well as remove any lingering build containers.`, @@ -247,6 +277,10 @@ export class BootcApiImpl implements BootcApi { return isLinux(); } + async isMac(): Promise { + return isMac(); + } + async getUidGid(): Promise { return getUidGid(); } @@ -272,6 +306,11 @@ export class BootcApiImpl implements BootcApi { return undefined; } + // Read from the podman desktop clipboard + async readFromClipboard(): Promise { + return podmanDesktopApi.env.clipboard.readText(); + } + // The API does not allow callbacks through the RPC, so instead // we send "notify" messages to the frontend to trigger a refresh // this method is internal and meant to be used by the API implementation diff --git a/packages/backend/src/launch-vm.ts b/packages/backend/src/launch-vm.ts new file mode 100644 index 00000000..259e480a --- /dev/null +++ b/packages/backend/src/launch-vm.ts @@ -0,0 +1,200 @@ +/********************************************************************** + * Copyright (C) 2024 Red Hat, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * SPDX-License-Identifier: Apache-2.0 + ***********************************************************************/ + +import path from 'node:path'; +import * as extensionApi from '@podman-desktop/api'; +import { isMac } from './machine-utils'; +import fs from 'node:fs'; + +// Ignore the following line as this is where we will be storing the pid file +// similar to other projects that use pid files in /tmp +// eslint-disable-next-line sonarjs/publicly-writable-directories +const pidFile = '/tmp/qemu-podman-desktop.pid'; + +// Must use "homebrew" qemu binaries on macOS +// as they are found to be the most stable and reliable for the project +// as well as containing the necessary "edk2-aarch64-code.fd" file +// it is not advised to use the qemu binaries from qemu.org due to edk2-aarch64-code.fd not being included. +const macQemuArm64Binary = '/opt/homebrew/bin/qemu-system-aarch64'; +const macQemuArm64Edk2 = '/opt/homebrew/share/qemu/edk2-aarch64-code.fd'; +const macQemuX86Binary = '/opt/homebrew/bin/qemu-system-x86_64'; + +// Host port forwarding for VM we will by default port forward 22 on the bootable container +// to :2222 on the host +const hostForwarding = 'hostfwd=tcp::2222-:22'; + +// Default memory size for the VM and websocket port location +const memorySize = '4G'; +const websocketPort = '45252'; + +// Raw image location +const rawImageLocation = 'image/disk.raw'; + +export async function launchVM(folder: string, architecture: string): Promise { + // Will ONLY work with RAW images located at image/disk.raw which is the default output location + const diskImage = path.join(folder, rawImageLocation); + + // Check to see that the disk image exists before continuing + if (!fs.existsSync(diskImage)) { + throw new Error(`Raw disk image not found: ${diskImage}`); + } + + // Generate the launch command and then run process.exec + try { + const command = generateLaunchCommand(diskImage, architecture); + + // If generateLaunchCommand returns an empty array, then we are not able to launch the VM + // so simply error out and return + if (command.length === 0) { + throw new Error( + 'Unable to generate the launch command for the VM, must be on the appropriate OS (mac or linux) and architecture (x86_64 or aarch64)', + ); + } + + // Execute the command + await extensionApi.process.exec('sh', ['-c', `${command.join(' ')}`]); + } catch (e) { + // Output the stderr information if it exists as that helps with debugging + // why the command could not run. + if (e instanceof Error && 'stderr' in e) { + console.error('Error launching VM: ', e.stderr); + } else { + console.error('Error launching VM: ', e); + } + throw e; + } +} + +// Stop VM by killing the process with the pid file (/tmp/qemu-podman-desktop.pid) +export async function stopVM(): Promise { + try { + await extensionApi.process.exec('sh', ['-c', `kill -9 \`cat ${pidFile}\``]); + } catch (e) { + // If it errors out, we will ignore the error if it has stderr, is a string + // and contains 'No such process' as that means the process is not running + if (e instanceof Error && 'stderr' in e && typeof e.stderr === 'string' && e.stderr.includes('No such process')) { + return; + } + + // if 'stderr' exists, we will throw an error with the stderr information + if (e instanceof Error && 'stderr' in e) { + throw new Error(typeof e.stderr === 'string' ? e.stderr : 'Unknown error'); + } else { + throw new Error('Unknown error'); + } + } +} + +// Prereqs checks before launching the VM +export async function checkVMLaunchPrereqs(folder: string, architecture: string): Promise { + // Check to see that the disk image exists before continuing + const diskImage = path.join(folder, rawImageLocation); + if (!fs.existsSync(diskImage)) { + return `Raw disk image not found at ${diskImage}. Please build a .raw disk image first.`; + } + + // Check to see if the architecture is supported + if (architecture !== 'amd64' && architecture !== 'arm64') { + return `Unsupported architecture: ${architecture}`; + } + + // If on macOS, check the qemu binaries exist as well as the edk2-aarch64-code.fd file + if (isMac()) { + const installDisclaimer = 'Please install qemu via our installation document'; + if (!fs.existsSync(macQemuX86Binary)) { + return `QEMU x86 binary not found at ${macQemuX86Binary}. ${installDisclaimer}`; + } + if (architecture === 'arm64' && !fs.existsSync(macQemuArm64Binary)) { + return `QEMU arm64 binary not found at ${macQemuArm64Binary}. ${installDisclaimer}`; + } + if (architecture === 'arm64' && !fs.existsSync(macQemuArm64Edk2)) { + return `QEMU arm64 edk2-aarch64-code.fd file not found at ${macQemuArm64Edk2}. ${installDisclaimer}`; + } + } + + return undefined; +} + +// Generate launch command for qemu +// this all depends on what architecture we are launching as well as +// operating system +function generateLaunchCommand(diskImage: string, architecture: string): string[] { + let command: string[] = []; + switch (architecture) { + // Case for anything amd64 + case 'amd64': + if (isMac()) { + command = [ + macQemuX86Binary, + '-m', + memorySize, + '-nographic', + '-cpu', + 'Broadwell-v4', + '-pidfile', + pidFile, + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-netdev', + `user,id=mynet0,${hostForwarding}`, + '-device', + 'e1000,netdev=mynet0', + // Make sure we always have snapshot here as we don't want to modify the original image + '-snapshot', + diskImage, + ]; + } + break; + + // For any arm64 images + case 'arm64': + if (isMac()) { + command = [ + macQemuArm64Binary, + '-m', + memorySize, + '-nographic', + '-M', + 'virt', + '-accel', + 'hvf', + '-cpu', + 'host', + '-smp', + '4', + '-serial', + `websocket:127.0.0.1:${websocketPort},server,nowait`, + '-pidfile', + pidFile, + '-netdev', + `user,id=usernet,${hostForwarding}`, + '-device', + 'virtio-net,netdev=usernet', + '-drive', + `file=${macQemuArm64Edk2},format=raw,if=pflash,readonly=on`, + // Make sure we always have snapshot here as we don't want to modify the original image + '-snapshot', + diskImage, + ]; + } + break; + default: + break; + } + return command; +} diff --git a/packages/backend/src/machine-utils.ts b/packages/backend/src/machine-utils.ts index cf081b09..8a6ec189 100644 --- a/packages/backend/src/machine-utils.ts +++ b/packages/backend/src/machine-utils.ts @@ -22,6 +22,7 @@ import * as path from 'node:path'; import * as fs from 'node:fs'; import { satisfies, coerce } from 'semver'; import type { ContainerProviderConnection } from '@podman-desktop/api'; +import { env } from '@podman-desktop/api'; function getMachineProviderEnv(connection: extensionApi.ContainerProviderConnection): string { switch (connection.vmType) { @@ -174,6 +175,10 @@ export function isLinux(): boolean { return linux; } +export function isMac(): boolean { + return env.isMac; +} + // Get the GID and UID of the current user and return in the format gid:uid // in order for this to work, we must get this information from process.exec // since there is no native way via node diff --git a/packages/frontend/src/App.svelte b/packages/frontend/src/App.svelte index 3fa59ec4..59b60266 100644 --- a/packages/frontend/src/App.svelte +++ b/packages/frontend/src/App.svelte @@ -9,6 +9,7 @@ import { getRouterState } from './api/client'; import Homepage from './Homepage.svelte'; import { rpcBrowser } from '/@/api/client'; import { Messages } from '/@shared/src/messages/Messages'; +import VM from './lib/disk-image/DiskImageDetailsVirtualMachine.svelte'; import DiskImageDetails from './lib/disk-image/DiskImageDetails.svelte'; router.mode.hash(); diff --git a/packages/frontend/src/Build.spec.ts b/packages/frontend/src/Build.spec.ts index 1e65c06b..aec71bf9 100644 --- a/packages/frontend/src/Build.spec.ts +++ b/packages/frontend/src/Build.spec.ts @@ -89,6 +89,7 @@ const mockImageInspect = { } as unknown as ImageInspectInfo; const mockIsLinux = false; +const mockIsMac = false; vi.mock('./api/client', async () => { return { @@ -97,9 +98,11 @@ vi.mock('./api/client', async () => { buildExists: vi.fn(), listHistoryInfo: vi.fn(), listBootcImages: vi.fn(), + listAllImages: vi.fn(), inspectImage: vi.fn(), inspectManifest: vi.fn(), isLinux: vi.fn().mockImplementation(() => mockIsLinux), + isMac: vi.fn().mockImplementation(() => mockIsMac), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/Homepage.spec.ts b/packages/frontend/src/Homepage.spec.ts index 56d2fa85..0c7831a4 100644 --- a/packages/frontend/src/Homepage.spec.ts +++ b/packages/frontend/src/Homepage.spec.ts @@ -52,6 +52,7 @@ vi.mock('./api/client', async () => { listBootcImages: vi.fn(), deleteBuilds: vi.fn(), telemetryLogUsage: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/VMConnectionStatus.svelte b/packages/frontend/src/VMConnectionStatus.svelte new file mode 100644 index 00000000..81cef20c --- /dev/null +++ b/packages/frontend/src/VMConnectionStatus.svelte @@ -0,0 +1,16 @@ + + +{#if status} + +{/if} diff --git a/packages/frontend/src/lib/BootcActions.spec.ts b/packages/frontend/src/lib/BootcActions.spec.ts index ae8b9763..78f23327 100644 --- a/packages/frontend/src/lib/BootcActions.spec.ts +++ b/packages/frontend/src/lib/BootcActions.spec.ts @@ -25,6 +25,7 @@ vi.mock('../api/client', async () => { return { bootcClient: { deleteBuilds: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { @@ -52,6 +53,7 @@ beforeEach(() => { }); test('Renders Delete Build button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); const deleteButton = screen.getAllByRole('button', { name: 'Delete Build' })[0]; @@ -59,6 +61,7 @@ test('Renders Delete Build button', async () => { }); test('Test clicking on delete button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); // spy on deleteBuild function @@ -72,6 +75,7 @@ test('Test clicking on delete button', async () => { }); test('Test clicking on logs button', async () => { + vi.mocked(bootcClient.isMac).mockResolvedValue(false); render(BootcActions, { object: mockHistoryInfo }); // Click on logs button diff --git a/packages/frontend/src/lib/BootcActions.svelte b/packages/frontend/src/lib/BootcActions.svelte index 01ba4141..e3f15f74 100644 --- a/packages/frontend/src/lib/BootcActions.svelte +++ b/packages/frontend/src/lib/BootcActions.svelte @@ -1,13 +1,16 @@ + +{#if object.arch && isMac} + gotoVM()} detailed={detailed} icon={faTerminal} /> +{/if} gotoLogs()} detailed={detailed} icon={faFileAlt} /> deleteBuild()} detailed={detailed} icon={faTrash} /> diff --git a/packages/frontend/src/lib/BootcColumnActions.spec.ts b/packages/frontend/src/lib/BootcColumnActions.spec.ts index 441f3c0e..e5eff6d0 100644 --- a/packages/frontend/src/lib/BootcColumnActions.spec.ts +++ b/packages/frontend/src/lib/BootcColumnActions.spec.ts @@ -33,6 +33,9 @@ const mockHistoryInfo: BootcBuildInfo = { vi.mock('../api/client', async () => { return { + bootcClient: { + isMac: vi.fn(), + }, rpcBrowser: { subscribe: () => { return { diff --git a/packages/frontend/src/lib/BootcEmptyScreen.spec.ts b/packages/frontend/src/lib/BootcEmptyScreen.spec.ts index 0cd5eb1b..5c81ccf8 100644 --- a/packages/frontend/src/lib/BootcEmptyScreen.spec.ts +++ b/packages/frontend/src/lib/BootcEmptyScreen.spec.ts @@ -51,6 +51,7 @@ vi.mock('../api/client', async () => { listHistoryInfo: vi.fn(), listBootcImages: vi.fn(), pullImage: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts index 7b0f1ffe..4acf5332 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.spec.ts @@ -39,6 +39,7 @@ vi.mock('/@/api/client', async () => { return { bootcClient: { listHistoryInfo: vi.fn(), + isMac: vi.fn(), }, rpcBrowser: { subscribe: () => { diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte index f7a259df..2a240573 100644 --- a/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte +++ b/packages/frontend/src/lib/disk-image/DiskImageDetails.svelte @@ -5,10 +5,13 @@ import DiskImageIcon from '/@/lib/DiskImageIcon.svelte'; import DiskImageDetailsBuild from './DiskImageDetailsBuild.svelte'; import Route from '../Route.svelte'; import DiskImageDetailsSummary from './DiskImageDetailsSummary.svelte'; -import { onMount } from 'svelte'; +import { onDestroy, onMount } from 'svelte'; import type { BootcBuildInfo } from '/@shared/src/models/bootc'; import { getTabUrl, isTabSelected } from '../upstream/Util'; import { historyInfo } from '/@/stores/historyInfo'; +import DiskImageDetailsVirtualMachine from './DiskImageDetailsVirtualMachine.svelte'; +import type { Unsubscriber } from 'svelte/store'; +import { bootcClient } from '/@/api/client'; export let id: string; @@ -16,9 +19,17 @@ let diskImage: BootcBuildInfo; let detailsPage: DetailsPage; -onMount(() => { +let historyInfoUnsubscribe: Unsubscriber; + +let isMac = false; + +onMount(async () => { + // See if we are on mac or not for the VM tab + isMac = await bootcClient.isMac(); + + // Subscribe to the history to update the details page const actualId = atob(id); - return historyInfo.subscribe(value => { + historyInfoUnsubscribe = historyInfo.subscribe(value => { const matchingImage = value.find(image => image.id === actualId); if (matchingImage) { try { @@ -33,6 +44,12 @@ onMount(() => { }); }); +onDestroy(() => { + if (historyInfoUnsubscribe) { + historyInfoUnsubscribe(); + } +}); + export function goToHomePage(): void { router.goto('/'); } @@ -50,6 +67,12 @@ export function goToHomePage(): void { + {#if isMac} + + {/if} @@ -58,5 +81,11 @@ export function goToHomePage(): void { + + + {#if diskImage?.folder && diskImage?.arch} + + {/if} + diff --git a/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte new file mode 100644 index 00000000..becbc699 --- /dev/null +++ b/packages/frontend/src/lib/disk-image/DiskImageDetailsVirtualMachine.svelte @@ -0,0 +1,312 @@ + + +{#if vmLaunchPrereqs} + +
+

+ View our guide for further information on completing the prerequisites: Virtual Machine Launcher BootC Guide. +

+
+
+{:else if vmLaunchError} + + +
+

+ View our guide for further information on troubleshooting steps: Virtual Machine Launcher BootC Guide. If you are still experiencing issues, please open an issue on our + GitHub repository. +

+
+
+{:else if noLogs} + +{/if} + +
+ +
+
+
diff --git a/packages/frontend/src/lib/upstream/Label.svelte b/packages/frontend/src/lib/upstream/Label.svelte new file mode 100644 index 00000000..2910d06a --- /dev/null +++ b/packages/frontend/src/lib/upstream/Label.svelte @@ -0,0 +1,19 @@ + + + +
+ + + {name} + +
+
diff --git a/packages/shared/src/BootcAPI.ts b/packages/shared/src/BootcAPI.ts index da1dd966..1b0d9663 100644 --- a/packages/shared/src/BootcAPI.ts +++ b/packages/shared/src/BootcAPI.ts @@ -21,6 +21,7 @@ import type { ImageInfo, ImageInspectInfo, ManifestInspectInfo } from '@podman-d export abstract class BootcApi { abstract checkPrereqs(): Promise; + abstract checkVMLaunchPrereqs(folder: string, architecture: string): Promise; abstract buildExists(folder: string, types: BuildType[]): Promise; abstract buildImage(build: BootcBuildInfo, overwrite?: boolean): Promise; abstract pullImage(image: string): Promise; @@ -36,9 +37,13 @@ export abstract class BootcApi { abstract generateUniqueBuildID(name: string): Promise; abstract openLink(link: string): Promise; abstract isLinux(): Promise; + abstract isMac(): Promise; abstract getUidGid(): Promise; abstract loadLogsFromFolder(folder: string): Promise; abstract getConfigurationValue(config: string, section: string): Promise; + abstract launchVM(folder: string, architecture: string): Promise; + abstract readFromClipboard(): Promise; + abstract stopVM(): Promise; abstract telemetryLogUsage(eventName: string, data?: Record | undefined): Promise; abstract telemetryLogError(eventName: string, data?: Record | undefined): Promise; } diff --git a/packages/shared/src/messages/MessageProxy.ts b/packages/shared/src/messages/MessageProxy.ts index 20500a70..a8e0a87d 100644 --- a/packages/shared/src/messages/MessageProxy.ts +++ b/packages/shared/src/messages/MessageProxy.ts @@ -19,6 +19,8 @@ import type { Webview } from '@podman-desktop/api'; +const specialChannels = ['launchVM']; + export interface IMessage { id: number; channel: string; @@ -198,12 +200,15 @@ export class RpcBrowser { args: args, } as IMessageRequest); - setTimeout(() => { - const { reject } = this.promises.get(requestId) ?? {}; - if (!reject) return; - reject(new Error('Timeout')); - this.promises.delete(requestId); - }, 10000); // 10 seconds + // Add a timeout of 10 seconds for each call. However, if there is any "special" call that should not have a timeout, we can add a check here. + if (!specialChannels.includes(channel)) { + setTimeout(() => { + const { reject } = this.promises.get(requestId) ?? {}; + if (!reject) return; + reject(new Error('Timeout')); + this.promises.delete(requestId); + }, 10000); // 10 seconds + } // Create a Promise return promise; diff --git a/packages/shared/src/messages/Messages.ts b/packages/shared/src/messages/Messages.ts index a59e8e46..cf58ab6c 100644 --- a/packages/shared/src/messages/Messages.ts +++ b/packages/shared/src/messages/Messages.ts @@ -20,4 +20,5 @@ export enum Messages { MSG_HISTORY_UPDATE = 'history-update', MSG_IMAGE_PULL_UPDATE = 'image-pull-update', // Responsible for any pull updates MSG_NAVIGATE_BUILD = 'navigate-build', + MSG_VM_LAUNCH_ERROR = 'vm-launch-error', } diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b9cb756f..554df461 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12,6 +12,9 @@ importers: .: dependencies: + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) js-yaml: specifier: ^4.1.0 version: 4.1.0 @@ -104,6 +107,9 @@ importers: '@vitest/coverage-v8': specifier: ^2.0.2 version: 2.0.5(vitest@2.0.5(@types/node@20.16.5)(jsdom@25.0.1)) + '@xterm/addon-attach': + specifier: ^0.11.0 + version: 0.11.0(@xterm/xterm@5.5.0) '@xterm/addon-fit': specifier: ^0.10.0 version: 0.10.0(@xterm/xterm@5.5.0) @@ -1719,6 +1725,11 @@ packages: '@vitest/utils@2.0.5': resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + '@xterm/addon-attach@0.11.0': + resolution: {integrity: sha512-JboCN0QAY6ZLY/SSB/Zl2cQ5zW1Eh4X3fH7BnuR1NB7xGRhzbqU2Npmpiw/3zFlxDaU88vtKzok44JKi2L2V2Q==} + peerDependencies: + '@xterm/xterm': ^5.0.0 + '@xterm/addon-fit@0.10.0': resolution: {integrity: sha512-UFYkDm4HUahf2lnEyHvio51TNGiLK66mqP2JoATy7hRZeXaGMRDr00JiSF7m63vR5WKATF605yEggJKsw0JpMQ==} peerDependencies: @@ -6070,6 +6081,10 @@ snapshots: loupe: 3.1.1 tinyrainbow: 1.2.0 + '@xterm/addon-attach@0.11.0(@xterm/xterm@5.5.0)': + dependencies: + '@xterm/xterm': 5.5.0 + '@xterm/addon-fit@0.10.0(@xterm/xterm@5.5.0)': dependencies: '@xterm/xterm': 5.5.0