// SPDX-License-Identifier: PolyForm-Noncommercial-1.0.0 // Copyright (C) 2026 Rune Olsen import SwiftUI /// Shown when an External MCP server's stdio `command` (typically `npx`) couldn't be found on /// PATH — the common cause is Node.js not being installed, or being installed somewhere Confab's /// PATH probing doesn't check. Offers a copyable install command, a one-click install via Homebrew /// (if present), and a link to the official Node.js installer as a fallback. struct NodeInstallHelpSheet: View { let missingCommand: String let onRetry: () -> Void let onDone: () -> Void @State private var isInstalling = false @State private var installResult: NodeInstallHelper.InstallResult? @State private var copiedFeedback = false private var homebrewPrefix: String? { LoginShellEnvironment.homebrewPrefix() } var body: some View { VStack(alignment: .leading, spacing: 20) { HStack(spacing: 12) { Image(systemName: "shippingbox") .font(.title2) .foregroundStyle(.orange) VStack(alignment: .leading, spacing: 2) { Text("Node.js Required") .font(.system(size: 17, weight: .semibold)) Text("'\(missingCommand)' couldn't be found on your Mac") .font(.system(size: 13)) .foregroundStyle(.secondary) } Spacer() } Text("This MCP server is launched with '\(missingCommand)', which comes bundled with Node.js. Confab checked its usual install locations (Homebrew, MacPorts, Volta, nvm) and didn't find it there either.") .font(.system(size: 13)) .fixedSize(horizontal: false, vertical: true) if let homebrewPrefix { VStack(alignment: .leading, spacing: 8) { Text("Homebrew is installed — this is the easiest way to install Node.js:") .font(.system(size: 13, weight: .medium)) HStack(spacing: 8) { Text("brew install node") .font(.system(size: 13, design: .monospaced)) .textSelection(.enabled) .padding(.horizontal, 10) .padding(.vertical, 6) .background(Color.secondary.opacity(0.1)) .clipShape(RoundedRectangle(cornerRadius: 6)) Button(copiedFeedback ? "Copied" : "Copy") { NSPasteboard.general.clearContents() NSPasteboard.general.setString("brew install node", forType: .string) copiedFeedback = true } .buttonStyle(.bordered) } if isInstalling { HStack(spacing: 8) { ProgressView().controlSize(.small) Text("Installing — this can take a minute…") .font(.system(size: 12)) .foregroundStyle(.secondary) } } else { Button("Install Now") { Task { isInstalling = true installResult = await NodeInstallHelper.installNode(homebrewPrefix: homebrewPrefix) isInstalling = false } } .buttonStyle(.borderedProminent) } if let installResult { VStack(alignment: .leading, spacing: 4) { Text(installResult.success ? "✓ Installed successfully" : "✗ Install failed") .font(.system(size: 13, weight: .medium)) .foregroundStyle(installResult.success ? .green : .red) if !installResult.success && !installResult.output.isEmpty { ScrollView { Text(installResult.output) .font(.system(size: 11, design: .monospaced)) .textSelection(.enabled) .frame(maxWidth: .infinity, alignment: .leading) } .frame(maxHeight: 120) .padding(8) .background(Color.secondary.opacity(0.08)) .clipShape(RoundedRectangle(cornerRadius: 6)) } } } } .padding(12) .background(Color.secondary.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) } else { VStack(alignment: .leading, spacing: 8) { Text("Homebrew wasn't found either. Install Node.js directly from its official installer:") .font(.system(size: 13)) .fixedSize(horizontal: false, vertical: true) Button("Open nodejs.org") { NSWorkspace.shared.open(URL(string: "https://nodejs.org")!) } .buttonStyle(.bordered) } .padding(12) .background(Color.secondary.opacity(0.05)) .clipShape(RoundedRectangle(cornerRadius: 10)) } Text("After installing, click Retry Connection below to try again.") .font(.system(size: 12)) .foregroundStyle(.secondary) HStack { Button("Open nodejs.org") { NSWorkspace.shared.open(URL(string: "https://nodejs.org")!) } Spacer() Button("Done") { onDone() } Button("Retry Connection") { onRetry() onDone() } .buttonStyle(.borderedProminent) } } .padding(24) .frame(minWidth: 480, minHeight: 340) } }