diff --git a/src/orchestrators/test.ts b/src/orchestrators/test.ts index bbc5410..67d30a5 100644 --- a/src/orchestrators/test.ts +++ b/src/orchestrators/test.ts @@ -1,15 +1,19 @@ import { OS, SpawnStatus } from 'codify-schemas'; import os from 'node:os'; +import fs from 'node:fs' +import path from 'node:path'; import { PluginInitOrchestrator } from '../common/initialize-plugins.js'; -import { ProcessName, ctx, SubProcessName } from '../events/context.js'; +import { ProcessName, SubProcessName, ctx } from '../events/context.js'; import { Reporter } from '../ui/reporters/reporter.js'; import { StubReporter } from '../ui/reporters/stub-reporter.js'; +import { FileUtils } from '../utils/file.js'; import { sleep } from '../utils/index.js'; import { spawn, spawnSafe } from '../utils/spawn.js'; import { PlanOrchestrator, PlanOrchestratorResponse } from './plan.js'; import { ValidateOrchestrator } from './validate.js'; +import { OsUtils } from '../utils/os-utils.js'; export interface TestArgs { path?: string; @@ -20,13 +24,17 @@ export interface TestArgs { export const TestOrchestrator = { async run(args: TestArgs, reporter: Reporter): Promise { + if (!OsUtils.isMacOS()) { + throw new Error('Only a MacOS host is supported currently for testing'); + } + ctx.processStarted(ProcessName.TEST); reporter.silent = true; ctx.subprocessStarted(SubProcessName.TEST_INITIALIZE_AND_VALIDATE); // Perform validation initially to ensure the project is valid - const initializationResult = await PluginInitOrchestrator.run({ ...args, noProgress: true }, new StubReporter()); - await ValidateOrchestrator.run({ existing: initializationResult, noProgress: true }, new StubReporter()); + const initializationResult = await PluginInitOrchestrator.run({ ...args, noProgress: true }, reporter); + await ValidateOrchestrator.run({ existing: initializationResult, noProgress: true }, reporter); ctx.subprocessFinished(SubProcessName.TEST_INITIALIZE_AND_VALIDATE); await this.ensureVmIsInstalled(reporter, args.vmOs); @@ -36,8 +44,14 @@ export const TestOrchestrator = { const vmName = this.generateVmName(); await spawnSafe(`tart clone ${baseVmName} ${vmName}`, { interactive: true }); + // We want to install the latest Codify version which usually exists in ~/.local/share/codify/client/current unless it's not there. + const codifyInstall = (await FileUtils.dirExists('~/.local/share/codify/client/current')) + ? '~/.local/share/codify/client/current' + : '/usr/local/lib/codify'; + // Run this in the background. The user will have to manually exit the GUI to stop the test. - spawnSafe(`tart run ${vmName}`, { interactive: true }) + // We bind mount the codify installation and the codify config directory. We choose not use :ro (read-only) because live changes are not supported in read-only mode. + spawnSafe(`tart run ${vmName} --dir=codify-lib:${codifyInstall}:ro --dir=codify-config:${path.dirname(initializationResult.project.codifyFiles[0])}:ro`, { interactive: true }) .finally(() => { ctx.subprocessFinished(SubProcessName.TEST_USER_CONTINUE_ON_VM); ctx.subprocessStarted(SubProcessName.TEST_DELETING_VM); @@ -49,7 +63,7 @@ export const TestOrchestrator = { console.log('VM has been killed... exiting.') process.exit(1); }) - await sleep(10_000); + await sleep(5000); await this.waitUntilVmIsReady(vmName); ctx.subprocessFinished(SubProcessName.TEST_STARTING_VM); @@ -58,12 +72,19 @@ export const TestOrchestrator = { // Install codify on the VM // await spawn(`tart exec ${vmName} /bin/bash -c "$(curl -fsSL https://releases.codifycli.com/install.sh)"`, { interactive: true }); const { data: ip } = await spawnSafe(`tart ip ${vmName}`, { interactive: true }); - await spawn(`sshpass -p "admin" scp -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${initializationResult.project.codifyFiles[0]} admin@${ip}:~/codify.jsonc`, { interactive: true }); - if (args.vmOs === OS.Darwin) { - await spawn(`tart exec ${vmName} osascript -e "tell application \\"Terminal\\" to do script \\"cd ~/ && codify apply\\""`, { interactive: true }); - } else { - await spawn(`tart exec ${vmName} gnome-terminal -- bash -c "cd ~/ && codify apply"`, { interactive: true }); + try { + // Add symlinks to the bind mount locations. + await spawn(`tart exec ${vmName} sudo ln -s /Volumes/My\\ Shared\\ Files/codify-lib/bin/codify /usr/local/bin/codify`, { interactive: true }); + await spawn(`tart exec ${vmName} ln -s /Volumes/My\\ Shared\\ Files/codify-config/${path.basename(initializationResult.project.codifyFiles[0])} /Users/admin/codify.jsonc`, { interactive: true }); + + // Launch terminal and run codify apply + await (args.vmOs === OS.Darwin ? spawn(`tart exec ${vmName} osascript -e "tell application \\"Terminal\\" to do script \\"cd ~ && codify apply\\""`, { interactive: true }) : spawn(`tart exec ${vmName} gnome-terminal -- bash -c "cd ~/ && codify apply"`, { interactive: true })); + + this.watchAndSyncFileChanges(initializationResult.project.codifyFiles[0], ip); + + } catch (error) { + ctx.log(`Error copying files to VM: ${error}`); } ctx.subprocessFinished(SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL); @@ -136,5 +157,28 @@ export const TestOrchestrator = { await sleep(1000); } + }, + + watchAndSyncFileChanges(filePath: string, ip: string): void { + const watcher = fs.watch(filePath, { persistent: false }, async (eventType) => { + if (eventType === 'change') { + ctx.log('Config file changed, syncing to VM...'); + try { + // Copy the updated config file to the VM + // This command will fail but it causes the bind mount to update for some reason. (seems like a bug in Tart). Leave this here for now. + await spawn(`sshpass -p "admin" scp -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null ${filePath} admin@${ip}:~/codify.jsonc`, { interactive: true }); + // ctx.log('Config file synced successfully'); + } catch (error) { + // ctx.log(`Error syncing config file: ${error}`); + } + } + }); + + // Clean up the watcher when the process finishes + const cleanupWatcher = () => { + watcher.close(); + }; + + process.once('exit', cleanupWatcher); } }; diff --git a/src/ui/reporters/default-reporter.tsx b/src/ui/reporters/default-reporter.tsx index 3ccb7e1..7073344 100644 --- a/src/ui/reporters/default-reporter.tsx +++ b/src/ui/reporters/default-reporter.tsx @@ -38,7 +38,7 @@ const ProgressLabelMapping = { [SubProcessName.TEST_INITIALIZE_AND_VALIDATE]: 'Initializing and validating your configs', [SubProcessName.TEST_CHECKING_VM_INSTALLED]: 'Checking if VM is installed', [SubProcessName.TEST_STARTING_VM]: 'Starting VM', - [SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL]: 'Copying over configs and opening terminal', + [SubProcessName.TEST_COPYING_OVER_CONFIGS_AND_OPENING_TERMINAL]: 'Copying over configs and opening terminal (if a confirmation dialog appears within the VM, please confirm it.)', [SubProcessName.TEST_USER_CONTINUE_ON_VM]: 'Done setup! Please continue on the VM UI', [SubProcessName.TEST_DELETING_VM]: 'Deleting VM', } diff --git a/src/utils/file.ts b/src/utils/file.ts index 1af7375..b622da8 100644 --- a/src/utils/file.ts +++ b/src/utils/file.ts @@ -22,6 +22,18 @@ export class FileUtils { } } + static async dirExists(dirPath: string, throwIfExistsButNotFile = true): Promise { + try { + const result = await fs.lstat(path.resolve(dirPath)) + if (throwIfExistsButNotFile && !result.isDirectory()) { + throw new Error(`File found at ${dirPath} instead of a file`) + } + return true; + } catch(e) { + return false; + } + } + static async isDir(fileOrDir: string): Promise { const lstat = await fs.lstat(path.resolve(fileOrDir)) return lstat.isDirectory() diff --git a/src/utils/spawn.ts b/src/utils/spawn.ts index 4f4bd9d..288154d 100644 --- a/src/utils/spawn.ts +++ b/src/utils/spawn.ts @@ -65,14 +65,7 @@ export async function spawnSafe(cmd: string, options?: SpawnOptions, pluginName? const initialCols = process.stdout.columns ?? 80; const initialRows = process.stdout.rows ?? 24; - // Mac OS uses -SN instead of -Sn - let command; - if (OsUtils.isMacOS()) { - command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -SN <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${cmd.replaceAll('\'', '\\\'')}"` : cmd; - } else { - command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c '${cmd.replaceAll('\'', '\\\'')}'` : cmd; - } - + const command = options?.requiresRoot ? `sudo -k >/dev/null 2>&1; sudo -S <<< "${password}" -E ${ShellUtils.getDefaultShell()} ${options?.interactive ? '-i' : ''} -c "${cmd.replaceAll('"', '\\"')}"` : cmd; const args = options?.interactive ? ['-i', '-c', command] : ['-c', command] // Run the command in a pty for interactivity