From f8f48f0d7ba5a839a3796209939679bef65a4243 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 01:40:20 +0000 Subject: [PATCH 1/3] Remove .vellum writes during remote setup, add Hatch! button with hatching animation - Stop writing to .vellum/workspace/config.json during GCP/AWS/self-hosted onboarding - Store cloud credentials in OnboardingState memory instead - Change Continue button to Hatch! on GCP credentials page - Add HatchingStepView with egg animation and live stdout streaming - Update CLILauncher to support --remote hatch with env var credential passing - Update CLI hatch.ts to read GCP credentials from env vars (VELLUM_GCP_SA_KEY_PATH) Co-Authored-By: vargas@vellum.ai --- cli/src/commands/hatch.ts | 24 +- .../vellum-assistant/App/CLILauncher.swift | 120 +++++++++ .../Features/Onboarding/APIKeyStepView.swift | 33 +-- .../Onboarding/CloudCredentialsStepView.swift | 146 ++--------- .../Onboarding/HatchingStepView.swift | 237 ++++++++++++++++++ .../Onboarding/ModelSelectionStepView.swift | 42 +--- .../Onboarding/OnboardingFlowView.swift | 17 +- .../Features/Onboarding/OnboardingState.swift | 13 + 8 files changed, 437 insertions(+), 195 deletions(-) create mode 100644 clients/macos/vellum-assistant/Features/Onboarding/HatchingStepView.swift diff --git a/cli/src/commands/hatch.ts b/cli/src/commands/hatch.ts index 9e60c16fd5d..2133c4807fe 100644 --- a/cli/src/commands/hatch.ts +++ b/cli/src/commands/hatch.ts @@ -428,7 +428,27 @@ interface WorkspaceConfig { cloudCredentials?: CloudCredentials; } -async function activateGcpCredentialsFromConfig(): Promise { +async function activateGcpCredentials(): Promise { + const envKeyPath = process.env.VELLUM_GCP_SA_KEY_PATH; + if (envKeyPath && existsSync(envKeyPath)) { + try { + await exec("gcloud", [ + "auth", + "activate-service-account", + `--key-file=${envKeyPath}`, + ]); + const project = process.env.GCP_PROJECT; + if (project) { + await exec("gcloud", ["config", "set", "project", project]); + } + } finally { + try { + unlinkSync(envKeyPath); + } catch {} + } + return; + } + const configPath = join(homedir(), ".vellum", "workspace", "config.json"); let config: WorkspaceConfig; try { @@ -465,7 +485,7 @@ async function hatchGcp( ): Promise { const startTime = Date.now(); try { - await activateGcpCredentialsFromConfig(); + await activateGcpCredentials(); const project = process.env.GCP_PROJECT ?? (await getActiveProject()); let instanceName: string; diff --git a/clients/macos/vellum-assistant/App/CLILauncher.swift b/clients/macos/vellum-assistant/App/CLILauncher.swift index 7f4a848aa13..287bacafa89 100644 --- a/clients/macos/vellum-assistant/App/CLILauncher.swift +++ b/clients/macos/vellum-assistant/App/CLILauncher.swift @@ -62,4 +62,124 @@ final class CLILauncher { log.info("CLI hatch completed successfully") } + + struct RemoteHatchConfig { + let remote: String + var gcpProjectId: String = "" + var gcpServiceAccountKey: String = "" + var awsRoleArn: String = "" + var sshHost: String = "" + var sshUser: String = "" + var sshPrivateKey: String = "" + var anthropicApiKey: String = "" + } + + func runRemoteHatch( + config: RemoteHatchConfig, + onOutput: @escaping @Sendable (String) -> Void + ) async throws { + guard let binaryURL = cliBinaryURL else { + log.info("No bundled CLI binary found — skipping hatch (dev mode)") + throw CLIError.binaryNotFound + } + + log.info("Running remote hatch via CLI at \(binaryURL.path) --remote \(config.remote)") + + let proc = Process() + proc.executableURL = binaryURL + proc.arguments = ["hatch", "--remote", config.remote] + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + proc.standardOutput = stdoutPipe + proc.standardError = stderrPipe + + var env = ProcessInfo.processInfo.environment + env["HOME"] = FileManager.default.homeDirectoryForCurrentUser.path + + if !config.anthropicApiKey.isEmpty { + env["ANTHROPIC_API_KEY"] = config.anthropicApiKey + } + + if config.remote == "gcp" { + if !config.gcpProjectId.isEmpty { + env["GCP_PROJECT"] = config.gcpProjectId + } + if !config.gcpServiceAccountKey.isEmpty { + let tmpKeyPath = FileManager.default.temporaryDirectory + .appendingPathComponent("vellum-sa-key-\(ProcessInfo.processInfo.processIdentifier).json") + try config.gcpServiceAccountKey.write(to: tmpKeyPath, atomically: true, encoding: .utf8) + env["GOOGLE_APPLICATION_CREDENTIALS"] = tmpKeyPath.path + env["VELLUM_GCP_SA_KEY_PATH"] = tmpKeyPath.path + } + } else if config.remote == "aws" { + if !config.awsRoleArn.isEmpty { + env["VELLUM_AWS_ROLE_ARN"] = config.awsRoleArn + } + } else if config.remote == "custom" { + if !config.sshHost.isEmpty { + let hostString = config.sshUser.isEmpty + ? config.sshHost + : "\(config.sshUser)@\(config.sshHost)" + env["VELLUM_CUSTOM_HOST"] = hostString + } + if !config.sshPrivateKey.isEmpty { + let tmpKeyPath = FileManager.default.temporaryDirectory + .appendingPathComponent("vellum-ssh-key-\(ProcessInfo.processInfo.processIdentifier)") + try config.sshPrivateKey.write(to: tmpKeyPath, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes( + [.posixPermissions: 0o600], + ofItemAtPath: tmpKeyPath.path + ) + env["VELLUM_SSH_KEY_PATH"] = tmpKeyPath.path + } + } + + let entryFile = FileManager.default.temporaryDirectory + .appendingPathComponent("vellum-hatch-entry-\(ProcessInfo.processInfo.processIdentifier).json") + env["VELLUM_HATCH_ENTRY_FILE"] = entryFile.path + + proc.environment = env + + try proc.run() + log.info("CLI remote hatch launched with pid \(proc.processIdentifier)") + + let stdoutHandle = stdoutPipe.fileHandleForReading + let stderrHandle = stderrPipe.fileHandleForReading + + stdoutHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { + return + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onOutput(trimmed) + } + } + + stderrHandle.readabilityHandler = { handle in + let data = handle.availableData + guard !data.isEmpty, let line = String(data: data, encoding: .utf8) else { + return + } + let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) + if !trimmed.isEmpty { + onOutput(trimmed) + } + } + + proc.waitUntilExit() + + stdoutHandle.readabilityHandler = nil + stderrHandle.readabilityHandler = nil + + let status = proc.terminationStatus + if status != 0 { + log.error("CLI remote hatch failed with exit code \(status)") + throw CLIError.executionFailed("Hatch process exited with code \(status)") + } + + log.info("CLI remote hatch completed successfully") + } } diff --git a/clients/macos/vellum-assistant/Features/Onboarding/APIKeyStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/APIKeyStepView.swift index 13b28cd25d7..adc8ba342d3 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/APIKeyStepView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/APIKeyStepView.swift @@ -80,7 +80,7 @@ struct APIKeyStepView: View { apiKey = existingKey hasExistingKey = true } - if userHostedEnabled, let saved = loadHostingModeFromConfig() { + if userHostedEnabled, let saved = loadHostingModeFromDefaults() { hostingMode = saved } withAnimation(.easeOut(duration: 0.5).delay(0.1)) { @@ -270,7 +270,6 @@ struct APIKeyStepView: View { private func saveAndContinue() { if userHostedEnabled { - saveHostingModeToConfig(hostingMode) state.cloudProvider = hostingMode.rawValue } @@ -310,34 +309,8 @@ struct APIKeyStepView: View { } } - private func saveHostingModeToConfig(_ mode: HostingMode) { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - - let dirURL = configURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - - do { - let data = try Data(contentsOf: configURL) - if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - json["hostingMode"] = mode.rawValue - let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try updated.write(to: configURL) - } - } catch { - let json: [String: Any] = ["hostingMode": mode.rawValue] - if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { - try? data.write(to: configURL) - } - } - } - - private func loadHostingModeFromConfig() -> HostingMode? { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - guard let data = try? Data(contentsOf: configURL), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let raw = json["hostingMode"] as? String, + private func loadHostingModeFromDefaults() -> HostingMode? { + guard let raw = UserDefaults.standard.string(forKey: "onboarding.cloudProvider"), let mode = HostingMode(rawValue: raw) else { return nil } diff --git a/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift index d61cb8ee092..695790c40b5 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift @@ -10,13 +10,7 @@ struct CloudCredentialsStepView: View { @State private var showTitle = false @State private var showContent = false - @State private var awsRoleArn: String = "" - @State private var gcpProjectId: String = "" - @State private var gcpServiceAccountKey: String = "" @State private var gcpServiceAccountFileName: String = "" - @State private var sshHost: String = "" - @State private var sshUser: String = "" - @State private var sshPrivateKey: String = "" @State private var sshPrivateKeyFileName: String = "" @FocusState private var arnFieldFocused: Bool @@ -68,7 +62,12 @@ struct CloudCredentialsStepView: View { .opacity(showContent ? 1 : 0) .offset(y: showContent ? 0 : 12) .onAppear { - loadCredentialsFromConfig() + if !state.gcpServiceAccountKey.isEmpty { + gcpServiceAccountFileName = "service-account-key.json" + } + if !state.sshPrivateKey.isEmpty { + sshPrivateKeyFileName = "id_rsa" + } withAnimation(.easeOut(duration: 0.5).delay(0.1)) { showTitle = true } @@ -95,7 +94,7 @@ struct CloudCredentialsStepView: View { Text("IAM Role ARN") .font(.system(size: 13, weight: .medium)) .foregroundColor(VColor.textSecondary) - TextField("arn:aws:iam::123456789012:role/VellumAssistantRole", text: $awsRoleArn) + TextField("arn:aws:iam::123456789012:role/VellumAssistantRole", text: $state.awsRoleArn) .textFieldStyle(.plain) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(VColor.textPrimary) @@ -126,7 +125,7 @@ struct CloudCredentialsStepView: View { Text("Host") .font(.system(size: 13, weight: .medium)) .foregroundColor(VColor.textSecondary) - TextField("192.168.1.100 or my-mac-mini.local", text: $sshHost) + TextField("192.168.1.100 or my-mac-mini.local", text: $state.sshHost) .textFieldStyle(.plain) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(VColor.textPrimary) @@ -143,7 +142,7 @@ struct CloudCredentialsStepView: View { Text("Username") .font(.system(size: 13, weight: .medium)) .foregroundColor(VColor.textSecondary) - TextField("admin", text: $sshUser) + TextField("admin", text: $state.sshUser) .textFieldStyle(.plain) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(VColor.textPrimary) @@ -164,7 +163,7 @@ struct CloudCredentialsStepView: View { prompt: "Select SSH Private Key File", onPick: { pickSSHKeyFile() }, onClear: { - sshPrivateKey = "" + state.sshPrivateKey = "" sshPrivateKeyFileName = "" } ) @@ -187,7 +186,7 @@ struct CloudCredentialsStepView: View { Text("Project ID") .font(.system(size: 13, weight: .medium)) .foregroundColor(VColor.textSecondary) - TextField("my-gcp-project-id", text: $gcpProjectId) + TextField("my-gcp-project-id", text: $state.gcpProjectId) .textFieldStyle(.plain) .font(.system(size: 14, weight: .medium, design: .monospaced)) .foregroundColor(VColor.textPrimary) @@ -209,7 +208,7 @@ struct CloudCredentialsStepView: View { prompt: "Select Service Account JSON File", onPick: { pickGCPServiceAccountFile() }, onClear: { - gcpServiceAccountKey = "" + state.gcpServiceAccountKey = "" gcpServiceAccountFileName = "" } ) @@ -322,10 +321,10 @@ struct CloudCredentialsStepView: View { if panel.runModal() == .OK, let url = panel.url { do { let contents = try String(contentsOf: url, encoding: .utf8) - gcpServiceAccountKey = contents + state.gcpServiceAccountKey = contents gcpServiceAccountFileName = url.lastPathComponent } catch { - gcpServiceAccountKey = "" + state.gcpServiceAccountKey = "" gcpServiceAccountFileName = "" } } @@ -343,10 +342,10 @@ struct CloudCredentialsStepView: View { if panel.runModal() == .OK, let url = panel.url { do { let contents = try String(contentsOf: url, encoding: .utf8) - sshPrivateKey = contents + state.sshPrivateKey = contents sshPrivateKeyFileName = url.lastPathComponent } catch { - sshPrivateKey = "" + state.sshPrivateKey = "" sshPrivateKeyFileName = "" } } @@ -356,7 +355,7 @@ struct CloudCredentialsStepView: View { private var continueButton: some View { Button(action: { saveAndContinue() }) { - Text("Continue") + Text(isAws || isCustomHardware ? "Continue" : "Hatch!") .font(.system(size: 15, weight: .medium)) .foregroundColor(adaptiveColor(light: .white, dark: .white)) .frame(maxWidth: .infinity) @@ -419,14 +418,14 @@ struct CloudCredentialsStepView: View { private var continueDisabled: Bool { if isCustomHardware { - return sshHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || sshUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || sshPrivateKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return state.sshHost.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || state.sshUser.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || state.sshPrivateKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } else if isAws { - return awsRoleArn.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return state.awsRoleArn.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } else { - return gcpProjectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - || gcpServiceAccountKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + return state.gcpProjectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty + || state.gcpServiceAccountKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty } } @@ -438,108 +437,11 @@ struct CloudCredentialsStepView: View { private func saveAndContinue() { guard !continueDisabled else { return } - saveCredentialsToConfig() - saveModelToConfig("claude-opus-4-6") if state.cloudProvider == "gcp" { - Task { - try? await cliLauncher.runHatch() - } + state.isHatching = true } state.advance() } - - private func saveModelToConfig(_ model: String) { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - - let dirURL = configURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - - do { - let data = try Data(contentsOf: configURL) - if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - json["model"] = model - let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try updated.write(to: configURL) - } - } catch { - let json: [String: Any] = ["model": model] - if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { - try? data.write(to: configURL) - } - } - } - - private func saveCredentialsToConfig() { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - - let dirURL = configURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - - var credentials: [String: Any] = [:] - if isCustomHardware { - credentials["provider"] = "customHardware" - credentials["sshHost"] = sshHost.trimmingCharacters(in: .whitespacesAndNewlines) - credentials["sshUser"] = sshUser.trimmingCharacters(in: .whitespacesAndNewlines) - credentials["sshPrivateKey"] = sshPrivateKey.trimmingCharacters(in: .whitespacesAndNewlines) - } else if isAws { - credentials["provider"] = "aws" - credentials["roleArn"] = awsRoleArn.trimmingCharacters(in: .whitespacesAndNewlines) - } else { - credentials["provider"] = "gcp" - credentials["projectId"] = gcpProjectId.trimmingCharacters(in: .whitespacesAndNewlines) - credentials["serviceAccountKey"] = gcpServiceAccountKey.trimmingCharacters(in: .whitespacesAndNewlines) - } - - do { - let data = try Data(contentsOf: configURL) - if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - json["cloudCredentials"] = credentials - let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try updated.write(to: configURL) - } - } catch { - let json: [String: Any] = ["cloudCredentials": credentials] - if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { - try? data.write(to: configURL) - } - } - } - - private func loadCredentialsFromConfig() { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - guard let data = try? Data(contentsOf: configURL), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let creds = json["cloudCredentials"] as? [String: Any] else { - return - } - if let roleArn = creds["roleArn"] as? String { - awsRoleArn = roleArn - } - if let projectId = creds["projectId"] as? String { - gcpProjectId = projectId - } - if let key = creds["serviceAccountKey"] as? String { - gcpServiceAccountKey = key - if !key.isEmpty { - gcpServiceAccountFileName = "service-account-key.json" - } - } - if let host = creds["sshHost"] as? String { - sshHost = host - } - if let user = creds["sshUser"] as? String { - sshUser = user - } - if let privKey = creds["sshPrivateKey"] as? String { - sshPrivateKey = privKey - if !privKey.isEmpty { - sshPrivateKeyFileName = "id_rsa" - } - } - } } #Preview("AWS") { diff --git a/clients/macos/vellum-assistant/Features/Onboarding/HatchingStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/HatchingStepView.swift new file mode 100644 index 00000000000..d885b92825a --- /dev/null +++ b/clients/macos/vellum-assistant/Features/Onboarding/HatchingStepView.swift @@ -0,0 +1,237 @@ +import VellumAssistantShared +import SwiftUI + +@MainActor +struct HatchingStepView: View { + @Bindable var state: OnboardingState + + @State private var cliLauncher = CLILauncher() + @State private var showContent = false + @State private var eggWobble = false + @State private var eggCracked = false + @State private var eggHatched = false + @State private var crackScale: CGFloat = 0.0 + @State private var wobbleAngle: Double = 0 + @State private var wobbleTimer: Timer? + @State private var hatchStarted = false + + private var latestLogLine: String { + state.hatchLogLines.last ?? "" + } + + var body: some View { + VStack(spacing: VSpacing.lg) { + Spacer() + + eggAnimation + .padding(.bottom, VSpacing.xl) + + statusText + + logOutput + + Spacer() + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .opacity(showContent ? 1 : 0) + .onAppear { + withAnimation(.easeOut(duration: 0.5)) { + showContent = true + } + startWobble() + if !hatchStarted { + hatchStarted = true + startHatching() + } + } + .onDisappear { + wobbleTimer?.invalidate() + } + .onChange(of: state.hatchCompleted) { _, completed in + if completed { + wobbleTimer?.invalidate() + withAnimation(.spring(duration: 0.6, bounce: 0.3)) { + eggHatched = true + } + } + } + .onChange(of: state.hatchFailed) { _, failed in + if failed { + wobbleTimer?.invalidate() + } + } + } + + // MARK: - Egg Animation + + private var eggAnimation: some View { + ZStack { + if eggHatched && !state.hatchFailed { + hatchedChick + .transition(.scale.combined(with: .opacity)) + } else { + wobbleEgg + .transition(.opacity) + } + } + .frame(width: 120, height: 120) + .animation(.spring(duration: 0.5), value: eggHatched) + } + + private var wobbleEgg: some View { + Text(state.hatchFailed ? "\u{1F480}" : eggCracked ? "\u{1F423}" : "\u{1F95A}") + .font(.system(size: 72)) + .rotationEffect(.degrees(wobbleAngle)) + .scaleEffect(eggCracked ? 1.1 : 1.0) + .animation(.spring(duration: 0.3), value: eggCracked) + } + + private var hatchedChick: some View { + Text("\u{1F425}") + .font(.system(size: 72)) + .scaleEffect(1.2) + } + + // MARK: - Status Text + + private var statusText: some View { + VStack(spacing: VSpacing.sm) { + if state.hatchFailed { + Text("Hatching failed") + .font(.system(size: 24, weight: .regular, design: .serif)) + .foregroundColor(VColor.textPrimary) + } else if state.hatchCompleted { + Text("Your assistant has hatched!") + .font(.system(size: 24, weight: .regular, design: .serif)) + .foregroundColor(VColor.textPrimary) + } else { + Text("Hatching\u{2026}") + .font(.system(size: 24, weight: .regular, design: .serif)) + .foregroundColor(VColor.textPrimary) + + Text("Setting up your assistant on GCP") + .font(.system(size: 14)) + .foregroundColor(VColor.textSecondary) + } + } + } + + // MARK: - Log Output + + private var logOutput: some View { + VStack(spacing: VSpacing.xs) { + ScrollViewReader { proxy in + ScrollView { + VStack(alignment: .leading, spacing: 2) { + ForEach(Array(state.hatchLogLines.enumerated()), id: \.offset) { index, line in + Text(line) + .font(.system(size: 11, design: .monospaced)) + .foregroundColor(VColor.textMuted) + .frame(maxWidth: .infinity, alignment: .leading) + .id(index) + } + } + .padding(VSpacing.sm) + } + .frame(maxWidth: 380, maxHeight: 140) + .background( + RoundedRectangle(cornerRadius: VRadius.lg) + .fill(adaptiveColor( + light: Color(nsColor: NSColor(red: 0.95, green: 0.95, blue: 0.97, alpha: 1)), + dark: VColor.surface.opacity(0.3) + )) + ) + .overlay( + RoundedRectangle(cornerRadius: VRadius.lg) + .stroke(VColor.surfaceBorder.opacity(0.5), lineWidth: 1) + ) + .onChange(of: state.hatchLogLines.count) { _, _ in + if let last = state.hatchLogLines.indices.last { + withAnimation(.easeOut(duration: 0.2)) { + proxy.scrollTo(last, anchor: .bottom) + } + } + } + } + } + .padding(.horizontal, VSpacing.xxl) + .padding(.top, VSpacing.md) + } + + // MARK: - Wobble + + private func startWobble() { + wobbleTimer = Timer.scheduledTimer(withTimeInterval: 0.5, repeats: true) { _ in + Task { @MainActor in + withAnimation(.easeInOut(duration: 0.25)) { + wobbleAngle = Double.random(in: -8...8) + } + try? await Task.sleep(nanoseconds: 250_000_000) + withAnimation(.easeInOut(duration: 0.25)) { + wobbleAngle = 0 + } + } + } + } + + // MARK: - Hatching + + private func startHatching() { + let apiKey = APIKeyManager.getKey() ?? "" + + let config = CLILauncher.RemoteHatchConfig( + remote: state.cloudProvider, + gcpProjectId: state.gcpProjectId, + gcpServiceAccountKey: state.gcpServiceAccountKey, + awsRoleArn: state.awsRoleArn, + sshHost: state.sshHost, + sshUser: state.sshUser, + sshPrivateKey: state.sshPrivateKey, + anthropicApiKey: apiKey + ) + + Task.detached { [config] in + do { + try await cliLauncher.runRemoteHatch(config: config) { line in + Task { @MainActor in + state.hatchLogLines.append(line) + if !eggCracked && state.hatchLogLines.count > 3 { + withAnimation(.spring(duration: 0.4)) { + eggCracked = true + } + } + } + } + await MainActor.run { + state.hatchCompleted = true + } + } catch { + await MainActor.run { + state.hatchLogLines.append("Error: \(error.localizedDescription)") + state.hatchFailed = true + } + } + } + } +} + +#Preview { + ZStack { + VColor.background.ignoresSafeArea() + HatchingStepView(state: { + let s = OnboardingState() + s.isHatching = true + s.cloudProvider = "gcp" + s.hatchLogLines = [ + "Creating new assistant: vellum-abc123", + "Species: vellum", + "Cloud: GCP", + "Project: my-project", + "Zone: us-central1-a", + "Creating instance with startup script...", + ] + return s + }()) + } + .frame(width: 460, height: 620) +} diff --git a/clients/macos/vellum-assistant/Features/Onboarding/ModelSelectionStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/ModelSelectionStepView.swift index 62e947736d0..a788cfa6698 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/ModelSelectionStepView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/ModelSelectionStepView.swift @@ -11,7 +11,6 @@ struct ModelSelectionStepView: View { @State private var showTitle = false @State private var showContent = false - @State private var selectedModel = "claude-opus-4-6" private static let models: [(id: String, name: String, detail: String)] = [ ("claude-opus-4-6", "Opus 4.6", "Most capable"), @@ -85,9 +84,6 @@ struct ModelSelectionStepView: View { .opacity(showContent ? 1 : 0) .offset(y: showContent ? 0 : 12) .onAppear { - if let existing = loadModelFromConfig() { - selectedModel = existing - } withAnimation(.easeOut(duration: 0.5).delay(0.1)) { showTitle = true } @@ -100,8 +96,8 @@ struct ModelSelectionStepView: View { // MARK: - Model Card private func modelCard(id: String, name: String, detail: String) -> some View { - let isSelected = selectedModel == id - return Button(action: { selectedModel = id }) { + let isSelected = state.selectedModel == id + return Button(action: { state.selectedModel = id }) { HStack { VStack(alignment: .leading, spacing: 2) { Text(name) @@ -155,42 +151,8 @@ struct ModelSelectionStepView: View { } private func saveModelAndContinue() { - saveModelToConfig(selectedModel) state.advance() } - - private func saveModelToConfig(_ model: String) { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - - let dirURL = configURL.deletingLastPathComponent() - try? FileManager.default.createDirectory(at: dirURL, withIntermediateDirectories: true) - - do { - let data = try Data(contentsOf: configURL) - if var json = try JSONSerialization.jsonObject(with: data) as? [String: Any] { - json["model"] = model - let updated = try JSONSerialization.data(withJSONObject: json, options: [.prettyPrinted, .sortedKeys]) - try updated.write(to: configURL) - } - } catch { - let json: [String: Any] = ["model": model] - if let data = try? JSONSerialization.data(withJSONObject: json, options: .prettyPrinted) { - try? data.write(to: configURL) - } - } - } - - private func loadModelFromConfig() -> String? { - let configURL = FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".vellum/workspace/config.json") - guard let data = try? Data(contentsOf: configURL), - let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], - let model = json["model"] as? String else { - return nil - } - return model - } } #Preview { diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingFlowView.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingFlowView.swift index bf2e5c982af..22a54fa39c6 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingFlowView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingFlowView.swift @@ -20,7 +20,22 @@ struct OnboardingFlowView: View { ZStack { VColor.background.ignoresSafeArea() - if (0...maxOnboardingStep).contains(state.currentStep) { + if state.isHatching { + HatchingStepView(state: state) + .frame(maxWidth: .infinity, maxHeight: .infinity) + .background( + RadialGradient( + colors: [ + adaptiveColor(light: Slate._100, dark: Slate._900), + adaptiveColor(light: Slate._200, dark: Slate._950) + ], + center: .center, + startRadius: 0, + endRadius: 500 + ) + .ignoresSafeArea() + ) + } else if (0...maxOnboardingStep).contains(state.currentStep) { // Trimmed onboarding flow. // When userHostedEnabled: WakeUp → APIKey → CloudCredentials (steps 0–2) // Otherwise: WakeUp → APIKey (steps 0–1) diff --git a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift index 7dda9f27f59..24eb9b6c4f7 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/OnboardingState.swift @@ -47,6 +47,19 @@ final class OnboardingState { /// When false, step changes are not written to UserDefaults (used by auth gate). var shouldPersist: Bool = true + // Cloud credentials held in memory during onboarding (never written to .vellum) + var gcpProjectId: String = "" + var gcpServiceAccountKey: String = "" + var awsRoleArn: String = "" + var sshHost: String = "" + var sshUser: String = "" + var sshPrivateKey: String = "" + var selectedModel: String = "claude-opus-4-6" + var isHatching: Bool = false + var hatchLogLines: [String] = [] + var hatchCompleted: Bool = false + var hatchFailed: Bool = false + // First-meeting-specific state var firstMeetingCrackProgress: CGFloat = 0.0 var conversationCompleted: Bool = false From a5462b47590a6ac18eceb40922d2cd6329ea9e7e Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 02:19:39 +0000 Subject: [PATCH 2/3] Address PR feedback: use env vars directly instead of gcloud activation, always show Hatch! button Co-Authored-By: vargas@vellum.ai --- cli/src/commands/hatch.ts | 18 +----------------- .../vellum-assistant/App/CLILauncher.swift | 1 - .../Onboarding/CloudCredentialsStepView.swift | 6 ++---- 3 files changed, 3 insertions(+), 22 deletions(-) diff --git a/cli/src/commands/hatch.ts b/cli/src/commands/hatch.ts index 2133c4807fe..63d6d580f59 100644 --- a/cli/src/commands/hatch.ts +++ b/cli/src/commands/hatch.ts @@ -429,23 +429,7 @@ interface WorkspaceConfig { } async function activateGcpCredentials(): Promise { - const envKeyPath = process.env.VELLUM_GCP_SA_KEY_PATH; - if (envKeyPath && existsSync(envKeyPath)) { - try { - await exec("gcloud", [ - "auth", - "activate-service-account", - `--key-file=${envKeyPath}`, - ]); - const project = process.env.GCP_PROJECT; - if (project) { - await exec("gcloud", ["config", "set", "project", project]); - } - } finally { - try { - unlinkSync(envKeyPath); - } catch {} - } + if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { return; } diff --git a/clients/macos/vellum-assistant/App/CLILauncher.swift b/clients/macos/vellum-assistant/App/CLILauncher.swift index 287bacafa89..0c480d2934f 100644 --- a/clients/macos/vellum-assistant/App/CLILauncher.swift +++ b/clients/macos/vellum-assistant/App/CLILauncher.swift @@ -110,7 +110,6 @@ final class CLILauncher { .appendingPathComponent("vellum-sa-key-\(ProcessInfo.processInfo.processIdentifier).json") try config.gcpServiceAccountKey.write(to: tmpKeyPath, atomically: true, encoding: .utf8) env["GOOGLE_APPLICATION_CREDENTIALS"] = tmpKeyPath.path - env["VELLUM_GCP_SA_KEY_PATH"] = tmpKeyPath.path } } else if config.remote == "aws" { if !config.awsRoleArn.isEmpty { diff --git a/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift b/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift index 695790c40b5..b39cc85547d 100644 --- a/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift +++ b/clients/macos/vellum-assistant/Features/Onboarding/CloudCredentialsStepView.swift @@ -355,7 +355,7 @@ struct CloudCredentialsStepView: View { private var continueButton: some View { Button(action: { saveAndContinue() }) { - Text(isAws || isCustomHardware ? "Continue" : "Hatch!") + Text("Hatch!") .font(.system(size: 15, weight: .medium)) .foregroundColor(adaptiveColor(light: .white, dark: .white)) .frame(maxWidth: .infinity) @@ -437,9 +437,7 @@ struct CloudCredentialsStepView: View { private func saveAndContinue() { guard !continueDisabled else { return } - if state.cloudProvider == "gcp" { - state.isHatching = true - } + state.isHatching = true state.advance() } } From a1cb20fcfea8d654ecf31a6034811ffc9e759bd3 Mon Sep 17 00:00:00 2001 From: Devin AI <158243242+devin-ai-integration[bot]@users.noreply.github.com> Date: Thu, 19 Feb 2026 11:36:46 +0000 Subject: [PATCH 3/3] Use --account flag for gcloud commands, remove activateGcpCredentials and VELLUM_HATCH_ENTRY_FILE Co-Authored-By: vargas@vellum.ai --- cli/src/commands/hatch.ts | 104 +++++++----------- cli/src/lib/gcp.ts | 42 ++++--- .../vellum-assistant/App/CLILauncher.swift | 10 +- 3 files changed, 71 insertions(+), 85 deletions(-) diff --git a/cli/src/commands/hatch.ts b/cli/src/commands/hatch.ts index 63d6d580f59..fb7fd36e2e8 100644 --- a/cli/src/commands/hatch.ts +++ b/cli/src/commands/hatch.ts @@ -1,7 +1,7 @@ import { spawn } from "child_process"; import { randomBytes } from "crypto"; -import { existsSync, readFileSync, unlinkSync, writeFileSync } from "fs"; -import { homedir, tmpdir, userInfo } from "os"; +import { existsSync, unlinkSync, writeFileSync } from "fs"; +import { tmpdir, userInfo } from "os"; import { join } from "path"; import { buildOpenclawStartupScript } from "../adapters/openclaw"; @@ -197,6 +197,7 @@ async function pollInstance( instanceName: string, project: string, zone: string, + account?: string, ): Promise { try { const remoteCmd = @@ -204,7 +205,7 @@ async function pollInstance( "S=$(systemctl is-active google-startup-scripts.service 2>/dev/null || true); " + "E=$(cat /var/log/startup-error 2>/dev/null || true); " + 'printf "%s\\n===HATCH_SEP===\\n%s\\n===HATCH_ERR===\\n%s" "$L" "$S" "$E"'; - const output = await execOutput("gcloud", [ + const args = [ "compute", "ssh", instanceName, @@ -216,7 +217,9 @@ async function pollInstance( "--ssh-flag=-o ConnectTimeout=10", "--ssh-flag=-o LogLevel=ERROR", `--command=${remoteCmd}`, - ]); + ]; + if (account) args.push(`--account=${account}`); + const output = await execOutput("gcloud", args); const sepIdx = output.indexOf("===HATCH_SEP==="); if (sepIdx === -1) { return { lastLine: output.trim() || null, done: false, failed: false }; @@ -258,9 +261,10 @@ async function checkCurlFailure( instanceName: string, project: string, zone: string, + account?: string, ): Promise { try { - const output = await execOutput("gcloud", [ + const args = [ "compute", "ssh", instanceName, @@ -272,7 +276,9 @@ async function checkCurlFailure( "--ssh-flag=-o ConnectTimeout=10", "--ssh-flag=-o LogLevel=ERROR", `--command=test -s ${INSTALL_SCRIPT_REMOTE_PATH} && echo EXISTS || echo MISSING`, - ]); + ]; + if (account) args.push(`--account=${account}`); + const output = await execOutput("gcloud", args); return output.trim() === "MISSING"; } catch { return false; @@ -284,30 +290,35 @@ async function recoverFromCurlFailure( project: string, zone: string, sshUser: string, + account?: string, ): Promise { if (!existsSync(INSTALL_SCRIPT_PATH)) { throw new Error(`Install script not found at ${INSTALL_SCRIPT_PATH}`); } - console.log("📋 Uploading install script to instance..."); - await exec("gcloud", [ + const scpArgs = [ "compute", "scp", INSTALL_SCRIPT_PATH, `${instanceName}:${INSTALL_SCRIPT_REMOTE_PATH}`, `--zone=${zone}`, `--project=${project}`, - ]); + ]; + if (account) scpArgs.push(`--account=${account}`); + console.log("📋 Uploading install script to instance..."); + await exec("gcloud", scpArgs); - console.log("🔧 Running install script on instance..."); - await exec("gcloud", [ + const sshArgs = [ "compute", "ssh", `${sshUser}@${instanceName}`, `--zone=${zone}`, `--project=${project}`, `--command=source ${INSTALL_SCRIPT_REMOTE_PATH}`, - ]); + ]; + if (account) sshArgs.push(`--account=${account}`); + console.log("🔧 Running install script on instance..."); + await exec("gcloud", sshArgs); } export async function watchHatching( @@ -418,49 +429,6 @@ export async function watchHatching( }); } -interface CloudCredentials { - provider: string; - projectId?: string; - serviceAccountKey?: string; -} - -interface WorkspaceConfig { - cloudCredentials?: CloudCredentials; -} - -async function activateGcpCredentials(): Promise { - if (process.env.GOOGLE_APPLICATION_CREDENTIALS) { - return; - } - - const configPath = join(homedir(), ".vellum", "workspace", "config.json"); - let config: WorkspaceConfig; - try { - config = JSON.parse(readFileSync(configPath, "utf8")) as WorkspaceConfig; - } catch { - return; - } - - const creds = config.cloudCredentials; - if (!creds || creds.provider !== "gcp" || !creds.serviceAccountKey || !creds.projectId) { - return; - } - - const keyPath = join(tmpdir(), `vellum-sa-key-${Date.now()}.json`); - writeFileSync(keyPath, creds.serviceAccountKey); - try { - await exec("gcloud", [ - "auth", - "activate-service-account", - `--key-file=${keyPath}`, - ]); - await exec("gcloud", ["config", "set", "project", creds.projectId]); - } finally { - try { - unlinkSync(keyPath); - } catch {} - } -} async function hatchGcp( species: Species, @@ -468,8 +436,8 @@ async function hatchGcp( name: string | null, ): Promise { const startTime = Date.now(); + const account = process.env.GCP_ACCOUNT_EMAIL; try { - await activateGcpCredentials(); const project = process.env.GCP_PROJECT ?? (await getActiveProject()); let instanceName: string; @@ -495,14 +463,14 @@ async function hatchGcp( console.log(""); if (name) { - if (await instanceExists(name, project, zone)) { + if (await instanceExists(name, project, zone, account)) { console.error( `Error: Instance name '${name}' is already taken. Please choose a different name.`, ); process.exit(1); } } else { - while (await instanceExists(instanceName, project, zone)) { + while (await instanceExists(instanceName, project, zone, account)) { console.log(`⚠️ Instance name ${instanceName} already exists, generating a new name...`); const suffix = generateRandomSuffix(); instanceName = `${species}-${suffix}`; @@ -522,7 +490,7 @@ async function hatchGcp( console.log("🔨 Creating instance with startup script..."); try { - await exec("gcloud", [ + const createArgs = [ "compute", "instances", "create", @@ -537,7 +505,9 @@ async function hatchGcp( `--metadata-from-file=startup-script=${startupScriptPath}`, `--labels=species=${species},vellum-assistant=true`, "--tags=vellum-assistant", - ]); + ]; + if (account) createArgs.push(`--account=${account}`); + await exec("gcloud", createArgs); } finally { try { unlinkSync(startupScriptPath); @@ -545,13 +515,13 @@ async function hatchGcp( } console.log("🔒 Syncing firewall rules..."); - await syncFirewallRules(DESIRED_FIREWALL_RULES, project, FIREWALL_TAG); + await syncFirewallRules(DESIRED_FIREWALL_RULES, project, FIREWALL_TAG, account); console.log(`✅ Instance ${instanceName} created successfully\n`); let externalIp: string | null = null; try { - const ipOutput = await execOutput("gcloud", [ + const describeArgs = [ "compute", "instances", "describe", @@ -559,7 +529,9 @@ async function hatchGcp( `--project=${project}`, `--zone=${zone}`, "--format=get(networkInterfaces[0].accessConfigs[0].natIP)", - ]); + ]; + if (account) describeArgs.push(`--account=${account}`); + const ipOutput = await execOutput("gcloud", describeArgs); externalIp = ipOutput.trim() || null; } catch { console.log("⚠️ Could not retrieve external IP yet (instance may still be starting)"); @@ -598,7 +570,7 @@ async function hatchGcp( console.log(""); const success = await watchHatching( - () => pollInstance(instanceName, project, zone), + () => pollInstance(instanceName, project, zone, account), instanceName, startTime, species, @@ -607,11 +579,11 @@ async function hatchGcp( if (!success) { if ( species === "vellum" && - (await checkCurlFailure(instanceName, project, zone)) + (await checkCurlFailure(instanceName, project, zone, account)) ) { console.log(""); console.log("🔄 Detected install script curl failure, attempting recovery..."); - await recoverFromCurlFailure(instanceName, project, zone, sshUser); + await recoverFromCurlFailure(instanceName, project, zone, sshUser, account); console.log("✅ Recovery successful!"); } else { console.log(""); diff --git a/cli/src/lib/gcp.ts b/cli/src/lib/gcp.ts index 24598d0549e..9f4e21c5672 100644 --- a/cli/src/lib/gcp.ts +++ b/cli/src/lib/gcp.ts @@ -41,16 +41,19 @@ interface FirewallRuleState { async function describeFirewallRule( ruleName: string, project: string, + account?: string, ): Promise { try { - const output = await execOutput("gcloud", [ + const args = [ "compute", "firewall-rules", "describe", ruleName, `--project=${project}`, "--format=json(name,direction,allowed,sourceRanges,destinationRanges,targetTags,description)", - ]); + ]; + if (account) args.push(`--account=${account}`); + const output = await execOutput("gcloud", args); const parsed = JSON.parse(output); const allowed = (parsed.allowed ?? []) .map((a: { IPProtocol: string; ports?: string[] }) => { @@ -87,7 +90,7 @@ function ruleNeedsUpdate(spec: FirewallRuleSpec, state: FirewallRuleState): bool ); } -async function createFirewallRule(spec: FirewallRuleSpec, project: string): Promise { +async function createFirewallRule(spec: FirewallRuleSpec, project: string, account?: string): Promise { const args = [ "compute", "firewall-rules", @@ -106,35 +109,41 @@ async function createFirewallRule(spec: FirewallRuleSpec, project: string): Prom if (spec.destinationRanges) { args.push(`--destination-ranges=${spec.destinationRanges}`); } + if (account) args.push(`--account=${account}`); await exec("gcloud", args); } -async function deleteFirewallRule(ruleName: string, project: string): Promise { - await exec("gcloud", [ +async function deleteFirewallRule(ruleName: string, project: string, account?: string): Promise { + const args = [ "compute", "firewall-rules", "delete", ruleName, `--project=${project}`, "--quiet", - ]); + ]; + if (account) args.push(`--account=${account}`); + await exec("gcloud", args); } export async function syncFirewallRules( desiredRules: FirewallRuleSpec[], project: string, tag: string, + account?: string, ): Promise { let existingNames: string[]; try { - const output = await execOutput("gcloud", [ + const listArgs = [ "compute", "firewall-rules", "list", `--project=${project}`, `--filter=targetTags:${tag}`, "--format=value(name)", - ]); + ]; + if (account) listArgs.push(`--account=${account}`); + const output = await execOutput("gcloud", listArgs); existingNames = output .split("\n") .map((s) => s.trim()) @@ -148,23 +157,23 @@ export async function syncFirewallRules( for (const existingName of existingNames) { if (!desiredNames.has(existingName)) { console.log(` 🗑️ Deleting stale firewall rule: ${existingName}`); - await deleteFirewallRule(existingName, project); + await deleteFirewallRule(existingName, project, account); } } for (const spec of desiredRules) { - const state = await describeFirewallRule(spec.name, project); + const state = await describeFirewallRule(spec.name, project, account); if (!state) { console.log(` ➕ Creating firewall rule: ${spec.name}`); - await createFirewallRule(spec, project); + await createFirewallRule(spec, project, account); continue; } if (ruleNeedsUpdate(spec, state)) { console.log(` 🔄 Updating firewall rule: ${spec.name}`); - await deleteFirewallRule(spec.name, project); - await createFirewallRule(spec, project); + await deleteFirewallRule(spec.name, project, account); + await createFirewallRule(spec, project, account); continue; } @@ -224,9 +233,10 @@ export async function instanceExists( instanceName: string, project: string, zone: string, + account?: string, ): Promise { try { - await execOutput("gcloud", [ + const args = [ "compute", "instances", "describe", @@ -234,7 +244,9 @@ export async function instanceExists( `--project=${project}`, `--zone=${zone}`, "--format=get(name)", - ]); + ]; + if (account) args.push(`--account=${account}`); + await execOutput("gcloud", args); return true; } catch { return false; diff --git a/clients/macos/vellum-assistant/App/CLILauncher.swift b/clients/macos/vellum-assistant/App/CLILauncher.swift index 0c480d2934f..5b514ac370f 100644 --- a/clients/macos/vellum-assistant/App/CLILauncher.swift +++ b/clients/macos/vellum-assistant/App/CLILauncher.swift @@ -110,6 +110,12 @@ final class CLILauncher { .appendingPathComponent("vellum-sa-key-\(ProcessInfo.processInfo.processIdentifier).json") try config.gcpServiceAccountKey.write(to: tmpKeyPath, atomically: true, encoding: .utf8) env["GOOGLE_APPLICATION_CREDENTIALS"] = tmpKeyPath.path + + if let data = config.gcpServiceAccountKey.data(using: .utf8), + let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let email = json["client_email"] as? String { + env["GCP_ACCOUNT_EMAIL"] = email + } } } else if config.remote == "aws" { if !config.awsRoleArn.isEmpty { @@ -134,10 +140,6 @@ final class CLILauncher { } } - let entryFile = FileManager.default.temporaryDirectory - .appendingPathComponent("vellum-hatch-entry-\(ProcessInfo.processInfo.processIdentifier).json") - env["VELLUM_HATCH_ENTRY_FILE"] = entryFile.path - proc.environment = env try proc.run()