diff --git a/AGENTS.md b/AGENTS.md index c90b9ea8..4076e8f9 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -217,7 +217,7 @@ When to use a branch instance: Standard workflow: - If `.hack/` is missing: `hack init` - Start services: `hack up --detach` -- Check status: `hack ps` or `hack projects status` +- Check status: `hack ps` or `hack status` - Open app: `hack open` (use `--json` for machine parsing) - Stop services: `hack down` @@ -249,22 +249,15 @@ Docker compose notes: - Prefer `hack` commands; they include the right files/networks. - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. -Sessions (tmux-based): -- Interactive picker: `hack session` (requires fzf) -- Start/attach: `hack session start ` (attaches if exists) +Sessions (mux-based): +- Interactive picker: `hack session` (clack picker; switches inside tmux, attaches outside) +- Start/attach: `hack session start ` (attaches if exists, switches if in tmux) - Force new: `hack session start --new --name agent-1` - With infra: `hack session start --up` - List: `hack session list` - Stop: `hack session stop ` - Exec in session: `hack session exec ""` -- List panes: `hack session panes [--pretty]` -- Capture pane output (NDJSON, defaults to active pane): `hack session capture [--pretty]` -- Tail pane output (short window, defaults to active pane): `hack session tail [--pretty]` -- Setup tmux: `hack setup tmux` (installs tmux if missing) - -Supervisor (remote jobs): -- Use `hack supervisor` when you need long-running tasks on remote hosts, scheduled jobs, or jobs that must outlive your local machine. -- Prefer sessions for interactive tmux work; prefer supervisor for detached/background jobs. +- Setup tmux: `hack setup tmux` (adds a keybinding; requires tmux installed) Agent setup (CLI-first): - Cursor rules: `hack setup cursor` diff --git a/CLAUDE.md b/CLAUDE.md index e372e75d..176a8af4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -112,7 +112,7 @@ When to use a branch instance: Standard workflow: - If `.hack/` is missing: `hack init` - Start services: `hack up --detach` -- Check status: `hack ps` or `hack projects status` +- Check status: `hack ps` or `hack status` - Open app: `hack open` (use `--json` for machine parsing) - Stop services: `hack down` @@ -144,22 +144,15 @@ Docker compose notes: - Prefer `hack` commands; they include the right files/networks. - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. -Sessions (tmux-based): -- Interactive picker: `hack session` (requires fzf) -- Start/attach: `hack session start ` (attaches if exists) +Sessions (mux-based): +- Interactive picker: `hack session` (clack picker; switches inside tmux, attaches outside) +- Start/attach: `hack session start ` (attaches if exists, switches if in tmux) - Force new: `hack session start --new --name agent-1` - With infra: `hack session start --up` - List: `hack session list` - Stop: `hack session stop ` - Exec in session: `hack session exec ""` -- List panes: `hack session panes [--pretty]` -- Capture pane output (NDJSON, defaults to active pane): `hack session capture [--pretty]` -- Tail pane output (short window, defaults to active pane): `hack session tail [--pretty]` -- Setup tmux: `hack setup tmux` (installs tmux if missing) - -Supervisor (remote jobs): -- Use `hack supervisor` when you need long-running tasks on remote hosts, scheduled jobs, or jobs that must outlive your local machine. -- Prefer sessions for interactive tmux work; prefer supervisor for detached/background jobs. +- Setup tmux: `hack setup tmux` (adds a keybinding; requires tmux installed) Agent setup (CLI-first): - Cursor rules: `hack setup cursor` diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 5b287ef9..ab527a36 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -32,6 +32,7 @@ public enum ProjectTab: String, CaseIterable { @MainActor public final class DashboardModel { public private(set) var projects: [ProjectSummary] = [] + public private(set) var projectMetaById: [String: ProjectMeta] = [:] public private(set) var daemonStatus: DaemonStatus? = nil public private(set) var globalStatus: GlobalStatusResponse? = nil public private(set) var runtimeOk: Bool? = nil @@ -101,11 +102,13 @@ public final class DashboardModel { lastUpdated = Date() } + let selectedProjectForMeta = selectedProject async let projectsTask = fetchProjects() + async let metaTask = fetchProjectMeta(for: selectedProjectForMeta) async let daemonTask = fetchDaemonStatus() async let globalTask = fetchGlobalStatus() - let errors = await [projectsTask, daemonTask, globalTask].compactMap { $0 } + let errors = await [projectsTask, metaTask, daemonTask, globalTask].compactMap { $0 } if !errors.isEmpty { errorMessage = errors.joined(separator: "\n") } @@ -155,6 +158,18 @@ public final class DashboardModel { } } + public func stopSession(sessionName: String) async { + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + errorMessage = "Missing session name" + return + } + + await runAction(message: "Stopping session…") { + try await self.client.stopSession(sessionName: trimmed) + } + } + public func showLogs(for project: ProjectSummary) { selectedItem = .project(project.id) if selectedProjectTab == .logs { @@ -291,6 +306,19 @@ public final class DashboardModel { } } + private func fetchProjectMeta(for project: ProjectSummary?) async -> String? { + guard let project else { return nil } + + do { + if let meta = try await client.fetchProjectMeta(projectName: project.name) { + projectMetaById[project.id] = meta + } + return nil + } catch { + return error.localizedDescription + } + } + private func fetchDaemonStatus() async -> String? { do { daemonStatus = try await client.daemonStatus() diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index f2f89830..c26f8ec3 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -153,6 +153,7 @@ public struct DashboardView: View { runtimeConfigured: nil, runtimeStatus: nil, runtime: nil, + meta: nil, kind: .unregistered, status: .unknown ) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift index b8e3f6f2..f322889e 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift @@ -19,6 +19,7 @@ final class GhosttyTerminalSession { enum Mode { case logs(path: String) case shell(workingDirectory: URL) + case sessionAttach(sessionName: String, workingDirectory: URL?) } let project: ProjectSummary @@ -42,10 +43,14 @@ final class GhosttyTerminalSession { private var initialCommand: String? var allowsInput: Bool { - if case .shell = mode { + switch mode { + case .shell: + return true + case .sessionAttach: return true + case .logs: + return false } - return false } init(project: ProjectSummary) { @@ -120,7 +125,14 @@ final class GhosttyTerminalSession { self?.handleReadableData(handle) } self.pty = pty - statusMessage = allowsInput ? "Shell ready" : "Streaming logs…" + switch mode { + case .shell: + statusMessage = "Shell ready" + case .logs: + statusMessage = "Streaming logs…" + case let .sessionAttach(sessionName, _): + statusMessage = "Attached: \(sessionName)" + } flushPendingWrites() startRefreshLoop() } catch { @@ -326,6 +338,32 @@ final class GhosttyTerminalSession { environment: environment, workingDirectory: workingDirectory ) + case let .sessionAttach(sessionName, workingDirectory): + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + return TerminalCommand( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["echo", "Missing session name"], + environment: environment, + workingDirectory: workingDirectory + ) + } + + if let hackPath = HackCLILocator.resolveHackExecutable(in: environment) { + return TerminalCommand( + executableURL: URL(fileURLWithPath: hackPath), + arguments: ["session", "attach", trimmed], + environment: environment, + workingDirectory: workingDirectory + ) + } + + return TerminalCommand( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["hack", "session", "attach", trimmed], + environment: environment, + workingDirectory: workingDirectory + ) } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift index 60ad2c03..e2fb2947 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift @@ -382,3 +382,4 @@ struct GlobalStatusStrip: View { } } } + diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 7812a789..e05f948d 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -5,29 +5,41 @@ import HackDesktopModels struct ProjectDetailView: View { @Environment(DashboardModel.self) private var model @Environment(\.openURL) private var openURL - @Environment(\.colorScheme) private var colorScheme let project: ProjectSummary - @State private var showOverviewSidebar = true + @State private var showInspectorSidebar = true @State private var selectedService: String? = nil @State private var hoveredService: String? = nil @State private var isControlBarHovered = false @State private var hoveredControl: ProjectTab? = nil @State private var isStartHovered = false @State private var isStopHovered = false - @State private var showInfoPanel = false + @State private var activeSession: MuxSessionSummary? = nil + @State private var pendingStopSession: MuxSessionSummary? = nil var body: some View { @Bindable var model = model - tabContent - .id(effectiveTab) - .transition(.opacity.combined(with: .move(edge: .trailing))) - .animation(.easeInOut(duration: 0.2), value: effectiveTab) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .overlay(alignment: .bottom) { - bottomControlBar - .padding(.bottom, 18) + ZStack(alignment: .top) { + VStack(alignment: .leading, spacing: 0) { + tabContent + .id(effectiveTab) + .transition(.opacity.combined(with: .move(edge: .trailing))) + .animation(.easeInOut(duration: 0.2), value: effectiveTab) + .padding(.top, headerHeight + 8) + .padding(.bottom, 72) } + + header + .padding(.horizontal, 24) + .padding(.top, 10) + .padding(.bottom, 8) + .frame(maxWidth: .infinity, alignment: .leading) + .background(topFadeOverlay) + } + .overlay(alignment: .bottom) { + bottomControlBar + .padding(.bottom, 18) + } .onAppear { ensureSelectedTab() } .onChange(of: project.id) { _, _ in ensureSelectedTab() @@ -35,19 +47,55 @@ struct ProjectDetailView: View { .onChange(of: model.selectedProjectTab) { _, _ in ensureSelectedTab() } + .sheet(item: $activeSession) { session in + SessionAttachView(project: project, session: session) + } + .confirmationDialog( + "Stop session?", + isPresented: Binding( + get: { pendingStopSession != nil }, + set: { value in + if value == false { pendingStopSession = nil } + } + ) + ) { + Button("Stop", role: .destructive) { + guard let session = pendingStopSession else { return } + pendingStopSession = nil + Task { + await model.stopSession(sessionName: session.name) + await model.refresh() + } + } + Button("Cancel", role: .cancel) { + pendingStopSession = nil + } + } message: { + if let session = pendingStopSession { + Text("This will kill \(session.name).") + } + } } @ViewBuilder private var tabContent: some View { switch effectiveTab { case .overview: - overviewContent + projectTabContainer { + overviewContent + } case .logs: - terminalMovedCard(kind: .logs) + projectTabContainer { + terminalMovedCard(kind: .logs) + } case .shell: - terminalMovedCard(kind: .shell) + projectTabContainer { + terminalMovedCard(kind: .shell) + } case .tickets: - TicketsView(project: project) + projectTabContainer { + TicketsView(project: project) + } } } @@ -66,45 +114,172 @@ struct ProjectDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var overviewContent: some View { - ScrollView { - HStack(alignment: .top, spacing: 24) { - VStack(alignment: .leading, spacing: 20) { - if !project.isRuntimeConfigured { - runtimeNotConfiguredCard - } - servicesSection - if showInfoPanel { - infoSection - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } + private func openTerminal(kind: TerminalDrawerModel.Kind) { + switch kind { + case .logs: + model.showLogs(for: project) + case .shell: + model.showShell(for: project) + } + } + + private func projectTabContainer(@ViewBuilder content: () -> some View) -> some View { + HStack(alignment: .top, spacing: 0) { + content() .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - if showOverviewSidebar, selectedService != nil { - Divider() - .opacity(0.2) - .transition(.opacity) - serviceDetailPanel - .frame(minWidth: 260, idealWidth: 300, maxWidth: 360, maxHeight: .infinity, alignment: .topLeading) - .transition(.move(edge: .trailing).combined(with: .opacity)) + if showInspectorSidebar { + Divider() + .opacity(0.2) + .transition(.opacity) + + ProjectInspectorColumn( + project: project, + meta: projectMeta, + selectedService: $selectedService, + onAttachSession: { session in + activeSession = session + }, + onStopSession: { session in + pendingStopSession = session + }, + onShowLogs: { + model.showLogs(for: project) + }, + onShowShell: { + model.showShell(for: project) + } + ) + .frame( + minWidth: 260, + idealWidth: 320, + maxWidth: 380, + maxHeight: .infinity, + alignment: .topLeading + ) + .transition(.move(edge: .trailing).combined(with: .opacity)) + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .background( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: 18, style: .continuous) + .stroke(Color.white.opacity(0.06), lineWidth: 1) + ) + ) + .padding(.horizontal, 24) + .padding(.bottom, 32) + .animation(.easeInOut(duration: 0.2), value: showInspectorSidebar) + } + + private var overviewContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !project.isRuntimeConfigured { + runtimeNotConfiguredCard } + servicesSection } .padding(24) .frame(maxWidth: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .fill(.ultraThinMaterial) - .overlay( - RoundedRectangle(cornerRadius: 18, style: .continuous) - .stroke(Color.white.opacity(0.06), lineWidth: 1) - ) - ) - .padding(.horizontal, 24) - .padding(.bottom, 32) } } + private var header: some View { + HStack(alignment: .center, spacing: 12) { + Image(systemName: project.isRuntimeConfigured ? "cube.transparent.fill" : "puzzlepiece.extension.fill") + .font(.mono(.title2)) + .foregroundStyle(project.isRuntimeConfigured ? .blue : .purple) + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .center, spacing: 8) { + Text(project.name) + .font(.mono(.headline, weight: .semibold)) + headerStatus + } + if let headerSubtitle { + Text(headerSubtitle) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + Spacer() + primaryActionsBar + Button { + withAnimation(.easeInOut(duration: 0.2)) { + showInspectorSidebar.toggle() + } + } label: { + Image(systemName: "sidebar.trailing") + .font(.mono(.title3)) + } + .buttonStyle(PressableCircleButtonStyle()) + .accessibilityLabel(showInspectorSidebar ? "Hide details sidebar" : "Show details sidebar") + } + } + + @ViewBuilder + private var headerStatus: some View { + if project.isRuntimeConfigured { + RuntimeStatusBadge(status: runtimeStatus, runtimeHealthy: runtimeHealthy) + } else { + if let label = project.featureLabel { + LabelBadge(label: label, color: .purple) + } else { + LabelBadge(label: "Extensions", color: .purple) + } + } + } + + private var headerSubtitle: String? { + if let devHost = project.devHost { + return devHost + } + if let featureSummary = project.featureSummary { + return featureSummary + } + return nil + } + + private var primaryActionsBar: some View { + Menu { + Button("Refresh") { + Task { await model.refresh() } + } + if canStart { + Button("Start") { + Task { await model.startProject(project) } + } + } + if canStop { + Button("Stop") { + Task { await model.stopProject(project) } + } + } + if let url = devUrl { + Button("Open in Browser") { + openURL(url) + } + } + Divider() + Button("View Logs") { + model.showLogs(for: project) + } + Button("Open Shell") { + model.showShell(for: project) + } + if project.supportsTickets { + Button("Open Tickets") { + model.showTickets(for: project) + } + } + } label: { + Image(systemName: "ellipsis.circle") + } + .buttonStyle(PressableIconButtonStyle()) + } + private var runtimeNotConfiguredCard: some View { GlassCard { VStack(alignment: .leading, spacing: 12) { @@ -196,7 +371,7 @@ struct ProjectDetailView: View { .onTapGesture { withAnimation(.easeInOut(duration: 0.2)) { selectedService = service - showOverviewSidebar = true + showInspectorSidebar = true } } .onHover { hovering in @@ -216,93 +391,30 @@ struct ProjectDetailView: View { } } - private var featuresList: [String] { - project.features ?? project.extensionsEnabled ?? [] - } - - private var infoSection: some View { - VStack(alignment: .leading, spacing: 16) { - if !overviewRows.isEmpty { - sectionHeader("Meta") - DetailRows(rows: overviewRows) - } - if !pathRows.isEmpty { - sectionHeader("Paths") - DetailRows(rows: pathRows) - } - if !featuresList.isEmpty { - sectionHeader("Features") - LazyVGrid(columns: [GridItem(.adaptive(minimum: 120), spacing: 8)], alignment: .leading, spacing: 8) { - ForEach(featuresList, id: \.self) { feature in - BadgePill(label: feature, tint: .secondary) - } - } - } - } - } - - private func sectionHeader(_ title: String) -> some View { - Text(title) - .instrumentLabel() + private var headerHeight: CGFloat { + 56 } - private var serviceDetailPanel: some View { - VStack(alignment: .leading, spacing: 12) { - HStack { - Text("Details") - .font(.mono(.headline, weight: .semibold)) - Spacer() - Button("All") { - withAnimation(.easeInOut(duration: 0.2)) { - self.selectedService = nil - } - } - .font(.mono(.caption)) - .buttonStyle(PressableIconButtonStyle()) - } - if let selectedService { - serviceDetailCard(for: selectedService) - } - } - .padding(16) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.white.opacity(0.04)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.white.opacity(0.08), lineWidth: 1) + private var topFadeOverlay: some View { + Rectangle() + .fill(.ultraThinMaterial) + .frame(height: headerHeight + 20) + .mask( + LinearGradient( + colors: [Color.white, Color.white.opacity(0)], + startPoint: .top, + endPoint: .bottom ) - ) - } - - private var overviewRows: [DetailRowItem] { - var rows: [DetailRowItem] = [] - if let devHost = project.devHost { - rows.append(DetailRowItem(label: "Dev host", value: devHost)) - } - if let featureSummary = project.featureSummary { - rows.append(DetailRowItem(label: "Features", value: featureSummary)) - } - if project.isRuntimeConfigured { - rows.append(DetailRowItem(label: "Runtime", value: runtimeStatusValue)) - rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) - rows.append(DetailRowItem(label: "Status", value: project.status.rawValue)) - } else { - rows.append(DetailRowItem(label: "Runtime", value: "Not configured")) - rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) - } - return rows + ) + .allowsHitTesting(false) } - private var pathRows: [DetailRowItem] { - var rows: [DetailRowItem] = [] - if let repoRoot = project.repoRoot { - rows.append(DetailRowItem(label: "Repo root", value: repoRoot)) - } - if let projectDir = project.projectDir, projectDir != project.repoRoot { - rows.append(DetailRowItem(label: "Project dir", value: projectDir)) + private var devUrl: URL? { + guard let host = project.devHost, !host.isEmpty else { return nil } + if host.contains("://") { + return URL(string: host) } - return rows + return URL(string: "https://\(host)") } private var canStart: Bool { @@ -317,16 +429,12 @@ struct ProjectDetailView: View { project.runtimeStatus ?? fallbackRuntimeStatus } - private var runtimeHealthy: Bool? { - model.runtimeOverallOk + private var projectMeta: ProjectMeta? { + project.meta ?? model.projectMetaById[project.id] } - private var runtimeStatusValue: String { - let base = project.runtimeStatusLabel - if runtimeHealthy == false, runtimeStatus == .running { - return "\(base) (degraded)" - } - return base + private var runtimeHealthy: Bool? { + model.runtimeOverallOk } private struct ServiceStatus { @@ -338,7 +446,10 @@ struct ProjectDetailView: View { private var runtimeServicesByName: [String: RuntimeService] { guard let runtime = project.runtime else { return [:] } - return Dictionary(uniqueKeysWithValues: runtime.services.map { ($0.service, $0) }) + return Dictionary( + runtime.services.map { ($0.service, $0) }, + uniquingKeysWith: { first, _ in first } + ) } private var serviceHostsByName: [String: [String]] { @@ -383,66 +494,6 @@ struct ProjectDetailView: View { return "\(service).\(host)" } - private func serviceDetailCard(for service: String) -> some View { - let runtime = runtimeServicesByName[service] - let containers = runtime?.containers ?? [] - return VStack(alignment: .leading, spacing: 10) { - Text(service) - .font(.mono(.subheadline, weight: .semibold)) - if let hostLabel = serviceHostLabel(for: service) { - Button { - openServiceHost(hostLabel) - } label: { - Text(hostLabel) - .font(.mono(.caption)) - } - .buttonStyle(.plain) - .linkHover() - } - if let hosts = serviceHostsByName[service], hosts.count > 1 { - VStack(alignment: .leading, spacing: 4) { - ForEach(hosts, id: \.self) { host in - Button { - openServiceHost(host) - } label: { - Text(host) - .font(.mono(.caption2)) - } - .buttonStyle(.plain) - .linkHover() - } - } - } - if let runtime { - let runningCount = containers.filter { $0.state.lowercased() == "running" }.count - DetailRows(rows: [ - DetailRowItem(label: "Containers", value: "\(containers.count)"), - DetailRowItem(label: "Running", value: "\(runningCount)") - ]) - ForEach(containers, id: \.id) { container in - VStack(alignment: .leading, spacing: 6) { - Text(container.name) - .font(.mono(.caption, weight: .semibold)) - Text(container.status) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - if !container.ports.isEmpty { - Text(container.ports) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - Divider() - .opacity(0.2) - } - } else { - Text("No running containers.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - } - private func openServiceHost(_ host: String) { let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) guard !trimmed.isEmpty else { return } @@ -470,7 +521,8 @@ struct ProjectDetailView: View { private var availableTabs: [ProjectTab] { var tabs: [ProjectTab] = [.overview] if project.isRuntimeConfigured { - tabs.append(contentsOf: [.logs, .shell]) + tabs.append(.logs) + tabs.append(.shell) } if project.supportsTickets { tabs.append(.tickets) @@ -496,15 +548,14 @@ struct ProjectDetailView: View { HStack(spacing: 6) { ForEach(availableTabs, id: \.self) { tab in Button { - if tab == .logs { + switch tab { + case .logs: openTerminal(kind: .logs) - return - } - if tab == .shell { + case .shell: openTerminal(kind: .shell) - return + case .overview, .tickets: + model.selectedProjectTab = tab } - model.selectedProjectTab = tab } label: { Image(systemName: tabIcon(tab)) .font(.mono(.caption, weight: .semibold)) @@ -569,32 +620,21 @@ struct ProjectDetailView: View { .padding(.horizontal, 14) .padding(.vertical, 8) .background( - controlBarBackground - ) - .onHover { hovering in - isControlBarHovered = hovering - } - .animation(.easeInOut(duration: 0.12), value: isControlBarHovered) - } - - @ViewBuilder - private var controlBarBackground: some View { - let shape = Capsule(style: .continuous) - if colorScheme == .dark { - shape - .fill(.regularMaterial) + Capsule(style: .continuous) + .fill(.ultraThinMaterial) .overlay( - shape.stroke(Color.white.opacity(0.10), lineWidth: 1) + Capsule(style: .continuous) + .fill(isControlBarHovered ? Color.white.opacity(0.06) : .clear) ) - .shadow(color: Color.black.opacity(0.22), radius: 18, x: 0, y: 10) - } else { - shape - .fill(Color.white.opacity(0.78)) .overlay( - shape.stroke(Color.black.opacity(0.08), lineWidth: 1) + Capsule(style: .continuous) + .stroke(Color.white.opacity(0.12), lineWidth: 1) ) - .shadow(color: Color.black.opacity(0.10), radius: 18, x: 0, y: 10) + ) + .onHover { hovering in + isControlBarHovered = hovering } + .animation(.easeInOut(duration: 0.12), value: isControlBarHovered) } private func tabIcon(_ tab: ProjectTab) -> String { @@ -610,15 +650,4 @@ struct ProjectDetailView: View { } } - private func openTerminal(kind: TerminalDrawerModel.Kind) { - NotificationCenter.default.post( - name: .hackTerminalOpenRequested, - object: nil, - userInfo: [ - TerminalOpenRequest.projectIdKey: project.id, - TerminalOpenRequest.kindKey: kind.rawValue - ] - ) - } - } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift new file mode 100644 index 00000000..c80dd666 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectInspectorColumn.swift @@ -0,0 +1,836 @@ +import SwiftUI + +import HackDesktopModels + +struct ProjectInspectorColumn: View { + @Environment(\.openURL) private var openURL + + let project: ProjectSummary + let meta: ProjectMeta? + @Binding var selectedService: String? + let onAttachSession: (MuxSessionSummary) -> Void + let onStopSession: (MuxSessionSummary) -> Void + let onShowLogs: () -> Void + let onShowShell: () -> Void + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + VStack(alignment: .leading, spacing: 12) { + headerRow + projectCard + } + .padding(16) + + Divider() + .opacity(0.2) + + ScrollView { + VStack(alignment: .leading, spacing: 14) { + if let selectedService { + serviceCard(service: selectedService) + serviceEnvCard(service: selectedService) + serviceBuildCard(service: selectedService) + } + + metaSection + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .topLeading) + } + .frame(maxHeight: .infinity, alignment: .topLeading) + } + } + + private var headerRow: some View { + HStack(alignment: .center, spacing: 10) { + Text("Details") + .font(.mono(.headline, weight: .semibold)) + if let selectedService { + BadgePill(label: selectedService, tint: .secondary) + } + Spacer() + if selectedService != nil { + Button("All") { + withAnimation(.easeInOut(duration: 0.2)) { + selectedService = nil + } + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + .accessibilityLabel("Show all services") + } + } + } + + private var projectCard: some View { + GlassCard(title: "Project", systemImage: "cube.transparent") { + VStack(alignment: .leading, spacing: 12) { + if !projectRows.isEmpty { + DetailRows(rows: projectRows) + } + + if let featureSummary = project.featureSummary, !featureSummary.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Features") + .instrumentLabel() + Text(featureSummary) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + } + } + } + } + + private var projectRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + rows.append(DetailRowItem(label: "Status", value: pretty(project.status.rawValue))) + rows.append(DetailRowItem(label: "Runtime", value: project.runtimeStatusLabel)) + rows.append(DetailRowItem(label: "Kind", value: pretty(project.kind.rawValue))) + if let devHost = project.devHost, !devHost.isEmpty { + rows.append(DetailRowItem(label: "Dev host", value: devHost)) + } + if let repoRoot = project.repoRoot, !repoRoot.isEmpty { + rows.append(DetailRowItem(label: "Repo root", value: repoRoot)) + } + if let projectDir = project.projectDir, !projectDir.isEmpty, projectDir != project.repoRoot { + rows.append(DetailRowItem(label: "Project dir", value: projectDir)) + } + return rows + } + + private func serviceCard(service: String) -> some View { + GlassCard(title: "Service", systemImage: "shippingbox") { + VStack(alignment: .leading, spacing: 12) { + if let runtime = runtimeServicesByName[service] { + let containers = runtime.containers + let runningCount = containers.filter { $0.state.lowercased() == "running" }.count + let images = Array(Set(containers.compactMap(\.image))).sorted() + let ips = Array(Set(containers.compactMap(\.ip))).sorted() + let mounts = containers.first?.mounts ?? [] + let labels = containers.first?.labels ?? [:] + + let domainHosts = (serviceHostsByName[service] ?? []).sorted() + + HStack(spacing: 8) { + if let primaryHost = domainHosts.first { + Button("Open") { + openServiceHost(primaryHost) + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + } + + Button("Logs") { + onShowLogs() + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + + Button("Shell") { + onShowShell() + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + + Spacer() + } + + if !domainHosts.isEmpty { + VStack(alignment: .leading, spacing: 6) { + Text("Domains") + .instrumentLabel() + ForEach(limited(domainHosts, limit: 6), id: \.self) { host in + Button { + openServiceHost(host) + } label: { + Text(host) + .font(.mono(.caption)) + } + .buttonStyle(.plain) + .linkHover() + } + let hiddenCount = max(0, domainHosts.count - 6) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + DetailRows( + rows: [ + DetailRowItem(label: "Containers", value: "\(containers.count)"), + DetailRowItem(label: "Running", value: "\(runningCount)"), + DetailRowItem(label: "Image", value: images.count == 1 ? (images.first ?? "—") : "\(images.count) images"), + DetailRowItem(label: "IP", value: ips.count == 1 ? (ips.first ?? "—") : (ips.isEmpty ? "—" : "\(ips.count) IPs")), + ], + labelWidth: 120 + ) + + Divider() + .opacity(0.2) + + if containers.isEmpty { + Text("No containers.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 8) { + ForEach(limited(containers, limit: 8), id: \.id) { container in + VStack(alignment: .leading, spacing: 4) { + Text(container.name) + .font(.mono(.caption, weight: .semibold)) + HStack(spacing: 8) { + Text(shortId(container.id)) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + if let image = container.image, !image.isEmpty { + Text(image) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + Text(container.status) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + if !container.ports.isEmpty { + Text(container.ports) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + if let ip = container.ip, !ip.isEmpty { + Text("IP: \(ip)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + Divider() + .opacity(0.2) + } + + let hiddenCount = max(0, containers.count - 8) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + if !mounts.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Mounts") + .instrumentLabel() + ForEach(limited(mounts, limit: 6).indices, id: \.self) { idx in + let mount = mounts[idx] + let source = mount.source ?? "—" + let destination = mount.destination ?? "—" + HStack(spacing: 8) { + Text(source) + .font(.mono(.caption2, weight: .semibold)) + .lineLimit(1) + .truncationMode(.middle) + Text("→") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + Text(destination) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + } + } + let hiddenCount = max(0, mounts.count - 6) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + if !labels.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Labels") + .instrumentLabel() + ForEach(limited(labels.keys.sorted(), limit: 10), id: \.self) { key in + let value = labels[key] ?? "" + VStack(alignment: .leading, spacing: 2) { + Text(key) + .font(.mono(.caption2, weight: .semibold)) + .foregroundStyle(.secondary) + Text(value) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + .lineLimit(2) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + let hiddenCount = max(0, labels.keys.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } else { + Text("No running containers.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } + } + + private func serviceEnvCard(service: String) -> some View { + GlassCard(title: "Env", systemImage: "key") { + if let meta { + let vars = envVars(for: service, meta: meta) + let missingRequired = vars.filter { $0.required && !$0.hasValue }.map(\.key) + + VStack(alignment: .leading, spacing: 12) { + DetailRows( + rows: [ + DetailRowItem(label: "Vars", value: "\(vars.count)"), + DetailRowItem(label: "Missing", value: "\(missingRequired.count)"), + ], + labelWidth: 120 + ) + + if !missingRequired.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Missing required") + .instrumentLabel() + ForEach(limited(missingRequired, limit: 10), id: \.self) { key in + Text(key) + .font(.mono(.caption)) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + let hiddenCount = max(0, missingRequired.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + if !vars.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 8) { + Text("Variables") + .instrumentLabel() + ForEach(limited(vars, limit: 12)) { variable in + envVarRow(variable) + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, vars.count - 12) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } else { + metaLoadingRow + } + } + } + + private func serviceBuildCard(service: String) -> some View { + GlassCard(title: "Build", systemImage: "hammer") { + if let meta { + if let serviceMeta = meta.composeBuild.services.first(where: { $0.service == service }) { + VStack(alignment: .leading, spacing: 12) { + let rows = [ + DetailRowItem(label: "Build", value: serviceMeta.build ? "Yes" : "No"), + DetailRowItem(label: "Context", value: serviceMeta.context ?? "—"), + DetailRowItem(label: "Dockerfile", value: serviceMeta.dockerfile ?? "—"), + DetailRowItem(label: "Path", value: serviceMeta.dockerfilePath ?? "—"), + ] + DetailRows(rows: rows, labelWidth: 120) + if serviceMeta.dockerfilePath != nil { + let exists = serviceMeta.dockerfileExists == true + StatusPill(text: exists ? "Dockerfile found" : "Dockerfile missing", tone: exists ? .good : .warn) + } + } + } else { + Text("No build metadata for this service.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } else { + metaLoadingRow + } + } + } + + @ViewBuilder + private var metaSection: some View { + if let meta { + gitCard(meta: meta) + sessionsCard(meta: meta) + envCard(meta: meta) + composeBuildCard(meta: meta) + hackBranchesCard(meta: meta) + } else { + GlassCard(title: "Meta", systemImage: "info.circle") { + metaLoadingRow + } + } + } + + private func gitCard(meta: ProjectMeta) -> some View { + GlassCard(title: "Git", systemImage: "arrow.triangle.branch") { + VStack(alignment: .leading, spacing: 12) { + DetailRows(rows: gitRows(meta: meta), labelWidth: 120) + if let worktrees = meta.git.worktrees, !worktrees.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Worktrees") + .instrumentLabel() + ForEach(limited(worktrees, limit: 8), id: \.path) { worktree in + VStack(alignment: .leading, spacing: 2) { + Text(worktree.path) + .font(.mono(.caption, weight: .semibold)) + .textSelection(.enabled) + if let branch = worktree.branch { + Text(branch) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } else if worktree.detached, let head = worktree.head { + Text("Detached @ \(head)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, worktrees.count - 8) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private func gitRows(meta: ProjectMeta) -> [DetailRowItem] { + var rows: [DetailRowItem] = [] + rows.append(DetailRowItem(label: "Repo", value: meta.git.isRepo ? "Yes" : "No")) + if let branch = meta.git.branch, !branch.isEmpty { + rows.append(DetailRowItem(label: "Branch", value: branch)) + } + if let head = meta.git.head, !head.isEmpty { + rows.append(DetailRowItem(label: "HEAD", value: head)) + } + if let detached = meta.git.detached { + rows.append(DetailRowItem(label: "Detached", value: detached ? "Yes" : "No")) + } + if let dirty = meta.git.dirty { + rows.append(DetailRowItem(label: "Dirty", value: dirty ? "Yes" : "No")) + } + if let localCount = meta.git.localBranchCount { + rows.append(DetailRowItem(label: "Branches", value: "\(localCount)")) + } + if let worktrees = meta.git.worktrees { + rows.append(DetailRowItem(label: "Worktrees", value: "\(worktrees.count)")) + } + if let error = meta.git.error, !error.isEmpty { + rows.append(DetailRowItem(label: "Error", value: error)) + } + return rows + } + + private func sessionsCard(meta: ProjectMeta) -> some View { + GlassCard(title: "Sessions", systemImage: "rectangle.split.3x1") { + VStack(alignment: .leading, spacing: 12) { + DetailRows( + rows: [ + DetailRowItem(label: "Count", value: "\(meta.sessions.sessions.count)"), + ], + labelWidth: 120 + ) + if meta.sessions.sessions.isEmpty { + Text("No sessions detected.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 8) { + ForEach(limited(meta.sessions.sessions, limit: 10)) { session in + sessionRow(session) + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, meta.sessions.sessions.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private func sessionRow(_ session: MuxSessionSummary) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(session.name) + .font(.mono(.caption, weight: .semibold)) + .textSelection(.enabled) + if let path = session.path { + Text(path) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + Spacer() + BadgePill(label: session.backend, tint: .secondary) + if let windows = session.windows { + BadgePill(label: "\(windows)w", tint: .secondary) + } + if session.attached == true { + StatusPill(text: "Attached", tone: .good) + } + HStack(spacing: 6) { + Button("Attach") { + onAttachSession(session) + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + .accessibilityLabel("Attach to session \(session.name)") + + Button("Stop") { + onStopSession(session) + } + .font(.mono(.caption, weight: .semibold)) + .buttonStyle(PressableIconButtonStyle()) + .accessibilityLabel("Stop session \(session.name)") + } + } + } + + private func envCard(meta: ProjectMeta) -> some View { + GlassCard(title: "Env", systemImage: "key") { + VStack(alignment: .leading, spacing: 12) { + DetailRows( + rows: [ + DetailRowItem(label: "Contract", value: meta.env.contractExists ? "Found" : "Missing"), + DetailRowItem(label: "Vars", value: "\(meta.env.vars.count)"), + DetailRowItem(label: "Missing", value: "\(meta.env.missingRequired.count)"), + ], + labelWidth: 120 + ) + + if let parseError = meta.env.contractParseError, !parseError.isEmpty { + Divider() + .opacity(0.2) + Text(parseError) + .font(.mono(.caption)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + + if !meta.env.missingRequired.isEmpty { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 6) { + Text("Missing required") + .instrumentLabel() + ForEach(limited(meta.env.missingRequired, limit: 10), id: \.self) { key in + Text(key) + .font(.mono(.caption)) + .foregroundStyle(.orange) + .textSelection(.enabled) + } + let hiddenCount = max(0, meta.env.missingRequired.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 8) { + Text("Variables") + .instrumentLabel() + ForEach(limited(meta.env.vars, limit: 12)) { variable in + envVarRow(variable) + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, meta.env.vars.count - 12) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } + + private func envVarRow(_ variable: EnvVarMeta) -> some View { + HStack(alignment: .firstTextBaseline, spacing: 10) { + VStack(alignment: .leading, spacing: 2) { + Text(variable.key) + .font(.mono(.caption, weight: .semibold)) + .textSelection(.enabled) + if let services = variable.services, !services.isEmpty { + Text(services.sorted().joined(separator: ", ")) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.tail) + } else if let description = variable.description, !description.isEmpty { + Text(description) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(2) + } + } + Spacer() + + if variable.required { + BadgePill(label: "Required", tint: .orange) + } + + BadgePill(label: variable.source == .keychain ? "Keychain" : "Env", tint: .secondary) + + let tone: StatusTone = variable.hasValue ? .good : (variable.required ? .warn : .neutral) + StatusPill(text: variable.hasValue ? "Set" : "Missing", tone: tone) + } + } + + private func composeBuildCard(meta: ProjectMeta) -> some View { + GlassCard(title: "Compose build", systemImage: "hammer") { + let services = meta.composeBuild.services.sorted { $0.service < $1.service } + VStack(alignment: .leading, spacing: 12) { + DetailRows( + rows: [ + DetailRowItem(label: "Services", value: "\(services.count)"), + ], + labelWidth: 120 + ) + if services.isEmpty { + Text("No build config detected.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 8) { + ForEach(limited(services, limit: 10)) { service in + composeBuildRow(service) + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, services.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private func composeBuildRow(_ service: ComposeBuildServiceMeta) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(service.service) + .font(.mono(.caption, weight: .semibold)) + Spacer() + StatusPill(text: service.build ? "Build" : "No build", tone: service.build ? .good : .neutral) + } + if let dockerfilePath = service.dockerfilePath, !dockerfilePath.isEmpty { + Text(dockerfilePath) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } else if let context = service.context, !context.isEmpty { + Text(context) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .lineLimit(1) + .truncationMode(.middle) + .textSelection(.enabled) + } + } + } + + private func hackBranchesCard(meta: ProjectMeta) -> some View { + GlassCard(title: "Hack branches", systemImage: "point.3.filled.connected.trianglepath.dotted") { + let branches = meta.hackBranches.branches + VStack(alignment: .leading, spacing: 12) { + DetailRows( + rows: [ + DetailRowItem(label: "File", value: meta.hackBranches.path), + DetailRowItem(label: "Branches", value: "\(branches.count)"), + ], + labelWidth: 120 + ) + + if let parseError = meta.hackBranches.parseError, !parseError.isEmpty { + Divider() + .opacity(0.2) + Text(parseError) + .font(.mono(.caption)) + .foregroundStyle(.orange) + .fixedSize(horizontal: false, vertical: true) + } + + if branches.isEmpty { + Text("No branches detected.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } else { + Divider() + .opacity(0.2) + VStack(alignment: .leading, spacing: 8) { + ForEach(limited(branches, limit: 10)) { branch in + hackBranchRow(branch) + Divider() + .opacity(0.2) + } + let hiddenCount = max(0, branches.count - 10) + if hiddenCount > 0 { + Text("+\(hiddenCount) more") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private func hackBranchRow(_ branch: HackBranchEntry) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(alignment: .firstTextBaseline, spacing: 10) { + Text(branch.name) + .font(.mono(.caption, weight: .semibold)) + Spacer() + BadgePill(label: branch.slug, tint: .secondary) + } + if let note = branch.note, !note.isEmpty { + Text(note) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + } + if let lastUsedAt = branch.lastUsedAt { + Text("Last used: \(lastUsedAt)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } else if let createdAt = branch.createdAt { + Text("Created: \(createdAt)") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + } + + private var metaLoadingRow: some View { + HStack(spacing: 10) { + ProgressView() + .controlSize(.small) + Text("Fetching meta…") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + .frame(maxWidth: .infinity, alignment: .leading) + } + + private var runtimeServicesByName: [String: RuntimeService] { + guard let runtime = project.runtime else { return [:] } + return Dictionary( + runtime.services.map { ($0.service, $0) }, + uniquingKeysWith: { first, _ in first } + ) + } + + private var serviceHostsByName: [String: [String]] { + project.serviceHosts ?? [:] + } + + private func serviceHostLabel(for service: String) -> String? { + if let hosts = serviceHostsByName[service], let first = hosts.first { + if hosts.count > 1 { + return "\(first) +\(hosts.count - 1)" + } + return first + } + guard let host = project.devHost, !host.isEmpty else { return nil } + return "\(service).\(host)" + } + + private func envVars(for service: String, meta: ProjectMeta) -> [EnvVarMeta] { + meta.env.vars + .filter { $0.services == nil || $0.services?.contains(service) == true } + .sorted { $0.key < $1.key } + } + + private func pretty(_ value: String) -> String { + value.replacingOccurrences(of: "_", with: " ").capitalized + } + + private func openServiceHost(_ host: String) { + let trimmed = host.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + let urlString = trimmed.contains("://") ? trimmed : "https://\(trimmed)" + if let url = URL(string: urlString) { + openURL(url) + } + } + + private func limited(_ items: [Element], limit: Int) -> [Element] { + if items.count <= limit { return items } + return Array(items.prefix(limit)) + } + + private func shortId(_ id: String) -> String { + let trimmed = id.trimmingCharacters(in: .whitespacesAndNewlines) + guard trimmed.count > 12 else { return trimmed } + return String(trimmed.prefix(12)) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift new file mode 100644 index 00000000..28bc927d --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SessionAttachView.swift @@ -0,0 +1,85 @@ +import SwiftUI + +import HackDesktopModels + +struct SessionAttachView: View { + let project: ProjectSummary + let session: MuxSessionSummary + @Environment(\.dismiss) private var dismiss + @State private var terminal: GhosttyTerminalSession + + init(project: ProjectSummary, session: MuxSessionSummary) { + self.project = project + self.session = session + + let workingDirectory: URL? + if let path = session.path, !path.isEmpty { + workingDirectory = URL(fileURLWithPath: path) + } else if let path = project.repoRoot ?? project.projectDir { + workingDirectory = URL(fileURLWithPath: path) + } else { + workingDirectory = nil + } + + _terminal = State( + initialValue: GhosttyTerminalSession( + project: project, + mode: .sessionAttach(sessionName: session.name, workingDirectory: workingDirectory) + ) + ) + } + + var body: some View { + @Bindable var terminal = terminal + + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Session") + .font(.mono(.headline, weight: .semibold)) + Text(session.name) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + Spacer() + Button("Close") { + dismiss() + } + .adaptiveToolbarButton() + } + + Text(terminal.statusMessage) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + if terminal.isAvailable { + GhosttyTerminalView(session: terminal) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .terminalSurface() + } else { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Terminal unavailable") + .font(.mono(.subheadline, weight: .medium)) + } + Text("Run `bun run macos:ghostty:setup` to build the Ghostty VT library.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + } + } + .padding(20) + .frame(minWidth: 820, minHeight: 520) + .onAppear { terminal.start() } + .onDisappear { terminal.stop() } + } +} + diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift index 5e079d8f..1a723712 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift @@ -208,6 +208,7 @@ struct SetupAssistantView: View { runtimeConfigured: nil, runtimeStatus: nil, runtime: nil, + meta: nil, kind: .unregistered, status: .unknown ) diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 5c5e0ced..43b851d8 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -32,6 +32,15 @@ public actor HackCLIClient { return try decode(ProjectListResponse.self, from: result.stdout) } + public func fetchProjectMeta(projectName: String) async throws -> ProjectMeta? { + let trimmed = projectName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return nil } + + let result = try await run(["projects", "--json", "--include-global", "--project", trimmed, "--meta"]) + let response = try decode(ProjectListResponse.self, from: result.stdout) + return response.projects.first?.meta + } + public func daemonStatus() async throws -> DaemonStatus { let result = try await run(["daemon", "status", "--json"], allowNonZeroExit: true) return try decodeJsonOrThrow(DaemonStatus.self, result: result) @@ -66,6 +75,14 @@ public actor HackCLIClient { _ = try await run(["down", "--path", path]) } + public func stopSession(sessionName: String) async throws { + let trimmed = sessionName.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { + throw HackCLIError.commandFailed(exitCode: 1, stderr: "Missing session name") + } + _ = try await run(["session", "stop", trimmed]) + } + public func listTickets(path: String) async throws -> TicketsListResponse { let result = try await run(["x", "tickets", "list", "--json"], cwd: path) return try decodeLenient(TicketsListResponse.self, from: result.stdout) diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift index 743e153d..4ff81e52 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift @@ -4,27 +4,57 @@ public enum HackCLILocator { public static func buildEnvironment() -> [String: String] { var env = ProcessInfo.processInfo.environment let home = (env["HOME"] ?? NSHomeDirectory()).trimmingCharacters(in: .whitespacesAndNewlines) - let homeBinPaths = home.isEmpty - ? [] - : [ - "\(home)/.hack/bin", - "\(home)/.local/bin", - "\(home)/.bun/bin", - "\(home)/.cargo/bin" - ] - let defaultPaths = [ + + // NOTE: GUI-launched apps often have a minimal PATH that doesn't include user-installed tools. + // We try to start with the existing PATH, but fall back to `path_helper` when PATH is missing/empty. + let existing = env["PATH"]?.split(separator: ":").map(String.init) ?? [] + let base = existing.isEmpty ? resolvePathHelperPaths() : existing + + var extras: [String] = [] + if !home.isEmpty { + extras.append(contentsOf: [ + "\(home)/.hack/bin", + "\(home)/.local/bin", + "\(home)/.bun/bin", + "\(home)/.cargo/bin", + "\(home)/.asdf/shims", + "\(home)/.volta/bin", + "\(home)/.nix-profile/bin", + "\(home)/.local/share/mise/shims" + ]) + } + + if let bunInstall = env["BUN_INSTALL"]?.trimmingCharacters(in: .whitespacesAndNewlines), + !bunInstall.isEmpty { + extras.append("\(bunInstall)/bin") + } + + let defaults = [ "/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin", "/usr/sbin", - "/sbin" + "/sbin", + "/run/current-system/sw/bin" ] - let existing = env["PATH"]?.split(separator: ":").map(String.init) ?? [] - let merged = existing - + homeBinPaths.filter { !existing.contains($0) } - + defaultPaths.filter { !existing.contains($0) } - env["PATH"] = merged.joined(separator: ":") + + // De-dupe while preserving order. + var seen = Set() + func push(_ path: String) { + let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmed.isEmpty else { return } + guard !seen.contains(trimmed) else { return } + seen.insert(trimmed) + extrasMerged.append(trimmed) + } + + var extrasMerged: [String] = [] + for p in base { push(p) } + for p in extras { push(p) } + for p in defaults { push(p) } + + env["PATH"] = extrasMerged.joined(separator: ":") return env } @@ -43,4 +73,35 @@ public enum HackCLILocator { } return nil } + + private static func resolvePathHelperPaths() -> [String] { + let url = URL(fileURLWithPath: "/usr/libexec/path_helper") + guard FileManager.default.isExecutableFile(atPath: url.path) else { return [] } + + let process = Process() + process.executableURL = url + process.arguments = ["-s"] + + let pipe = Pipe() + process.standardOutput = pipe + process.standardError = Pipe() + + do { + try process.run() + process.waitUntilExit() + } catch { + return [] + } + + let data = pipe.fileHandleForReading.readDataToEndOfFile() + guard let text = String(data: data, encoding: .utf8) else { return [] } + + // `path_helper -s` prints shell code like: + // PATH="..."; export PATH; + guard let range = text.range(of: "PATH=\"") else { return [] } + let after = text[range.upperBound...] + guard let end = after.firstIndex(of: "\"") else { return [] } + let pathValue = String(after[..|"Routes by labels"| Upstream["Service upstream"] ``` -## Lifecycle (init → up → logs) +## Project env + secrets + +Projects can declare a shareable env contract (no values) and safely inject secrets into compose: + +- Contract: `.hack/hack.env.json` (committed) +- Plain values: `.hack/.env` (gitignored, per-project) +- Secrets: OS keychain via `Bun.secrets` (namespaced per project) + +At runtime, hack generates `.hack/.internal/compose.env.override.yml` containing `${KEY}` placeholders +for the contract variables and invokes `docker compose` with a process environment that includes the +resolved values (including keychain secrets). + +See `docs/env.md` for the full contract format and CLI/API surface. + +## Project lifecycle hooks + host processes + +Projects can run host-side hooks around `hack up/down` and start managed host processes (auth steps, +local proxies/tunnels). Processes are started inside a mux session (tmux or zellij) so they have a +stable home and can be torn down on `hack down`. + +See `docs/lifecycle.md` for config and behavior. + +## Workflow (init → up → logs) ```mermaid sequenceDiagram diff --git a/docs/cli.md b/docs/cli.md index 7941492b..dc290115 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -29,6 +29,10 @@ Run `hack help` or `hack help ` for interactive help. | `hack tui` | Open the project TUI (services + logs) | Project | | `hack branch` | Manage branch aliases for a project | Project | | `hack config` | Read/write hack.config.json values | Project | +| `hack env` | Manage project environment variables and secrets | Project | +| `hack session` | Manage terminal sessions for hack projects | Project | +| `hack ssh` | Show SSH connection info for remote access | Project | +| `hack tickets` | Git-backed ticket management | Project | | `hack internal` | Manage hack-managed internal overrides | Internal | | `hack gateway` | Manage gateway enablement | Extensions | | `hack remote` | Remote workflow helpers | Extensions | @@ -40,6 +44,7 @@ Run `hack help` or `hack help ` for interactive help. | `hack daemon` | Manage the local hack daemon (hackd) | Diagnostics | | `hack log-pipe` | Read log lines from stdin and pretty-print them | Diagnostics | | `hack help` | Show help for a command | Diagnostics | +| `hack update` | Update hack to the latest release | Diagnostics | | `hack version` | Print version | Diagnostics | | `hack secrets` | Manage secrets in OS keychain (Bun.secrets) | Secrets | | `hack the` | Fun commands | Fun | @@ -120,6 +125,7 @@ Options: | --- | --- | --- | --- | | `--project ` | string | - | Filter to a registered project name | | `--details` | boolean | false | Show per-project service tables | +| `--meta` | boolean | false | Include git/worktree/session/env metadata (implies --details) | | `--include-global` | boolean | false | Include global infra projects under `~/.hack` | | `--all` | boolean | false | Include unregistered docker compose projects | | `--json` | boolean | false | Output JSON (machine-readable) | @@ -455,6 +461,147 @@ Options: | `--project ` | string | - | Target a registered project by name | | `--global` | boolean | false | Write global `~/.hack/hack.config.json` | +### hack env + +Usage: `hack env ` + +Subcommands: + +| Subcommand | Summary | +| --- | --- | +| `list` | List env contract vars and resolution state | +| `set` | Set an env value (.hack/.env or keychain) | +| `unset` | Unset an env value (.hack/.env and keychain) | + +#### hack env list + +Usage: `hack env list [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--json` | boolean | false | Output JSON (machine-readable) | +| `--show-secrets` | boolean | false | Print secret values (keychain) in plaintext | + +#### hack env set + +Usage: `hack env set [spec] [options]` + +`spec` can be `KEY` or `KEY=VALUE`. If omitted, hack will prompt interactively. + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--secret` | boolean | false | Store value in OS keychain (Bun.secrets) instead of .hack/.env | + +#### hack env unset + +Usage: `hack env unset [key] [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | + +### hack session + +Usage: `hack session [subcommand]` + +With no subcommand, opens an interactive picker of active sessions and available projects. + +Subcommands: + +| Subcommand | Summary | +| --- | --- | +| `list` | List active sessions | +| `start` | Start or attach to a session for a project | +| `stop` | Stop (kill) a session | +| `attach` | Attach to an existing session | +| `exec` | Execute a command in a session | +| `panes` | List panes in a tmux session | +| `capture` | Capture recent output from a tmux session | +| `tail` | Tail output from a tmux session | + +#### hack session start + +Usage: `hack session start [project] [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--up` | boolean | false | Run hack up -d before attaching | +| `--new` | boolean | false | Force create new session even if one exists | +| `--name ` | string | - | Custom suffix for new session (e.g., agent-1) | + +#### hack session panes + +Usage: `hack session panes [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--json` | boolean | false | Output NDJSON stream (start/log/end) | +| `--pretty` | boolean | false | Output human-friendly text | + +#### hack session capture + +Usage: `hack session capture [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--target ` | string | - | Tmux pane target (default: active pane) | +| `--lines ` | number | 200 | Number of lines to capture | +| `--json` | boolean | false | Output NDJSON stream (start/log/end) | +| `--pretty` | boolean | false | Output human-friendly text | + +#### hack session tail + +Usage: `hack session tail [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--target ` | string | - | Tmux pane target (default: active pane) | +| `--lines ` | number | 200 | Number of lines to capture | +| `--interval-ms ` | number | 500 | Polling interval in milliseconds | +| `--max-ms ` | number | 5000 | Stop tailing after N milliseconds | +| `--json` | boolean | false | Output NDJSON stream (start/log/end) | +| `--pretty` | boolean | false | Output human-friendly text | + +### hack ssh + +Usage: `hack ssh [session] [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-H`, `--host ` | string | - | SSH host (hostname or IP) | +| `-u`, `--user ` | string | - | SSH username | +| `-t`, `--tailscale` | boolean | false | Use Tailscale SSH | +| `-d`, `--direct` | boolean | false | Use direct SSH (requires --host) | +| `-p`, `--port ` | string | - | SSH port for direct connection (default: 22) | + +### hack tickets + +Usage: `hack tickets ` + +`hack tickets` is a convenience alias for the tickets extension (`hack x tickets ...`). +Run `hack tickets` with no args to see available subcommands. + ## Internal commands ### hack internal @@ -895,6 +1042,19 @@ Arguments: | --- | --- | --- | --- | | `path` | string[] | no | Command path to show help for (e.g. `global logs`) | +### hack update + +Usage: `hack update [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--check` | boolean | false | Check for updates (do not install) | +| `--yes` | boolean | false | Apply update without prompting | +| `--tag ` | string | - | Update to a specific release tag (e.g. v1.4.0) | +| `--json` | boolean | false | Output JSON (machine-readable) | + ### hack version Usage: `hack version` diff --git a/docs/env.md b/docs/env.md new file mode 100644 index 00000000..1102376d --- /dev/null +++ b/docs/env.md @@ -0,0 +1,120 @@ +# Env & secrets + +hack supports a project-scoped env contract (shareable, no values) plus safe secret storage for local development. + +## Files and storage + +- `.hack/hack.env.json` (committed): declares env vars, required vs optional, per-service scope, and where values should come from. +- `.hack/.env` (gitignored): stores non-secret values (`source: "plain_env"`). +- OS keychain (via `Bun.secrets`): stores secret values (`source: "keychain"`), namespaced as `hack-`. + +## Contract format (`.hack/hack.env.json`) + +```json +{ + "$schema": "https://schemas.hack/hack.env.schema.json", + "version": 1, + "vars": [ + { + "key": "AWS_PROFILE", + "required": true, + "source": "plain_env", + "services": ["api"], + "description": "AWS profile used by the API service" + }, + { + "key": "DATABASE_URL", + "required": true, + "source": "keychain", + "services": ["api", "worker"], + "description": "Database connection string" + } + ] +} +``` + +Fields: +- `key`: uppercase snake-case env var name (e.g. `AWS_PROFILE`). +- `required`: if true, `hack up/run/restart` fails when missing (for targeted services). +- `source`: + - `plain_env`: read from `.hack/.env`, then fall back to the current process env (`process.env`). + - `keychain`: read from the OS keychain only. +- `services`: `null` (or omitted) means all services; otherwise a list of Compose service names. +- `description`: optional, for humans/UI. + +## CLI + +- `hack env list [--json] [--show-secrets]` + - shows contract + resolution state + - exits `1` if required vars are missing +- `hack env set KEY=VALUE` + - writes to `.hack/.env` +- `hack env set --secret KEY=VALUE` + - stores in OS keychain (`Bun.secrets`) +- `hack env unset KEY` + - removes from `.hack/.env` and deletes the keychain entry (best-effort) + +Notes: +- `hack env set` also supports interactive prompting when `KEY` or `VALUE` is omitted. +- Keychain service name is `hack-` (project name from `.hack/hack.config.json`). + +## Runtime injection (compose) + +When you run `hack up`, `hack restart`, or `hack run`, hack: + +1. Resolves `.hack/hack.env.json` for the target services. +2. In interactive shells, offers to prompt for missing required vars (and writes to `.hack/.env` and/or keychain). +3. Generates `.hack/.internal/compose.env.override.yml` that injects `${KEY}` placeholders into `services..environment` based on the contract. +4. Invokes `docker compose` with an environment that includes resolved values (including keychain secrets). + +Security posture: +- Secret values are never written into `.hack/` YAML files. +- Plain env values live in `.hack/.env` (expected to be gitignored in most repos). + +## Daemon/gateway API (UI integration) + +`hackd` exposes env endpoints for UIs. When accessed through the gateway, all requests require an auth token: + +- `Authorization: Bearer $HACK_GATEWAY_TOKEN` +- Non-GET requests additionally require `controlPlane.gateway.allowWrites = true` and a write-scoped token. + +Endpoints: + +- `GET /v1/env?project=` (or `?project_id=`) +- `POST /v1/env/set` +- `POST /v1/env/unset` + +Example (read): + +```bash +curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + "http://127.0.0.1:7788/v1/env?project=my-project" +``` + +Example (set plain env): + +```bash +curl -X POST -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + -H "Content-Type: application/json" \ + http://127.0.0.1:7788/v1/env/set \ + -d '{"project":"my-project","key":"AWS_PROFILE","value":"dev"}' +``` + +Example (set secret): + +```bash +curl -X POST -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + -H "Content-Type: application/json" \ + http://127.0.0.1:7788/v1/env/set \ + -d '{"project":"my-project","key":"DATABASE_URL","value":"postgres://...","secret":true}' +``` + +Example (unset): + +```bash +curl -X POST -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + -H "Content-Type: application/json" \ + http://127.0.0.1:7788/v1/env/unset \ + -d '{"project":"my-project","key":"AWS_PROFILE"}' +``` + diff --git a/docs/gateway-api.md b/docs/gateway-api.md index 2ba68acd..62561fca 100644 --- a/docs/gateway-api.md +++ b/docs/gateway-api.md @@ -199,12 +199,15 @@ Base URL: `http://127.0.0.1:7788` (or your tunnel URL) | GET | `/v1/metrics` | no | Cache + stream metrics | | GET | `/v1/projects` | no | Gateway-enabled projects + runtime snapshot | | GET | `/v1/ps` | no | Compose project container list | -| GET | `/v1/sessions` | no | List tmux sessions | -| POST | `/v1/sessions` | no | Create tmux session | +| GET | `/v1/sessions` | no | List mux sessions (tmux/zellij) | +| POST | `/v1/sessions` | yes | Create mux session | | GET | `/v1/sessions/:id` | no | Get session details | -| POST | `/v1/sessions/:id/stop` | no | Stop (kill) session | -| POST | `/v1/sessions/:id/exec` | no | Execute command in session | -| POST | `/v1/sessions/:id/input` | no | Send raw keystrokes | +| POST | `/v1/sessions/:id/stop` | yes | Stop (kill) session | +| POST | `/v1/sessions/:id/exec` | yes | Execute command in session | +| POST | `/v1/sessions/:id/input` | yes | Send raw keystrokes | +| GET | `/v1/env` | no | Env contract + resolution state (values redacted) | +| POST | `/v1/env/set` | yes | Set env (.hack/.env) or secret (keychain) | +| POST | `/v1/env/unset` | yes | Unset env + keychain entry | | GET | `/control-plane/projects/:projectId/jobs` | no | List jobs | | POST | `/control-plane/projects/:projectId/jobs` | yes | Create job | | GET | `/control-plane/projects/:projectId/jobs/:jobId` | no | Fetch job | @@ -215,6 +218,8 @@ Base URL: `http://127.0.0.1:7788` (or your tunnel URL) | WS | `/control-plane/projects/:projectId/shells/:shellId/stream` | yes | Stream shell PTY | > **Sessions API**: For detailed sessions endpoint documentation, see [Sessions](sessions.md#daemon-sessions-api). +> +> **Env API**: For contract format and env endpoints, see [Env & secrets](env.md#daemongateway-api-ui-integration). ### GET /v1/status @@ -272,6 +277,7 @@ Query parameters: | `filter` | string | no | Project name filter | | `include_global` | boolean | no | Include global infra entries | | `include_unregistered` | boolean | no | Ignored over the gateway (always false) | +| `include_meta` | boolean | no | Include git/worktree/session/env metadata (opt-in; only returned for enabled projects) | ```bash curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ @@ -542,6 +548,10 @@ Non-JSON text frames are treated as raw input. | `status` | string | Human-readable status | | `name` | string | Container name | | `ports` | string | Port mapping string | +| `image` | string or null | Container image | +| `ip` | string or null | Container IP address | +| `mounts` | RuntimeMount[] | Volume/bind mounts | +| `labels` | object | Container labels | | `working_dir` | string or null | Compose working directory | ### PsItem diff --git a/docs/guides/global-settings.md b/docs/guides/global-settings.md index 410e8b3c..e5e2a701 100644 --- a/docs/guides/global-settings.md +++ b/docs/guides/global-settings.md @@ -12,6 +12,7 @@ Common settings: - `controlPlane.gateway.bind` (default `127.0.0.1`) - `controlPlane.gateway.port` (default `7788`) - `controlPlane.gateway.allowWrites` (default `false`) +- `sessions.mux` (default `auto`; `auto|tmux|zellij|none`) - `controlPlane.extensions["dance.hack.cloudflare"].config.hostname` - `controlPlane.tui.logs.maxEntries` (TUI log buffer cap, default `2000`) - `controlPlane.tui.logs.maxLines` (rendered log lines cap, default `400`) diff --git a/docs/guides/init-project.md b/docs/guides/init-project.md index 6cb76a03..aa29f580 100644 --- a/docs/guides/init-project.md +++ b/docs/guides/init-project.md @@ -11,6 +11,7 @@ hack open Notes: - `hack init` writes `.hack/` files (Compose + config). +- `hack init` also scaffolds a `.hack/hack.env.json` env contract (no values). See `docs/env.md`. - `hack up` starts the stack on an isolated network. - `hack open` resolves the routed URL via the global proxy. @@ -18,6 +19,7 @@ Optional: - `hack logs --pretty` for log tailing. - `hack tui` for the interactive dashboard. - Configure log retention in `hack.config.json` via `logs.retention_period` (e.g. `7d`) and `logs.clear_on_down`. +- Add startup hooks/host processes in `.hack/hack.config.json` under `lifecycle`. See `docs/lifecycle.md`. Note: - Inside containers, `localhost` points at the container itself. Update any `localhost:PORT` references to: diff --git a/docs/lifecycle.md b/docs/lifecycle.md new file mode 100644 index 00000000..47abcda0 --- /dev/null +++ b/docs/lifecycle.md @@ -0,0 +1,101 @@ +# Lifecycle (startup hooks + host processes) + +Many projects need host-side setup around `hack up` (auth, local proxies, tunnels). Lifecycle config lets you run short-lived commands and manage long-running host processes alongside the project runtime. + +Lifecycle is configured in `.hack/hack.config.json` under `lifecycle`. + +## Config + +```json +{ + "lifecycle": { + "up": { + "before": [ + { "name": "aws sso login", "command": "aws sso login", "cwd": "." } + ], + "after": [] + }, + "down": { + "before": [], + "after": [] + }, + "processes": [ + { + "name": "aws-proxy", + "command": "bun run dev:aws-proxy", + "cwd": "." + } + ] + } +} +``` + +### Hooks + +Hook lists live under: +- `lifecycle.up.before` +- `lifecycle.up.after` +- `lifecycle.down.before` +- `lifecycle.down.after` + +Each entry can be either: +- a string (shell command), or +- an object: + - `name` (optional): label for logs + - `command` (required): shell command + - `cwd` (optional): working directory; relative paths are resolved from repo root + +Hooks run on the host as `sh -lc `. Failures stop the operation. + +### Processes + +Long-running processes live under `lifecycle.processes` and are objects with: +- `name` (required): stable identifier (used for window naming) +- `command` (required): shell command (run via `sh -lc`) +- `cwd` (optional): working directory (defaults to repo root) + +Processes receive the resolved env contract (see `env.md`) as their environment. + +## Runtime behavior + +### `hack up` + +1. Resolve env contract (and optionally prompt for missing required env in interactive shells). +2. Run `lifecycle.up.before` hooks. +3. Start lifecycle processes (if any) inside a dedicated session. +4. Run `docker compose up` (or `up -d` when `--detach`). +5. Run `lifecycle.up.after` hooks. + +### `hack down` + +1. Run `lifecycle.down.before` hooks. +2. Run `docker compose down`. +3. Stop lifecycle processes by killing the lifecycle session. +4. Run `lifecycle.down.after` hooks. + +### `hack restart` + +`hack restart` performs the same lifecycle steps as `hack down` followed by `hack up`. + +## Sessions backend + +Lifecycle processes run inside the configured mux backend (tmux or zellij), using the same selection rules as `hack session`. + +Config: +- Project: `.hack/hack.config.json` → `sessions.mux = auto|tmux|zellij|none` +- Env override: `HACK_SESSIONS_MUX=auto|tmux|zellij|none` + +Lifecycle session name: +- No branch: `--lifecycle` +- With `--branch `: `--lifecycle-` + +Notes: +- If no mux backend is available, lifecycle process startup fails with an actionable error. +- Teardown is implemented by killing the lifecycle session; anything running inside that session will be stopped. + +## Tips + +- Keep `up.before` hooks short and deterministic; prefer long-running things as `processes`. +- Use `source: "keychain"` in the env contract for secrets and keep `.hack/.env` non-sensitive. +- If a hook requires interactive auth (e.g. browser-based SSO), it will still work; it runs with `stdin: inherit`. + diff --git a/docs/sessions.md b/docs/sessions.md index bdfa050f..0166be16 100644 --- a/docs/sessions.md +++ b/docs/sessions.md @@ -1,8 +1,8 @@ # Sessions -Sessions provide a way to manage persistent terminal workspaces using tmux. They're designed for: +Sessions provide a way to manage persistent terminal workspaces using **tmux** or **zellij**. They're designed for: - **Remote access**: SSH into your machine and attach to an existing workspace -- **Agent execution**: Run long-running agents in isolated tmux sessions +- **Agent execution**: Run long-running agents in isolated sessions - **Multi-terminal workflows**: Keep multiple project contexts alive across sessions ## Quick start @@ -21,7 +21,7 @@ hack session list hack session attach # Run a command in a session -hack session exec "npm test" +hack session exec "bun test" # Stop a session hack session stop @@ -41,12 +41,12 @@ hack session panes ### Interactive picker Running `hack session` without arguments opens an interactive picker showing: -- **Active sessions**: Attached and detached tmux sessions +- **Active sessions**: tmux and zellij sessions (tmux shows attached/detached; zellij attach state is unknown) - **Available projects**: Registered projects without active sessions When selecting an attached session, you can choose to: - **Attach**: Detach other clients and take over the session -- **Create new**: Start a new numbered session (e.g., `project:2`) +- **Create new**: Start a new numbered session (e.g., `project--2`) ### Creating sessions @@ -59,7 +59,7 @@ hack session start my-project --new # Create with custom name suffix hack session start my-project --name agent-1 -# Creates: my-project:agent-1 +# Creates: my-project--agent-1 # Run `hack up -d` before attaching hack session start my-project --up @@ -82,12 +82,13 @@ When attaching, the `-d` flag detaches other clients to avoid terminal size conf ```bash # Send a command to a running session -hack session exec my-project "npm run dev" +hack session exec my-project "bun test" -# This sends the command + Enter to the session's active pane +# tmux: sends the command + Enter to the session's active pane +# zellij: opens a new pane and runs the command (no "active pane" concept) ``` -### Listing panes +### Listing panes (tmux only) Use `hack session panes` to list panes with useful metadata (target, active flag, window/pane indices, command, path). @@ -99,7 +100,7 @@ hack session panes my-project hack session panes my-project --pretty ``` -### Capturing output +### Capturing output (tmux only) `hack session capture` emits NDJSON events by default for machine parsing (start/log/end). By default it targets the active pane; use `--target` to select a specific pane. Use `--pretty` for raw pane output. @@ -107,14 +108,14 @@ hack session panes my-project --pretty # Capture last 200 lines (default) as NDJSON hack session capture my-project -# Capture a specific pane target and line count +# Capture a specific pane target and line count (tmux pane targets use `:` and `.`) hack session capture my-project --target my-project:0.1 --lines 500 # Human-friendly raw output hack session capture my-project --pretty ``` -### Tailing output +### Tailing output (tmux only) `hack session tail` also emits NDJSON events by default and stops after `--max-ms` (default 5000). By default it tails the active pane; use `--target` to select a specific pane. @@ -154,7 +155,7 @@ hack ssh my-session 1. **SSH command**: Copy-paste command to connect 2. **QR code**: Scan with mobile SSH apps (Blink, Termius) -3. **Active sessions**: List of tmux sessions on this machine +3. **Active sessions**: List of active tmux sessions on this machine (currently tmux-only) 4. **Action picker**: Done or connect to a session ### Tailscale setup @@ -180,20 +181,49 @@ If you're already in tmux and want to switch sessions without detaching: tmux switch-client -t ``` +## Configuration + +Project config: `.hack/hack.config.json` + +```json +{ + "sessions": { + "mux": "auto" + } +} +``` + +Values: +- `auto` (default): prefer tmux, fall back to zellij +- `tmux`: require tmux +- `zellij`: require zellij +- `none`: disable sessions + +Env override: `HACK_SESSIONS_MUX=auto|tmux|zellij|none` + +Global default: + +```bash +hack config set --global sessions.mux zellij +``` + ## Daemon sessions API -The hack daemon exposes a REST API for managing tmux sessions programmatically. This is useful for: +The hack daemon exposes a REST API for managing mux sessions programmatically (tmux and zellij). This is useful for: - Remote session control via the gateway - Building automation tools - Agent orchestration +When accessed over HTTP, this API is served by the **gateway** and requires authentication. +See `docs/gateway-api.md` for token creation and the full security model. + See the [Gateway API](gateway-api.md) for authentication and endpoint details. ### Endpoints | Method | Path | Description | | --- | --- | --- | -| GET | `/v1/sessions` | List all tmux sessions | +| GET | `/v1/sessions` | List all sessions | | POST | `/v1/sessions` | Create a new session | | GET | `/v1/sessions/:id` | Get session details | | POST | `/v1/sessions/:id/stop` | Stop (kill) a session | @@ -203,7 +233,8 @@ See the [Gateway API](gateway-api.md) for authentication and endpoint details. ### List sessions ```bash -curl http://127.0.0.1:7788/v1/sessions +curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + http://127.0.0.1:7788/v1/sessions ``` Response: @@ -211,6 +242,7 @@ Response: { "sessions": [ { + "backend": "tmux", "name": "my-project", "attached": false, "path": "/Users/dev/my-project", @@ -231,6 +263,7 @@ Response: ```bash curl -X POST http://127.0.0.1:7788/v1/sessions \ + -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"name": "agent-1", "cwd": "/path/to/project"}' ``` @@ -238,13 +271,15 @@ curl -X POST http://127.0.0.1:7788/v1/sessions \ Request body: | Field | Type | Required | Description | | --- | --- | --- | --- | -| `name` | string | yes | Session name (alphanumeric, dash, underscore, dot) | +| `name` | string | yes | Session name (alphanumeric, dash, underscore) | | `cwd` | string | no | Working directory | +| `backend` | string | no | Override backend (`tmux` or `zellij`) | Response (201): ```json { "session": { + "backend": "tmux", "name": "agent-1", "attached": false, "path": "/path/to/project", @@ -257,7 +292,8 @@ Response (201): ### Get session ```bash -curl http://127.0.0.1:7788/v1/sessions/agent-1 +curl -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + http://127.0.0.1:7788/v1/sessions/agent-1 ``` Response includes connection info for SSH access: @@ -277,8 +313,9 @@ Response includes connection info for SSH access: ```bash curl -X POST http://127.0.0.1:7788/v1/sessions/agent-1/exec \ + -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ -H "Content-Type: application/json" \ - -d '{"command": "npm test"}' + -d '{"command": "bun test"}' ``` This sends the command followed by Enter to the session. @@ -295,6 +332,7 @@ Response: ```bash curl -X POST http://127.0.0.1:7788/v1/sessions/agent-1/input \ + -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ -H "Content-Type: application/json" \ -d '{"keys": "C-c"}' ``` @@ -306,6 +344,10 @@ Send raw keystrokes without Enter. Useful for: - Arrow keys: `Up`, `Down`, `Left`, `Right` - `Tab` +Note: +- tmux: `keys` are passed to `tmux send-keys` +- zellij: `keys` are best-effort character injection (not full key chord support) + Response: ```json { @@ -317,7 +359,8 @@ Response: ### Stop session ```bash -curl -X POST http://127.0.0.1:7788/v1/sessions/agent-1/stop +curl -X POST -H "Authorization: Bearer $HACK_GATEWAY_TOKEN" \ + http://127.0.0.1:7788/v1/sessions/agent-1/stop ``` Response: @@ -340,10 +383,10 @@ Response: | 400 | `missing_session_id` | Session ID not in URL | | 404 | `session_not_found` | Session doesn't exist | | 409 | `session_exists` | Session already exists (on create) | -| 500 | `create_failed` | tmux create failed | -| 500 | `stop_failed` | tmux kill failed | -| 500 | `exec_failed` | tmux send-keys failed | -| 500 | `input_failed` | tmux send-keys failed | +| 500 | `create_failed` | Session create failed | +| 500 | `stop_failed` | Session kill failed | +| 500 | `exec_failed` | Session exec failed | +| 500 | `input_failed` | Session input failed | ## Example: Remote agent workflow @@ -358,12 +401,12 @@ Response: ```bash curl -X POST http://gateway.example.com/v1/sessions/agent-task-1/exec \ -H "Authorization: Bearer $TOKEN" \ - -d '{"command": "git pull && npm install && npm test"}' + -d '{"command": "git pull && bun install && bun test"}' ``` 3. **SSH in to check progress**: ```bash - ssh laptop.tail1234.ts.net -t "tmux attach -t agent-task-1" + ssh laptop.tail1234.ts.net -t "hack session attach agent-task-1" ``` 4. **Clean up when done**: diff --git a/examples/basic/AGENTS.md b/examples/basic/AGENTS.md index ca6d1615..cb9e1613 100644 --- a/examples/basic/AGENTS.md +++ b/examples/basic/AGENTS.md @@ -16,7 +16,7 @@ When to use a branch instance: Standard workflow: - If `.hack/` is missing: `hack init` - Start services: `hack up --detach` -- Check status: `hack ps` or `hack projects status` +- Check status: `hack ps` or `hack status` - Open app: `hack open` (use `--json` for machine parsing) - Stop services: `hack down` @@ -48,6 +48,16 @@ Docker compose notes: - Prefer `hack` commands; they include the right files/networks. - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. +Sessions (mux-based): +- Interactive picker: `hack session` (clack picker; switches inside tmux, attaches outside) +- Start/attach: `hack session start ` (attaches if exists, switches if in tmux) +- Force new: `hack session start --new --name agent-1` +- With infra: `hack session start --up` +- List: `hack session list` +- Stop: `hack session stop ` +- Exec in session: `hack session exec ""` +- Setup tmux: `hack setup tmux` (adds a keybinding; requires tmux installed) + Agent setup (CLI-first): - Cursor rules: `hack setup cursor` - Claude hooks: `hack setup claude` diff --git a/examples/basic/CLAUDE.md b/examples/basic/CLAUDE.md index ca6d1615..cb9e1613 100644 --- a/examples/basic/CLAUDE.md +++ b/examples/basic/CLAUDE.md @@ -16,7 +16,7 @@ When to use a branch instance: Standard workflow: - If `.hack/` is missing: `hack init` - Start services: `hack up --detach` -- Check status: `hack ps` or `hack projects status` +- Check status: `hack ps` or `hack status` - Open app: `hack open` (use `--json` for machine parsing) - Stop services: `hack down` @@ -48,6 +48,16 @@ Docker compose notes: - Prefer `hack` commands; they include the right files/networks. - Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container. +Sessions (mux-based): +- Interactive picker: `hack session` (clack picker; switches inside tmux, attaches outside) +- Start/attach: `hack session start ` (attaches if exists, switches if in tmux) +- Force new: `hack session start --new --name agent-1` +- With infra: `hack session start --up` +- List: `hack session list` +- Stop: `hack session stop ` +- Exec in session: `hack session exec ""` +- Setup tmux: `hack setup tmux` (adds a keybinding; requires tmux installed) + Agent setup (CLI-first): - Cursor rules: `hack setup cursor` - Claude hooks: `hack setup claude` diff --git a/scripts/build-release.ts b/scripts/build-release.ts index d84363c7..5f18e9e6 100644 --- a/scripts/build-release.ts +++ b/scripts/build-release.ts @@ -6,6 +6,7 @@ import { basename, relative, resolve } from "node:path"; import { renderProjectBranchesSchemaJson, renderProjectConfigSchemaJson, + renderProjectEnvSchemaJson, } from "../src/templates.ts"; interface BuildArgs { @@ -104,6 +105,10 @@ async function main({ args }: { readonly args: BuildArgs }): Promise { resolve(schemasDir, "hack.config.schema.json"), renderProjectConfigSchemaJson() ); + await Bun.write( + resolve(schemasDir, "hack.env.schema.json"), + renderProjectEnvSchemaJson() + ); await Bun.write( resolve(schemasDir, "hack.branches.schema.json"), renderProjectBranchesSchemaJson() diff --git a/src/backends/runtime-backend.ts b/src/backends/runtime-backend.ts index 1a9a4790..5ee39de6 100644 --- a/src/backends/runtime-backend.ts +++ b/src/backends/runtime-backend.ts @@ -18,6 +18,7 @@ export interface RuntimeBaseOptions { readonly composeProject?: string | null; readonly profiles?: readonly string[]; readonly cwd: string; + readonly env?: Record; } export interface RuntimeUpOptions extends RuntimeBaseOptions { @@ -55,11 +56,13 @@ export const composeRuntimeBackend: RuntimeBackend = { ...(opts.detach ? ["-d"] : []), ]; if (opts.detach) { - return await run(cmd, { cwd: opts.cwd }); + return await run(cmd, { cwd: opts.cwd, env: opts.env }); } + const env = mergeSpawnEnv(opts.env); const proc = Bun.spawn(cmd, { cwd: opts.cwd, + env, stdin: "inherit", stdout: "pipe", stderr: "pipe", @@ -92,15 +95,15 @@ export const composeRuntimeBackend: RuntimeBackend = { }, async down(opts) { const cmd = [...buildComposeArgs(opts), "down"]; - return await run(cmd, { cwd: opts.cwd }); + return await run(cmd, { cwd: opts.cwd, env: opts.env }); }, async psJson(opts) { const cmd = [...buildComposeArgs(opts), "ps", "--format", "json"]; - return await exec(cmd, { cwd: opts.cwd, stdin: "ignore" }); + return await exec(cmd, { cwd: opts.cwd, stdin: "ignore", env: opts.env }); }, async ps(opts) { const cmd = [...buildComposeArgs(opts), "ps"]; - return await run(cmd, { cwd: opts.cwd }); + return await run(cmd, { cwd: opts.cwd, env: opts.env }); }, async run(opts) { const cmd = [ @@ -111,6 +114,22 @@ export const composeRuntimeBackend: RuntimeBackend = { opts.service, ...(opts.cmdArgs.length > 0 ? opts.cmdArgs : []), ]; - return await run(cmd, { cwd: opts.cwd, stdin: "inherit" }); + return await run(cmd, { cwd: opts.cwd, stdin: "inherit", env: opts.env }); }, }; + +function mergeSpawnEnv( + override: Record | undefined +): Record | undefined { + if (!override) { + return undefined; + } + + const base: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + base[key] = value; + } + } + return { ...base, ...override }; +} diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 34409a12..3cac91e8 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -4,6 +4,7 @@ import { branchCommand } from "../commands/branch.ts"; import { configCommand } from "../commands/config.ts"; import { daemonCommand } from "../commands/daemon.ts"; import { doctorCommand } from "../commands/doctor.ts"; +import { envCommand } from "../commands/env.ts"; import { gatewayCommand } from "../commands/gateway.ts"; import { globalCommand } from "../commands/global.ts"; import { helpCommand } from "../commands/help.ts"; @@ -67,6 +68,7 @@ export const CLI_SPEC = defineCli({ daemonCommand, theCommand, secretsCommand, + envCommand, configCommand, mcpCommand, setupCommand, diff --git a/src/commands/doctor.ts b/src/commands/doctor.ts index bca464fd..beb36f03 100644 --- a/src/commands/doctor.ts +++ b/src/commands/doctor.ts @@ -391,12 +391,12 @@ function checkOptionalFzf(): CheckResult { const fzf = getFzfPath(); if (!fzf) { return { - name: "fzf (sessions)", + name: "fzf (optional)", status: "warn", - message: "fzf not found (needed for hack session picker)", + message: "fzf not found (optional)", }; } - return { name: "fzf (sessions)", status: "ok", message: fzf }; + return { name: "fzf (optional)", status: "ok", message: fzf }; } async function checkDockerRunning(): Promise { @@ -805,71 +805,154 @@ function parseDnsResponse(opts: { readonly expectedId: number; readonly recordType: "A" | "AAAA"; }): string | null { - if (opts.msg.length < 12) { + const header = parseDnsHeader(opts.msg); + if (!header) { return null; } - const id = opts.msg.readUInt16BE(0); - if (id !== opts.expectedId) { + if (header.id !== opts.expectedId) { return null; } - const qd = opts.msg.readUInt16BE(4); - const an = opts.msg.readUInt16BE(6); + const answersOffset = skipDnsQuestions({ + msg: opts.msg, + offset: header.offsetAfterHeader, + qd: header.qd, + }); + if (answersOffset === null) { + return null; + } + + return parseDnsAnswers({ + msg: opts.msg, + offset: answersOffset, + an: header.an, + recordType: opts.recordType, + }); +} - let offset = 12; +type DnsHeader = { + readonly id: number; + readonly qd: number; + readonly an: number; + readonly offsetAfterHeader: number; +}; + +function parseDnsHeader(msg: Buffer): DnsHeader | null { + if (msg.length < 12) { + return null; + } + return { + id: msg.readUInt16BE(0), + qd: msg.readUInt16BE(4), + an: msg.readUInt16BE(6), + offsetAfterHeader: 12, + }; +} - for (let i = 0; i < qd; i += 1) { +function skipDnsQuestions(opts: { + readonly msg: Buffer; + readonly offset: number; + readonly qd: number; +}): number | null { + let offset = opts.offset; + for (let i = 0; i < opts.qd; i += 1) { offset = skipDnsName(opts.msg, offset); offset += 4; // QTYPE + QCLASS if (offset > opts.msg.length) { return null; } } + return offset; +} + +type DnsAnswerHeader = { + readonly type: number; + readonly klass: number; + readonly rdlength: number; + readonly rdataOffset: number; + readonly nextOffset: number; +}; +function readDnsAnswerHeader(opts: { + readonly msg: Buffer; + readonly offset: number; +}): DnsAnswerHeader | null { + if (opts.offset + 10 > opts.msg.length) { + return null; + } + const type = opts.msg.readUInt16BE(opts.offset); + const klass = opts.msg.readUInt16BE(opts.offset + 2); + const rdlength = opts.msg.readUInt16BE(opts.offset + 8); + const rdataOffset = opts.offset + 10; + const nextOffset = rdataOffset + rdlength; + if (nextOffset > opts.msg.length) { + return null; + } + return { type, klass, rdlength, rdataOffset, nextOffset }; +} + +function parseDnsAnswers(opts: { + readonly msg: Buffer; + readonly offset: number; + readonly an: number; + readonly recordType: "A" | "AAAA"; +}): string | null { const expectedType = opts.recordType === "AAAA" ? 28 : 1; const expectedRdlength = opts.recordType === "AAAA" ? 16 : 4; - for (let i = 0; i < an; i += 1) { + let offset = opts.offset; + for (let i = 0; i < opts.an; i += 1) { offset = skipDnsName(opts.msg, offset); - if (offset + 10 > opts.msg.length) { - return null; - } - const type = opts.msg.readUInt16BE(offset); - const klass = opts.msg.readUInt16BE(offset + 2); - const rdlength = opts.msg.readUInt16BE(offset + 8); - offset += 10; - if (offset + rdlength > opts.msg.length) { + + const header = readDnsAnswerHeader({ msg: opts.msg, offset }); + if (!header) { return null; } - if (type === expectedType && klass === 1 && rdlength === expectedRdlength) { - if (opts.recordType === "A") { - const a = opts.msg[offset]; - const b = opts.msg[offset + 1]; - const c = opts.msg[offset + 2]; - const d = opts.msg[offset + 3]; - return `${a}.${b}.${c}.${d}`; - } - // AAAA record - parse IPv6 address - const parts: string[] = []; - for (let j = 0; j < 16; j += 2) { - const word = opts.msg.readUInt16BE(offset + j); - parts.push(word.toString(16)); - } - // Simplify ::1 representation - const ipv6 = parts.join(":"); - if (ipv6 === "0:0:0:0:0:0:0:1") { - return "::1"; - } - return ipv6; + if ( + header.type === expectedType && + header.klass === 1 && + header.rdlength === expectedRdlength + ) { + return opts.recordType === "A" + ? parseDnsARecord({ msg: opts.msg, rdataOffset: header.rdataOffset }) + : parseDnsAAAARecord({ + msg: opts.msg, + rdataOffset: header.rdataOffset, + }); } - offset += rdlength; + offset = header.nextOffset; } return null; } +function parseDnsARecord(opts: { + readonly msg: Buffer; + readonly rdataOffset: number; +}): string { + const a = opts.msg[opts.rdataOffset]; + const b = opts.msg[opts.rdataOffset + 1]; + const c = opts.msg[opts.rdataOffset + 2]; + const d = opts.msg[opts.rdataOffset + 3]; + return `${a}.${b}.${c}.${d}`; +} + +function parseDnsAAAARecord(opts: { + readonly msg: Buffer; + readonly rdataOffset: number; +}): string { + const parts: string[] = []; + for (let j = 0; j < 16; j += 2) { + const word = opts.msg.readUInt16BE(opts.rdataOffset + j); + parts.push(word.toString(16)); + } + + const ipv6 = parts.join(":"); + return ipv6 === "0:0:0:0:0:0:0:1" ? "::1" : ipv6; +} + function skipDnsName(buf: Buffer, startOffset: number): number { let offset = startOffset; while (offset < buf.length) { @@ -1228,25 +1311,96 @@ async function readLegacyEnvDevHost(envFile: string): Promise { } async function runDoctorFix(): Promise { - const ok = await confirm({ + const ok = await confirmOrThrow({ message: "Attempt safe auto-remediations now? (network + CoreDNS + CA)", initialValue: true, }); - if (isCancel(ok)) { - throw new Error("Canceled"); - } if (!ok) { return; } - const dockerOk = await exec(["docker", "info"], { stdin: "ignore" }); - if (dockerOk.exitCode !== 0) { + const dockerOk = await dockerInfoOk(); + if (!dockerOk) { note("Docker is not reachable; cannot apply fixes.", "doctor"); return; } + await maybeRepairHackd(); + + const paths = getGlobalPaths(); + await ensureDir(paths.caddyDir); + + const ingressAfterFix = await ensureIngressNetwork(); + await ensureLoggingNetwork(); + + const useStaticIps = ingressAfterFix.hasSubnet; + await writeWithPromptIfDifferent( + paths.caddyCompose, + renderGlobalCaddyCompose({ + useStaticCoreDnsIp: useStaticIps, + useStaticCaddyIp: useStaticIps, + }) + ); + await writeWithPromptIfDifferent( + paths.coreDnsConfig, + renderGlobalCoreDnsConfig({ useStaticCaddyIp: useStaticIps }) + ); + + await maybeStartGlobalCaddyCompose({ paths }); + await maybeExportCaddyCaCert({ paths }); + await maybeMigrateDnsmasq(); +} + +async function confirmOrThrow(opts: { + readonly message: string; + readonly initialValue: boolean; +}): Promise { + const ok = await confirm({ + message: opts.message, + initialValue: opts.initialValue, + }); + if (isCancel(ok)) { + throw new Error("Canceled"); + } + return ok; +} + +async function dockerInfoOk(): Promise { + const dockerOk = await exec(["docker", "info"], { stdin: "ignore" }); + return dockerOk.exitCode === 0; +} + +async function maybeRepairHackd(): Promise { + const report = await resolveDaemonReportForDoctorFix(); + if (report.status === "running") { + return; + } + + if (report.status === "stale") { + const okClear = await confirmOrThrow({ + message: "Clear stale hackd pid/socket files?", + initialValue: true, + }); + if (okClear) { + await runHackSubcommand({ args: ["daemon", "clear"] }); + } + } + + const okStart = await confirmOrThrow({ + message: "Start hackd now?", + initialValue: true, + }); + if (okStart) { + await runHackSubcommand({ args: ["daemon", "start"] }); + } +} + +async function resolveDaemonReportForDoctorFix(): Promise< + ReturnType +> { const daemonPaths = resolveDaemonPaths({}); const daemonStatus = await readDaemonStatus({ paths: daemonPaths }); + let apiOk = false; if (daemonStatus.socketExists) { const ping = await requestDaemonJson({ @@ -1257,144 +1411,131 @@ async function runDoctorFix(): Promise { apiOk = ping?.ok ?? false; } - const daemonReport = buildDaemonStatusReport({ + return buildDaemonStatusReport({ pid: daemonStatus.pid, processRunning: daemonStatus.running, socketExists: daemonStatus.socketExists, logExists: daemonStatus.logExists, apiOk, }); +} - if (daemonReport.status !== "running") { - if (daemonReport.status === "stale") { - const okStale = await confirm({ - message: "Clear stale hackd pid/socket files?", - initialValue: true, - }); - if (isCancel(okStale)) { - throw new Error("Canceled"); - } - if (okStale) { - const invocation = await resolveHackInvocation(); - await run([invocation.bin, ...invocation.args, "daemon", "clear"], { - stdin: "inherit", - }); - } - } +async function runHackSubcommand(opts: { + readonly args: readonly string[]; +}): Promise { + const invocation = await resolveHackInvocation(); + await run([invocation.bin, ...invocation.args, ...opts.args], { + stdin: "inherit", + }); +} - const okStart = await confirm({ - message: "Start hackd now?", - initialValue: true, - }); - if (isCancel(okStart)) { - throw new Error("Canceled"); - } - if (okStart) { - const invocation = await resolveHackInvocation(); - await run([invocation.bin, ...invocation.args, "daemon", "start"], { - stdin: "inherit", - }); - } +async function ensureIngressNetwork(): Promise<{ + exists: boolean; + hasSubnet: boolean; +}> { + const ingress = await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK); + if (ingress.exists && ingress.hasSubnet) { + return ingress; } - const paths = getGlobalPaths(); - await ensureDir(paths.caddyDir); + const action = ingress.exists ? "Recreate" : "Create"; + const okNetwork = await confirmOrThrow({ + message: `${action} ${DEFAULT_INGRESS_NETWORK} with subnet ${DEFAULT_INGRESS_SUBNET}?`, + initialValue: true, + }); + if (!okNetwork) { + return ingress; + } - const ingress = await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK); - if (!(ingress.exists && ingress.hasSubnet)) { - const action = ingress.exists ? "Recreate" : "Create"; - const okNetwork = await confirm({ - message: `${action} ${DEFAULT_INGRESS_NETWORK} with subnet ${DEFAULT_INGRESS_SUBNET}?`, - initialValue: true, + if (ingress.exists) { + await run(["docker", "network", "rm", DEFAULT_INGRESS_NETWORK], { + stdin: "inherit", }); - if (isCancel(okNetwork)) { - throw new Error("Canceled"); - } - if (okNetwork) { - if (ingress.exists) { - await run(["docker", "network", "rm", DEFAULT_INGRESS_NETWORK], { - stdin: "inherit", - }); - } - await run( - [ - "docker", - "network", - "create", - DEFAULT_INGRESS_NETWORK, - "--subnet", - DEFAULT_INGRESS_SUBNET, - "--gateway", - DEFAULT_INGRESS_GATEWAY, - ], - { stdin: "inherit" } - ); - } } + await run( + [ + "docker", + "network", + "create", + DEFAULT_INGRESS_NETWORK, + "--subnet", + DEFAULT_INGRESS_SUBNET, + "--gateway", + DEFAULT_INGRESS_GATEWAY, + ], + { stdin: "inherit" } + ); + + return await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK); +} +async function ensureLoggingNetwork(): Promise { const logging = await inspectDockerNetwork(DEFAULT_LOGGING_NETWORK); - if (!logging.exists) { - await run(["docker", "network", "create", DEFAULT_LOGGING_NETWORK], { - stdin: "inherit", - }); + if (logging.exists) { + return; } - // Re-check ingress network after potential recreation - const ingressAfterFix = await inspectDockerNetwork(DEFAULT_INGRESS_NETWORK); - const useStaticIps = ingressAfterFix.hasSubnet; - await writeWithPromptIfDifferent( - paths.caddyCompose, - renderGlobalCaddyCompose({ - useStaticCoreDnsIp: useStaticIps, - useStaticCaddyIp: useStaticIps, - }) - ); - await writeWithPromptIfDifferent( - paths.coreDnsConfig, - renderGlobalCoreDnsConfig({ useStaticCaddyIp: useStaticIps }) - ); + await run(["docker", "network", "create", DEFAULT_LOGGING_NETWORK], { + stdin: "inherit", + }); +} - if (await pathExists(paths.caddyCompose)) { - await run( - [ - "docker", - "compose", - "-f", - paths.caddyCompose, - "up", - "-d", - "--remove-orphans", - ], - { - cwd: dirname(paths.caddyCompose), - stdin: "inherit", - } - ); +async function maybeStartGlobalCaddyCompose(opts: { + readonly paths: ReturnType; +}): Promise { + if (!(await pathExists(opts.paths.caddyCompose))) { + return; } - if (!(await pathExists(paths.caddyCaCert))) { - const okCa = await confirm({ - message: "Export Caddy Local CA cert for container trust?", - initialValue: true, - }); - if (isCancel(okCa)) { - throw new Error("Canceled"); - } - if (okCa) { - await exportCaddyLocalCaCert({ paths }); + await run( + [ + "docker", + "compose", + "-f", + opts.paths.caddyCompose, + "up", + "-d", + "--remove-orphans", + ], + { + cwd: dirname(opts.paths.caddyCompose), + stdin: "inherit", } + ); +} + +async function maybeExportCaddyCaCert(opts: { + readonly paths: ReturnType; +}): Promise { + if (await pathExists(opts.paths.caddyCaCert)) { + return; } - // Check for legacy localhost dnsmasq config and offer to migrate to container IP - if (isMac()) { - const migrationResult = await migrateDnsmasqToContainerIpIfNeeded(); - if (migrationResult === "migrated") { - note( - "dnsmasq migrated to container IP - port forwarding issues resolved", - "doctor" - ); - } + const okCa = await confirmOrThrow({ + message: "Export Caddy Local CA cert for container trust?", + initialValue: true, + }); + if (!okCa) { + return; + } + + await exportCaddyLocalCaCert({ paths: opts.paths }); +} + +async function maybeMigrateDnsmasq(): Promise { + if (!isMac()) { + return; } + + const migrationResult = await migrateDnsmasqToContainerIpIfNeeded(); + if (migrationResult !== "migrated") { + return; + } + + note( + "dnsmasq migrated to container IP - port forwarding issues resolved", + "doctor" + ); } /** diff --git a/src/commands/env.ts b/src/commands/env.ts new file mode 100644 index 00000000..f1c01c8a --- /dev/null +++ b/src/commands/env.ts @@ -0,0 +1,356 @@ +import { resolve } from "node:path"; +import { confirm, isCancel, password, text } from "@clack/prompts"; + +import { secrets } from "bun"; +import type { CliContext, CommandHandlerFor } from "../cli/command.ts"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; +import { optJson, optPath, optProject } from "../cli/options.ts"; +import { PROJECT_ENV_FILENAME } from "../constants.ts"; +import { + removeDotEnvKey, + resolveHackEnv, + resolveKeychainServiceName, + upsertDotEnvValue, +} from "../lib/hack-env.ts"; +import type { ProjectContext } from "../lib/project.ts"; +import { + defaultProjectSlugFromPath, + findProjectContext, + readProjectConfig, + sanitizeProjectSlug, +} from "../lib/project.ts"; +import { resolveRegisteredProjectByName } from "../lib/projects-registry.ts"; +import { logger } from "../ui/logger.ts"; + +const optShowSecrets = defineOption({ + name: "showSecrets", + type: "boolean", + long: "--show-secrets", + description: "Print secret values (keychain) in plaintext", +} as const); + +const optSecret = defineOption({ + name: "secret", + type: "boolean", + long: "--secret", + description: "Store value in OS keychain (Bun.secrets) instead of .hack/.env", +} as const); + +const listSpec = defineCommand({ + name: "list", + summary: "List env contract vars and resolution state", + group: "Project", + options: [optPath, optProject, optJson, optShowSecrets], + positionals: [], + subcommands: [], +} as const); + +const setSpec = defineCommand({ + name: "set", + summary: "Set an env value (.hack/.env or keychain)", + group: "Project", + options: [optPath, optProject, optSecret], + positionals: [{ name: "spec", required: false }], + subcommands: [], +} as const); + +const unsetSpec = defineCommand({ + name: "unset", + summary: "Unset an env value (.hack/.env and keychain)", + group: "Project", + options: [optPath, optProject], + positionals: [{ name: "key", required: false }], + subcommands: [], +} as const); + +const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +async function resolveProjectForEnv(opts: { + readonly ctx: CliContext; + readonly pathOpt: string | undefined; + readonly projectOpt: string | undefined; +}): Promise { + if (opts.pathOpt && opts.projectOpt) { + throw new CliUsageError("Use either --path or --project (not both)."); + } + + if (opts.projectOpt) { + const name = sanitizeProjectSlug(opts.projectOpt); + if (!name) { + throw new CliUsageError("Invalid --project value."); + } + const project = await resolveRegisteredProjectByName({ name }); + if (!project) { + throw new CliUsageError( + `Unknown project "${name}". Run 'hack init' in that repo (or run 'hack projects' to see registered projects).` + ); + } + return project; + } + + const startDir = opts.pathOpt + ? resolve(opts.ctx.cwd, opts.pathOpt) + : opts.ctx.cwd; + const project = await findProjectContext(startDir); + if (!project) { + throw new CliUsageError("No .hack/ found. Run: hack init"); + } + return project; +} + +async function resolveProjectName(project: ProjectContext): Promise { + const cfg = await readProjectConfig(project); + const derived = defaultProjectSlugFromPath(project.projectRoot); + const raw = (cfg.name ?? derived).trim(); + return sanitizeProjectSlug(raw.length > 0 ? raw : derived); +} + +const handleEnvList: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const project = await resolveProjectForEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + const projectName = await resolveProjectName(project); + const showSecrets = args.options.showSecrets === true; + const json = args.options.json === true; + + const resolved = await resolveHackEnv({ + projectDir: project.projectDir, + projectName, + }); + + if (json) { + process.stdout.write( + `${JSON.stringify( + { + project: projectName, + vars: resolved.values.map((v) => ({ + key: v.key, + required: v.required, + source: v.source, + services: v.services, + resolved_from: v.resolvedFrom, + value: + v.source === "keychain" && !showSecrets && v.value !== null + ? "***" + : v.value, + })), + missing_required: resolved.missingRequired.map((v) => v.key), + }, + null, + 2 + )}\n` + ); + return resolved.missingRequired.length > 0 ? 1 : 0; + } + + if (resolved.contract.vars.length === 0) { + logger.info({ + message: `No ${project.projectDir}/hack.env.json contract found (or it has no vars).`, + }); + return 0; + } + + for (const v of resolved.values) { + const value = + v.source === "keychain" && !showSecrets && v.value !== null + ? "***" + : (v.value ?? ""); + const required = v.required ? "required" : "optional"; + const from = v.resolvedFrom ?? "missing"; + const services = v.services ? v.services.join(",") : "*"; + process.stdout.write( + `${v.key}\t${required}\t${v.source}\t${from}\t${services}\t${value}\n` + ); + } + + if (resolved.missingRequired.length > 0) { + logger.warn({ + message: `Missing required env: ${resolved.missingRequired.map((v) => v.key).join(", ")}`, + }); + return 1; + } + + return 0; +}; + +const handleEnvSet: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const project = await resolveProjectForEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + const projectName = await resolveProjectName(project); + const service = resolveKeychainServiceName({ projectName }); + const storeInKeychain = args.options.secret === true; + + const spec = (args.positionals.spec ?? "").trim(); + const [keyFromSpec, valueFromSpec] = parseKeyValueSpec(spec); + + const key = await resolveEnvKey({ key: keyFromSpec }); + const value = await resolveEnvValue({ + key, + value: valueFromSpec, + secret: storeInKeychain, + }); + + if (storeInKeychain) { + await secrets.set({ service, name: key, value }); + logger.success({ + message: `Stored secret "${key}" in keychain (${service})`, + }); + return 0; + } + + const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME); + const result = await upsertDotEnvValue({ envFile, key, value }); + logger.success({ + message: result.changed ? `Updated ${envFile}` : "No changes needed.", + }); + return 0; +}; + +const handleEnvUnset: CommandHandlerFor = async ({ + ctx, + args, +}): Promise => { + const project = await resolveProjectForEnv({ + ctx, + pathOpt: args.options.path, + projectOpt: args.options.project, + }); + const projectName = await resolveProjectName(project); + const service = resolveKeychainServiceName({ projectName }); + + const key = await resolveEnvKey({ key: (args.positionals.key ?? "").trim() }); + + const ok = await confirm({ + message: `Unset "${key}" from ${project.projectDir}/.env and keychain (${service})?`, + initialValue: true, + }); + if (isCancel(ok)) { + return 1; + } + if (!ok) { + return 0; + } + + const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME); + const [dotenvResult, keychainDeleted] = await Promise.all([ + removeDotEnvKey({ envFile, key }), + secrets.delete({ service, name: key }), + ]); + + logger.success({ + message: [ + dotenvResult.changed ? `Updated ${envFile}` : `No ${key} in ${envFile}`, + keychainDeleted + ? `Deleted from keychain (${service})` + : "No keychain entry", + ].join(" • "), + }); + return 0; +}; + +function parseKeyValueSpec(spec: string): readonly [string, string | null] { + const trimmed = spec.trim(); + if (trimmed.length === 0) { + return ["", null]; + } + + const idx = trimmed.indexOf("="); + if (idx === -1) { + return [trimmed, null]; + } + + const key = trimmed.slice(0, idx).trim(); + const value = trimmed.slice(idx + 1); + return [key, value]; +} + +async function resolveEnvKey(opts: { readonly key: string }): Promise { + const fromPos = opts.key.trim(); + if (fromPos.length > 0) { + if (!ENV_KEY_PATTERN.test(fromPos)) { + throw new CliUsageError(`Invalid env key: ${fromPos}`); + } + return fromPos; + } + + const key = await text({ + message: "Env key:", + validate: (value) => { + const v = value?.trim(); + if (!v) { + return "Required"; + } + if (!ENV_KEY_PATTERN.test(v)) { + return "Use uppercase snake-case (e.g. AWS_PROFILE)"; + } + return undefined; + }, + }); + if (isCancel(key)) { + throw new Error("Canceled"); + } + return key.trim(); +} + +async function resolveEnvValue(opts: { + readonly key: string; + readonly value: string | null; + readonly secret: boolean; +}): Promise { + const fromSpec = opts.value; + if (typeof fromSpec === "string" && fromSpec.length > 0) { + return fromSpec; + } + + if (opts.secret) { + const v = await password({ + message: `Value for secret "${opts.key}":`, + validate: (value) => + !value || value.length === 0 ? "Required" : undefined, + }); + if (isCancel(v)) { + throw new Error("Canceled"); + } + return v; + } + + const v = await text({ + message: `Value for "${opts.key}":`, + validate: (value) => + !value || value.length === 0 ? "Required" : undefined, + }); + if (isCancel(v)) { + throw new Error("Canceled"); + } + return v; +} + +export const envCommand = defineCommand({ + name: "env", + summary: "Manage project environment variables and secrets", + group: "Project", + expandInRootHelp: true, + options: [], + positionals: [], + subcommands: [ + withHandler(listSpec, handleEnvList), + withHandler(setSpec, handleEnvSet), + withHandler(unsetSpec, handleEnvUnset), + ], +} as const); diff --git a/src/commands/gateway.ts b/src/commands/gateway.ts index 82e27186..41e89a12 100644 --- a/src/commands/gateway.ts +++ b/src/commands/gateway.ts @@ -171,11 +171,83 @@ async function handleGatewaySetup({ const identity = await resolveProjectIdentityForQr({ project }); + await renderGatewaySetupIntro({ + projectName: identity.projectName, + }); + + const currentConfig = await readControlPlaneConfig({ + projectDir: project.projectDir, + }); + const allowWritesCurrent = currentConfig.config.gateway.allowWrites; + + const enabled = await ensureGatewayEnabledForSetup({ project }); + if (!enabled.ok) { + logger.error({ message: enabled.error }); + return 1; + } + + const writes = await resolveGatewayWritesForSetup({ + allowWritesCurrent, + }); + if (!writes.ok) { + logger.error({ message: writes.error }); + return 1; + } + + const daemonExit = await ensureDaemonForGatewaySetup({ + restart: + enabled.gatewayChanged || + enabled.extensionChanged || + writes.allowWritesChanged, + }); + if (daemonExit !== 0) { + return daemonExit; + } + + const scope = await resolveGatewayTokenScope({ + allowWrites: writes.allowWrites, + }); + const label = await resolveGatewayTokenLabel(); + const issued = await issueGatewayToken({ scope, label }); + await renderIssuedGatewayToken({ issued }); + + const exposure = await resolveExposureForGatewaySetup({ project }); + const printQr = args.options.noQr !== true; + if (printQr) { + await renderGatewaySetupQr({ + exposurePlan: exposure.exposurePlan, + gatewayUrl: exposure.gatewayUrl, + token: issued.token, + projectId: identity.projectId, + yes: args.options.yes === true, + }); + } + + await display.panel({ + title: "Next steps", + tone: "info", + lines: [ + `Gateway URL: ${exposure.gatewayUrl}`, + "Remote status: hack remote status", + "Remote shell: hack x supervisor shell --token (write scope required)", + "Expose gateway for off-network access (Cloudflare/Tailscale/SSH)", + ], + }); + await renderExposureHints({ + config: exposure.finalConfig.config, + projectName: identity.projectName, + }); + return 0; +} + +async function renderGatewaySetupIntro(opts: { + readonly projectName: string; +}): Promise { await display.panel({ title: "Gateway setup", tone: "info", lines: [ - `Project: ${identity.projectName}`, + `Project: ${opts.projectName}`, "One-command remote setup (gateway + token + exposure).", "Steps:", "1) Enable gateway for this project", @@ -185,25 +257,24 @@ async function handleGatewaySetup({ "5) Choose an exposure option (Cloudflare/Tailscale/SSH)", ], }); +} - const currentConfig = await readControlPlaneConfig({ - projectDir: project.projectDir, - }); - const allowWritesCurrent = currentConfig.config.gateway.allowWrites; - - const updated = await setGatewayEnabled({ project, enabled: true }); - if (!updated.ok) { - logger.error({ message: updated.error }); - return 1; - } - - const enableExtension = await setExtensionEnabled({ - scope: "global", - extensionId: "dance.hack.gateway", +async function ensureGatewayEnabledForSetup(opts: { + readonly project: ProjectContext; +}): Promise< + | { + readonly ok: true; + readonly gatewayChanged: boolean; + readonly extensionChanged: boolean; + } + | { readonly ok: false; readonly error: string } +> { + const updated = await setGatewayEnabled({ + project: opts.project, enabled: true, }); - if (!enableExtension.ok) { - logger.warn({ message: enableExtension.error }); + if (!updated.ok) { + return updated; } if (updated.changed) { @@ -212,70 +283,116 @@ async function handleGatewaySetup({ logger.info({ message: "Gateway already enabled." }); } - let allowWrites = allowWritesCurrent; - if (!allowWritesCurrent && isTty() && isGumAvailable()) { - const confirmed = await gumConfirm({ - prompt: - "Enable write access for jobs/shells? (recommended for remote shell)", - default: false, - }); - if (confirmed.ok && confirmed.value) { - allowWrites = true; - } - } + const extensionChanged = await setExtensionEnabledOrWarn({ + extensionId: "dance.hack.gateway", + }); + return { ok: true, gatewayChanged: updated.changed, extensionChanged }; +} - let allowWritesChanged = false; - if (allowWrites && !allowWritesCurrent) { - const writeUpdate = await setGatewayAllowWrites({ allowWrites: true }); - if (!writeUpdate.ok) { - logger.error({ message: writeUpdate.error }); - return 1; - } - allowWritesChanged = writeUpdate.changed; - if (writeUpdate.changed) { - logger.success({ message: "Gateway writes enabled." }); +async function resolveGatewayWritesForSetup(opts: { + readonly allowWritesCurrent: boolean; +}): Promise< + | { + readonly ok: true; + readonly allowWrites: boolean; + readonly allowWritesChanged: boolean; } + | { readonly ok: false; readonly error: string } +> { + const allowWrites = await promptAllowWrites({ + allowWritesCurrent: opts.allowWritesCurrent, + }); + const allowWritesChanged = await maybeEnableAllowWrites({ + allowWrites, + allowWritesCurrent: opts.allowWritesCurrent, + }); + if (allowWritesChanged instanceof Error) { + return { ok: false, error: allowWritesChanged.message }; } - if (!(allowWrites || allowWritesCurrent)) { + if (!(allowWrites || opts.allowWritesCurrent)) { logger.info({ message: "Gateway writes remain disabled (shell/jobs require allowWrites + write token).", }); } - const extensionChanged = enableExtension.ok ? enableExtension.changed : false; - if (updated.changed || allowWritesChanged || extensionChanged) { - const restart = await restartDaemon(); - if (restart !== 0) { - return restart; - } - } else { - await startDaemon({ - onRunningMessage: "hackd already running; no restart needed.", - }); + return { ok: true, allowWrites, allowWritesChanged }; +} + +async function promptAllowWrites(opts: { + readonly allowWritesCurrent: boolean; +}): Promise { + if (opts.allowWritesCurrent) { + return true; + } + if (!(isTty() && isGumAvailable())) { + return false; } - const scope = await resolveGatewayTokenScope({ - allowWrites, + const confirmed = await gumConfirm({ + prompt: + "Enable write access for jobs/shells? (recommended for remote shell)", + default: false, }); - const label = await resolveGatewayTokenLabel(); + return confirmed.ok && confirmed.value; +} + +async function maybeEnableAllowWrites(opts: { + readonly allowWrites: boolean; + readonly allowWritesCurrent: boolean; +}): Promise { + if (!(opts.allowWrites && !opts.allowWritesCurrent)) { + return false; + } + + const writeUpdate = await setGatewayAllowWrites({ allowWrites: true }); + if (!writeUpdate.ok) { + return new Error(writeUpdate.error); + } + + if (writeUpdate.changed) { + logger.success({ message: "Gateway writes enabled." }); + } + + return writeUpdate.changed; +} + +async function ensureDaemonForGatewaySetup(opts: { + readonly restart: boolean; +}): Promise { + if (opts.restart) { + return await restartDaemon(); + } + return await startDaemon({ + onRunningMessage: "hackd already running; no restart needed.", + }); +} + +async function issueGatewayToken(opts: { + readonly scope: "read" | "write"; + readonly label: string | undefined; +}): Promise>> { const paths = resolveDaemonPaths({}); - const issued = await createGatewayToken({ + return await createGatewayToken({ rootDir: paths.root, - ...(label ? { label } : {}), - scope, + ...(opts.label ? { label: opts.label } : {}), + scope: opts.scope, }); +} +async function renderIssuedGatewayToken(opts: { + readonly issued: Awaited>; +}): Promise { await display.kv({ title: "Gateway token", entries: [ - ["id", issued.record.id], - ["label", issued.record.label ?? ""], - ["scope", issued.record.scope], - ["created_at", issued.record.createdAt], - ["token", issued.token], + ["id", opts.issued.record.id], + ["label", opts.issued.record.label ?? ""], + ["scope", opts.issued.record.scope], + ["created_at", opts.issued.record.createdAt], + ["token", opts.issued.token], ], }); @@ -283,19 +400,27 @@ async function handleGatewaySetup({ message: "Store this token securely; it cannot be recovered once lost.", }); logger.info({ message: "Export it as HACK_GATEWAY_TOKEN for future use." }); +} +async function resolveExposureForGatewaySetup(opts: { + readonly project: ProjectContext; +}): Promise<{ + readonly finalConfig: Awaited>; + readonly exposurePlan: ExposurePlan; + readonly gatewayUrl: string; +}> { let finalConfig = await readControlPlaneConfig({ - projectDir: project.projectDir, + projectDir: opts.project.projectDir, }); const exposurePlan = await runExposureWizard({ - project, + project: opts.project, config: finalConfig.config, }); if (exposurePlan.configChanged) { finalConfig = await readControlPlaneConfig({ - projectDir: project.projectDir, + projectDir: opts.project.projectDir, }); } @@ -304,46 +429,36 @@ async function handleGatewaySetup({ override: exposurePlan.gatewayUrlOverride, }); - const printQr = args.options.noQr !== true; - - if (printQr) { - if (exposurePlan.sshQrPayload) { - await renderQrPayload({ - label: "SSH", - payload: exposurePlan.sshQrPayload, - sensitive: false, - yes: true, - }); - } + return { finalConfig, exposurePlan, gatewayUrl }; +} - const payload = buildGatewayQrPayload({ - baseUrl: gatewayUrl, - token: issued.token, - projectId: identity.projectId, - }); +async function renderGatewaySetupQr(opts: { + readonly exposurePlan: ExposurePlan; + readonly gatewayUrl: string; + readonly token: string; + readonly projectId: string | undefined; + readonly yes: boolean; +}): Promise { + if (opts.exposurePlan.sshQrPayload) { await renderQrPayload({ - label: "Gateway", - payload, - sensitive: true, - yes: args.options.yes === true, + label: "SSH", + payload: opts.exposurePlan.sshQrPayload, + sensitive: false, + yes: true, }); } - await display.panel({ - title: "Next steps", - tone: "info", - lines: [ - `Gateway URL: ${gatewayUrl}`, - "Remote status: hack remote status", - "Remote shell: hack x supervisor shell --token (write scope required)", - "Expose gateway for off-network access (Cloudflare/Tailscale/SSH)", - ], + const payload = buildGatewayQrPayload({ + baseUrl: opts.gatewayUrl, + token: opts.token, + projectId: opts.projectId, }); - await renderExposureHints({ - config: finalConfig.config, - projectName: identity.projectName, + await renderQrPayload({ + label: "Gateway", + payload, + sensitive: true, + yes: opts.yes, }); - return 0; } async function handleGatewayDisable({ @@ -431,130 +546,200 @@ async function configureCloudflareExposure(opts: { readonly project: ProjectContext; readonly config: ControlPlaneConfig; }): Promise { - const existingHost = getString( - opts.config.extensions["dance.hack.cloudflare"]?.config ?? {}, - "hostname" - ); - const existingSshHost = getString( - opts.config.extensions["dance.hack.cloudflare"]?.config ?? {}, - "sshHostname" - ); - const existingSshOrigin = getString( - opts.config.extensions["dance.hack.cloudflare"]?.config ?? {}, - "sshOrigin" - ); - + const existing = readExistingCloudflareExposure({ config: opts.config }); const hostname = await promptHostname({ label: "Cloudflare hostname", placeholder: "gateway.example.com", - initial: existingHost, + initial: existing.hostname ?? undefined, }); - if (!hostname) { logger.warn({ message: "Cloudflare selected but no hostname provided." }); return { mode: "cloudflare", configChanged: false }; } + const ssh = await promptCloudflareSsh({ existing }); + const configChanged = await configureCloudflareExtension({ + hostname, + sshHostname: ssh.sshHostname, + sshOrigin: ssh.sshOrigin, + }); + await maybeRunCloudflareSetup({ + project: opts.project, + hostname, + sshHostname: ssh.sshHostname, + sshOrigin: ssh.sshOrigin, + }); + + return { + mode: "cloudflare", + gatewayUrlOverride: `https://${hostname}`, + configChanged, + }; +} + +type CloudflareExposureInputs = { + readonly hostname: string | null; + readonly sshHostname: string | null; + readonly sshOrigin: string | null; +}; + +function readExistingCloudflareExposure(opts: { + readonly config: ControlPlaneConfig; +}): CloudflareExposureInputs { + const config = opts.config.extensions["dance.hack.cloudflare"]?.config ?? {}; + return { + hostname: getString(config, "hostname") ?? null, + sshHostname: getString(config, "sshHostname") ?? null, + sshOrigin: getString(config, "sshOrigin") ?? null, + }; +} + +async function promptCloudflareSsh(opts: { + readonly existing: CloudflareExposureInputs; +}): Promise> { const sshHostname = await promptHostname({ label: "Cloudflare SSH hostname (optional)", placeholder: "ssh.example.com", - initial: existingSshHost, + initial: opts.existing.sshHostname ?? undefined, }); - let sshOrigin: string | null = null; - if (sshHostname) { - const defaultPort = parseSshOriginPort(existingSshOrigin) ?? 22; - const sshPort = await promptNumber({ - label: "SSH port for tunnel (default 22)", - fallback: defaultPort, - }); - sshOrigin = `ssh://127.0.0.1:${sshPort}`; + if (!sshHostname) { + return { sshHostname: null, sshOrigin: null }; } - let configChanged = false; - const enableResult = await setExtensionEnabled({ - scope: "global", - extensionId: "dance.hack.cloudflare", - enabled: true, + const defaultPort = + parseSshOriginPort(opts.existing.sshOrigin ?? undefined) ?? 22; + const sshPort = await promptNumber({ + label: "SSH port for tunnel (default 22)", + fallback: defaultPort, }); - if (!enableResult.ok) { - logger.warn({ message: enableResult.error }); - } else if (enableResult.changed) { + return { + sshHostname, + sshOrigin: `ssh://127.0.0.1:${sshPort}`, + }; +} + +async function configureCloudflareExtension(opts: { + readonly hostname: string; + readonly sshHostname: string | null; + readonly sshOrigin: string | null; +}): Promise { + let configChanged = false; + + if ( + await setExtensionEnabledOrWarn({ extensionId: "dance.hack.cloudflare" }) + ) { configChanged = true; } - const hostnameResult = await setExtensionConfigValue({ - scope: "global", - extensionId: "dance.hack.cloudflare", - path: ["hostname"], - value: hostname, - }); - if (!hostnameResult.ok) { - logger.warn({ message: hostnameResult.error }); - } else if (hostnameResult.changed) { + if ( + await setExtensionConfigValueOrWarn({ + extensionId: "dance.hack.cloudflare", + path: ["hostname"], + value: opts.hostname, + }) + ) { configChanged = true; } - if (sshHostname && sshOrigin) { - const sshHostnameResult = await setExtensionConfigValue({ - scope: "global", + if (!(opts.sshHostname && opts.sshOrigin)) { + return configChanged; + } + + if ( + await setExtensionConfigValueOrWarn({ extensionId: "dance.hack.cloudflare", path: ["sshHostname"], - value: sshHostname, - }); - if (!sshHostnameResult.ok) { - logger.warn({ message: sshHostnameResult.error }); - } else if (sshHostnameResult.changed) { - configChanged = true; - } + value: opts.sshHostname, + }) + ) { + configChanged = true; + } - const sshOriginResult = await setExtensionConfigValue({ - scope: "global", + if ( + await setExtensionConfigValueOrWarn({ extensionId: "dance.hack.cloudflare", path: ["sshOrigin"], - value: sshOrigin, - }); - if (!sshOriginResult.ok) { - logger.warn({ message: sshOriginResult.error }); - } else if (sshOriginResult.changed) { - configChanged = true; - } + value: opts.sshOrigin, + }) + ) { + configChanged = true; } + return configChanged; +} + +async function setExtensionEnabledOrWarn(opts: { + readonly extensionId: string; +}): Promise { + const enableResult = await setExtensionEnabled({ + scope: "global", + extensionId: opts.extensionId, + enabled: true, + }); + if (!enableResult.ok) { + logger.warn({ message: enableResult.error }); + return false; + } + return enableResult.changed; +} + +async function setExtensionConfigValueOrWarn(opts: { + readonly extensionId: string; + readonly path: readonly string[]; + readonly value: unknown; +}): Promise { + const result = await setExtensionConfigValue({ + scope: "global", + extensionId: opts.extensionId, + path: opts.path, + value: opts.value, + }); + if (!result.ok) { + logger.warn({ message: result.error }); + return false; + } + return result.changed; +} + +async function maybeRunCloudflareSetup(opts: { + readonly project: ProjectContext; + readonly hostname: string; + readonly sshHostname: string | null; + readonly sshOrigin: string | null; +}): Promise { const runSetup = await gumConfirm({ prompt: "Run Cloudflare tunnel setup now?", default: true, }); - if (runSetup.ok && runSetup.value) { - await runHackCommand({ - cwd: opts.project.projectRoot, - args: [ - "x", - "cloudflare", - "tunnel-setup", - "--hostname", - hostname, - ...(sshHostname ? ["--ssh-hostname", sshHostname] : []), - ...(sshOrigin ? ["--ssh-origin", sshOrigin] : []), - ], - }); + if (!(runSetup.ok && runSetup.value)) { + return; + } - const runStart = await gumConfirm({ - prompt: "Start the Cloudflare tunnel now?", - default: true, - }); - if (runStart.ok && runStart.value) { - await runHackCommand({ - cwd: opts.project.projectRoot, - args: ["x", "cloudflare", "tunnel-start"], - }); - } + await runHackCommand({ + cwd: opts.project.projectRoot, + args: [ + "x", + "cloudflare", + "tunnel-setup", + "--hostname", + opts.hostname, + ...(opts.sshHostname ? ["--ssh-hostname", opts.sshHostname] : []), + ...(opts.sshOrigin ? ["--ssh-origin", opts.sshOrigin] : []), + ], + }); + + const runStart = await gumConfirm({ + prompt: "Start the Cloudflare tunnel now?", + default: true, + }); + if (!(runStart.ok && runStart.value)) { + return; } - return { - mode: "cloudflare", - gatewayUrlOverride: `https://${hostname}`, - configChanged, - }; + await runHackCommand({ + cwd: opts.project.projectRoot, + args: ["x", "cloudflare", "tunnel-start"], + }); } async function configureTailscaleExposure(opts: { @@ -792,53 +977,78 @@ async function readConfigJsonForGateway(opts: { readonly allowMissing?: boolean; }): Promise { if (opts.scope === "global") { - const jsonPath = resolveGlobalConfigPath(); - const jsonText = await readTextFile(jsonPath); - if (jsonText === null) { - if (opts.allowMissing) { - return { ok: true, path: jsonPath, value: {} }; - } - return { - ok: false, - error: `Missing global config at ${jsonPath}. Run: hack config set --global `, - }; - } + return await readGlobalConfigJsonForGateway({ + allowMissing: opts.allowMissing === true, + }); + } - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Invalid JSON"; - return { ok: false, error: `Failed to parse ${jsonPath}: ${message}` }; - } + if (!opts.project) { + return { + ok: false, + error: "Missing project context to update gateway config.", + }; + } - if (!isRecord(parsed)) { - return { ok: false, error: `Expected ${jsonPath} to be an object.` }; - } + return await readProjectConfigJsonForGateway({ project: opts.project }); +} - return { ok: true, path: jsonPath, value: parsed }; +type JsonObjectParseResult = + | { readonly ok: true; readonly value: Record } + | { readonly ok: false; readonly error: string }; + +function parseJsonObjectFromFile(opts: { + readonly path: string; + readonly text: string; +}): JsonObjectParseResult { + let parsed: unknown; + try { + parsed = JSON.parse(opts.text); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Invalid JSON"; + return { ok: false, error: `Failed to parse ${opts.path}: ${message}` }; } - if (!opts.project) { + if (!isRecord(parsed)) { + return { ok: false, error: `Expected ${opts.path} to be an object.` }; + } + + return { ok: true, value: parsed }; +} + +async function readGlobalConfigJsonForGateway(opts: { + readonly allowMissing: boolean; +}): Promise { + const jsonPath = resolveGlobalConfigPath(); + const jsonText = await readTextFile(jsonPath); + if (jsonText === null) { + if (opts.allowMissing) { + return { ok: true, path: jsonPath, value: {} }; + } return { ok: false, - error: "Missing project context to update gateway config.", + error: `Missing global config at ${jsonPath}. Run: hack config set --global `, }; } + const parsed = parseJsonObjectFromFile({ path: jsonPath, text: jsonText }); + if (!parsed.ok) { + return parsed; + } + + return { ok: true, path: jsonPath, value: parsed.value }; +} + +async function readProjectConfigJsonForGateway(opts: { + readonly project: ProjectContext; +}): Promise { const jsonPath = resolve(opts.project.projectDir, PROJECT_CONFIG_FILENAME); const jsonText = await readTextFile(jsonPath); if (jsonText === null) { - const tomlPath = resolve( - opts.project.projectDir, - PROJECT_CONFIG_LEGACY_FILENAME - ); - const tomlText = await readTextFile(tomlPath); - if (tomlText !== null) { - return { - ok: false, - error: `Legacy config found at ${tomlPath}. Convert to ${PROJECT_CONFIG_FILENAME} to use gateway commands.`, - }; + const legacyCheck = await findLegacyProjectConfig({ + project: opts.project, + }); + if (!legacyCheck.ok) { + return legacyCheck; } return { ok: false, @@ -846,19 +1056,31 @@ async function readConfigJsonForGateway(opts: { }; } - let parsed: unknown; - try { - parsed = JSON.parse(jsonText); - } catch (error: unknown) { - const message = error instanceof Error ? error.message : "Invalid JSON"; - return { ok: false, error: `Failed to parse ${jsonPath}: ${message}` }; + const parsed = parseJsonObjectFromFile({ path: jsonPath, text: jsonText }); + if (!parsed.ok) { + return parsed; } - if (!isRecord(parsed)) { - return { ok: false, error: `Expected ${jsonPath} to be an object.` }; - } + return { ok: true, path: jsonPath, value: parsed.value }; +} - return { ok: true, path: jsonPath, value: parsed }; +async function findLegacyProjectConfig(opts: { + readonly project: ProjectContext; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + const tomlPath = resolve( + opts.project.projectDir, + PROJECT_CONFIG_LEGACY_FILENAME + ); + const tomlText = await readTextFile(tomlPath); + if (tomlText !== null) { + return { + ok: false, + error: `Legacy config found at ${tomlPath}. Convert to ${PROJECT_CONFIG_FILENAME} to use gateway commands.`, + }; + } + return { ok: true }; } async function setGatewayEnabled(opts: { diff --git a/src/commands/global.ts b/src/commands/global.ts index fff42798..d05d28e2 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -753,51 +753,95 @@ async function findIngressIpConflicts(opts: { return []; } + const parsed = parseDockerNetworkInspect({ stdout: inspect.stdout }); + if (!parsed) { + return []; + } + + const records = extractIngressContainerRecords({ inspect: parsed }); + return records + .map((record) => ({ + ip: extractIpv4Address({ raw: record.ipv4AddressRaw }), + containerName: record.name, + })) + .filter((conflict) => opts.reservedIps.includes(conflict.ip)); +} + +function extractIpv4Address(opts: { readonly raw: string }): string { + return opts.raw.split("/")[0] ?? ""; +} + +type IngressContainerRecord = { + readonly name: string; + readonly ipv4AddressRaw: string; +}; + +function parseDockerNetworkInspect(opts: { + readonly stdout: string; +}): readonly unknown[] | null { let parsed: unknown; try { - parsed = JSON.parse(inspect.stdout); + parsed = JSON.parse(opts.stdout); } catch { - return []; - } - if (!Array.isArray(parsed)) { - return []; + return null; } + return Array.isArray(parsed) ? parsed : null; +} - const conflicts: IngressIpConflict[] = []; - for (const entry of parsed) { - if (!entry || typeof entry !== "object") { - continue; - } - const containers = (entry as { Containers?: Record }) - .Containers; - if (!containers || typeof containers !== "object") { +function extractIngressContainerRecords(opts: { + readonly inspect: readonly unknown[]; +}): IngressContainerRecord[] { + const records: IngressContainerRecord[] = []; + + for (const entry of opts.inspect) { + const containers = readInspectContainers({ entry }); + if (!containers) { continue; } for (const info of Object.values(containers)) { - if (!info || typeof info !== "object") { + const record = readInspectContainerRecord({ info }); + if (!record) { continue; } - const record = info as { Name?: unknown; IPv4Address?: unknown }; - const name = typeof record.Name === "string" ? record.Name : ""; - const ipRaw = - typeof record.IPv4Address === "string" ? record.IPv4Address : ""; - if (!(name && ipRaw)) { - continue; - } - const ip = extractIpv4Address({ raw: ipRaw }); - if (!opts.reservedIps.includes(ip)) { - continue; - } - conflicts.push({ ip, containerName: name }); + records.push(record); } } - return conflicts; + return records; } -function extractIpv4Address(opts: { readonly raw: string }): string { - return opts.raw.split("/")[0] ?? ""; +function readInspectContainers(opts: { + readonly entry: unknown; +}): Record | null { + if (!opts.entry || typeof opts.entry !== "object") { + return null; + } + + const containers = (opts.entry as { Containers?: unknown }).Containers; + if (!containers || typeof containers !== "object") { + return null; + } + + return containers as Record; +} + +function readInspectContainerRecord(opts: { + readonly info: unknown; +}): IngressContainerRecord | null { + if (!opts.info || typeof opts.info !== "object") { + return null; + } + + const record = opts.info as { Name?: unknown; IPv4Address?: unknown }; + const name = typeof record.Name === "string" ? record.Name : ""; + const ipv4AddressRaw = + typeof record.IPv4Address === "string" ? record.IPv4Address : ""; + if (!(name.length > 0 && ipv4AddressRaw.length > 0)) { + return null; + } + + return { name, ipv4AddressRaw }; } function isGlobalProxyContainer(opts: { readonly name: string }): boolean { @@ -1788,46 +1832,83 @@ async function hasMkcertLocalCa({ } async function ensureMacHackDns(): Promise { + const brewOk = await ensureBrewForDnsmasq(); + if (!brewOk) { + return; + } + + const dnsmasqOk = await ensureDnsmasqInstalled(); + if (!dnsmasqOk) { + return; + } + + const brewPrefix = await resolveBrewPrefix(); + const dnsmasqConf = resolve(brewPrefix, "etc", "dnsmasq.conf"); + await ensureDnsmasqHackAliases({ dnsmasqConf }); + await ensureMacResolverFiles(); + await restartDnsmasq(); + await flushMacDnsCache(); + noteDnsConfigured({ dnsmasqConf }); +} + +async function ensureBrewForDnsmasq(): Promise { const brew = await findExecutableInPath("brew"); if (!brew) { logger.warn({ message: "Homebrew not found; skipping dnsmasq bootstrap." }); - return; + return false; } + return true; +} - const hasDnsmasq = - (await exec(["brew", "list", "dnsmasq"], { stdin: "ignore" })).exitCode === - 0; +async function ensureDnsmasqInstalled(): Promise { + const hasDnsmasq = await isBrewFormulaInstalled({ formula: "dnsmasq" }); + if (hasDnsmasq) { + return true; + } - if (!hasDnsmasq) { - const ok = await confirm({ - message: "Install dnsmasq via Homebrew? (required for *.hack DNS)", - initialValue: true, - }); - if (isCancel(ok)) { - throw new Error("Canceled"); - } - if (!ok) { - logger.warn({ - message: "Skipping dnsmasq install; *.hack hostnames may not resolve.", - }); - return; - } - logger.step({ message: "Installing dnsmasq via Homebrew…" }); - const installExit = await run(["brew", "install", "dnsmasq"], { - stdin: "inherit", + const ok = await confirm({ + message: "Install dnsmasq via Homebrew? (required for *.hack DNS)", + initialValue: true, + }); + if (isCancel(ok)) { + throw new Error("Canceled"); + } + if (!ok) { + logger.warn({ + message: "Skipping dnsmasq install; *.hack hostnames may not resolve.", }); - if (installExit !== 0) { - throw new Error(`brew install dnsmasq failed (exit ${installExit})`); - } + return false; + } + + logger.step({ message: "Installing dnsmasq via Homebrew…" }); + const installExit = await run(["brew", "install", "dnsmasq"], { + stdin: "inherit", + }); + if (installExit !== 0) { + throw new Error(`brew install dnsmasq failed (exit ${installExit})`); } - // Configure dnsmasq: map local dev domains → Caddy container IP - // (bypasses OrbStack port forwarding issues with port 443) + return true; +} + +async function isBrewFormulaInstalled(opts: { + readonly formula: string; +}): Promise { + const result = await exec(["brew", "list", opts.formula], { + stdin: "ignore", + }); + return result.exitCode === 0; +} + +async function resolveBrewPrefix(): Promise { const prefixRes = await exec(["brew", "--prefix"], { stdin: "ignore" }); - const brewPrefix = - prefixRes.exitCode === 0 ? prefixRes.stdout.trim() : "/opt/homebrew"; - const dnsmasqConf = resolve(brewPrefix, "etc", "dnsmasq.conf"); + const prefix = prefixRes.exitCode === 0 ? prefixRes.stdout.trim() : ""; + return prefix.length > 0 ? prefix : "/opt/homebrew"; +} +async function ensureDnsmasqHackAliases(opts: { + readonly dnsmasqConf: string; +}): Promise { const containerIpLines = [ `address=/.${DEFAULT_PROJECT_TLD}/${DEFAULT_CADDY_IP}`, `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/${DEFAULT_CADDY_IP}`, @@ -1839,67 +1920,100 @@ async function ensureMacHackDns(): Promise { `address=/.${DEFAULT_OAUTH_ALIAS_ROOT}/::1`, ] as const; - let existing = (await readTextFile(dnsmasqConf)) ?? ""; - - // Migrate: replace legacy localhost lines with container IP - let migrated = false; - for (const legacyLine of legacyLines) { - if (existing.includes(legacyLine)) { - existing = existing.replace(legacyLine, ""); - migrated = true; - } - } - if (migrated) { - // Clean up any double newlines left from removal - existing = existing.replace(/\n{3,}/g, "\n\n").trim(); - logger.info({ message: "Migrating dnsmasq to use container IP..." }); - } - - const missing = containerIpLines.filter((line) => !existing.includes(line)); - if (missing.length > 0 || migrated) { - const next = - existing.length === 0 - ? `${missing.join("\n")}\n` - : `${existing.trimEnd()}\n${missing.join("\n")}\n`; - await ensureDir(dirname(dnsmasqConf)); - await Bun.write(dnsmasqConf, next); - logger.success({ message: `Updated ${dnsmasqConf}` }); - } else { + const existing = (await readTextFile(opts.dnsmasqConf)) ?? ""; + const migrated = removeLegacyDnsmasqLines({ + content: existing, + legacyLines, + }); + const missing = containerIpLines.filter( + (line) => !migrated.content.includes(line) + ); + const shouldWrite = migrated.changed || missing.length > 0; + if (!shouldWrite) { logger.info({ message: `dnsmasq already configured for .${DEFAULT_PROJECT_TLD} and .${DEFAULT_OAUTH_ALIAS_ROOT}`, }); + return; } - // Configure macOS resolver(s) → 127.0.0.1 (dnsmasq) - for (const domain of [ - DEFAULT_PROJECT_TLD, - DEFAULT_OAUTH_ALIAS_ROOT, - ] as const) { - const resolverPath = `/etc/resolver/${domain}`; - const resolverOk = await confirm({ - message: `Write ${resolverPath} (requires sudo)?`, - initialValue: true, - }); - if (isCancel(resolverOk)) { - throw new Error("Canceled"); - } - if (resolverOk) { - await run([ - "sudo", - "sh", - "-c", - `mkdir -p /etc/resolver && printf '%s\\n' 'nameserver 127.0.0.1' > ${resolverPath}`, - ]); - logger.success({ message: `Wrote ${resolverPath}` }); - } else { - logger.warn({ - message: `Skipping /etc/resolver setup for ${domain}; *.${domain} may not resolve.`, - }); + const next = buildDnsmasqConf({ + existing: migrated.content, + lines: missing, + }); + await ensureDir(dirname(opts.dnsmasqConf)); + await Bun.write(opts.dnsmasqConf, next); + logger.success({ message: `Updated ${opts.dnsmasqConf}` }); +} + +function removeLegacyDnsmasqLines(opts: { + readonly content: string; + readonly legacyLines: readonly string[]; +}): { readonly content: string; readonly changed: boolean } { + let updated = opts.content; + let changed = false; + + for (const legacyLine of opts.legacyLines) { + if (!updated.includes(legacyLine)) { + continue; } + updated = updated.replaceAll(legacyLine, ""); + changed = true; + } + + if (!changed) { + return { content: updated, changed: false }; + } + + // Clean up any double newlines left from removal. + const cleaned = updated.replace(/\n{3,}/g, "\n\n").trim(); + logger.info({ message: "Migrating dnsmasq to use container IP..." }); + return { content: cleaned, changed: true }; +} + +function buildDnsmasqConf(opts: { + readonly existing: string; + readonly lines: readonly string[]; +}): string { + const existing = opts.existing.trimEnd(); + if (existing.length === 0) { + return `${opts.lines.join("\n")}\n`; } + return `${existing}\n${opts.lines.join("\n")}\n`; +} + +async function ensureMacResolverFiles(): Promise { + await maybeWriteResolver({ domain: DEFAULT_PROJECT_TLD }); + await maybeWriteResolver({ domain: DEFAULT_OAUTH_ALIAS_ROOT }); +} - // Start/restart dnsmasq. - // +async function maybeWriteResolver(opts: { + readonly domain: string; +}): Promise { + const resolverPath = `/etc/resolver/${opts.domain}`; + const resolverOk = await confirm({ + message: `Write ${resolverPath} (requires sudo)?`, + initialValue: true, + }); + if (isCancel(resolverOk)) { + throw new Error("Canceled"); + } + if (!resolverOk) { + logger.warn({ + message: `Skipping /etc/resolver setup for ${opts.domain}; *.${opts.domain} may not resolve.`, + }); + return; + } + + await run([ + "sudo", + "sh", + "-c", + `mkdir -p /etc/resolver && printf '%s\\n' 'nameserver 127.0.0.1' > ${resolverPath}`, + ]); + logger.success({ message: `Wrote ${resolverPath}` }); +} + +async function restartDnsmasq(): Promise { // We run via sudo so dnsmasq can bind :53 (required for /etc/resolver/). logger.step({ message: "Restarting dnsmasq (requires sudo)…" }); const restartExit = await run( @@ -1913,17 +2027,20 @@ async function ensureMacHackDns(): Promise { `sudo brew services restart dnsmasq failed (exit ${restartExit})` ); } +} - // Flush macOS DNS cache to clear stale entries +async function flushMacDnsCache(): Promise { logger.step({ message: "Flushing DNS cache…" }); await run(["sudo", "dscacheutil", "-flushcache"], { stdin: "inherit" }); await run(["sudo", "killall", "-HUP", "mDNSResponder"], { stdin: "inherit" }); +} +function noteDnsConfigured(opts: { readonly dnsmasqConf: string }): void { note( [ `DNS configured: *.${DEFAULT_PROJECT_TLD} → ${DEFAULT_CADDY_IP} (container)`, `DNS configured: *.${DEFAULT_OAUTH_ALIAS_ROOT} → ${DEFAULT_CADDY_IP} (container)`, - `- dnsmasq: ${dnsmasqConf}`, + `- dnsmasq: ${opts.dnsmasqConf}`, `- resolver: /etc/resolver/${DEFAULT_PROJECT_TLD}`, `- resolver: /etc/resolver/${DEFAULT_OAUTH_ALIAS_ROOT}`, ].join("\n"), diff --git a/src/commands/mcp.ts b/src/commands/mcp.ts index d68357d2..3f509b5d 100644 --- a/src/commands/mcp.ts +++ b/src/commands/mcp.ts @@ -182,8 +182,32 @@ async function handleMcpInstall({ projectRoot, }); + let exitCode = logMcpInstallResults({ results }); + + const docTargets = resolveDocTargets({ + docs: args.options.docs === true, + agentsMd: args.options.agentsMd === true, + claudeMd: args.options.claudeMd === true, + }); + if (docTargets.length > 0) { + exitCode = Math.max( + exitCode, + await upsertAgentDocsForMcpInstall({ + ctx, + pathOpt: args.options.path, + targets: docTargets, + }) + ); + } + + return exitCode; +} + +function logMcpInstallResults(opts: { + readonly results: Awaited>; +}): number { let exitCode = 0; - for (const result of results) { + for (const result of opts.results) { if (result.status === "error") { logger.error({ message: result.message ?? `Failed to update ${result.target} config`, @@ -203,40 +227,41 @@ async function handleMcpInstall({ message: `Updated ${result.target} MCP config at ${result.path ?? "unknown path"}`, }); } + return exitCode; +} - const docTargets = resolveDocTargets({ - docs: args.options.docs === true, - agentsMd: args.options.agentsMd === true, - claudeMd: args.options.claudeMd === true, +async function upsertAgentDocsForMcpInstall(opts: { + readonly ctx: CliContext; + readonly pathOpt: string | undefined; + readonly targets: readonly AgentDocTarget[]; +}): Promise { + const docsRoot = resolveDocsRoot({ + ctx: opts.ctx, + pathOpt: opts.pathOpt, + }); + const results = await upsertAgentDocs({ + projectRoot: docsRoot, + targets: opts.targets, }); - if (docTargets.length > 0) { - const docsRoot = resolveDocsRoot({ - ctx, - pathOpt: args.options.path, - }); - const docResults = await upsertAgentDocs({ - projectRoot: docsRoot, - targets: docTargets, - }); - for (const result of docResults) { - if (result.status === "error") { - logger.error({ - message: result.message ?? `Failed to update ${result.path}`, - }); - exitCode = 1; - continue; - } - - if (result.status === "noop") { - logger.info({ message: `No changes for ${result.path}` }); - continue; - } - - logger.success({ - message: `${result.status === "created" ? "Created" : "Updated"} ${result.path}`, + let exitCode = 0; + for (const result of results) { + if (result.status === "error") { + logger.error({ + message: result.message ?? `Failed to update ${result.path}`, }); + exitCode = 1; + continue; + } + + if (result.status === "noop") { + logger.info({ message: `No changes for ${result.path}` }); + continue; } + + logger.success({ + message: `${result.status === "created" ? "Created" : "Updated"} ${result.path}`, + }); } return exitCode; diff --git a/src/commands/project.ts b/src/commands/project.ts index f722c75c..f99bf896 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -5,10 +5,11 @@ import { isCancel, multiselect, note, + password, select, text, } from "@clack/prompts"; -import { YAML } from "bun"; +import { secrets, YAML } from "bun"; import { installClaudeHooks } from "../agents/claude.ts"; import { installCodexSkill } from "../agents/codex-skill.ts"; import { installCursorRules } from "../agents/cursor.ts"; @@ -48,6 +49,7 @@ import { PROJECT_COMPOSE_FILENAME, PROJECT_CONFIG_FILENAME, PROJECT_CONFIG_LEGACY_FILENAME, + PROJECT_ENV_CONTRACT_FILENAME, PROJECT_ENV_FILENAME, } from "../constants.ts"; import { requestDaemonJson } from "../daemon/client.ts"; @@ -71,6 +73,11 @@ import { writeTextFileIfChanged, } from "../lib/fs.ts"; import { getString, isRecord } from "../lib/guards.ts"; +import { + resolveHackEnv, + resolveKeychainServiceName, + upsertDotEnvValue, +} from "../lib/hack-env.ts"; import { parseJsonLines } from "../lib/json-lines.ts"; import { buildLogSelector, @@ -82,6 +89,8 @@ import { defaultProjectSlugFromPath, findProjectContext, findRepoRootForInit, + type ProjectLifecycleCommand, + type ProjectLifecycleProcess, readProjectConfig, readProjectDevHost, resolveProjectOauthTld, @@ -93,12 +102,22 @@ import { resolveRegisteredProjectByName, upsertProjectRegistration, } from "../lib/projects-registry.ts"; -import { exec } from "../lib/shell.ts"; +import { exec, run } from "../lib/shell.ts"; import { parseTimeInput } from "../lib/time.ts"; import { upsertAgentDocs } from "../mcp/agent-docs.ts"; import type { McpTarget } from "../mcp/install.ts"; import { installMcpConfig } from "../mcp/install.ts"; -import { renderProjectConfigJson } from "../templates.ts"; +import type { MuxBackendName } from "../mux/mux-backend.ts"; +import { + getMuxBackends, + resolveDefaultBackendName, + resolveMux, +} from "../mux/mux-resolver.ts"; +import { buildSessionName } from "../mux/session-names.ts"; +import { + renderProjectConfigJson, + renderProjectEnvContractJson, +} from "../templates.ts"; import { display } from "../ui/display.ts"; import type { LogStreamContext } from "../ui/log-stream.ts"; import { logger } from "../ui/logger.ts"; @@ -521,42 +540,64 @@ async function buildBranchComposeOverride(opts: { function normalizeLabels(raw: unknown): Record | null { if (isRecord(raw)) { - const out: Record = {}; - for (const [k, v] of Object.entries(raw)) { - if ( - typeof v === "string" || - typeof v === "number" || - typeof v === "boolean" - ) { - out[k] = String(v); - } - } - return Object.keys(out).length > 0 ? out : null; + return normalizeLabelRecord(raw); } if (Array.isArray(raw)) { - const out: Record = {}; - for (const item of raw) { - if (typeof item !== "string") { - continue; - } - const idx = item.indexOf("="); - if (idx <= 0) { - continue; - } - const key = item.slice(0, idx).trim(); - const value = item.slice(idx + 1).trim(); - if (key.length === 0) { - continue; - } - out[key] = value; - } - return Object.keys(out).length > 0 ? out : null; + return normalizeLabelList(raw); } return null; } +function normalizeLabelRecord( + raw: Record +): Record | null { + const out: Record = {}; + for (const [k, v] of Object.entries(raw)) { + if ( + typeof v === "string" || + typeof v === "number" || + typeof v === "boolean" + ) { + out[k] = String(v); + } + } + return Object.keys(out).length > 0 ? out : null; +} + +function normalizeLabelList( + raw: readonly unknown[] +): Record | null { + const out: Record = {}; + for (const item of raw) { + const parsed = parseLabelEntry({ item }); + if (!parsed) { + continue; + } + out[parsed.key] = parsed.value; + } + return Object.keys(out).length > 0 ? out : null; +} + +function parseLabelEntry(opts: { + readonly item: unknown; +}): { readonly key: string; readonly value: string } | null { + if (typeof opts.item !== "string") { + return null; + } + const idx = opts.item.indexOf("="); + if (idx <= 0) { + return null; + } + const key = opts.item.slice(0, idx).trim(); + if (key.length === 0) { + return null; + } + const value = opts.item.slice(idx + 1).trim(); + return { key, value }; +} + function rewriteCaddyLabelForBranch(opts: { readonly value: string; readonly branch: string; @@ -690,244 +731,755 @@ function ensureTrailingNewline(text: string): string { return text.endsWith("\n") ? text : `${text}\n`; } -async function resolveBranchComposeFiles(opts: { - readonly project: Awaited>; - readonly branch: string; - readonly devHost: string; - readonly aliasHost: string | null; -}): Promise { - const override = await buildBranchComposeOverride(opts); - if (!override) { - return [opts.project.composeFile]; - } - - const overrideDir = resolve(opts.project.projectDir, ".branch"); - await ensureDir(overrideDir); - const overridePath = resolve( - overrideDir, - `compose.${opts.branch}.override.yml` - ); - await writeTextFileIfChanged(overridePath, override); - return [opts.project.composeFile, overridePath]; +function buildComposeEnvInterpolation(key: string): string { + return `\${${key}}`; } -const INTERNAL_CA_CONTAINER_DIR = "/etc/hack/ca"; -const INTERNAL_CA_CONTAINER_PATH = `${INTERNAL_CA_CONTAINER_DIR}/caddy-local-authority.crt`; +function isEnvVarRelevantToServices(opts: { + readonly services: readonly string[]; + readonly varServices: readonly string[] | null; +}): boolean { + if (!opts.varServices) { + return true; + } + if (opts.services.length === 0) { + return true; + } + const serviceSet = new Set(opts.services); + return opts.varServices.some((svc) => serviceSet.has(svc)); +} -async function resolveInternalComposeOverride(opts: { +async function resolveComposeEnvOverrides(opts: { readonly project: Awaited>; - readonly cfg: Awaited>; - readonly branch?: string | null; - readonly devHost?: string | null; - readonly aliasHost?: string | null; -}): Promise { - const internal = resolveInternalSettings(opts.cfg); - - const managedExtraHosts = await readInternalExtraHostsFile({ + readonly projectName: string; + readonly targetServices: readonly string[]; +}): Promise<{ + readonly composeFiles: readonly string[]; + readonly env: Readonly>; +}> { + const resolved = await resolveHackEnv({ projectDir: opts.project.projectDir, + projectName: opts.projectName, }); - const hasAnyExtraHosts = - (internal.extraHosts && Object.keys(internal.extraHosts).length > 0) || - Object.keys(managedExtraHosts).length > 0; - if (!(internal.dns || internal.tls || hasAnyExtraHosts)) { - return null; + if (resolved.contractParseError) { + logger.warn({ + message: `Failed to parse ${resolved.contractPath}: ${resolved.contractParseError}`, + }); } - const services = await readComposeServiceNames(opts.project.composeFile); - if (services.length === 0) { - return null; + if (resolved.contract.vars.length === 0) { + return { composeFiles: [], env: {} }; } - let dnsServer: string | null = null; - let caddyIp: string | null = null; - let caddyHosts: readonly string[] = []; - if (internal.dns) { - dnsServer = await resolveCoreDnsServer(); - if (!dnsServer) { - logger.warn({ - message: - "CoreDNS is not reachable; internal DNS for *.hack is disabled. Run `hack global install` (or `hack global up`).", - }); - } - caddyIp = await resolveCaddyServer(); - if (!caddyIp) { - logger.warn({ - message: - "Caddy is not reachable; internal *.hack host mappings are disabled. Run `hack global install` (or `hack global up`).", - }); - } - caddyHosts = await readComposeCaddyHosts(opts.project.composeFile); - if (caddyHosts.length > 0 && opts.branch) { - const devHost = - opts.devHost ?? (await resolveBranchDevHost({ project: opts.project })); - const baseHosts = [devHost, opts.aliasHost ?? null].filter( - (host): host is string => typeof host === "string" && host.length > 0 - ); - if (baseHosts.length > 0) { - caddyHosts = applyBranchToHosts({ - hosts: caddyHosts, - branch: opts.branch, - baseHosts, - }); - } - } - } + const missingRelevant = resolved.missingRequired.filter((v) => + isEnvVarRelevantToServices({ + services: opts.targetServices, + varServices: v.services, + }) + ); - let caPath: string | null = null; - if (internal.tls) { - caPath = await resolveCaddyLocalCaPath(); - if (!caPath) { - logger.warn({ - message: - "Caddy Local CA cert not found; internal TLS trust is disabled. Run `hack global trust` (or `hack global ca`).", - }); + if (missingRelevant.length > 0) { + const fixed = await maybePromptToFixMissingEnv({ + missing: missingRelevant, + envFile: resolve(opts.project.projectDir, PROJECT_ENV_FILENAME), + keychainService: resolveKeychainServiceName({ + projectName: opts.projectName, + }), + }); + if (fixed) { + return await resolveComposeEnvOverrides(opts); } - } - if (!(dnsServer || caPath || caddyIp)) { - return null; + const keys = missingRelevant.map((v) => v.key).join(", "); + logger.error({ + message: `Missing required env: ${keys}`, + }); + logger.info({ + message: + "Run: hack env set KEY=VALUE (or: hack env set --secret KEY=VALUE)", + }); + throw new Error("Missing required env"); } const overrideServices: Record> = {}; - for (const service of services) { - const entry: Record = {}; - if (dnsServer) { - entry.dns = [dnsServer]; - } - const extraHosts: Record = { - ...(caddyIp && caddyHosts.length > 0 - ? buildExtraHostsMap({ hosts: caddyHosts, ip: caddyIp }) - : {}), - ...(internal.extraHosts ? internal.extraHosts : {}), - ...managedExtraHosts, - }; - if (Object.keys(extraHosts).length > 0) { - entry.extra_hosts = extraHosts; - } - if (caPath) { - entry.volumes = [`${caPath}:${INTERNAL_CA_CONTAINER_PATH}:ro`]; - entry.environment = { - SSL_CERT_FILE: INTERNAL_CA_CONTAINER_PATH, - SSL_CERT_DIR: INTERNAL_CA_CONTAINER_DIR, - NODE_EXTRA_CA_CERTS: INTERNAL_CA_CONTAINER_PATH, - REQUESTS_CA_BUNDLE: INTERNAL_CA_CONTAINER_PATH, - CURL_CA_BUNDLE: INTERNAL_CA_CONTAINER_PATH, - GIT_SSL_CAINFO: INTERNAL_CA_CONTAINER_PATH, - }; + for (const service of opts.targetServices) { + const env: Record = {}; + for (const v of resolved.values) { + if (v.value === null) { + continue; + } + if ( + !isEnvVarRelevantToServices({ + services: [service], + varServices: v.services, + }) + ) { + continue; + } + env[v.key] = buildComposeEnvInterpolation(v.key); } - overrideServices[service] = entry; + if (Object.keys(env).length > 0) { + overrideServices[service] = { environment: env }; + } + } + + if (Object.keys(overrideServices).length === 0) { + return { composeFiles: [], env: resolved.envForCompose }; } const override = { services: overrideServices }; const yaml = YAML.stringify(override, null, 2); const text = ensureTrailingNewline(cleanupYaml(yaml)); - const overrideDir = resolve(opts.project.projectDir, ".internal"); await ensureDir(overrideDir); - const overridePath = resolve(overrideDir, "compose.override.yml"); + const overridePath = resolve(overrideDir, "compose.env.override.yml"); await writeTextFileIfChanged(overridePath, text); - return overridePath; -} -function resolveInternalSettings( - cfg: Awaited> -): { - readonly dns: boolean; - readonly tls: boolean; - readonly extraHosts: Record | null; -} { - return { - dns: cfg.internal?.dns ?? true, - tls: cfg.internal?.tls ?? true, - extraHosts: cfg.internal?.extraHosts ?? null, - }; + return { composeFiles: [overridePath], env: resolved.envForCompose }; } -async function readComposeServiceNames( - composeFile: string -): Promise { - const text = await readTextFile(composeFile); - if (!text) { - return []; +async function maybePromptToFixMissingEnv(opts: { + readonly missing: readonly { + readonly key: string; + readonly source: "plain_env" | "keychain"; + }[]; + readonly envFile: string; + readonly keychainService: string; +}): Promise { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return false; } - let parsed: unknown; - try { - parsed = YAML.parse(text); - } catch { - return []; - } + await display.panel({ + title: "Missing required env", + tone: "warn", + lines: [ + ...opts.missing.map((v) => `- ${v.key} (${v.source})`), + "", + "Fill them in now?", + ], + }); - if (!isRecord(parsed)) { - return []; + const ok = await confirm({ + message: "Set missing env now?", + initialValue: true, + }); + if (isCancel(ok)) { + throw new Error("Canceled"); } - const servicesRaw = parsed.services; - if (!isRecord(servicesRaw)) { - return []; + if (!ok) { + return false; } - return Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)); + + for (const v of opts.missing) { + const value = + v.source === "keychain" + ? await password({ + message: `Value for secret "${v.key}" (${opts.keychainService}):`, + validate: (input) => + !input || input.length === 0 ? "Required" : undefined, + }) + : await text({ + message: `Value for "${v.key}" (${opts.envFile}):`, + validate: (input) => + !input || input.length === 0 ? "Required" : undefined, + }); + + if (isCancel(value)) { + throw new Error("Canceled"); + } + + if (v.source === "keychain") { + await secrets.set({ service: opts.keychainService, name: v.key, value }); + } else { + await upsertDotEnvValue({ envFile: opts.envFile, key: v.key, value }); + } + } + + return true; } -async function readComposeCaddyHosts( - composeFile: string -): Promise { - const text = await readTextFile(composeFile); - if (!text) { - return []; +function resolveLifecycleSessionName(opts: { + readonly projectName: string; + readonly branch: string | null; +}): string { + const suffix = opts.branch ? `lifecycle-${opts.branch}` : "lifecycle"; + return buildSessionName({ base: opts.projectName, suffix }); +} + +function resolveLifecycleCwd(opts: { + readonly projectRoot: string; + readonly cwd: string | undefined; +}): string { + const raw = (opts.cwd ?? "").trim(); + if (raw.length === 0) { + return opts.projectRoot; } + if (raw.startsWith("/")) { + return raw; + } + return resolve(opts.projectRoot, raw); +} - let parsed: unknown; - try { - parsed = YAML.parse(text); - } catch { - return []; +async function runLifecycleCommands(opts: { + readonly title: string; + readonly commands: readonly ProjectLifecycleCommand[] | undefined; + readonly projectRoot: string; + readonly env: Readonly>; +}): Promise { + const commands = opts.commands ?? []; + for (const cmd of commands) { + const label = cmd.name ? `${cmd.name}: ${cmd.command}` : cmd.command; + logger.step({ message: `${opts.title}: ${label}` }); + + const cwd = resolveLifecycleCwd({ + projectRoot: opts.projectRoot, + cwd: cmd.cwd, + }); + + const exitCode = await run(["sh", "-lc", cmd.command], { + cwd, + env: opts.env, + stdin: "inherit", + }); + if (exitCode !== 0) { + logger.error({ + message: `${opts.title} failed (exit ${exitCode}): ${label}`, + }); + return exitCode; + } } + return 0; +} - if (!isRecord(parsed)) { - return []; +async function startLifecycleProcesses(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly branch: string | null; + readonly env: Readonly>; +}): Promise { + const processes = opts.cfg.lifecycle?.processes ?? []; + if (processes.length === 0) { + return; } - const servicesRaw = parsed.services; - if (!isRecord(servicesRaw)) { - return []; + + const mux = await resolveMux({ project: opts.project }); + const backendName = resolveDefaultBackendName({ + mode: mux.mode, + backends: mux.backends, + }); + if (!backendName) { + throw new Error( + [ + "No session mux backend available for lifecycle processes.", + "Install tmux or zellij, or set sessions.mux to auto|tmux|zellij.", + ].join("\n") + ); } - const hosts = new Set(); - for (const serviceRaw of Object.values(servicesRaw)) { - if (!isRecord(serviceRaw)) { - continue; - } - const labels = normalizeLabels(serviceRaw.labels); - if (!labels) { + const sessionName = resolveLifecycleSessionName({ + projectName: opts.projectName, + branch: opts.branch, + }); + + // Kill any existing lifecycle session to avoid duplicated processes. + const backends = getMuxBackends(); + for (const backend of backends.values()) { + if (!backend.available) { continue; } - const caddyRaw = labels.caddy; - if (typeof caddyRaw !== "string") { + const sessions = await backend.listSessions(); + if (!sessions.some((s) => s.name === sessionName)) { continue; } - for (const host of extractCaddyHosts(caddyRaw)) { - hosts.add(host); + await backend.killSession({ name: sessionName }); + } + + const backend = mux.backends.get(backendName); + if (!backend?.available) { + throw new Error(`${backendName} is not available`); + } + + const created = await backend.createSession({ + name: sessionName, + cwd: opts.project.projectRoot, + }); + if (!created.ok) { + throw new Error(`Failed to create lifecycle session: ${sessionName}`); + } + + if (backendName === "tmux") { + for (const [key, value] of Object.entries(opts.env)) { + await exec(["tmux", "set-environment", "-t", sessionName, key, value], { + stdin: "ignore", + }); } } - return Array.from(hosts).sort((a, b) => a.localeCompare(b)); + for (const [index, proc] of processes.entries()) { + await startLifecycleProcess({ + backend: backendName, + sessionName, + projectRoot: opts.project.projectRoot, + env: opts.env, + index, + process: proc, + }); + } } -function extractCaddyHosts(value: string): readonly string[] { - const out: string[] = []; - for (const part of value.split(",")) { - let host = part.trim(); - if (!host) { - continue; - } +async function startLifecycleProcess(opts: { + readonly backend: MuxBackendName; + readonly sessionName: string; + readonly projectRoot: string; + readonly env: Readonly>; + readonly index: number; + readonly process: ProjectLifecycleProcess; +}): Promise { + const windowNameRaw = sanitizeBranchSlug(opts.process.name); + const windowName = + windowNameRaw.length > 0 ? windowNameRaw : `proc-${opts.index + 1}`; + const cwd = resolveLifecycleCwd({ + projectRoot: opts.projectRoot, + cwd: opts.process.cwd, + }); - if (host.startsWith("http://")) { - host = host.slice("http://".length); + if (opts.backend === "tmux") { + const result = await exec( + [ + "tmux", + "new-window", + "-t", + opts.sessionName, + "-n", + windowName, + "-c", + cwd, + "sh", + "-lc", + opts.process.command, + ], + { stdin: "ignore" } + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}` + ); } - if (host.startsWith("https://")) { - host = host.slice("https://".length); + return; + } + + const result = await exec( + ["zellij", "run", "--", "sh", "-lc", opts.process.command], + { + stdin: "ignore", + cwd, + env: { ...opts.env, ZELLIJ_SESSION_NAME: opts.sessionName }, } - const slashIdx = host.indexOf("/"); + ); + if (result.exitCode !== 0) { + throw new Error( + `Failed to start lifecycle process "${opts.process.name}": ${result.stderr.trim()}` + ); + } +} + +async function stopLifecycleProcesses(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly branch: string | null; +}): Promise { + const lifecycle = opts.cfg.lifecycle; + if (!lifecycle) { + return; + } + + const sessionName = resolveLifecycleSessionName({ + projectName: opts.projectName, + branch: opts.branch, + }); + + const backends = getMuxBackends(); + for (const backend of backends.values()) { + if (!backend.available) { + continue; + } + const sessions = await backend.listSessions(); + if (!sessions.some((s) => s.name === sessionName)) { + continue; + } + await backend.killSession({ name: sessionName }); + } +} + +async function resolveBranchComposeFiles(opts: { + readonly project: Awaited>; + readonly branch: string; + readonly devHost: string; + readonly aliasHost: string | null; +}): Promise { + const override = await buildBranchComposeOverride(opts); + if (!override) { + return [opts.project.composeFile]; + } + + const overrideDir = resolve(opts.project.projectDir, ".branch"); + await ensureDir(overrideDir); + const overridePath = resolve( + overrideDir, + `compose.${opts.branch}.override.yml` + ); + await writeTextFileIfChanged(overridePath, override); + return [opts.project.composeFile, overridePath]; +} + +const INTERNAL_CA_CONTAINER_DIR = "/etc/hack/ca"; +const INTERNAL_CA_CONTAINER_PATH = `${INTERNAL_CA_CONTAINER_DIR}/caddy-local-authority.crt`; + +async function resolveInternalComposeOverride(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly branch?: string | null; + readonly devHost?: string | null; + readonly aliasHost?: string | null; +}): Promise { + const internal = resolveInternalSettings(opts.cfg); + + const managedExtraHosts = await readInternalExtraHostsFile({ + projectDir: opts.project.projectDir, + }); + if (!shouldBuildInternalOverride({ internal, managedExtraHosts })) { + return null; + } + + const services = await readComposeServiceNames(opts.project.composeFile); + if (services.length === 0) { + return null; + } + + const dns = await resolveInternalDnsSettings({ + project: opts.project, + composeFile: opts.project.composeFile, + enabled: internal.dns, + branch: opts.branch ?? null, + devHost: opts.devHost ?? null, + aliasHost: opts.aliasHost ?? null, + }); + const caPath = await resolveInternalTlsCaPath({ enabled: internal.tls }); + if (!(dns.dnsServer || caPath || dns.caddyIp)) { + return null; + } + + const extraHosts = buildInternalExtraHosts({ + caddyIp: dns.caddyIp, + caddyHosts: dns.caddyHosts, + internalExtraHosts: internal.extraHosts, + managedExtraHosts, + }); + const text = renderInternalOverride({ + services, + dnsServer: dns.dnsServer, + extraHosts, + caPath, + }); + + return await writeInternalComposeOverride({ + projectDir: opts.project.projectDir, + text, + }); +} + +function shouldBuildInternalOverride(opts: { + readonly internal: ReturnType; + readonly managedExtraHosts: Record; +}): boolean { + const internalExtraHosts = opts.internal.extraHosts; + const hasInternalExtraHosts = + internalExtraHosts && Object.keys(internalExtraHosts).length > 0; + const hasManagedExtraHosts = Object.keys(opts.managedExtraHosts).length > 0; + const hasExtraHosts = hasInternalExtraHosts || hasManagedExtraHosts; + return opts.internal.dns || opts.internal.tls || hasExtraHosts; +} + +type InternalDnsSettings = { + readonly dnsServer: string | null; + readonly caddyIp: string | null; + readonly caddyHosts: readonly string[]; +}; + +async function resolveInternalDnsSettings(opts: { + readonly project: Awaited>; + readonly composeFile: string; + readonly enabled: boolean; + readonly branch: string | null; + readonly devHost: string | null; + readonly aliasHost: string | null; +}): Promise { + if (!opts.enabled) { + return { dnsServer: null, caddyIp: null, caddyHosts: [] }; + } + + const dnsServer = await resolveCoreDnsServerWithWarning(); + const caddyIp = await resolveCaddyServerWithWarning(); + const caddyHosts = await resolveCaddyHostsForBranch({ + project: opts.project, + composeFile: opts.composeFile, + branch: opts.branch, + devHost: opts.devHost, + aliasHost: opts.aliasHost, + }); + + return { dnsServer, caddyIp, caddyHosts }; +} + +async function resolveCoreDnsServerWithWarning(): Promise { + const dnsServer = await resolveCoreDnsServer(); + if (!dnsServer) { + logger.warn({ + message: + "CoreDNS is not reachable; internal DNS for *.hack is disabled. Run `hack global install` (or `hack global up`).", + }); + } + return dnsServer; +} + +async function resolveCaddyServerWithWarning(): Promise { + const caddyIp = await resolveCaddyServer(); + if (!caddyIp) { + logger.warn({ + message: + "Caddy is not reachable; internal *.hack host mappings are disabled. Run `hack global install` (or `hack global up`).", + }); + } + return caddyIp; +} + +async function resolveCaddyHostsForBranch(opts: { + readonly project: Awaited>; + readonly composeFile: string; + readonly branch: string | null; + readonly devHost: string | null; + readonly aliasHost: string | null; +}): Promise { + const caddyHosts = await readComposeCaddyHosts(opts.composeFile); + if (!(caddyHosts.length > 0 && opts.branch)) { + return caddyHosts; + } + + const devHost = + opts.devHost ?? (await resolveBranchDevHost({ project: opts.project })); + const baseHosts = [devHost, opts.aliasHost ?? null].filter( + (host): host is string => typeof host === "string" && host.length > 0 + ); + if (baseHosts.length === 0) { + return caddyHosts; + } + + return applyBranchToHosts({ + hosts: caddyHosts, + branch: opts.branch, + baseHosts, + }); +} + +async function resolveInternalTlsCaPath(opts: { + readonly enabled: boolean; +}): Promise { + if (!opts.enabled) { + return null; + } + + const caPath = await resolveCaddyLocalCaPath(); + if (!caPath) { + logger.warn({ + message: + "Caddy Local CA cert not found; internal TLS trust is disabled. Run `hack global trust` (or `hack global ca`).", + }); + } + return caPath; +} + +function buildInternalExtraHosts(opts: { + readonly caddyIp: string | null; + readonly caddyHosts: readonly string[]; + readonly internalExtraHosts: Record | null; + readonly managedExtraHosts: Record; +}): Record { + return { + ...(opts.caddyIp && opts.caddyHosts.length > 0 + ? buildExtraHostsMap({ hosts: opts.caddyHosts, ip: opts.caddyIp }) + : {}), + ...(opts.internalExtraHosts ? opts.internalExtraHosts : {}), + ...opts.managedExtraHosts, + }; +} + +function renderInternalOverride(opts: { + readonly services: readonly string[]; + readonly dnsServer: string | null; + readonly extraHosts: Record; + readonly caPath: string | null; +}): string { + const overrideServices = buildInternalOverrideServices({ + services: opts.services, + dnsServer: opts.dnsServer, + extraHosts: opts.extraHosts, + caPath: opts.caPath, + }); + const override = { services: overrideServices }; + const yaml = YAML.stringify(override, null, 2); + return ensureTrailingNewline(cleanupYaml(yaml)); +} + +function buildInternalOverrideServices(opts: { + readonly services: readonly string[]; + readonly dnsServer: string | null; + readonly extraHosts: Record; + readonly caPath: string | null; +}): Record> { + const overrideServices: Record> = {}; + + for (const service of opts.services) { + const entry: Record = {}; + if (opts.dnsServer) { + entry.dns = [opts.dnsServer]; + } + if (Object.keys(opts.extraHosts).length > 0) { + entry.extra_hosts = opts.extraHosts; + } + if (opts.caPath) { + entry.volumes = [`${opts.caPath}:${INTERNAL_CA_CONTAINER_PATH}:ro`]; + entry.environment = buildInternalTlsEnvironment(); + } + overrideServices[service] = entry; + } + + return overrideServices; +} + +function buildInternalTlsEnvironment(): Record { + return { + SSL_CERT_FILE: INTERNAL_CA_CONTAINER_PATH, + SSL_CERT_DIR: INTERNAL_CA_CONTAINER_DIR, + NODE_EXTRA_CA_CERTS: INTERNAL_CA_CONTAINER_PATH, + REQUESTS_CA_BUNDLE: INTERNAL_CA_CONTAINER_PATH, + CURL_CA_BUNDLE: INTERNAL_CA_CONTAINER_PATH, + GIT_SSL_CAINFO: INTERNAL_CA_CONTAINER_PATH, + }; +} + +async function writeInternalComposeOverride(opts: { + readonly projectDir: string; + readonly text: string; +}): Promise { + const overrideDir = resolve(opts.projectDir, ".internal"); + await ensureDir(overrideDir); + const overridePath = resolve(overrideDir, "compose.override.yml"); + await writeTextFileIfChanged(overridePath, opts.text); + return overridePath; +} + +function resolveInternalSettings( + cfg: Awaited> +): { + readonly dns: boolean; + readonly tls: boolean; + readonly extraHosts: Record | null; +} { + return { + dns: cfg.internal?.dns ?? true, + tls: cfg.internal?.tls ?? true, + extraHosts: cfg.internal?.extraHosts ?? null, + }; +} + +async function readComposeServiceNames( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (!text) { + return []; + } + + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + return []; + } + + if (!isRecord(parsed)) { + return []; + } + const servicesRaw = parsed.services; + if (!isRecord(servicesRaw)) { + return []; + } + return Object.keys(servicesRaw).sort((a, b) => a.localeCompare(b)); +} + +async function readComposeCaddyHosts( + composeFile: string +): Promise { + const text = await readTextFile(composeFile); + if (!text) { + return []; + } + + let parsed: unknown; + try { + parsed = YAML.parse(text); + } catch { + return []; + } + + if (!isRecord(parsed)) { + return []; + } + const servicesRaw = parsed.services; + if (!isRecord(servicesRaw)) { + return []; + } + + const hosts = new Set(); + for (const serviceRaw of Object.values(servicesRaw)) { + if (!isRecord(serviceRaw)) { + continue; + } + const labels = normalizeLabels(serviceRaw.labels); + if (!labels) { + continue; + } + const caddyRaw = labels.caddy; + if (typeof caddyRaw !== "string") { + continue; + } + for (const host of extractCaddyHosts(caddyRaw)) { + hosts.add(host); + } + } + + return Array.from(hosts).sort((a, b) => a.localeCompare(b)); +} + +function extractCaddyHosts(value: string): readonly string[] { + const out: string[] = []; + for (const part of value.split(",")) { + let host = part.trim(); + if (!host) { + continue; + } + + if (host.startsWith("http://")) { + host = host.slice("http://".length); + } + if (host.startsWith("https://")) { + host = host.slice("https://".length); + } + const slashIdx = host.indexOf("/"); if (slashIdx !== -1) { host = host.slice(0, slashIdx); } @@ -1098,81 +1650,276 @@ async function resolveCaddyServer(): Promise { return null; } - const network = parsed[DEFAULT_INGRESS_NETWORK]; - if (!isRecord(network)) { - return null; + const network = parsed[DEFAULT_INGRESS_NETWORK]; + if (!isRecord(network)) { + return null; + } + const ip = network.IPAddress; + return typeof ip === "string" && ip.length > 0 ? ip : null; +} + +async function resolveCaddyLocalCaPath(): Promise { + const home = process.env.HOME; + if (!home) { + return null; + } + const certPath = resolve( + home, + GLOBAL_HACK_DIR_NAME, + GLOBAL_CADDY_DIR_NAME, + "pki", + "caddy-local-authority.crt" + ); + return (await pathExists(certPath)) ? certPath : null; +} + +async function resolveBranchDevHost(opts: { + readonly project: Awaited>; +}): Promise { + const devHost = await readProjectDevHost(opts.project); + if (devHost) { + return devHost; + } + throw new Error( + `Missing dev_host in ${opts.project.configFile} (or ${PROJECT_CONFIG_LEGACY_FILENAME}). Run: hack init` + ); +} + +function resolveBranchAliasHost(opts: { + readonly devHost: string; + readonly cfg: Awaited>; +}): string | null { + const tld = resolveProjectOauthTld(opts.cfg.oauth); + return tld ? `${opts.devHost}.${tld}` : null; +} + +async function touchBranchUsageIfNeeded(opts: { + readonly project: Awaited>; + readonly branch: string | null; +}): Promise { + if (!opts.branch) { + return; + } + const res = await touchBranchUsage({ + projectDir: opts.project.projectDir, + branch: opts.branch, + createIfMissing: true, + }); + if (res.error) { + logger.warn({ + message: `Failed to update ${res.path}: ${res.error}`, + }); + } +} + +async function touchProjectRegistration( + project: Awaited> +): Promise { + const outcome = await upsertProjectRegistration({ project }); + if (outcome.status === "conflict") { + logger.warn({ + message: [ + `Project name conflict: "${outcome.conflictName}" is already registered at ${outcome.existing.repoRoot}`, + `Incoming project dir: ${outcome.incoming.projectDir}`, + "Tip: rename one project (hack.config.json name) to keep names unique.", + ].join("\n"), + }); + } +} + +function validateInitProjectSlug( + value: string | undefined +): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + const s = sanitizeProjectSlug(v); + if (s.length === 0) { + return "Invalid"; + } + return undefined; +} + +async function promptInitProjectSlug(opts: { + readonly repoRoot: string; + readonly nameOption: string | undefined; +}): Promise { + const defaultSlug = defaultProjectSlugFromPath(opts.repoRoot); + const initialSlug = sanitizeProjectSlug(opts.nameOption ?? defaultSlug); + const name = await text({ + message: "Project name (slug):", + initialValue: initialSlug, + validate: validateInitProjectSlug, + }); + if (isCancel(name)) { + return null; + } + return sanitizeProjectSlug(name); +} + +async function ensureInitProjectSlugUnique(opts: { + readonly repoRoot: string; + readonly slug: string; +}): Promise { + const registry = await readProjectsRegistry(); + const existing = registry.projects.find((p) => p.name === opts.slug) ?? null; + if (!existing) { + return; + } + + const expectedProjectDir = resolve(opts.repoRoot, HACK_PROJECT_DIR_PRIMARY); + const isSame = existing.projectDir === expectedProjectDir; + if (isSame) { + return; + } + + const stillExists = await pathExists(existing.projectDir); + if (!stillExists) { + return; + } + + throw new Error( + [ + `Project name "${opts.slug}" is already registered.`, + `Existing: ${existing.repoRoot}`, + `This repo: ${opts.repoRoot}`, + "Tip: choose a different name (or rename the other project).", + ].join("\n") + ); +} + +function validateInitDevHost(value: string | undefined): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + if (v.includes(" ")) { + return "No spaces"; + } + if (v.includes("://")) { + return "Host only (no scheme)"; + } + if (v.includes("/")) { + return "Host only (no path)"; + } + if (v.includes(":")) { + return "Host only (no port)"; + } + return undefined; +} + +async function promptInitDevHost(opts: { + readonly slug: string; + readonly devHostOption: string | undefined; +}): Promise { + const defaultHost = `${opts.slug}.${DEFAULT_PROJECT_TLD}`; + const initialHost = (opts.devHostOption ?? defaultHost).trim(); + const devHost = await text({ + message: "DEV_HOST:", + initialValue: initialHost, + validate: validateInitDevHost, + }); + if (isCancel(devHost)) { + return null; + } + return devHost.trim(); +} + +function validateInitOauthTld(value: string | undefined): string | undefined { + const v = value?.trim().toLowerCase(); + if (!v) { + return "Required"; } - const ip = network.IPAddress; - return typeof ip === "string" && ip.length > 0 ? ip : null; + if (!SLUG_LABEL_PATTERN.test(v)) { + return "Invalid TLD label"; + } + return undefined; } -async function resolveCaddyLocalCaPath(): Promise { - const home = process.env.HOME; - if (!home) { +async function promptInitOauthSettings(opts: { + readonly oauthEnabledDefault: boolean; + readonly oauthTldOption: string | undefined; +}): Promise<{ readonly enabled: boolean; readonly tld: string } | null> { + const enableOauthHost = await confirm({ + message: `Enable OAuth-safe alias host (https://.${DEFAULT_PROJECT_TLD}.${DEFAULT_OAUTH_ALIAS_TLD})?`, + initialValue: opts.oauthEnabledDefault, + }); + if (isCancel(enableOauthHost)) { return null; } - const certPath = resolve( - home, - GLOBAL_HACK_DIR_NAME, - GLOBAL_CADDY_DIR_NAME, - "pki", - "caddy-local-authority.crt" - ); - return (await pathExists(certPath)) ? certPath : null; -} -async function resolveBranchDevHost(opts: { - readonly project: Awaited>; -}): Promise { - const devHost = await readProjectDevHost(opts.project); - if (devHost) { - return devHost; + if (!enableOauthHost) { + return { enabled: false, tld: DEFAULT_OAUTH_ALIAS_TLD }; } - throw new Error( - `Missing dev_host in ${opts.project.configFile} (or ${PROJECT_CONFIG_LEGACY_FILENAME}). Run: hack init` - ); + + const oauthTld = await text({ + message: "OAuth alias TLD (optional):", + initialValue: opts.oauthTldOption ?? DEFAULT_OAUTH_ALIAS_TLD, + validate: validateInitOauthTld, + }); + if (isCancel(oauthTld)) { + return null; + } + + return { enabled: true, tld: String(oauthTld) }; } -function resolveBranchAliasHost(opts: { - readonly devHost: string; - readonly cfg: Awaited>; -}): string | null { - const tld = resolveProjectOauthTld(opts.cfg.oauth); - return tld ? `${opts.devHost}.${tld}` : null; +function renderInitDiscoveryNote(opts: { + readonly discovery: Awaited>; +}): string { + const monorepoLine = opts.discovery.isMonorepo + ? "Monorepo detected." + : "Single-package repo detected."; + const signalsLine = + opts.discovery.signals.length > 0 + ? `Signals: ${opts.discovery.signals.join(", ")}` + : "Signals: none"; + + return [ + `Detected ${opts.discovery.packages.length} package(s) and ${opts.discovery.candidates.length} dev-like script(s).`, + monorepoLine, + signalsLine, + ].join("\n"); } -async function touchBranchUsageIfNeeded(opts: { - readonly project: Awaited>; - readonly branch: string | null; -}): Promise { - if (!opts.branch) { - return; +async function promptInitUseDiscovery(opts: { + readonly canDiscover: boolean; + readonly forceManual: boolean; +}): Promise { + if (opts.forceManual || !opts.canDiscover) { + return false; } - const res = await touchBranchUsage({ - projectDir: opts.project.projectDir, - branch: opts.branch, - createIfMissing: true, + + const useDiscovery = await confirm({ + message: "Auto-discover dev scripts and generate services?", + initialValue: true, }); - if (res.error) { - logger.warn({ - message: `Failed to update ${res.path}: ${res.error}`, - }); + if (isCancel(useDiscovery)) { + return null; } + return useDiscovery; } -async function touchProjectRegistration( - project: Awaited> -): Promise { - const outcome = await upsertProjectRegistration({ project }); - if (outcome.status === "conflict") { - logger.warn({ - message: [ - `Project name conflict: "${outcome.conflictName}" is already registered at ${outcome.existing.repoRoot}`, - `Incoming project dir: ${outcome.incoming.projectDir}`, - "Tip: rename one project (hack.config.json name) to keep names unique.", - ].join("\n"), +async function ensureInitHackDir(opts: { + readonly hackDir: string; +}): Promise<"proceed" | "skip" | null> { + if (await pathExists(opts.hackDir)) { + const ok = await confirm({ + message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists. Overwrite scaffold files?`, + initialValue: false, }); + if (isCancel(ok)) { + return null; + } + if (!ok) { + return "skip"; + } + return "proceed"; } + + await ensureDir(opts.hackDir); + return "proceed"; } async function handleInit({ @@ -1189,100 +1936,30 @@ async function handleInit({ const startDir = resolveStartDir(ctx, args.options.path); const repoRoot = await findRepoRootForInit(startDir); - const defaultSlug = defaultProjectSlugFromPath(repoRoot); - const initialSlug = sanitizeProjectSlug(args.options.name ?? defaultSlug); - const name = await text({ - message: "Project name (slug):", - initialValue: initialSlug, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - const s = sanitizeProjectSlug(v); - if (s.length === 0) { - return "Invalid"; - } - return undefined; - }, + const slug = await promptInitProjectSlug({ + repoRoot, + nameOption: args.options.name, }); - if (isCancel(name)) { + if (!slug) { return 1; } - const slug = sanitizeProjectSlug(name); - // Enforce uniqueness of compose project name across registered projects. - const registry = await readProjectsRegistry(); - const existing = registry.projects.find((p) => p.name === slug) ?? null; - if (existing) { - const expectedProjectDir = resolve(repoRoot, HACK_PROJECT_DIR_PRIMARY); - const isSame = existing.projectDir === expectedProjectDir; - const stillExists = await pathExists(existing.projectDir); - if (!isSame && stillExists) { - throw new Error( - [ - `Project name "${slug}" is already registered.`, - `Existing: ${existing.repoRoot}`, - `This repo: ${repoRoot}`, - "Tip: choose a different name (or rename the other project).", - ].join("\n") - ); - } - } + await ensureInitProjectSlugUnique({ repoRoot, slug }); - const defaultHost = `${slug}.${DEFAULT_PROJECT_TLD}`; - const initialHost = (args.options.devHost ?? defaultHost).trim(); - const devHost = await text({ - message: "DEV_HOST:", - initialValue: initialHost, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (v.includes(" ")) { - return "No spaces"; - } - if (v.includes("://")) { - return "Host only (no scheme)"; - } - if (v.includes("/")) { - return "Host only (no path)"; - } - if (v.includes(":")) { - return "Host only (no port)"; - } - return undefined; - }, + const devHost = await promptInitDevHost({ + slug, + devHostOption: args.options.devHost, }); - if (isCancel(devHost)) { + if (!devHost) { return 1; } - const enableOauthHost = await confirm({ - message: `Enable OAuth-safe alias host (https://.${DEFAULT_PROJECT_TLD}.${DEFAULT_OAUTH_ALIAS_TLD})?`, - initialValue: args.options.oauth === true || Boolean(args.options.oauthTld), + const oauth = await promptInitOauthSettings({ + oauthEnabledDefault: + args.options.oauth === true || Boolean(args.options.oauthTld), + oauthTldOption: args.options.oauthTld, }); - if (isCancel(enableOauthHost)) { - return 1; - } - const oauthTld = enableOauthHost - ? await text({ - message: "OAuth alias TLD (optional):", - initialValue: args.options.oauthTld ?? DEFAULT_OAUTH_ALIAS_TLD, - validate: (value) => { - const v = value?.trim().toLowerCase(); - if (!v) { - return "Required"; - } - if (!SLUG_LABEL_PATTERN.test(v)) { - return "Invalid TLD label"; - } - return undefined; - }, - }) - : DEFAULT_OAUTH_ALIAS_TLD; - if (isCancel(oauthTld)) { + if (!oauth) { return 1; } @@ -1292,29 +1969,14 @@ async function handleInit({ const forceManual = args.options.manual || args.options.noDiscovery; if (canDiscover && !forceManual) { - note( - [ - `Detected ${discovery.packages.length} package(s) and ${discovery.candidates.length} dev-like script(s).`, - discovery.isMonorepo - ? "Monorepo detected." - : "Single-package repo detected.", - discovery.signals.length > 0 - ? `Signals: ${discovery.signals.join(", ")}` - : "Signals: none", - ].join("\n"), - "Discovery" - ); - } - - let useDiscovery: boolean | symbol = false; - if (!forceManual && canDiscover) { - useDiscovery = await confirm({ - message: "Auto-discover dev scripts and generate services?", - initialValue: true, - }); + note(renderInitDiscoveryNote({ discovery }), "Discovery"); } - if (isCancel(useDiscovery)) { + const useDiscovery = await promptInitUseDiscovery({ + canDiscover, + forceManual, + }); + if (useDiscovery === null) { return 1; } @@ -1322,19 +1984,12 @@ async function handleInit({ const composeFile = resolve(hackDir, PROJECT_COMPOSE_FILENAME); const configFile = resolve(hackDir, PROJECT_CONFIG_FILENAME); - if (await pathExists(hackDir)) { - const ok = await confirm({ - message: `${HACK_PROJECT_DIR_PRIMARY}/ already exists. Overwrite scaffold files?`, - initialValue: false, - }); - if (isCancel(ok)) { - return 1; - } - if (!ok) { - return 0; - } - } else { - await ensureDir(hackDir); + const hackDirAction = await ensureInitHackDir({ hackDir }); + if (!hackDirAction) { + return 1; + } + if (hackDirAction === "skip") { + return 0; } // Ensure .hack/.internal is gitignored (contains local paths, certs, etc) @@ -1349,7 +2004,7 @@ async function handleInit({ renderProjectConfigJson({ name: slug, devHost, - oauth: { enabled: enableOauthHost, tld: String(oauthTld) }, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, }) ); @@ -1359,13 +2014,13 @@ async function handleInit({ devHost, projectSlug: slug, candidates: discovery.candidates, - oauth: { enabled: enableOauthHost, tld: String(oauthTld) }, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, }) : await buildManualCompose({ repoRoot, devHost, projectSlug: slug, - oauth: { enabled: enableOauthHost, tld: String(oauthTld) }, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, }); await writeTextFileIfChanged(composeFile, compose); @@ -1373,10 +2028,15 @@ async function handleInit({ resolve(hackDir, "README.md"), renderHackFolderReadme({ devHost, - oauth: { enabled: enableOauthHost, tld: String(oauthTld) }, + oauth: { enabled: oauth.enabled, tld: oauth.tld }, }) ); + await writeTextFileIfChanged( + resolve(hackDir, PROJECT_ENV_CONTRACT_FILENAME), + renderProjectEnvContractJson() + ); + const registration = await upsertProjectRegistration({ project: { projectRoot: repoRoot, @@ -1504,6 +2164,11 @@ async function handleInitAuto({ }) ); + await writeTextFileIfChanged( + resolve(hackDir, PROJECT_ENV_CONTRACT_FILENAME), + renderProjectEnvContractJson() + ); + const registration = await upsertProjectRegistration({ project: { projectRoot: repoRoot, @@ -1805,6 +2470,95 @@ function buildCaddyHostLabelValue(opts: { return out.join(", "); } +function splitYamlInlineComment(rawAfter: string): { + readonly valueRaw: string; + readonly commentSuffix: string; +} { + const commentIdx = rawAfter.indexOf(" #"); + if (commentIdx < 0) { + return { valueRaw: rawAfter.trimEnd(), commentSuffix: "" }; + } + + return { + valueRaw: rawAfter.slice(0, commentIdx).trimEnd(), + commentSuffix: rawAfter.slice(commentIdx), + }; +} + +function splitCaddyHosts(value: string): string[] { + return value + .split(",") + .map((h) => h.trim()) + .filter((h) => h.length > 0); +} + +function expandCaddyHostsWithOauthAliases(opts: { + readonly hosts: readonly string[]; + readonly tld: string; +}): string[] { + const out: string[] = []; + const seen = new Set(); + + for (const host of opts.hosts) { + if (seen.has(host)) { + continue; + } + seen.add(host); + out.push(host); + } + + for (const host of opts.hosts) { + if (!host.endsWith(`.${DEFAULT_PROJECT_TLD}`)) { + continue; + } + const alias = `${host}.${opts.tld}`; + if (seen.has(alias)) { + continue; + } + seen.add(alias); + out.push(alias); + } + + return out; +} + +function maybePatchCaddyLabelLine(opts: { + readonly line: string; + readonly tld: string; +}): { readonly line: string; readonly changed: boolean } | null { + const caddyMatch = CADDY_LABEL_PATTERN.exec(opts.line); + if (!caddyMatch) { + return null; + } + + const indentStr = caddyMatch[1] ?? ""; + const rawAfter = caddyMatch[2] ?? ""; + + const { valueRaw, commentSuffix } = splitYamlInlineComment(rawAfter); + const valueTrimmed = valueRaw.trim(); + const quoted = parseQuotedValue(valueTrimmed); + const parts = splitCaddyHosts(quoted.value); + if (parts.length === 0) { + return null; + } + + const nextValue = expandCaddyHostsWithOauthAliases({ + hosts: parts, + tld: opts.tld, + }).join(", "); + if (nextValue === quoted.value) { + return null; + } + + const formatted = quoted.quote + ? `${quoted.quote}${nextValue}${quoted.quote}` + : nextValue; + return { + line: `${indentStr}caddy: ${formatted}${commentSuffix}`, + changed: true, + }; +} + function patchComposeOauthAliasesInCaddyLabels(opts: { readonly yamlText: string; readonly tld: string; @@ -1842,64 +2596,11 @@ function patchComposeOauthAliasesInCaddyLabels(opts: { continue; } - const caddyMatch = CADDY_LABEL_PATTERN.exec(line); - if (!caddyMatch) { - continue; - } - - const indentStr = caddyMatch[1] ?? ""; - const rawAfter = caddyMatch[2] ?? ""; - - const commentIdx = rawAfter.indexOf(" #"); - const valueRaw = ( - commentIdx >= 0 ? rawAfter.slice(0, commentIdx) : rawAfter - ).trimEnd(); - const commentSuffix = commentIdx >= 0 ? rawAfter.slice(commentIdx) : ""; - - const valueTrimmed = valueRaw.trim(); - const quoted = parseQuotedValue(valueTrimmed); - - const parts = quoted.value - .split(",") - .map((h) => h.trim()) - .filter((h) => h.length > 0); - if (parts.length === 0) { - continue; - } - - const out: string[] = []; - const seen = new Set(); - - for (const host of parts) { - if (seen.has(host)) { - continue; - } - seen.add(host); - out.push(host); - } - - for (const host of parts) { - if (!host.endsWith(`.${DEFAULT_PROJECT_TLD}`)) { - continue; - } - const alias = `${host}.${tld}`; - if (seen.has(alias)) { - continue; - } - seen.add(alias); - out.push(alias); - } - - const nextValue = out.join(", "); - if (nextValue === quoted.value) { - continue; + const patched = maybePatchCaddyLabelLine({ line, tld }); + if (patched) { + changed = true; + lines[i] = patched.line; } - - changed = true; - const formatted = quoted.quote - ? `${quoted.quote}${nextValue}${quoted.quote}` - : nextValue; - lines[i] = `${indentStr}caddy: ${formatted}${commentSuffix}`; } return { text: lines.join("\n"), changed }; @@ -1931,24 +2632,87 @@ async function maybeSyncOauthAliasesInCompose(opts: { await writeTextFileIfChanged(opts.project.composeFile, patched.text); } -async function buildDiscoveredCompose( - input: ComposeWizardInput -): Promise { - const byId = new Map(input.candidates.map((c) => [c.id, c] as const)); +function unwrapPromptValue(value: T | symbol): T { + if (isCancel(value)) { + throw new Error("Canceled"); + } + return value; +} - const selectedIds = await autocompleteMultiselect({ - message: "Select dev scripts to include as services:", - required: true, - options: input.candidates.map((c) => ({ - value: c.id, - label: formatCandidateLabel(c), - hint: formatCandidateHint(c), - })), - }); +const RESERVED_COMPOSE_SERVICE_NAMES = new Set(["db", "redis"]); - if (isCancel(selectedIds)) { - throw new Error("Canceled"); +function validateComposeServiceName(opts: { + readonly value: string | undefined; + readonly defaultName: string; + readonly usedServiceNames: ReadonlySet; + readonly reserved?: ReadonlySet; +}): string | undefined { + const v = opts.value?.trim(); + if (!v) { + return "Required"; + } + if (!SLUG_LABEL_PATTERN.test(v)) { + return "Use lowercase letters, numbers, and '-' only"; + } + if (opts.reserved?.has(v) === true) { + return "Reserved name"; + } + if (opts.usedServiceNames.has(v) && v !== opts.defaultName) { + return "Duplicate"; + } + return undefined; +} + +function validatePort(value: string | undefined): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + const n = Number.parseInt(v, 10); + if (!Number.isFinite(n) || n <= 0 || n >= 65_536) { + return "Invalid port"; + } + return undefined; +} + +function validateRequiredText(value: string | undefined): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + return undefined; +} + +function validateSubdomain(value: string | undefined): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + if (v.includes(".")) { + return "Subdomain only (no dots)"; + } + if (!SLUG_LABEL_PATTERN.test(v)) { + return "Invalid subdomain"; } + return undefined; +} + +async function selectCandidatesForDiscoveredCompose(opts: { + readonly candidates: readonly ServiceCandidate[]; +}): Promise { + const byId = new Map(opts.candidates.map((c) => [c.id, c] as const)); + + const selectedIds = unwrapPromptValue( + await autocompleteMultiselect({ + message: "Select dev scripts to include as services:", + required: true, + options: opts.candidates.map((c) => ({ + value: c.id, + label: formatCandidateLabel(c), + hint: formatCandidateHint(c), + })), + }) + ); const selectedCandidates: ServiceCandidate[] = []; for (const id of selectedIds) { @@ -1962,214 +2726,169 @@ async function buildDiscoveredCompose( throw new Error("No services selected"); } - const usedServiceNames = new Set(); - const drafts: Array<{ - name: string; - role: "http" | "internal"; - port?: number; - subdomain?: string; - workingDir: string; - command: string; - }> = []; + return selectedCandidates; +} - for (const candidate of selectedCandidates) { - note( - candidate.scriptCommand, - `${candidate.packageRelativeDir} (${candidate.scriptName})` - ); +async function promptDraftForDiscoveredCandidate(opts: { + readonly candidate: ServiceCandidate; + readonly usedServiceNames: Set; +}): Promise { + note( + opts.candidate.scriptCommand, + `${opts.candidate.packageRelativeDir} (${opts.candidate.scriptName})` + ); - const defaultName = uniqueName( - guessServiceName(candidate), - usedServiceNames - ); - const defaultRole = guessRole(candidate); + const defaultName = uniqueName( + guessServiceName(opts.candidate), + opts.usedServiceNames + ); + const defaultRole = guessRole(opts.candidate); - const role = await select<"http" | "internal">({ + const role = unwrapPromptValue( + await select<"http" | "internal">({ message: `Service role for "${defaultName}":`, initialValue: defaultRole, options: [ { value: "http", label: "HTTP (routed via Caddy)" }, { value: "internal", label: "Internal (not routed via Caddy)" }, ], - }); - if (isCancel(role)) { - throw new Error("Canceled"); - } + }) + ); - const name = await text({ + const name = unwrapPromptValue( + await text({ message: "docker compose service name:", initialValue: defaultName, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (!SLUG_LABEL_PATTERN.test(v)) { - return "Use lowercase letters, numbers, and '-' only"; - } - if (v === "db" || v === "redis") { - return "Reserved name"; - } - if (usedServiceNames.has(v) && v !== defaultName) { - return "Duplicate"; - } - return undefined; - }, - }); - if (isCancel(name)) { - throw new Error("Canceled"); - } - - usedServiceNames.add(name); - - const inferredPort = inferPortFromScript(candidate.scriptCommand); - const defaultPort = inferredPort ?? guessDefaultPort(name); - - const port = - role === "http" - ? await text({ - message: "Internal HTTP port:", - initialValue: String(defaultPort), - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - const n = Number.parseInt(v, 10); - if (!Number.isFinite(n) || n <= 0 || n >= 65_536) { - return "Invalid port"; - } - return undefined; - }, - }) - : "0"; - if (isCancel(port)) { - throw new Error("Canceled"); - } - - const portNum = role === "http" ? Number.parseInt(port, 10) : undefined; + validate: (value) => + validateComposeServiceName({ + value, + defaultName, + usedServiceNames: opts.usedServiceNames, + reserved: RESERVED_COMPOSE_SERVICE_NAMES, + }), + }) + ); + opts.usedServiceNames.add(name); + + const inferredPort = inferPortFromScript(opts.candidate.scriptCommand); + const defaultPort = inferredPort ?? guessDefaultPort(name); + + const portNum = + role === "http" + ? Number.parseInt( + unwrapPromptValue( + await text({ + message: "Internal HTTP port:", + initialValue: String(defaultPort), + validate: validatePort, + }) + ), + 10 + ) + : undefined; - const workingDir = - candidate.packageRelativeDir === "." - ? "/app" - : `/app/${candidate.packageRelativeDir}`; + const workingDir = + opts.candidate.packageRelativeDir === "." + ? "/app" + : `/app/${opts.candidate.packageRelativeDir}`; - const suggestedCommand = buildSuggestedCommand({ - candidate, - role, - port: portNum, - }); + const suggestedCommand = buildSuggestedCommand({ + candidate: opts.candidate, + role, + port: portNum, + }); - const command = await text({ + const command = unwrapPromptValue( + await text({ message: "Container command:", initialValue: suggestedCommand, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - return undefined; - }, - }); - if (isCancel(command)) { - throw new Error("Canceled"); - } + validate: validateRequiredText, + }) + ); - drafts.push({ - name, - role, - port: portNum, - workingDir, - command, - }); - } + return { + name, + role, + port: portNum, + workingDir, + command, + }; +} - const httpDrafts = drafts.filter((d) => d.role === "http"); - if (httpDrafts.length > 0) { - const primaryDefault = - httpDrafts.find((d) => d.name === "www")?.name ?? httpDrafts[0]?.name; +async function promptHttpSubdomainsForDrafts(opts: { + readonly drafts: AutoComposeDraft[]; + readonly devHost: string; + readonly primaryDefaultStrategy?: "prefer-www" | "first"; +}): Promise { + const httpDrafts = opts.drafts.filter((d) => d.role === "http"); + if (httpDrafts.length === 0) { + return; + } - const primary = await select({ - message: `Which service should be routed at https://${input.devHost}?`, + const primaryDefault = + opts.primaryDefaultStrategy === "first" + ? httpDrafts[0]?.name + : (httpDrafts.find((d) => d.name === "www")?.name ?? httpDrafts[0]?.name); + const primary = unwrapPromptValue( + await select({ + message: `Which service should be routed at https://${opts.devHost}?`, initialValue: primaryDefault, options: httpDrafts.map((d) => ({ value: d.name, label: d.name, })), - }); - if (isCancel(primary)) { - throw new Error("Canceled"); - } + }) + ); - for (const d of httpDrafts) { - if (d.name === primary) { - continue; - } + for (const d of httpDrafts) { + if (d.name === primary) { + continue; + } - const defaultSub = guessSubdomain(d.name); - const sub = await text({ - message: `Subdomain for "${d.name}" (https://.${input.devHost}):`, + const defaultSub = guessSubdomain(d.name); + const sub = unwrapPromptValue( + await text({ + message: `Subdomain for "${d.name}" (https://.${opts.devHost}):`, initialValue: defaultSub, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (v.includes(".")) { - return "Subdomain only (no dots)"; - } - if (!SLUG_LABEL_PATTERN.test(v)) { - return "Invalid subdomain"; - } - return undefined; - }, - }); - if (isCancel(sub)) { - throw new Error("Canceled"); - } - d.subdomain = sub; - } + validate: validateSubdomain, + }) + ); + d.subdomain = sub; + } - // Assign primary as root host - const primaryDraft = httpDrafts.find((d) => d.name === primary); - if (primaryDraft) { - primaryDraft.subdomain = ""; - } + const primaryDraft = httpDrafts.find((d) => d.name === primary); + if (primaryDraft) { + primaryDraft.subdomain = ""; } +} - const services = drafts.map((d) => { - const env = new Map([ - ["CHOKIDAR_USEPOLLING", "true"], - ["WATCHPACK_POLLING", "true"], - ]); +async function buildDiscoveredCompose( + input: ComposeWizardInput +): Promise { + const selectedCandidates = await selectCandidatesForDiscoveredCompose({ + candidates: input.candidates, + }); + const usedServiceNames = new Set(); + const drafts: AutoComposeDraft[] = []; - const labels = new Map(); - const networks = d.role === "http" ? ["hack-dev", "default"] : []; + for (const candidate of selectedCandidates) { + drafts.push( + await promptDraftForDiscoveredCandidate({ + candidate, + usedServiceNames, + }) + ); + } - if (d.role === "http") { - const port = d.port ?? 3000; - const host = - d.subdomain && d.subdomain.length > 0 - ? `${d.subdomain}.${input.devHost}` - : `${input.devHost}`; - labels.set( - "caddy", - buildCaddyHostLabelValue({ primaryHost: host, oauth: input.oauth }) - ); - labels.set("caddy.reverse_proxy", `{{upstreams ${port}}}`); - labels.set("caddy.tls", "internal"); - } + await promptHttpSubdomainsForDrafts({ + drafts, + devHost: input.devHost, + }); - return { - name: d.name, - role: d.role, - image: "imbios/bun-node:latest", - workingDir: d.workingDir, - command: d.command, - env, - labels, - networks, - }; + const services = buildServicesFromDrafts({ + drafts, + devHost: input.devHost, + oauth: input.oauth, }); return renderCompose({ name: input.projectSlug, services }); @@ -2242,244 +2961,156 @@ interface ManualComposeWizardInput { }; } -async function buildManualCompose( - input: ManualComposeWizardInput -): Promise { - note( - [ - "No dev scripts were auto-discovered (or you opted out).", - "Let’s define your services manually. You can always edit the generated compose after.", - ].join("\n"), - "Manual services" - ); - - const usedServiceNames = new Set(); - const drafts: Array<{ - name: string; - role: "http" | "internal"; - image: string; - port?: number; - subdomain?: string; - workingDir: string; - command: string; - }> = []; +function validateRepoRelativeWorkingDir( + value: string | undefined +): string | undefined { + const v = value?.trim(); + if (!v) { + return "Required"; + } + if (v.startsWith("/")) { + return "Use a repo-relative path (e.g. ., apps/web)"; + } + return undefined; +} - while (true) { - const defaultName = uniqueName("app", usedServiceNames); +function buildManualSuggestedCommand(opts: { + readonly role: "http" | "internal"; + readonly port: number | undefined; +}): string { + if (opts.role !== "http") { + return "bun run dev"; + } + const port = opts.port ?? 3000; + return `bun run dev -- --port ${port} --host 0.0.0.0`; +} - const role = await select<"http" | "internal">({ - message: `Service role for "${defaultName}":`, +async function promptManualServiceDraft(opts: { + readonly defaultName: string; + readonly usedServiceNames: Set; +}): Promise { + const role = unwrapPromptValue( + await select<"http" | "internal">({ + message: `Service role for "${opts.defaultName}":`, initialValue: "http", options: [ { value: "http", label: "HTTP (routed via Caddy)" }, { value: "internal", label: "Internal (not routed via Caddy)" }, ], - }); - if (isCancel(role)) { - throw new Error("Canceled"); - } + }) + ); - const name = await text({ + const name = unwrapPromptValue( + await text({ message: "docker compose service name:", - initialValue: defaultName, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (!SLUG_LABEL_PATTERN.test(v)) { - return "Use lowercase letters, numbers, and '-' only"; - } - if (usedServiceNames.has(v) && v !== defaultName) { - return "Duplicate"; - } - return undefined; - }, - }); - if (isCancel(name)) { - throw new Error("Canceled"); - } - usedServiceNames.add(name); + initialValue: opts.defaultName, + validate: (value) => + validateComposeServiceName({ + value, + defaultName: opts.defaultName, + usedServiceNames: opts.usedServiceNames, + }), + }) + ); + opts.usedServiceNames.add(name); - const image = await text({ + const image = unwrapPromptValue( + await text({ message: `Image for "${name}":`, initialValue: "imbios/bun-node:latest", - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - return undefined; - }, - }); - if (isCancel(image)) { - throw new Error("Canceled"); - } + validate: validateRequiredText, + }) + ); - const workingDirRel = await text({ + const workingDirRel = unwrapPromptValue( + await text({ message: `Working dir (relative to repo root) for "${name}":`, initialValue: ".", - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (v.startsWith("/")) { - return "Use a repo-relative path (e.g. ., apps/web)"; - } - return undefined; - }, - }); - if (isCancel(workingDirRel)) { - throw new Error("Canceled"); - } - - const port = - role === "http" - ? await text({ - message: `Internal HTTP port for "${name}":`, - initialValue: String(guessDefaultPort(name)), - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - const n = Number.parseInt(v, 10); - if (!Number.isFinite(n) || n <= 0 || n >= 65_536) { - return "Invalid port"; - } - return undefined; - }, - }) - : "0"; - if (isCancel(port)) { - throw new Error("Canceled"); - } + validate: validateRepoRelativeWorkingDir, + }) + ); - const portNum = role === "http" ? Number.parseInt(port, 10) : undefined; + const portNum = + role === "http" + ? Number.parseInt( + unwrapPromptValue( + await text({ + message: `Internal HTTP port for "${name}":`, + initialValue: String(guessDefaultPort(name)), + validate: validatePort, + }) + ), + 10 + ) + : undefined; - const command = await text({ + const command = unwrapPromptValue( + await text({ message: `Container command for "${name}":`, - initialValue: - role === "http" - ? `bun run dev -- --port ${portNum ?? 3000} --host 0.0.0.0` - : "bun run dev", - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - return undefined; - }, - }); - if (isCancel(command)) { - throw new Error("Canceled"); - } - - const relRaw = workingDirRel.trim(); - const rel = normalizeRelativePath(relRaw); - const workingDir = rel === "." ? "/app" : `/app/${rel}`; - - drafts.push({ - name, - role, - image: image.trim(), - port: portNum, - workingDir, - command, - }); + initialValue: buildManualSuggestedCommand({ role, port: portNum }), + validate: validateRequiredText, + }) + ); - const more = await confirm({ - message: "Add another service?", - initialValue: false, - }); - if (isCancel(more)) { - throw new Error("Canceled"); - } - if (!more) { - break; - } - } + const relRaw = workingDirRel.trim(); + const rel = normalizeRelativePath(relRaw); + const workingDir = rel === "." ? "/app" : `/app/${rel}`; - const httpDrafts = drafts.filter((d) => d.role === "http"); - if (httpDrafts.length > 0) { - const primaryDefault = httpDrafts[0]?.name; - const primary = await select({ - message: `Which service should be routed at https://${input.devHost}?`, - initialValue: primaryDefault, - options: httpDrafts.map((d) => ({ value: d.name, label: d.name })), - }); - if (isCancel(primary)) { - throw new Error("Canceled"); - } + return { + name, + role, + image: image.trim(), + port: portNum, + workingDir, + command, + }; +} - for (const d of httpDrafts) { - if (d.name === primary) { - continue; - } - const defaultSub = guessSubdomain(d.name); - const sub = await text({ - message: `Subdomain for "${d.name}" (https://.${input.devHost}):`, - initialValue: defaultSub, - validate: (value) => { - const v = value?.trim(); - if (!v) { - return "Required"; - } - if (v.includes(".")) { - return "Subdomain only (no dots)"; - } - if (!SLUG_LABEL_PATTERN.test(v)) { - return "Invalid subdomain"; - } - return undefined; - }, - }); - if (isCancel(sub)) { - throw new Error("Canceled"); - } - d.subdomain = sub; - } +async function buildManualCompose( + input: ManualComposeWizardInput +): Promise { + note( + [ + "No dev scripts were auto-discovered (or you opted out).", + "Let’s define your services manually. You can always edit the generated compose after.", + ].join("\n"), + "Manual services" + ); - const primaryDraft = httpDrafts.find((d) => d.name === primary); - if (primaryDraft) { - primaryDraft.subdomain = ""; - } - } + const usedServiceNames = new Set(); + const drafts: AutoComposeDraft[] = []; - const services = drafts.map((d) => { - const env = new Map([ - ["CHOKIDAR_USEPOLLING", "true"], - ["WATCHPACK_POLLING", "true"], - ]); + while (true) { + const defaultName = uniqueName("app", usedServiceNames); - const labels = new Map(); - const networks = d.role === "http" ? ["hack-dev", "default"] : []; + drafts.push( + await promptManualServiceDraft({ + defaultName, + usedServiceNames, + }) + ); - if (d.role === "http") { - const port = d.port ?? 3000; - const host = - d.subdomain && d.subdomain.length > 0 - ? `${d.subdomain}.${input.devHost}` - : `${input.devHost}`; - labels.set( - "caddy", - buildCaddyHostLabelValue({ primaryHost: host, oauth: input.oauth }) - ); - labels.set("caddy.reverse_proxy", `{{upstreams ${port}}}`); - labels.set("caddy.tls", "internal"); + const more = unwrapPromptValue( + await confirm({ + message: "Add another service?", + initialValue: false, + }) + ); + if (!more) { + break; } + } - return { - name: d.name, - role: d.role, - image: d.image, - workingDir: d.workingDir, - command: d.command, - env, - labels, - networks, - }; + await promptHttpSubdomainsForDrafts({ + drafts, + devHost: input.devHost, + primaryDefaultStrategy: "first", + }); + + const services = buildServicesFromDrafts({ + drafts, + devHost: input.devHost, + oauth: input.oauth, }); return renderCompose({ name: input.projectSlug, services }); @@ -2892,6 +3523,7 @@ async function handleUp({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; + const projectName = sanitizeProjectSlug(baseProjectName); const devHost = branch ? await resolveBranchDevHost({ project }) : null; const aliasHost = branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; @@ -2911,13 +3543,70 @@ async function handleUp({ const composeFilesWithInternal = internalOverride ? [...composeFiles, internalOverride] : composeFiles; - return await composeRuntimeBackend.up({ - composeFiles: composeFilesWithInternal, + + const targetServices = await readComposeServiceNames(project.composeFile); + const envOverrides = await resolveComposeEnvOverrides({ + project, + projectName, + targetServices, + }); + const composeFilesWithEnv = [ + ...composeFilesWithInternal, + ...envOverrides.composeFiles, + ]; + + const beforeCode = await runLifecycleCommands({ + title: "Lifecycle (up before)", + commands: cfg.lifecycle?.up?.before, + projectRoot: project.projectRoot, + env: envOverrides.env, + }); + if (beforeCode !== 0) { + return beforeCode; + } + + try { + await startLifecycleProcesses({ + project, + cfg, + projectName, + branch, + env: envOverrides.env, + }); + if ((cfg.lifecycle?.processes ?? []).length > 0) { + const sessionName = resolveLifecycleSessionName({ projectName, branch }); + logger.info({ + message: `Lifecycle processes running in session: ${sessionName}`, + }); + } + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to start lifecycle processes"; + logger.error({ message }); + return 1; + } + + const upCode = await composeRuntimeBackend.up({ + composeFiles: composeFilesWithEnv, composeProject: composeProjectName, profiles, detach, cwd: dirname(project.composeFile), + env: envOverrides.env, + }); + if (upCode !== 0) { + return upCode; + } + + const afterCode = await runLifecycleCommands({ + title: "Lifecycle (up after)", + commands: cfg.lifecycle?.up?.after, + projectRoot: project.projectRoot, + env: envOverrides.env, }); + return afterCode; } async function maybePromptToStartGlobal(opts: { @@ -2983,17 +3672,220 @@ async function handleDown({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; + const projectName = sanitizeProjectSlug(baseProjectName); + const envResolved = await resolveHackEnv({ + projectDir: project.projectDir, + projectName, + }); + if (envResolved.contractParseError) { + logger.warn({ + message: `Failed to parse ${envResolved.contractPath}: ${envResolved.contractParseError}`, + }); + } + + const beforeCode = await runLifecycleCommands({ + title: "Lifecycle (down before)", + commands: cfg.lifecycle?.down?.before, + projectRoot: project.projectRoot, + env: envResolved.envForCompose, + }); + if (beforeCode !== 0) { + return beforeCode; + } + const code = await composeRuntimeBackend.down({ composeFiles: [project.composeFile], composeProject: composeProjectName, profiles, cwd: dirname(project.composeFile), }); + + try { + await stopLifecycleProcesses({ project, cfg, projectName, branch }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to stop lifecycle processes"; + logger.warn({ message }); + } + if (code !== 0) { return code; } await maybeManageProjectLogsAfterDown({ project, branch }); - return 0; + + const afterCode = await runLifecycleCommands({ + title: "Lifecycle (down after)", + commands: cfg.lifecycle?.down?.after, + projectRoot: project.projectRoot, + env: envResolved.envForCompose, + }); + return afterCode; +} + +async function runRestartDownPhase(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly composeProjectName: string | null; + readonly profiles: readonly string[]; + readonly branch: string | null; + readonly envForCompose: Readonly>; +}): Promise { + const downBefore = await runLifecycleCommands({ + title: "Lifecycle (restart down before)", + commands: opts.cfg.lifecycle?.down?.before, + projectRoot: opts.project.projectRoot, + env: opts.envForCompose, + }); + if (downBefore !== 0) { + return downBefore; + } + + const downCode = await composeRuntimeBackend.down({ + composeFiles: [opts.project.composeFile], + composeProject: opts.composeProjectName, + profiles: opts.profiles, + cwd: dirname(opts.project.composeFile), + }); + try { + await stopLifecycleProcesses({ + project: opts.project, + cfg: opts.cfg, + projectName: opts.projectName, + branch: opts.branch, + }); + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to stop lifecycle processes"; + logger.warn({ message }); + } + if (downCode !== 0) { + return downCode; + } + + await maybeManageProjectLogsAfterDown({ + project: opts.project, + branch: opts.branch, + }); + + const downAfter = await runLifecycleCommands({ + title: "Lifecycle (restart down after)", + commands: opts.cfg.lifecycle?.down?.after, + projectRoot: opts.project.projectRoot, + env: opts.envForCompose, + }); + return downAfter; +} + +async function runRestartUpPhase(opts: { + readonly project: Awaited>; + readonly cfg: Awaited>; + readonly projectName: string; + readonly composeProjectName: string | null; + readonly profiles: readonly string[]; + readonly branch: string | null; +}): Promise { + await maybeSyncOauthAliasesInCompose({ project: opts.project }); + + const devHost = opts.branch + ? await resolveBranchDevHost({ project: opts.project }) + : null; + const aliasHost = + opts.branch && devHost + ? resolveBranchAliasHost({ devHost, cfg: opts.cfg }) + : null; + const internalOverride = await resolveInternalComposeOverride({ + project: opts.project, + cfg: opts.cfg, + branch: opts.branch, + devHost, + aliasHost, + }); + const composeFiles = + opts.branch && devHost + ? await resolveBranchComposeFiles({ + project: opts.project, + branch: opts.branch, + devHost, + aliasHost, + }) + : [opts.project.composeFile]; + const composeFilesWithInternal = internalOverride + ? [...composeFiles, internalOverride] + : composeFiles; + + const targetServices = await readComposeServiceNames( + opts.project.composeFile + ); + const envOverrides = await resolveComposeEnvOverrides({ + project: opts.project, + projectName: opts.projectName, + targetServices, + }); + const composeFilesWithEnv = [ + ...composeFilesWithInternal, + ...envOverrides.composeFiles, + ]; + + const upBefore = await runLifecycleCommands({ + title: "Lifecycle (restart up before)", + commands: opts.cfg.lifecycle?.up?.before, + projectRoot: opts.project.projectRoot, + env: envOverrides.env, + }); + if (upBefore !== 0) { + return upBefore; + } + + try { + await startLifecycleProcesses({ + project: opts.project, + cfg: opts.cfg, + projectName: opts.projectName, + branch: opts.branch, + env: envOverrides.env, + }); + if ((opts.cfg.lifecycle?.processes ?? []).length > 0) { + const sessionName = resolveLifecycleSessionName({ + projectName: opts.projectName, + branch: opts.branch, + }); + logger.info({ + message: `Lifecycle processes running in session: ${sessionName}`, + }); + } + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "Failed to start lifecycle processes"; + logger.error({ message }); + return 1; + } + + const upCode = await composeRuntimeBackend.up({ + composeFiles: composeFilesWithEnv, + composeProject: opts.composeProjectName, + profiles: opts.profiles, + detach: false, + cwd: dirname(opts.project.composeFile), + env: envOverrides.env, + }); + if (upCode !== 0) { + return upCode; + } + + const upAfter = await runLifecycleCommands({ + title: "Lifecycle (restart up after)", + commands: opts.cfg.lifecycle?.up?.after, + projectRoot: opts.project.projectRoot, + env: envOverrides.env, + }); + return upAfter; } async function handleRestart({ @@ -3023,44 +3915,37 @@ async function handleRestart({ const baseProjectName = await resolveComposeProjectName({ project, cfg }); const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; - const downCode = await composeRuntimeBackend.down({ - composeFiles: [project.composeFile], - composeProject: composeProjectName, - profiles, - cwd: dirname(project.composeFile), + const projectName = sanitizeProjectSlug(baseProjectName); + const envResolved = await resolveHackEnv({ + projectDir: project.projectDir, + projectName, }); - if (downCode !== 0) { - return downCode; + if (envResolved.contractParseError) { + logger.warn({ + message: `Failed to parse ${envResolved.contractPath}: ${envResolved.contractParseError}`, + }); } - await maybeManageProjectLogsAfterDown({ project, branch }); - - await maybeSyncOauthAliasesInCompose({ project }); - - const devHost = branch ? await resolveBranchDevHost({ project }) : null; - const aliasHost = - branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; - const internalOverride = await resolveInternalComposeOverride({ + const downCode = await runRestartDownPhase({ project, cfg, + projectName, + composeProjectName, + profiles, branch, - devHost, - aliasHost, + envForCompose: envResolved.envForCompose, }); - const composeFiles = - branch && devHost - ? await resolveBranchComposeFiles({ project, branch, devHost, aliasHost }) - : [project.composeFile]; - const composeFilesWithInternal = internalOverride - ? [...composeFiles, internalOverride] - : composeFiles; + if (downCode !== 0) { + return downCode; + } - return await composeRuntimeBackend.up({ - composeFiles: composeFilesWithInternal, - composeProject: composeProjectName, + return await runRestartUpPhase({ + project, + cfg, + projectName, + composeProjectName, profiles, - detach: false, - cwd: dirname(project.composeFile), + branch, }); } @@ -3281,11 +4166,8 @@ async function handleRun({ }); } - const baseProjectName = branch - ? await resolveComposeProjectName({ project, cfg }) - : null; - const composeProjectName = - branch && baseProjectName ? `${baseProjectName}--${branch}` : null; + const baseProjectName = await resolveComposeProjectName({ project, cfg }); + const composeProjectName = branch ? `${baseProjectName}--${branch}` : null; const devHost = branch ? await resolveBranchDevHost({ project }) : null; const aliasHost = branch && devHost ? resolveBranchAliasHost({ devHost, cfg }) : null; @@ -3299,14 +4181,235 @@ async function handleRun({ const composeFiles = internalOverride ? [project.composeFile, internalOverride] : [project.composeFile]; + + const projectName = sanitizeProjectSlug(baseProjectName); + const envOverrides = await resolveComposeEnvOverrides({ + project, + projectName, + targetServices: [service], + }); + const composeFilesWithEnv = [...composeFiles, ...envOverrides.composeFiles]; return await composeRuntimeBackend.run({ - composeFiles, + composeFiles: composeFilesWithEnv, composeProject: composeProjectName, profiles, service, workdir: workdir.length > 0 ? workdir : undefined, cmdArgs, cwd: dirname(project.composeFile), + env: envOverrides.env, + }); +} + +function computeWantsLokiExplicit(opts: { + readonly options: { + readonly loki: boolean | undefined; + readonly services: string | undefined; + readonly query: string | undefined; + readonly since: string | undefined; + readonly until: string | undefined; + }; +}): boolean { + return ( + opts.options.loki === true || + opts.options.services !== undefined || + opts.options.query !== undefined || + opts.options.since !== undefined || + opts.options.until !== undefined + ); +} + +function validateLogsArgs(opts: { + readonly forceCompose: boolean; + readonly wantsLokiExplicit: boolean; + readonly json: boolean; + readonly pretty: boolean | undefined; + readonly follow: boolean; + readonly timeRange: ReturnType; +}): { readonly ok: true } | { readonly ok: false; readonly message: string } { + if (opts.forceCompose && opts.wantsLokiExplicit) { + return { + ok: false, + message: + "Cannot combine --compose with --loki/--services/--query/--since/--until.", + }; + } + if (opts.json && opts.pretty) { + return { ok: false, message: "Cannot combine --json with --pretty." }; + } + if (opts.timeRange.error) { + return { ok: false, message: opts.timeRange.error }; + } + if (opts.follow && opts.timeRange.end) { + return { ok: false, message: "Cannot combine --until with --follow." }; + } + + return { ok: true }; +} + +function resolveLokiServices(opts: { + readonly servicesOpt: readonly string[]; + readonly positionalService: string | undefined; +}): string[] { + const serviceFromPositional = (opts.positionalService ?? "").trim(); + if (serviceFromPositional.length === 0) { + return [...opts.servicesOpt]; + } + if (opts.servicesOpt.includes(serviceFromPositional)) { + return [...opts.servicesOpt]; + } + return [...opts.servicesOpt, serviceFromPositional]; +} + +function buildLogStreamContext(opts: { + readonly json: boolean; + readonly backend: "loki" | "compose"; + readonly projectNameForPrefix: string; + readonly branch: string | null; + readonly services: readonly string[] | undefined; + readonly follow: boolean; + readonly since: string | undefined; + readonly until: string | undefined; +}): LogStreamContext | undefined { + if (!opts.json) { + return undefined; + } + + return { + backend: opts.backend, + project: + opts.projectNameForPrefix.length > 0 + ? opts.projectNameForPrefix + : undefined, + branch: opts.branch ?? undefined, + services: + opts.services && opts.services.length > 0 + ? [...opts.services] + : undefined, + follow: opts.follow, + since: opts.since, + until: opts.until, + }; +} + +function resolveLokiQuery(opts: { + readonly queryOpt: string | undefined; + readonly projectNameForPrefix: string; + readonly services: readonly string[]; +}): string { + const queryRaw = (opts.queryOpt ?? "").trim(); + if (queryRaw.length > 0) { + return queryRaw; + } + + return buildLogSelector({ + project: + opts.projectNameForPrefix.length > 0 ? opts.projectNameForPrefix : null, + services: [...opts.services], + }); +} + +async function runLogsWithLoki(opts: { + readonly baseUrl: string; + readonly lokiReachable: boolean; + readonly projectNameForPrefix: string; + readonly branch: string | null; + readonly json: boolean; + readonly follow: boolean; + readonly tail: number; + readonly format: ReturnType; + readonly timeRange: ReturnType; + readonly servicesOpt: string | undefined; + readonly positionalService: string | undefined; + readonly queryOpt: string | undefined; + readonly since: string | undefined; + readonly until: string | undefined; +}): Promise { + if (!opts.lokiReachable) { + process.stderr.write(`Loki is not reachable at ${opts.baseUrl}.\n`); + process.stderr.write( + "Tip: run `hack global install` (or `hack global up`) and ensure Loki is reachable.\n" + ); + return 1; + } + + const services = resolveLokiServices({ + servicesOpt: parseCsvList(opts.servicesOpt), + positionalService: opts.positionalService, + }); + + const streamContext = buildLogStreamContext({ + json: opts.json, + backend: "loki", + projectNameForPrefix: opts.projectNameForPrefix, + branch: opts.branch, + services, + follow: opts.follow, + since: opts.since, + until: opts.until, + }); + + const query = resolveLokiQuery({ + queryOpt: opts.queryOpt, + projectNameForPrefix: opts.projectNameForPrefix, + services, + }); + + return await lokiLogBackend.run({ + baseUrl: opts.baseUrl, + query, + follow: opts.follow, + tail: opts.tail, + format: opts.format, + showProjectPrefix: true, + streamContext, + start: opts.timeRange.start ?? undefined, + end: opts.timeRange.end ?? undefined, + }); +} + +async function runLogsWithCompose(opts: { + readonly project: Awaited>; + readonly projectNameForPrefix: string; + readonly composeProject: string | undefined; + readonly branch: string | null; + readonly json: boolean; + readonly follow: boolean; + readonly tail: number; + readonly service: string | undefined; + readonly profiles: readonly string[]; + readonly format: ReturnType; + readonly since: string | undefined; + readonly until: string | undefined; +}): Promise { + const serviceTrimmed = (opts.service ?? "").trim(); + const servicesForContext = + serviceTrimmed.length > 0 ? [serviceTrimmed] : undefined; + const streamContext = buildLogStreamContext({ + json: opts.json, + backend: "compose", + projectNameForPrefix: opts.projectNameForPrefix, + branch: opts.branch, + services: servicesForContext, + follow: opts.follow, + since: opts.since, + until: opts.until, + }); + + return await composeLogBackend.run({ + composeFile: opts.project.composeFile, + cwd: dirname(opts.project.composeFile), + follow: opts.follow, + tail: opts.tail, + service: opts.service, + projectName: + opts.projectNameForPrefix.length > 0 + ? opts.projectNameForPrefix + : undefined, + composeProject: opts.composeProject, + profiles: opts.profiles, + format: opts.format, + streamContext, }); } @@ -3335,29 +4438,17 @@ async function handleLogs({ }); await touchBranchUsageIfNeeded({ project, branch }); - const wantsLokiExplicit = - args.options.loki || - args.options.services !== undefined || - args.options.query !== undefined || - args.options.since !== undefined || - args.options.until !== undefined; - - if (args.options.compose && wantsLokiExplicit) { - process.stderr.write( - "Cannot combine --compose with --loki/--services/--query/--since/--until.\n" - ); - return 1; - } - if (json && args.options.pretty) { - process.stderr.write("Cannot combine --json with --pretty.\n"); - return 1; - } - if (timeRange.error) { - process.stderr.write(`${timeRange.error}\n`); - return 1; - } - if (follow && timeRange.end) { - process.stderr.write("Cannot combine --until with --follow.\n"); + const wantsLokiExplicit = computeWantsLokiExplicit({ options: args.options }); + const validation = validateLogsArgs({ + forceCompose: args.options.compose === true, + wantsLokiExplicit, + json, + pretty: args.options.pretty, + follow, + timeRange, + }); + if (!validation.ok) { + process.stderr.write(`${validation.message}\n`); return 1; } const baseUrl = (process.env.HACK_LOKI_URL ?? "http://127.0.0.1:3100").trim(); @@ -3395,90 +4486,38 @@ async function handleLogs({ }); if (useLoki) { - if (!lokiReachable) { - process.stderr.write(`Loki is not reachable at ${baseUrl}.\n`); - process.stderr.write( - "Tip: run `hack global install` (or `hack global up`) and ensure Loki is reachable.\n" - ); - return 1; - } - - const projectName = projectNameForPrefix; - - const services = parseCsvList(args.options.services); - const serviceFromPositional = - typeof service === "string" ? service.trim() : ""; - const allServices = - serviceFromPositional.length > 0 && - !services.includes(serviceFromPositional) - ? [...services, serviceFromPositional] - : services; - const streamContext: LogStreamContext | undefined = json - ? { - backend: "loki", - project: - projectNameForPrefix.length > 0 ? projectNameForPrefix : undefined, - branch: branch ?? undefined, - services: allServices.length > 0 ? allServices : undefined, - follow, - since: args.options.since, - until: args.options.until, - } - : undefined; - - const query = - typeof args.options.query === "string" && - args.options.query.trim().length > 0 - ? args.options.query.trim() - : buildLogSelector({ - project: projectName.length > 0 ? projectName : null, - services: allServices, - }); - - const showProjectPrefix = true; - - return await lokiLogBackend.run({ + return await runLogsWithLoki({ baseUrl, - query, + lokiReachable, + projectNameForPrefix, + branch, + json, follow, tail, format, - showProjectPrefix, - streamContext, - start: timeRange.start ?? undefined, - end: timeRange.end ?? undefined, + timeRange, + servicesOpt: args.options.services, + positionalService: typeof service === "string" ? service : undefined, + queryOpt: args.options.query, + since: args.options.since, + until: args.options.until, }); } // Fallback to docker compose logs when Loki isn't available. - const streamContext: LogStreamContext | undefined = json - ? { - backend: "compose", - project: - projectNameForPrefix.length > 0 ? projectNameForPrefix : undefined, - branch: branch ?? undefined, - services: - typeof service === "string" && service.trim().length > 0 - ? [service.trim()] - : undefined, - follow, - since: args.options.since, - until: args.options.until, - } - : undefined; - - return await composeLogBackend.run({ - composeFile: project.composeFile, - cwd: dirname(project.composeFile), + return await runLogsWithCompose({ + project, + projectNameForPrefix, + composeProject: branch ? projectNameForPrefix : undefined, + branch, + json, follow, tail, - service, - projectName: - projectNameForPrefix.length > 0 ? projectNameForPrefix : undefined, - composeProject: branch ? projectNameForPrefix : undefined, + service: typeof service === "string" ? service : undefined, profiles, format, - streamContext, + since: args.options.since, + until: args.options.until, }); } diff --git a/src/commands/projects.ts b/src/commands/projects.ts index 7c378ee8..5affc860 100644 --- a/src/commands/projects.ts +++ b/src/commands/projects.ts @@ -10,6 +10,7 @@ import { resolveGlobalCaddyIp, } from "../lib/caddy-hosts.ts"; import { pathExists } from "../lib/fs.ts"; +import { type ProjectMeta, resolveProjectMeta } from "../lib/project-meta.ts"; import type { ProjectView } from "../lib/project-views.ts"; import { buildProjectViews, @@ -55,16 +56,30 @@ const optAll = defineOption({ description: "Include unregistered docker compose projects (best-effort)", } as const); +const optMeta = defineOption({ + name: "meta", + type: "boolean", + long: "--meta", + description: "Include git/worktree/session/env metadata (implies --details)", +} as const); + const options = [ optProject, optDetails, + optMeta, optIncludeGlobal, optAll, optJson, ] as const; const positionals = [] as const; -const statusOptions = [optProject, optIncludeGlobal, optAll, optJson] as const; +const statusOptions = [ + optProject, + optIncludeGlobal, + optAll, + optMeta, + optJson, +] as const; const statusSpec = defineCommand({ name: "status", @@ -107,7 +122,8 @@ const handleProjects: CommandHandlerFor = async ({ filter, includeGlobal: args.options.includeGlobal === true, includeUnregistered: args.options.all === true, - details: args.options.details === true, + details: args.options.details === true || args.options.meta === true, + meta: args.options.meta === true, json: args.options.json === true, }); }; @@ -222,6 +238,7 @@ const handleStatus: CommandHandlerFor = async ({ includeGlobal: args.options.includeGlobal === true, includeUnregistered: args.options.all === true, details: true, + meta: args.options.meta === true, json: args.options.json === true, }); }; @@ -233,6 +250,7 @@ async function runProjects(opts: { readonly includeGlobal: boolean; readonly includeUnregistered: boolean; readonly details: boolean; + readonly meta: boolean; readonly json: boolean; }): Promise { if (opts.json) { @@ -242,6 +260,7 @@ async function runProjects(opts: { filter: opts.filter ?? null, include_global: opts.includeGlobal, include_unregistered: opts.includeUnregistered, + include_meta: opts.meta, }, }); if (daemon?.ok && daemon.json) { @@ -266,6 +285,9 @@ async function runProjects(opts: { filter: opts.filter, includeUnregistered: opts.includeUnregistered, }); + const metaByName = opts.meta + ? await buildMetaByProjectName({ views }) + : new Map(); if (opts.json) { const runtimeMeta = formatRuntimeMeta({ runtime }); const payload = { @@ -273,13 +295,17 @@ async function runProjects(opts: { filter: opts.filter, include_global: opts.includeGlobal, include_unregistered: opts.includeUnregistered, + include_meta: opts.meta, runtime_ok: runtimeMeta.ok, runtime_error: runtimeMeta.error, runtime_checked_at: runtimeMeta.checkedAt, runtime_last_ok_at: runtimeMeta.lastOkAt, runtime_reset_at: runtimeMeta.lastResetAt, runtime_reset_count: runtimeMeta.resetCount, - projects: views.map(serializeProjectView), + projects: views.map((view) => ({ + ...serializeProjectView(view), + ...(opts.meta ? { meta: metaByName.get(view.name) ?? null } : {}), + })), }; process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); return 0; @@ -334,6 +360,7 @@ async function runProjects(opts: { project: p, caddyIp, runtimeOk: runtime.ok, + meta: opts.meta ? (metaByName.get(p.name) ?? null) : null, }); } } @@ -345,6 +372,7 @@ async function renderProjectDetails(opts: { readonly project: ProjectView; readonly caddyIp: string | null; readonly runtimeOk: boolean; + readonly meta: ProjectMeta | null; }): Promise { const p = opts.project; await display.section(p.name); @@ -400,6 +428,10 @@ async function renderProjectDetails(opts: { rows, }); + if (opts.meta && p.kind === "registered") { + await renderProjectMeta({ meta: opts.meta }); + } + if (opts.runtimeOk && p.branchRuntime.length > 0) { const branchRows = p.branchRuntime .slice() @@ -424,6 +456,184 @@ async function renderProjectDetails(opts: { } } +async function buildMetaByProjectName(opts: { + readonly views: readonly ProjectView[]; +}): Promise> { + const out = new Map(); + const tasks = opts.views + .filter((p) => p.kind === "registered" && p.repoRoot && p.projectDir) + .map(async (p) => { + if (!(p.repoRoot && p.projectDir)) { + return; + } + const meta = await resolveProjectMeta({ + projectName: p.name, + repoRoot: p.repoRoot, + projectDir: p.projectDir, + composeFile: resolve(p.projectDir, PROJECT_COMPOSE_FILENAME), + }); + out.set(p.name, meta); + }); + + await Promise.all(tasks); + return out; +} + +async function renderProjectMeta(opts: { + readonly meta: ProjectMeta; +}): Promise { + await display.section("Meta"); + + await renderGitMeta({ git: opts.meta.git }); + await renderGitWorktrees({ worktrees: opts.meta.git.worktrees }); + await renderSessionsMeta({ sessions: opts.meta.sessions.sessions }); + await renderEnvMeta({ env: opts.meta.env }); + await renderHackBranchesMeta({ branches: opts.meta.hackBranches.branches }); + await renderComposeBuildMeta({ services: opts.meta.composeBuild.services }); +} + +function formatYesNoUnknown(value: boolean | null): string { + if (value === true) { + return "yes"; + } + if (value === false) { + return "no"; + } + return ""; +} + +async function renderGitMeta(opts: { + readonly git: ProjectMeta["git"]; +}): Promise { + const git = opts.git; + const entries: Array = [ + ["Git repo", git.isRepo ? "yes" : "no"], + ]; + + if (!git.isRepo) { + if (git.error) { + entries.push(["Git error", git.error]); + } + await display.kv({ entries }); + return; + } + + if (git.branch) { + entries.push(["Branch", git.branch]); + } + if (git.head) { + entries.push(["HEAD", git.head.slice(0, 12)]); + } + if (git.dirty !== null) { + entries.push(["Dirty", formatYesNoUnknown(git.dirty)]); + } + if (git.localBranchCount !== null) { + entries.push(["Local branches", String(git.localBranchCount)]); + } + if (git.worktrees) { + entries.push(["Worktrees", String(git.worktrees.length)]); + } + if (git.error) { + entries.push(["Git error", git.error]); + } + + await display.kv({ entries }); +} + +async function renderGitWorktrees(opts: { + readonly worktrees: ProjectMeta["git"]["worktrees"]; +}): Promise { + const worktrees = opts.worktrees ?? []; + if (worktrees.length === 0) { + return; + } + + await display.section("Git worktrees"); + await display.table({ + columns: ["Path", "Branch", "Detached"], + rows: worktrees.map((w) => [ + w.path, + w.branch ?? "", + w.detached ? "yes" : "", + ]), + }); +} + +async function renderSessionsMeta(opts: { + readonly sessions: ProjectMeta["sessions"]["sessions"]; +}): Promise { + const sessions = opts.sessions ?? []; + if (sessions.length === 0) { + return; + } + + await display.section("Sessions"); + await display.table({ + columns: ["Name", "Backend", "Attached"], + rows: sessions.map((s) => [ + s.name, + s.backend, + formatYesNoUnknown(s.attached), + ]), + }); +} + +async function renderEnvMeta(opts: { + readonly env: ProjectMeta["env"]; +}): Promise { + if (opts.env.vars.length === 0) { + return; + } + + await display.section("Env"); + const missing = opts.env.missingRequired; + await display.kv({ + entries: [ + ["Contract", opts.env.contractExists ? "yes" : "no"], + ["Missing required", missing.length > 0 ? missing.join(", ") : ""], + ], + }); +} + +async function renderHackBranchesMeta(opts: { + readonly branches: ProjectMeta["hackBranches"]["branches"]; +}): Promise { + if (opts.branches.length === 0) { + return; + } + + await display.section("Hack branches"); + await display.table({ + columns: ["Slug", "Name", "Note", "Last used"], + rows: opts.branches.map((b) => [ + b.slug, + b.name, + b.note ?? "", + b.last_used_at ?? "", + ]), + }); +} + +async function renderComposeBuildMeta(opts: { + readonly services: ProjectMeta["composeBuild"]["services"]; +}): Promise { + if (opts.services.length === 0) { + return; + } + + await display.section("Service build"); + await display.table({ + columns: ["Service", "Build", "Context", "Dockerfile", "Exists"], + rows: opts.services.map((s) => [ + s.service, + s.build ? "yes" : "no", + s.context ?? "", + s.dockerfile ?? "", + formatYesNoUnknown(s.dockerfileExists), + ]), + }); +} + function formatCaddySummary(opts: { readonly caddyIp: string | null; readonly mappedIp: string | null; diff --git a/src/commands/session.ts b/src/commands/session.ts index e3da631b..5e9f9e07 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -7,9 +7,31 @@ import type { } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; import { optJson, optPretty } from "../cli/options.ts"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_ENV_FILENAME, +} from "../constants.ts"; +import { type ProjectContext, sanitizeBranchSlug } from "../lib/project.ts"; import type { RegisteredProject } from "../lib/projects-registry.ts"; import { readProjectsRegistry } from "../lib/projects-registry.ts"; import { exec, run } from "../lib/shell.ts"; +import type { MuxBackendName, MuxSession } from "../mux/mux-backend.ts"; +import { + listMuxSessions, + resolveDefaultBackendName, + resolveMux, +} from "../mux/mux-resolver.ts"; +import { + buildSessionName, + getNextNumericSessionSuffix, + parseSessionBase, +} from "../mux/session-names.ts"; +import { attachTmuxSession, createTmuxBackend } from "../mux/tmux-backend.ts"; +import { + attachZellijSession, + createZellijBackend, +} from "../mux/zellij-backend.ts"; import { logger } from "../ui/logger.ts"; import { buildSessionPanesEndEvent, @@ -22,18 +44,13 @@ import { buildSessionStreamStartEvent, diffNewLines, parseTmuxPanesOutput, + type SessionStreamContext, splitLines, writeSessionStreamEvent, } from "./session-utils.ts"; -/** - * Parsed tmux session info. - */ -interface TmuxSession { - readonly name: string; - readonly attached: boolean; - readonly path: string | null; -} +const tmuxBackend = createTmuxBackend(); +const zellijBackend = createZellijBackend(); const optUp = defineOption({ name: "up", @@ -94,7 +111,7 @@ const optMaxMs = defineOption({ // Subcommand specs const listSpec = defineCommand({ name: "list", - summary: "List active tmux sessions", + summary: "List active sessions", group: "Project", options: [], positionals: [], @@ -114,7 +131,7 @@ const startSpec = defineCommand({ const stopSpec = defineCommand({ name: "stop", - summary: "Stop (kill) a tmux session", + summary: "Stop (kill) a session", group: "Project", options: [], positionals: [ @@ -125,7 +142,7 @@ const stopSpec = defineCommand({ const attachSpec = defineCommand({ name: "attach", - summary: "Attach to an existing tmux session", + summary: "Attach to an existing session", group: "Project", options: [], positionals: [ @@ -136,7 +153,7 @@ const attachSpec = defineCommand({ const execSpec = defineCommand({ name: "exec", - summary: "Execute a command in a tmux session", + summary: "Execute a command in a session", group: "Project", options: [], positionals: [ @@ -218,186 +235,295 @@ type TailArgs = CommandArgs< * Uses clack prompts with grouped options for sessions and projects. */ async function handleSessionPicker(): Promise { - const sessions = await listTmuxSessions(); + const mux = await resolveMux({ project: null }); + const sessions = await listMuxSessions({ + mode: mux.mode, + backends: mux.backends, + }); const registry = await readProjectsRegistry(); const projects = registry.projects; p.intro("Sessions"); - const sessionNames = new Set(sessions.map((s) => s.name)); - const home = process.env.HOME ?? ""; + const options = buildSessionPickerOptions({ + sessions, + projects, + home: process.env.HOME ?? "", + }); + + if (options.length === 0) { + p.log.warn( + "No sessions or projects found. Run 'hack init' in a project first." + ); + p.outro(""); + return 1; + } + + const selection = await p.select({ + message: "Select session or project", + options, + }); + + if (p.isCancel(selection)) { + p.outro("Cancelled"); + return 0; + } - // Helper to shorten paths with ~/ + const parsed = parseSessionPickerSelection({ selection }); + if (!parsed) { + p.log.error("Invalid selection"); + return 1; + } + + if (parsed.kind === "session") { + return await handlePickedSession({ + selection: parsed, + sessions, + projects, + }); + } + + const project = projects.find( + (proj: RegisteredProject) => proj.name === parsed.name + ); + if (!project) { + p.log.error(`Project not found: ${parsed.name}`); + return 1; + } + + return await startProjectSession({ + project, + forceNew: false, + runUp: false, + customSuffix: null, + }); +} + +type SessionPickerOption = { + readonly value: string; + readonly label: string; + readonly hint?: string; +}; + +type SessionPickerSelection = + | { + readonly kind: "session"; + readonly backend: MuxBackendName; + readonly name: string; + } + | { readonly kind: "project"; readonly name: string }; + +function buildSessionPickerOptions(opts: { + readonly sessions: readonly MuxSession[]; + readonly projects: readonly RegisteredProject[]; + readonly home: string; +}): SessionPickerOption[] { + const sessionNames = new Set(opts.sessions.map((s) => s.name)); const shortenPath = (path: string): string => { - if (home && path.startsWith(home)) { - return `~${path.slice(home.length)}`; + if (opts.home && path.startsWith(opts.home)) { + return `~${path.slice(opts.home.length)}`; } return path; }; - // Build options for clack select - type SessionOption = { - value: string; - label: string; - hint?: string; - }; - - const options: SessionOption[] = []; + const options: SessionPickerOption[] = []; - // Active sessions - const attachedSessions = sessions.filter((s) => s.attached); - const detachedSessions = sessions.filter((s) => !s.attached); + const attachedSessions = opts.sessions.filter((s) => s.attached === true); + const detachedSessions = opts.sessions.filter((s) => s.attached !== true); for (const session of attachedSessions) { options.push({ - value: `session:${session.name}`, + value: `session:${session.backend}:${session.name}`, label: session.name, - hint: `attached${session.path ? ` • ${shortenPath(session.path)}` : ""}`, + hint: formatSessionHint({ + backend: session.backend, + status: "attached", + path: session.path ? shortenPath(session.path) : null, + }), }); } for (const session of detachedSessions) { + const status = session.attached === false ? "detached" : "unknown"; options.push({ - value: `session:${session.name}`, + value: `session:${session.backend}:${session.name}`, label: session.name, - hint: session.path ? shortenPath(session.path) : "detached", + hint: formatSessionHint({ + backend: session.backend, + status, + path: session.path ? shortenPath(session.path) : null, + }), }); } - // Projects without active sessions - const availableProjects = projects.filter( - (proj: RegisteredProject) => !sessionNames.has(proj.name) - ); - - for (const project of availableProjects) { + for (const project of opts.projects) { + const base = project.name; + const hasSessions = [...sessionNames].some( + (name) => name === base || name.startsWith(`${base}--`) + ); options.push({ value: `project:${project.name}`, label: project.name, - hint: `new • ${shortenPath(project.repoRoot)}`, + hint: `${hasSessions ? "sessions" : "new"} • ${shortenPath(project.repoRoot)}`, }); } - if (options.length === 0) { - p.log.warn( - "No sessions or projects found. Run 'hack init' in a project first." - ); - p.outro(""); - return 1; - } + return options; +} - const selection = await p.select({ - message: "Select session or project", - options, - }); +function formatSessionHint(opts: { + readonly backend: MuxBackendName; + readonly status: "attached" | "detached" | "unknown"; + readonly path: string | null; +}): string | undefined { + const parts = [opts.backend, opts.status, opts.path].filter( + (part): part is string => typeof part === "string" && part.length > 0 + ); + return parts.length > 0 ? parts.join(" • ") : undefined; +} - if (p.isCancel(selection)) { - p.outro("Cancelled"); - return 0; +function parseSessionPickerSelection(opts: { + readonly selection: string; +}): SessionPickerSelection | null { + const [kind, a, ...rest] = opts.selection.split(":"); + + if (kind === "project") { + const name = [a, ...rest].join(":"); + return name.length > 0 ? { kind: "project", name } : null; } - // Parse selection - const [type, ...rest] = selection.split(":"); - const name = rest.join(":"); // Handle names with colons like "project:2" + if (kind === "session") { + const backend = + a === "tmux" || a === "zellij" ? (a as MuxBackendName) : null; + const name = rest.join(":"); + if (!backend || name.length === 0) { + return null; + } + return { kind: "session", backend, name }; + } - if (!name) { - p.log.error("Invalid selection"); + return null; +} + +async function handlePickedSession(opts: { + readonly selection: Extract; + readonly sessions: readonly MuxSession[]; + readonly projects: readonly RegisteredProject[]; +}): Promise { + const session = + opts.sessions.find( + (s) => + s.name === opts.selection.name && s.backend === opts.selection.backend + ) ?? null; + if (!session) { + p.log.error(`Session not found: ${opts.selection.name}`); return 1; } - if (type === "session") { - const session = sessions.find((s) => s.name === name); + const handled = await maybeHandleAttachedTmuxSession({ + session, + sessions: opts.sessions, + projects: opts.projects, + }); + if (handled !== null) { + return handled; + } - // If session is attached elsewhere, offer choice - if (session?.attached) { - const nextNum = getNextSessionNumber(sessions, name); + return await attachToSession({ + backend: opts.selection.backend, + name: opts.selection.name, + }); +} - const action = await p.select({ - message: `Session '${name}' is attached elsewhere`, - options: [ - { value: "attach", label: "Attach", hint: "detaches other clients" }, - { value: "new", label: "Create new", hint: `${name}:${nextNum}` }, - ], - }); +async function maybeHandleAttachedTmuxSession(opts: { + readonly session: MuxSession; + readonly sessions: readonly MuxSession[]; + readonly projects: readonly RegisteredProject[]; +}): Promise { + if (!(opts.session.backend === "tmux" && opts.session.attached === true)) { + return null; + } - if (p.isCancel(action)) { - p.outro("Cancelled"); - return 0; - } - - if (action === "new") { - const project = projects.find( - (proj: RegisteredProject) => proj.name === name - ); - const cwd = project?.repoRoot ?? session.path ?? process.cwd(); - return await createAndAttachSession({ - name: `${name}:${nextNum}`, - cwd, - }); - } - } + const base = parseSessionBase({ name: opts.session.name }); + const nextNum = getNextNumericSessionSuffix({ + sessions: opts.sessions, + base, + }); + const newName = buildSessionName({ base, suffix: String(nextNum) }); - return await attachToSession(name); + const action = await p.select({ + message: `Session '${opts.session.name}' is attached elsewhere`, + options: [ + { value: "attach", label: "Attach", hint: "detaches other clients" }, + { value: "new", label: "Create new", hint: newName }, + ], + }); + if (p.isCancel(action)) { + p.outro("Cancelled"); + return 0; + } + if (action !== "new") { + return null; } - // Create new session for project - const project = projects.find( - (proj: RegisteredProject) => proj.name === name + const project = opts.projects.find( + (proj: RegisteredProject) => proj.name === base ); - if (!project) { - p.log.error(`Project not found: ${name}`); - return 1; - } + const cwd = project?.repoRoot ?? opts.session.path ?? process.cwd(); return await createAndAttachSession({ - name: project.name, - cwd: project.repoRoot, + backend: opts.session.backend, + name: newName, + cwd, }); } -/** - * Get the next available session number for a base name. - */ -function getNextSessionNumber( - sessions: TmuxSession[], - baseName: string -): number { - const existing = sessions.filter( - (s) => s.name === baseName || s.name.startsWith(`${baseName}:`) - ); - let n = 2; - while (existing.some((s) => s.name === `${baseName}:${n}`)) { - n++; - } - return n; +function buildProjectContext(project: RegisteredProject): ProjectContext { + return { + projectRoot: project.repoRoot, + projectDirName: project.projectDirName, + projectDir: project.projectDir, + composeFile: resolve(project.projectDir, PROJECT_COMPOSE_FILENAME), + envFile: resolve(project.projectDir, PROJECT_ENV_FILENAME), + configFile: resolve(project.projectDir, PROJECT_CONFIG_FILENAME), + }; } const handleList: CommandHandlerFor< typeof listSpec > = async (): Promise => { - const sessions = await listTmuxSessions(); + const mux = await resolveMux({ project: null }); + const sessions = await listMuxSessions({ + mode: mux.mode, + backends: mux.backends, + }); const registry = await readProjectsRegistry(); const projects = registry.projects; if (sessions.length === 0) { - logger.info({ message: "No active tmux sessions" }); + logger.info({ message: "No active sessions" }); return 0; } console.log( - `${"Session".padEnd(20) + "Project".padEnd(20) + "Node".padEnd(10)}Status` + `${"Session".padEnd(26) + "Backend".padEnd(10) + "Project".padEnd(20)}Status` ); console.log("-".repeat(60)); for (const session of sessions) { - const project = projects.find( - (p: RegisteredProject) => p.name === session.name - ); + const base = parseSessionBase({ name: session.name }); + const project = projects.find((p: RegisteredProject) => p.name === base); const projectName = project?.name ?? "-"; - const status = session.attached ? "attached" : "detached"; + let status = "unknown"; + if (session.attached === true) { + status = "attached"; + } else if (session.attached === false) { + status = "detached"; + } console.log( - session.name.padEnd(20) + + session.name.padEnd(26) + + session.backend.padEnd(10) + projectName.padEnd(20) + - "local".padEnd(10) + status ); } @@ -446,48 +572,11 @@ const handleStart = async ({ return 1; } - const baseName = project.name; - let sessionName = baseName; - - if (forceNew || customName) { - if (customName) { - sessionName = `${baseName}:${customName}`; - } else { - // Find next available number - const sessions = await listTmuxSessions(); - const existing = sessions.filter( - (s) => s.name === baseName || s.name.startsWith(`${baseName}:`) - ); - if (existing.length > 0) { - let n = 2; - while (existing.some((s) => s.name === `${baseName}:${n}`)) { - n++; - } - sessionName = `${baseName}:${n}`; - } - } - } else { - // Check if session exists - const sessions = await listTmuxSessions(); - const existing = sessions.find((s) => s.name === baseName); - if (existing) { - logger.info({ message: `Attaching to existing session: ${baseName}` }); - if (runUp) { - await runHackUp(project.projectDir); - } - return await attachToSession(baseName); - } - } - - // Run hack up if requested - if (runUp) { - await runHackUp(project.repoRoot); - } - - // Use repoRoot (project root), not projectDir (.hack/) - return await createAndAttachSession({ - name: sessionName, - cwd: project.repoRoot, + return await startProjectSession({ + project, + forceNew, + runUp, + customSuffix: typeof customName === "string" ? customName : null, }); }; @@ -499,9 +588,14 @@ const handleStop = async ({ }): Promise => { const sessionName = args.positionals.session; - const result = await exec(["tmux", "kill-session", "-t", sessionName], { - stdin: "ignore", - }); + const session = await findSession({ name: sessionName }); + if (!session) { + logger.error({ message: `Session not found: ${sessionName}` }); + return 1; + } + + const backend = session.backend === "tmux" ? tmuxBackend : zellijBackend; + const result = await backend.killSession({ name: sessionName }); if (result.exitCode !== 0) { logger.error({ message: `Failed to stop session: ${sessionName}` }); return 1; @@ -518,7 +612,12 @@ const handleAttach = async ({ readonly args: AttachArgs; }): Promise => { const sessionName = args.positionals.session; - return await attachToSession(sessionName); + const session = await findSession({ name: sessionName }); + if (!session) { + logger.error({ message: `Session not found: ${sessionName}` }); + return 1; + } + return await attachToSession({ backend: session.backend, name: sessionName }); }; const handleExec = async ({ @@ -530,21 +629,20 @@ const handleExec = async ({ const sessionName = args.positionals.session; const command = args.positionals.command; - const result = await exec( - ["tmux", "send-keys", "-t", sessionName, command, "Enter"], - { - stdin: "ignore", - } - ); + const session = await findSession({ name: sessionName }); + if (!session) { + logger.error({ message: `Session not found: ${sessionName}` }); + return 1; + } + const backend = session.backend === "tmux" ? tmuxBackend : zellijBackend; + const result = await backend.execInSession({ name: sessionName, command }); if (result.exitCode !== 0) { - logger.error({ - message: `Failed to send command to session: ${sessionName}`, - }); + logger.error({ message: `Failed to execute in session: ${sessionName}` }); return 1; } - logger.success({ message: `Sent command to ${sessionName}: ${command}` }); + logger.success({ message: `Executed in ${sessionName}: ${command}` }); return 0; }; @@ -555,6 +653,17 @@ const handlePanes = async ({ readonly args: PanesArgs; }): Promise => { const sessionName = args.positionals.session; + const session = await findSession({ name: sessionName }); + if (!session) { + process.stderr.write(`Session not found: ${sessionName}\n`); + return 1; + } + if (session.backend !== "tmux") { + process.stderr.write( + `Session panes are only supported for tmux sessions (got ${session.backend}).\n` + ); + return 1; + } const pretty = args.options.pretty === true; const json = args.options.json === true || !pretty; @@ -614,6 +723,17 @@ const handleCapture = async ({ readonly args: CaptureArgs; }): Promise => { const sessionName = args.positionals.session; + const session = await findSession({ name: sessionName }); + if (!session) { + process.stderr.write(`Session not found: ${sessionName}\n`); + return 1; + } + if (session.backend !== "tmux") { + process.stderr.write( + `Session capture is only supported for tmux sessions (got ${session.backend}).\n` + ); + return 1; + } const target = args.options.target ?? (await resolveActiveTarget(sessionName)); const lines = args.options.lines ?? 200; @@ -677,101 +797,207 @@ const handleTail = async ({ readonly args: TailArgs; }): Promise => { const sessionName = args.positionals.session; + const resolved = await resolveTailSession({ sessionName }); + if (!resolved.ok) { + process.stderr.write(`${resolved.error}\n`); + return 1; + } + + const outputMode = resolveTailOutputMode({ + json: args.options.json === true, + pretty: args.options.pretty === true, + }); + if (!outputMode.ok) { + process.stderr.write(`${outputMode.error}\n`); + return 1; + } + const target = args.options.target ?? (await resolveActiveTarget(sessionName)); const lines = args.options.lines ?? 200; const intervalMs = args.options.intervalMs ?? 500; const maxMs = args.options.maxMs ?? 5000; - const pretty = args.options.pretty === true; - const json = args.options.json === true || !pretty; - if (json && pretty) { - process.stderr.write("Cannot combine --json with --pretty.\n"); - return 1; - } - - const context = { - session: sessionName, + return await runTailStream({ + sessionName, target, lines, - follow: true, intervalMs, maxMs, + json: outputMode.json, + }); +}; + +async function resolveTailSession(opts: { + readonly sessionName: string; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + const session = await findSession({ name: opts.sessionName }); + if (!session) { + return { ok: false, error: `Session not found: ${opts.sessionName}` }; + } + + if (session.backend !== "tmux") { + return { + ok: false, + error: `Session tail is only supported for tmux sessions (got ${session.backend}).`, + }; + } + + return { ok: true }; +} + +function resolveTailOutputMode(opts: { + readonly json: boolean; + readonly pretty: boolean; +}): + | { readonly ok: true; readonly json: boolean } + | { readonly ok: false; readonly error: string } { + const json = opts.json || !opts.pretty; + if (json && opts.pretty) { + return { ok: false, error: "Cannot combine --json with --pretty." }; + } + + return { ok: true, json }; +} + +async function runTailStream(opts: { + readonly sessionName: string; + readonly target: string; + readonly lines: number; + readonly intervalMs: number; + readonly maxMs: number; + readonly json: boolean; +}): Promise { + const context = { + session: opts.sessionName, + target: opts.target, + lines: opts.lines, + follow: true, + intervalMs: opts.intervalMs, + maxMs: opts.maxMs, }; - if (json) { + if (opts.json) { writeSessionStreamEvent({ event: buildSessionStreamStartEvent({ context }), }); } - const initial = await capturePane({ target, lines }); - if (initial.exitCode !== 0) { - const message = initial.stderr || `Failed to capture ${sessionName}`; - if (json) { - writeSessionStreamEvent({ - event: buildSessionStreamErrorEvent({ context, message }), - }); - writeSessionStreamEvent({ - event: buildSessionStreamEndEvent({ context, reason: "error" }), - }); - } else { - console.error(message); - } - return 1; + const initial = await capturePaneOrError({ + sessionName: opts.sessionName, + target: opts.target, + lines: opts.lines, + }); + if (!initial.ok) { + return renderTailCaptureError({ + context, + json: opts.json, + message: initial.error, + }); } let lastOutput = initial.stdout; const start = Date.now(); - while (Date.now() - start < maxMs) { - await delay(intervalMs); - - const result = await capturePane({ target, lines }); - if (result.exitCode !== 0) { - const message = result.stderr || `Failed to capture ${sessionName}`; - if (json) { - writeSessionStreamEvent({ - event: buildSessionStreamErrorEvent({ context, message }), - }); - writeSessionStreamEvent({ - event: buildSessionStreamEndEvent({ context, reason: "error" }), - }); - } else { - console.error(message); - } - return 1; + while (Date.now() - start < opts.maxMs) { + await delay(opts.intervalMs); + + const result = await capturePaneOrError({ + sessionName: opts.sessionName, + target: opts.target, + lines: opts.lines, + }); + if (!result.ok) { + return renderTailCaptureError({ + context, + json: opts.json, + message: result.error, + }); } - const nextOutput = result.stdout; - const suffix = diffNewLines({ previous: lastOutput, next: nextOutput }); + const suffix = diffNewLines({ previous: lastOutput, next: result.stdout }); if (suffix) { - if (json) { - for (const line of splitLines(suffix)) { - writeSessionStreamEvent({ - event: buildSessionStreamLogEvent({ context, line }), - }); - } - } else { - process.stdout.write(suffix); - } + writeTailOutput({ + json: opts.json, + context, + output: suffix, + }); } - lastOutput = nextOutput; + lastOutput = result.stdout; } - if (json) { + if (opts.json) { writeSessionStreamEvent({ event: buildSessionStreamEndEvent({ context, reason: "timeout" }), }); } return 0; -}; +} + +async function capturePaneOrError(opts: { + readonly sessionName: string; + readonly target: string; + readonly lines: number; +}): Promise< + | { readonly ok: true; readonly stdout: string } + | { readonly ok: false; readonly error: string } +> { + const result = await capturePane({ target: opts.target, lines: opts.lines }); + if (result.exitCode !== 0) { + const message = result.stderr || `Failed to capture ${opts.sessionName}`; + return { ok: false, error: message }; + } + return { ok: true, stdout: result.stdout }; +} + +function renderTailCaptureError(opts: { + readonly context: SessionStreamContext; + readonly json: boolean; + readonly message: string; +}): number { + if (opts.json) { + writeSessionStreamEvent({ + event: buildSessionStreamErrorEvent({ + context: opts.context, + message: opts.message, + }), + }); + writeSessionStreamEvent({ + event: buildSessionStreamEndEvent({ + context: opts.context, + reason: "error", + }), + }); + } else { + console.error(opts.message); + } + return 1; +} + +function writeTailOutput(opts: { + readonly json: boolean; + readonly context: SessionStreamContext; + readonly output: string; +}): void { + if (opts.json) { + for (const line of splitLines(opts.output)) { + writeSessionStreamEvent({ + event: buildSessionStreamLogEvent({ context: opts.context, line }), + }); + } + return; + } + + process.stdout.write(opts.output); +} export const sessionCommand = defineCommand({ name: "session", - summary: "Manage tmux sessions for hack projects", + summary: "Manage terminal sessions for hack projects", group: "Project", options: [], positionals: [], @@ -855,87 +1081,239 @@ function delay(ms: number): Promise { }); } -/** - * List all tmux sessions. - */ -async function listTmuxSessions(): Promise { - const result = await exec( - [ - "tmux", - "list-sessions", - "-F", - "#{session_name}:#{session_attached}:#{session_path}", - ], - { stdin: "ignore" } - ); +function resolveBackend(backend: MuxBackendName) { + return backend === "tmux" ? tmuxBackend : zellijBackend; +} - if (result.exitCode !== 0) { - return []; +async function listAllSessions(): Promise { + const out: MuxSession[] = []; + if (tmuxBackend.available) { + out.push(...(await tmuxBackend.listSessions())); } - - const sessions: TmuxSession[] = []; - for (const line of result.stdout.trim().split("\n")) { - if (!line) { - continue; - } - const [name, attached, path] = line.split(":"); - if (name) { - sessions.push({ - name, - attached: attached === "1", - path: path || null, - }); - } + if (zellijBackend.available) { + out.push(...(await zellijBackend.listSessions())); } - - return sessions; + return out; } -/** - * Attach to or switch to an existing tmux session. - * Uses switch-client when already inside tmux to avoid nesting. - * Uses -d to detach other clients (avoids size conflicts from different terminals). - */ -async function attachToSession(name: string): Promise { - const insideTmux = Boolean(process.env.TMUX); +async function findSession(opts: { + readonly name: string; +}): Promise { + const sessions = await listAllSessions(); + return sessions.find((s) => s.name === opts.name) ?? null; +} - if (insideTmux) { - // Already in tmux - switch to the session instead of nesting - const exitCode = await run(["tmux", "switch-client", "-t", name], { - stdin: "inherit", - }); - return exitCode; +async function attachToSession(opts: { + readonly backend: MuxBackendName; + readonly name: string; +}): Promise { + if (opts.backend === "tmux") { + return await attachTmuxSession({ name: opts.name, run }); } - - // Outside tmux - attach with -d to detach other clients - const exitCode = await run(["tmux", "attach", "-d", "-t", name], { - stdin: "inherit", + return await attachZellijSession({ + name: opts.name, + createIfMissing: false, + run, }); - return exitCode; } -/** - * Create a new tmux session and attach/switch to it. - */ async function createAndAttachSession(opts: { + readonly backend: MuxBackendName; readonly name: string; readonly cwd: string; }): Promise { - // Create detached session first - const createResult = await exec( - ["tmux", "new-session", "-d", "-s", opts.name, "-c", opts.cwd], - { stdin: "ignore" } - ); + const backend = resolveBackend(opts.backend); + if (!backend.available) { + logger.error({ message: `${opts.backend} is not available` }); + return 1; + } - if (createResult.exitCode !== 0) { - logger.error({ message: `Failed to create session: ${opts.name}` }); + const create = await backend.createSession({ + name: opts.name, + cwd: opts.cwd, + }); + if (!create.ok) { + logger.error({ + message: `Failed to create session: ${opts.name}`, + fields: { error: create.error }, + }); + if (create.stderr) { + logger.error({ message: create.stderr }); + } return 1; } logger.info({ message: `Created session: ${opts.name}` }); + return await attachToSession({ backend: opts.backend, name: opts.name }); +} + +async function startProjectSession(opts: { + readonly project: RegisteredProject; + readonly forceNew: boolean; + readonly runUp: boolean; + readonly customSuffix: string | null; +}): Promise { + const ctx = buildProjectContext(opts.project); + const mux = await resolveMux({ project: ctx }); + + if (mux.mode === "none") { + logger.error({ + message: + "Sessions are disabled (sessions.mux=none). Set sessions.mux to auto|tmux|zellij to enable.", + }); + return 1; + } + + const sessions = await listMuxSessions({ + mode: mux.mode, + backends: mux.backends, + }); + const baseName = opts.project.name; + + const baseSession = sessions.find((s) => s.name === baseName) ?? null; + + const desiredName = resolveDesiredSessionName({ + baseName, + sessions, + baseSession, + forceNew: opts.forceNew, + customSuffix: opts.customSuffix, + }); + if (!desiredName.ok) { + logger.error({ message: desiredName.error }); + return 1; + } + + const defaultBackend = resolveDefaultBackendName({ + mode: mux.mode, + backends: mux.backends, + }); + const backend = resolveProjectSessionBackend({ + mode: mux.mode, + backends: mux.backends, + baseSession, + defaultBackend, + }); + if (!backend.ok) { + logger.error({ message: backend.error }); + return 1; + } + + if (opts.runUp) { + await runHackUp(opts.project.repoRoot); + } - // Switch or attach depending on context (attachToSession handles this) - return await attachToSession(opts.name); + const existing = + sessions.find( + (s) => s.backend === backend.value && s.name === desiredName.value + ) ?? null; + if ( + existing && + shouldAttachToExistingProjectSession({ + baseName, + desiredName: desiredName.value, + forceNew: opts.forceNew, + customSuffix: opts.customSuffix, + }) + ) { + logger.info({ + message: `Attaching to existing session: ${desiredName.value}`, + }); + return await attachToSession({ + backend: backend.value, + name: desiredName.value, + }); + } + + return await createAndAttachSession({ + backend: backend.value, + name: desiredName.value, + cwd: opts.project.repoRoot, + }); +} + +type ParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +function resolveDesiredSessionName(opts: { + readonly baseName: string; + readonly sessions: readonly MuxSession[]; + readonly baseSession: MuxSession | null; + readonly forceNew: boolean; + readonly customSuffix: string | null; +}): ParseResult { + if (opts.customSuffix) { + const suffix = sanitizeBranchSlug(opts.customSuffix); + if (suffix.length === 0) { + return { ok: false, error: "Invalid --name (empty after sanitization)." }; + } + return { + ok: true, + value: buildSessionName({ base: opts.baseName, suffix }), + }; + } + + if (!opts.forceNew) { + return { ok: true, value: opts.baseName }; + } + + if (!opts.baseSession) { + return { ok: true, value: opts.baseName }; + } + + const n = getNextNumericSessionSuffix({ + sessions: opts.sessions, + base: opts.baseName, + }); + return { + ok: true, + value: buildSessionName({ base: opts.baseName, suffix: String(n) }), + }; +} + +function resolveProjectSessionBackend(opts: { + readonly mode: Awaited>["mode"]; + readonly backends: Awaited>["backends"]; + readonly baseSession: MuxSession | null; + readonly defaultBackend: MuxBackendName | null; +}): ParseResult { + const backend: MuxBackendName | null = + opts.baseSession?.backend ?? opts.defaultBackend; + if (backend) { + return { ok: true, value: backend }; + } + + const available = [ + opts.backends.get("tmux")?.available ? "tmux" : null, + opts.backends.get("zellij")?.available ? "zellij" : null, + ] + .filter((v): v is string => typeof v === "string") + .join(", "); + + if (available.length > 0) { + return { + ok: false, + error: `No session backend available for sessions.mux=${opts.mode}. Available: ${available}`, + }; + } + + return { + ok: false, + error: + "No session backend available (install tmux or zellij, or set sessions.mux=none).", + }; +} + +function shouldAttachToExistingProjectSession(opts: { + readonly baseName: string; + readonly desiredName: string; + readonly forceNew: boolean; + readonly customSuffix: string | null; +}): boolean { + return ( + !(opts.forceNew || opts.customSuffix) && opts.desiredName === opts.baseName + ); } /** diff --git a/src/commands/setup.ts b/src/commands/setup.ts index b76eb080..940fda22 100644 --- a/src/commands/setup.ts +++ b/src/commands/setup.ts @@ -227,6 +227,10 @@ export const setupCommand = defineCommand({ ], } as const); +const HACK_SESSION_BINDING_COMMENT = "# hack session picker"; +const HACK_SESSION_BINDING_COMMAND = + 'display-popup -E -w 40% -h 60% "hack session"'; + async function handleSetupTmux({ args, }: { @@ -238,7 +242,6 @@ async function handleSetupTmux({ remove: args.options.remove === true, }); - // Check if tmux is installed const tmuxPath = await findExecutableInPath("tmux"); if (!tmuxPath) { logger.error({ @@ -248,127 +251,107 @@ async function handleSetupTmux({ return 1; } - // Detect tmux config locations + if (action === "check") { + return await checkTmuxIntegration(); + } + + if (action === "remove") { + return await removeTmuxIntegration(); + } + + return await installTmuxIntegration(); +} + +async function resolveTmuxConfigPaths(): Promise<{ + readonly home: string; + readonly xdgConfig: string; + readonly homeConfig: string; + readonly existingConfigs: readonly string[]; +}> { const home = homedir(); const xdgConfig = resolve(home, ".config/tmux/tmux.conf"); const homeConfig = resolve(home, ".tmux.conf"); - const configCandidates = [xdgConfig, homeConfig]; + const candidates = [xdgConfig, homeConfig]; const existingConfigs: string[] = []; - for (const candidate of configCandidates) { + for (const candidate of candidates) { if (await pathExists(candidate)) { existingConfigs.push(candidate); } } - const HACK_SESSION_BINDING = `# hack session picker -bind-key s display-popup -E -w 40% -h 60% "hack session"`; + return { home, xdgConfig, homeConfig, existingConfigs }; +} - if (action === "check") { - if (existingConfigs.length === 0) { - logger.warn({ message: "No tmux.conf found" }); - return 1; - } - for (const configPath of existingConfigs) { - const content = await readTextFile(configPath); - if (content?.includes("hack session")) { - logger.success({ - message: `tmux integration installed at ${configPath}`, - }); - return 0; - } - } - logger.warn({ message: "hack session keybinding not found in tmux.conf" }); +function buildHackSessionBinding(key: "s" | "S"): string { + return [ + HACK_SESSION_BINDING_COMMENT, + `bind-key ${key} ${HACK_SESSION_BINDING_COMMAND}`, + ].join("\n"); +} + +async function checkTmuxIntegration(): Promise { + const paths = await resolveTmuxConfigPaths(); + if (paths.existingConfigs.length === 0) { + logger.warn({ message: "No tmux.conf found" }); return 1; } - if (action === "remove") { - let removed = false; - for (const configPath of existingConfigs) { - const content = await readTextFile(configPath); - if (content?.includes("hack session")) { - const newContent = content - .replace( - /\n?# hack session picker\nbind-key [sS] display-popup[^\n]*\n?/g, - "\n" - ) - .replace(LEADING_NEWLINES_PATTERN, ""); - await writeTextFile(configPath, newContent); - logger.success({ - message: `Removed hack session keybinding from ${configPath}`, - }); - removed = true; - } - } - if (!removed) { - logger.info({ message: "No hack session keybinding found to remove" }); + for (const configPath of paths.existingConfigs) { + const content = await readTextFile(configPath); + if (content?.includes("hack session")) { + logger.success({ + message: `tmux integration installed at ${configPath}`, + }); + return 0; } - return 0; } - // Interactive install - logger.info({ message: "Setting up tmux integration for hack sessions..." }); + logger.warn({ message: "hack session keybinding not found in tmux.conf" }); + return 1; +} - // Select config file - let selectedConfig: string; - if (existingConfigs.length === 1 && existingConfigs[0]) { - selectedConfig = existingConfigs[0]; - logger.info({ message: `Using ${selectedConfig}` }); - } else if (existingConfigs.length > 1) { - const choice = await select({ - message: "Where is your tmux.conf?", - options: [ - ...existingConfigs.map((p) => ({ value: p, label: p })), - { value: "custom", label: "Custom path..." }, - ], - }); - if (isCancel(choice)) { - return 1; - } - if (choice === "custom") { - const customPath = await text({ - message: "Enter path to tmux.conf:", - placeholder: "~/.config/tmux/tmux.conf", - }); - if (isCancel(customPath) || !customPath) { - return 1; - } - selectedConfig = customPath.startsWith("~") - ? resolve(home, customPath.slice(2)) - : customPath; - } else { - selectedConfig = choice as string; +async function removeTmuxIntegration(): Promise { + const paths = await resolveTmuxConfigPaths(); + let removed = false; + + for (const configPath of paths.existingConfigs) { + const content = await readTextFile(configPath); + if (!content?.includes("hack session")) { + continue; } - } else { - // No existing config, ask where to create - const choice = await select({ - message: "No tmux.conf found. Where should we create one?", - options: [ - { - value: xdgConfig, - label: `${xdgConfig} (recommended)`, - }, - { value: homeConfig, label: homeConfig }, - { value: "custom", label: "Custom path..." }, - ], + + const newContent = content + .replace( + /\n?# hack session picker\nbind-key [sS] display-popup[^\n]*\n?/g, + "\n" + ) + .replace(LEADING_NEWLINES_PATTERN, ""); + await writeTextFile(configPath, newContent); + logger.success({ + message: `Removed hack session keybinding from ${configPath}`, }); - if (isCancel(choice)) { - return 1; - } - if (choice === "custom") { - const customPath = await text({ - message: "Enter path to tmux.conf:", - placeholder: "~/.config/tmux/tmux.conf", - }); - if (isCancel(customPath) || !customPath) { - return 1; - } - selectedConfig = customPath.startsWith("~") - ? resolve(home, customPath.slice(2)) - : customPath; - } else { - selectedConfig = choice as string; - } + removed = true; + } + + if (!removed) { + logger.info({ message: "No hack session keybinding found to remove" }); + } + return 0; +} + +async function installTmuxIntegration(): Promise { + logger.info({ message: "Setting up tmux integration for hack sessions..." }); + + const paths = await resolveTmuxConfigPaths(); + const selectedConfig = await resolveTmuxConfigToEdit({ + home: paths.home, + existingConfigs: paths.existingConfigs, + xdgConfig: paths.xdgConfig, + homeConfig: paths.homeConfig, + }); + if (!selectedConfig) { + return 1; } // Check if already installed @@ -396,13 +379,12 @@ bind-key s display-popup -E -w 40% -h 60% "hack session"`; if (keyChoice === "none") { logger.info({ message: "Skipping keybinding configuration" }); logger.info({ - message: `Add this to your tmux.conf manually:\n\n${HACK_SESSION_BINDING}`, + message: `Add this to your tmux.conf manually:\n\n${buildHackSessionBinding("s")}`, }); return 0; } - const binding = `# hack session picker -bind-key ${keyChoice} display-popup -E -w 40% -h 60% "hack session"`; + const binding = buildHackSessionBinding(keyChoice); // Append to config const newContent = @@ -418,6 +400,57 @@ bind-key ${keyChoice} display-popup -E -w 40% -h 60% "hack session"`; return 0; } +async function resolveTmuxConfigToEdit(opts: { + readonly home: string; + readonly existingConfigs: readonly string[]; + readonly xdgConfig: string; + readonly homeConfig: string; +}): Promise { + if (opts.existingConfigs.length === 1 && opts.existingConfigs[0]) { + const selected = opts.existingConfigs[0]; + logger.info({ message: `Using ${selected}` }); + return selected; + } + + const options = + opts.existingConfigs.length > 0 + ? [ + ...opts.existingConfigs.map((p) => ({ value: p, label: p })), + { value: "custom", label: "Custom path..." }, + ] + : [ + { value: opts.xdgConfig, label: `${opts.xdgConfig} (recommended)` }, + { value: opts.homeConfig, label: opts.homeConfig }, + { value: "custom", label: "Custom path..." }, + ]; + + const message = + opts.existingConfigs.length > 0 + ? "Where is your tmux.conf?" + : "No tmux.conf found. Where should we create one?"; + + const choice = await select({ message, options }); + if (isCancel(choice)) { + return null; + } + + if (choice !== "custom") { + return choice as string; + } + + const customPath = await text({ + message: "Enter path to tmux.conf:", + placeholder: "~/.config/tmux/tmux.conf", + }); + if (isCancel(customPath) || !customPath) { + return null; + } + + return customPath.startsWith("~") + ? resolve(opts.home, customPath.slice(2)) + : customPath; +} + async function handleSetupCursor({ ctx, args, diff --git a/src/commands/ssh.ts b/src/commands/ssh.ts index 1b0df76a..2353a954 100644 --- a/src/commands/ssh.ts +++ b/src/commands/ssh.ts @@ -94,91 +94,19 @@ async function handleSsh(opts: { p.intro("Remote Access"); - // Step 1: Determine connection method - let method: ConnectionMethod; - let hostname: string; - - if (args.options.direct || hostOverride) { - method = "direct"; - hostname = hostOverride ?? ""; - - if (!hostname) { - const hostInput = await p.text({ - message: "SSH host (hostname or IP)", - placeholder: "example.com or 192.168.1.100", - validate: (value) => { - if (!value?.trim()) { - return "Host is required"; - } - return undefined; - }, - }); - - if (p.isCancel(hostInput)) { - p.outro("Cancelled"); - return 0; - } - - hostname = hostInput; - } - } else if (args.options.tailscale) { - method = "tailscale"; - const result = await setupTailscale(); - if (!result.ok) { - return 1; - } - hostname = hostOverride ?? result.hostname; - } else { - // Interactive: ask which method - const selected = await p.select({ - message: "Connection method", - options: [ - { - value: "tailscale" as const, - label: "Tailscale", - hint: "secure, no port forwarding", - }, - { - value: "direct" as const, - label: "Direct SSH", - hint: "traditional SSH", - }, - ], - }); - - if (p.isCancel(selected)) { + const connection = await resolveConnection({ + direct: args.options.direct === true, + tailscale: args.options.tailscale === true, + hostOverride, + }); + if (!connection.ok) { + if (connection.reason === "cancelled") { p.outro("Cancelled"); return 0; } - - method = selected; - - if (method === "tailscale") { - const result = await setupTailscale(); - if (!result.ok) { - return 1; - } - hostname = result.hostname; - } else { - const hostInput = await p.text({ - message: "SSH host (hostname or IP)", - placeholder: "example.com or 192.168.1.100", - validate: (value) => { - if (!value?.trim()) { - return "Host is required"; - } - return undefined; - }, - }); - - if (p.isCancel(hostInput)) { - p.outro("Cancelled"); - return 0; - } - - hostname = hostInput; - } + return 1; } + const { method, hostname } = connection; // Step 2: Build and show SSH command const sshCommand = @@ -219,6 +147,12 @@ async function handleSsh(opts: { const sessionArg = args.positionals.session; if (sessionArg) { + if (!SESSION_NAME_PATTERN.test(sessionArg)) { + p.log.error( + "Invalid session name (only letters, numbers, dashes, underscores, or dots)" + ); + return 1; + } // Direct connect to specified session return await connectToSession({ hostname, @@ -228,6 +162,120 @@ async function handleSsh(opts: { }); } + const sessionName = await resolveSessionNameToConnect({ sessions }); + if (!sessionName) { + p.outro("Copy the SSH command above to connect from other devices"); + return 0; + } + + return await connectToSession({ hostname, user, port, sessionName }); +} + +async function resolveConnection(opts: { + readonly direct: boolean; + readonly tailscale: boolean; + readonly hostOverride: string | undefined; +}): Promise< + | { + readonly ok: true; + readonly method: ConnectionMethod; + readonly hostname: string; + } + | { readonly ok: false; readonly reason: "cancelled" | "error" } +> { + if (opts.direct || opts.hostOverride) { + const hostname = await resolveDirectHost({ + hostOverride: opts.hostOverride, + }); + if (!hostname) { + return { ok: false, reason: "cancelled" }; + } + return { ok: true, method: "direct", hostname }; + } + + if (opts.tailscale) { + const result = await setupTailscale(); + if (!result.ok) { + return { ok: false, reason: "error" }; + } + return { + ok: true, + method: "tailscale", + hostname: opts.hostOverride ?? result.hostname, + }; + } + + const method = await selectConnectionMethod(); + if (!method) { + return { ok: false, reason: "cancelled" }; + } + + if (method === "tailscale") { + const result = await setupTailscale(); + if (!result.ok) { + return { ok: false, reason: "error" }; + } + return { ok: true, method, hostname: result.hostname }; + } + + const hostname = await resolveDirectHost({ hostOverride: opts.hostOverride }); + if (!hostname) { + return { ok: false, reason: "cancelled" }; + } + return { ok: true, method, hostname }; +} + +async function selectConnectionMethod(): Promise { + const selected = await p.select({ + message: "Connection method", + options: [ + { + value: "tailscale" as const, + label: "Tailscale", + hint: "secure, no port forwarding", + }, + { + value: "direct" as const, + label: "Direct SSH", + hint: "traditional SSH", + }, + ], + }); + if (p.isCancel(selected)) { + return null; + } + return selected; +} + +async function resolveDirectHost(opts: { + readonly hostOverride: string | undefined; +}): Promise { + const hostname = (opts.hostOverride ?? "").trim(); + if (hostname.length > 0) { + return hostname; + } + + const hostInput = await p.text({ + message: "SSH host (hostname or IP)", + placeholder: "example.com or 192.168.1.100", + validate: (value) => { + if (!value?.trim()) { + return "Host is required"; + } + return undefined; + }, + }); + + if (p.isCancel(hostInput)) { + return null; + } + + return hostInput.trim(); +} + +async function resolveSessionNameToConnect(opts: { + readonly sessions: readonly TmuxSession[]; +}): Promise { const action = await p.select({ message: "What would you like to do?", options: [ @@ -245,13 +293,11 @@ async function handleSsh(opts: { }); if (p.isCancel(action) || action === "done") { - p.outro("Copy the SSH command above to connect from other devices"); - return 0; + return null; } - // Pick or create session const sessionOptions = [ - ...sessions.map((s) => ({ + ...opts.sessions.map((s) => ({ value: s.name, label: s.name, hint: s.attached ? "attached" : undefined, @@ -265,34 +311,30 @@ async function handleSsh(opts: { }); if (p.isCancel(selectedSession)) { - p.outro("Cancelled"); - return 0; + return null; } - let sessionName = selectedSession; - - if (selectedSession === "__new__") { - const name = await p.text({ - message: "Session name", - placeholder: "main", - defaultValue: "main", - validate: (value) => { - if (value && !SESSION_NAME_PATTERN.test(value)) { - return "Only letters, numbers, dashes, underscores, or dots"; - } - return undefined; - }, - }); + if (selectedSession !== "__new__") { + return selectedSession; + } - if (p.isCancel(name)) { - p.outro("Cancelled"); - return 0; - } + const name = await p.text({ + message: "Session name", + placeholder: "main", + defaultValue: "main", + validate: (value) => { + if (value && !SESSION_NAME_PATTERN.test(value)) { + return "Only letters, numbers, dashes, underscores, or dots"; + } + return undefined; + }, + }); - sessionName = name || "main"; + if (p.isCancel(name)) { + return null; } - return await connectToSession({ hostname, user, port, sessionName }); + return (name || "main").trim(); } /** diff --git a/src/commands/usage.ts b/src/commands/usage.ts index 30e9b401..360d36af 100644 --- a/src/commands/usage.ts +++ b/src/commands/usage.ts @@ -1,6 +1,6 @@ import { homedir } from "node:os"; import { resolve } from "node:path"; -import type { CommandHandlerFor } from "../cli/command.ts"; +import type { CommandArgs, CommandHandlerFor } from "../cli/command.ts"; import { CliUsageError, defineCommand, @@ -79,6 +79,8 @@ const spec = defineCommand({ expandInRootHelp: true, } as const); +type UsageArgs = CommandArgs; + const handleUsage: CommandHandlerFor = async ({ args, }): Promise => { @@ -86,22 +88,20 @@ const handleUsage: CommandHandlerFor = async ({ throw new CliUsageError("--json is not supported with --watch."); } - const filter = - typeof args.options.project === "string" - ? sanitizeProjectSlug(args.options.project) - : null; + const filter = resolveUsageFilter({ projectOpt: args.options.project }); + const includeGlobal = args.options.includeGlobal === true; + const includeHost = args.options.noHost !== true; const controlPlane = await readControlPlaneConfig({}); const usageConfig = controlPlane.config.usage; const watchIntervalMs = resolveIntervalMs({ cliValue: args.options.interval, configValue: usageConfig.watchIntervalMs, }); - const includeHost = args.options.noHost !== true; if (args.options.watch) { await runUsageWatch({ filter, - includeGlobal: args.options.includeGlobal === true, + includeGlobal, includeHost, intervalMs: watchIntervalMs, historySize: usageConfig.historySize, @@ -109,209 +109,12 @@ const handleUsage: CommandHandlerFor = async ({ return 0; } - const runtimeResult = await readRuntimeProjects({ - includeGlobal: args.options.includeGlobal === true, + return await runUsageOnce({ + args, + filter, + includeGlobal, + includeHost, }); - const runtime = runtimeResult.ok ? runtimeResult.runtime : []; - const filtered = filter - ? runtime.filter((project) => project.project === filter) - : runtime; - - const index = buildContainerIndex({ projects: filtered }); - const hostReport = includeHost - ? await readHostUsage() - : { rows: [], total: null }; - const stats = - index.containerIds.length === 0 - ? { ok: true as const, samples: [] } - : await readDockerStats({ containerIds: index.containerIds }); - if (!runtimeResult.ok) { - if (args.options.json === true) { - process.stdout.write( - `${JSON.stringify( - { - projects: [], - total: null, - host: hostReport.rows, - host_total: hostReport.total, - runtime_ok: false, - runtime_error: runtimeResult.error, - }, - null, - 2 - )}\n` - ); - return 1; - } - await display.panel({ - title: "Runtime unavailable", - tone: "error", - lines: [runtimeResult.error ?? "Docker runtime is not responding."], - }); - if (hostReport.rows.length > 0) { - await display.table({ - columns: ["Host", "CPU", "Memory", "PIDs", "Processes"], - rows: hostReport.rows.map((row) => [ - row.name, - formatPercent({ percent: row.cpuPercent }), - formatBytesMaybe({ bytes: row.memBytes }), - row.pids.length > 0 ? row.pids.join(",") : "n/a", - String(row.processes), - ]), - }); - } - return 1; - } - - if (!stats.ok) { - if (args.options.json === true) { - process.stdout.write( - `${JSON.stringify( - { - projects: [], - total: null, - host: hostReport.rows, - host_total: hostReport.total, - error: stats.error, - runtime_ok: runtimeResult.ok, - runtime_error: runtimeResult.error, - }, - null, - 2 - )}\n` - ); - return 1; - } - await display.panel({ - title: "Usage", - tone: "error", - lines: [stats.error], - }); - if (hostReport.rows.length > 0) { - await display.table({ - columns: ["Host", "CPU", "Memory", "PIDs", "Processes"], - rows: hostReport.rows.map((row) => [ - row.name, - formatPercent({ percent: row.cpuPercent }), - formatBytesMaybe({ bytes: row.memBytes }), - row.pids.length > 0 ? row.pids.join(",") : "n/a", - String(row.processes), - ]), - }); - } - return 1; - } - - const report = buildUsageReport({ - projects: filtered, - samples: stats.samples, - index, - }); - if (report.projects.length === 0 && hostReport.rows.length === 0) { - if (args.options.json === true) { - process.stdout.write( - `${JSON.stringify( - { - projects: [], - total: null, - host: [], - host_total: null, - warning: "no_usage_samples", - runtime_ok: runtimeResult.ok, - runtime_error: runtimeResult.error, - }, - null, - 2 - )}\n` - ); - return 0; - } - await display.panel({ - title: "Usage", - tone: "info", - lines: ["No running containers or host processes found."], - }); - return 0; - } - - if (args.options.json === true) { - process.stdout.write( - `${JSON.stringify( - { - projects: report.projects, - total: report.total, - host: hostReport.rows, - host_total: hostReport.total, - runtime_ok: runtimeResult.ok, - runtime_error: runtimeResult.error, - }, - null, - 2 - )}\n` - ); - return 0; - } - - if (report.projects.length > 0) { - await display.table({ - columns: ["Project", "CPU", "Memory", "PIDs", "Containers"], - rows: report.projects.map((project) => [ - project.project, - formatPercent({ percent: project.cpuPercent }), - formatMemoryLabel({ - used: project.memUsedBytes, - limit: project.memLimitBytes, - percent: project.memPercent, - }), - project.pids !== null ? String(project.pids) : "n/a", - String(project.containers), - ]), - }); - } - - if (hostReport.rows.length > 0) { - await display.table({ - columns: ["Host", "CPU", "Memory", "PIDs", "Processes"], - rows: hostReport.rows.map((row) => [ - row.name, - formatPercent({ percent: row.cpuPercent }), - formatBytesMaybe({ bytes: row.memBytes }), - row.pids.length > 0 ? row.pids.join(",") : "n/a", - String(row.processes), - ]), - }); - } - - if (report.total) { - await display.panel({ - title: "Total", - tone: "info", - lines: [ - `CPU: ${formatPercent({ percent: report.total.cpuPercent })}`, - `Memory: ${formatMemoryLabel({ - used: report.total.memUsedBytes, - limit: report.total.memLimitBytes, - percent: report.total.memPercent, - })}`, - `PIDs: ${report.total.pids ?? "n/a"}`, - `Containers: ${report.total.containers}`, - ], - }); - } - - if (hostReport.total) { - await display.panel({ - title: "Host total", - tone: "info", - lines: [ - `CPU: ${formatPercent({ percent: hostReport.total.cpuPercent })}`, - `Memory: ${formatBytesMaybe({ bytes: hostReport.total.memBytes })}`, - `Processes: ${hostReport.total.processes}`, - ], - }); - } - - return 0; }; export const usageCommand = withHandler(spec, handleUsage); @@ -737,7 +540,7 @@ function resolveHostProcessKind(opts: { return "hack tui"; } if (normalized.includes(" hack remote")) { - return "hack tui"; + return "hack remote"; } if (normalized.includes(" hack logs")) { return "log-stream"; @@ -883,71 +686,371 @@ async function readDockerStats(opts: { function parseDockerStatsOutput(opts: { readonly output: string; }): DockerStatsSample[] { - const lines = opts.output - .split("\n") - .map((line) => line.trim()) - .filter((line) => line.length > 0); const samples: DockerStatsSample[] = []; - for (const line of lines) { - let parsed: unknown; - try { - parsed = JSON.parse(line); - } catch { - continue; - } - if (!isRecord(parsed)) { + for (const line of splitDockerStatsLines({ output: opts.output })) { + const sample = parseDockerStatsSampleLine({ line }); + if (!sample) { continue; } + samples.push(sample); + } + + return samples; +} + +function splitDockerStatsLines(opts: { readonly output: string }): string[] { + return opts.output + .split("\n") + .map((line) => line.trim()) + .filter((line) => line.length > 0); +} + +function parseDockerStatsSampleLine(opts: { + readonly line: string; +}): DockerStatsSample | null { + const parsed = tryParseDockerStatsJson({ line: opts.line }); + if (!parsed) { + return null; + } + + const containerId = getString(parsed, "ID") ?? getString(parsed, "Container"); + const cpuPercent = parsePercent({ + value: typeof parsed.CPUPerc === "string" ? parsed.CPUPerc : null, + }); + const memPercent = parsePercent({ + value: typeof parsed.MemPerc === "string" ? parsed.MemPerc : null, + }); + const netIo = parseIoPair({ + value: typeof parsed.NetIO === "string" ? parsed.NetIO : null, + }); + const blockIo = parseIoPair({ + value: typeof parsed.BlockIO === "string" ? parsed.BlockIO : null, + }); + const memUsageRaw = + typeof parsed.MemUsage === "string" ? parsed.MemUsage : null; + const memUsage = parseMemUsage({ raw: memUsageRaw }); + + const pids = parseDockerStatsPids({ + value: typeof parsed.PIDs === "string" ? parsed.PIDs : null, + }); + + return { + containerId, + cpuPercent, + memUsedBytes: memUsage.usedBytes, + memLimitBytes: memUsage.limitBytes, + memPercent, + netInputBytes: netIo.inputBytes, + netOutputBytes: netIo.outputBytes, + blockInputBytes: blockIo.inputBytes, + blockOutputBytes: blockIo.outputBytes, + pids, + }; +} + +function tryParseDockerStatsJson(opts: { + readonly line: string; +}): Record | null { + let parsed: unknown; + try { + parsed = JSON.parse(opts.line); + } catch { + return null; + } + return isRecord(parsed) ? parsed : null; +} + +function parseMemUsage(opts: { readonly raw: string | null }): { + readonly usedBytes: number | null; + readonly limitBytes: number | null; +} { + if (!opts.raw) { + return { usedBytes: null, limitBytes: null }; + } + + const [usedRaw = "", limitRaw = ""] = opts.raw + .split("/") + .map((part) => part.trim()); + return { + usedBytes: parseBytes({ value: usedRaw.length > 0 ? usedRaw : null }), + limitBytes: parseBytes({ value: limitRaw.length > 0 ? limitRaw : null }), + }; +} + +function parseDockerStatsPids(opts: { + readonly value: string | null; +}): number | null { + const parsed = opts.value ? Number.parseInt(opts.value, 10) : Number.NaN; + return Number.isFinite(parsed) ? parsed : null; +} + +function resolveUsageFilter(opts: { + readonly projectOpt: unknown; +}): string | null { + return typeof opts.projectOpt === "string" + ? sanitizeProjectSlug(opts.projectOpt) + : null; +} - const containerId = - getString(parsed, "ID") ?? getString(parsed, "Container"); - const cpuPercent = parsePercent({ - value: typeof parsed.CPUPerc === "string" ? parsed.CPUPerc : null, +async function runUsageOnce(opts: { + readonly args: UsageArgs; + readonly filter: string | null; + readonly includeGlobal: boolean; + readonly includeHost: boolean; +}): Promise { + const runtimeResult = await readRuntimeProjects({ + includeGlobal: opts.includeGlobal, + }); + const runtime = runtimeResult.ok ? runtimeResult.runtime : []; + const filtered = opts.filter + ? runtime.filter((project) => project.project === opts.filter) + : runtime; + const index = buildContainerIndex({ projects: filtered }); + const hostReport = opts.includeHost + ? await readHostUsage() + : { rows: [], total: null }; + const stats = + index.containerIds.length === 0 + ? { ok: true as const, samples: [] } + : await readDockerStats({ containerIds: index.containerIds }); + + if (!runtimeResult.ok) { + return await renderRuntimeUnavailable({ + args: opts.args, + runtimeError: runtimeResult.error ?? "Docker runtime is not responding.", + hostReport, }); - const memUsageRaw = - typeof parsed.MemUsage === "string" ? parsed.MemUsage : null; - const memPercent = parsePercent({ - value: typeof parsed.MemPerc === "string" ? parsed.MemPerc : null, + } + + if (!stats.ok) { + return await renderStatsError({ + args: opts.args, + error: stats.error, + runtimeOk: runtimeResult.ok, + runtimeError: runtimeResult.error, + hostReport, }); - const netIo = parseIoPair({ - value: typeof parsed.NetIO === "string" ? parsed.NetIO : null, + } + + const report = buildUsageReport({ + projects: filtered, + samples: stats.samples, + index, + }); + + if (report.projects.length === 0 && hostReport.rows.length === 0) { + return await renderNoSamples({ + args: opts.args, + runtimeOk: runtimeResult.ok, + runtimeError: runtimeResult.error, }); - const blockIo = parseIoPair({ - value: typeof parsed.BlockIO === "string" ? parsed.BlockIO : null, + } + + return await renderUsageSuccess({ + args: opts.args, + report, + hostReport, + runtimeOk: runtimeResult.ok, + runtimeError: runtimeResult.error, + }); +} + +async function renderRuntimeUnavailable(opts: { + readonly args: UsageArgs; + readonly runtimeError: string; + readonly hostReport: HostUsageReport; +}): Promise { + if (opts.args.options.json === true) { + writeJson({ + payload: { + projects: [], + total: null, + host: opts.hostReport.rows, + host_total: opts.hostReport.total, + runtime_ok: false, + runtime_error: opts.runtimeError, + }, }); - const pidsValue = typeof parsed.PIDs === "string" ? parsed.PIDs : null; - - let memUsedBytes: number | null = null; - let memLimitBytes: number | null = null; - if (memUsageRaw) { - const [usedRaw = "", limitRaw = ""] = memUsageRaw - .split("/") - .map((part) => part.trim()); - memUsedBytes = parseBytes({ value: usedRaw.length > 0 ? usedRaw : null }); - memLimitBytes = parseBytes({ - value: limitRaw.length > 0 ? limitRaw : null, - }); - } + return 1; + } - const parsedPids = pidsValue ? Number.parseInt(pidsValue, 10) : Number.NaN; - const pids = Number.isFinite(parsedPids) ? parsedPids : null; + await display.panel({ + title: "Runtime unavailable", + tone: "error", + lines: [opts.runtimeError], + }); + await renderHostUsageTable({ hostReport: opts.hostReport }); + return 1; +} - samples.push({ - containerId, - cpuPercent, - memUsedBytes, - memLimitBytes, - memPercent, - netInputBytes: netIo.inputBytes, - netOutputBytes: netIo.outputBytes, - blockInputBytes: blockIo.inputBytes, - blockOutputBytes: blockIo.outputBytes, - pids, +async function renderStatsError(opts: { + readonly args: UsageArgs; + readonly error: string; + readonly runtimeOk: boolean; + readonly runtimeError: string | null; + readonly hostReport: HostUsageReport; +}): Promise { + if (opts.args.options.json === true) { + writeJson({ + payload: { + projects: [], + total: null, + host: opts.hostReport.rows, + host_total: opts.hostReport.total, + error: opts.error, + runtime_ok: opts.runtimeOk, + runtime_error: opts.runtimeError, + }, + }); + return 1; + } + + await display.panel({ + title: "Usage", + tone: "error", + lines: [opts.error], + }); + await renderHostUsageTable({ hostReport: opts.hostReport }); + return 1; +} + +async function renderNoSamples(opts: { + readonly args: UsageArgs; + readonly runtimeOk: boolean; + readonly runtimeError: string | null; +}): Promise { + if (opts.args.options.json === true) { + writeJson({ + payload: { + projects: [], + total: null, + host: [], + host_total: null, + warning: "no_usage_samples", + runtime_ok: opts.runtimeOk, + runtime_error: opts.runtimeError, + }, }); + return 0; } - return samples; + await display.panel({ + title: "Usage", + tone: "info", + lines: ["No running containers or host processes found."], + }); + return 0; +} + +async function renderUsageSuccess(opts: { + readonly args: UsageArgs; + readonly report: UsageReport; + readonly hostReport: HostUsageReport; + readonly runtimeOk: boolean; + readonly runtimeError: string | null; +}): Promise { + if (opts.args.options.json === true) { + writeJson({ + payload: { + projects: opts.report.projects, + total: opts.report.total, + host: opts.hostReport.rows, + host_total: opts.hostReport.total, + runtime_ok: opts.runtimeOk, + runtime_error: opts.runtimeError, + }, + }); + return 0; + } + + await renderProjectUsageTable({ report: opts.report }); + await renderHostUsageTable({ hostReport: opts.hostReport }); + await renderTotalUsagePanels({ + report: opts.report, + hostReport: opts.hostReport, + }); + return 0; +} + +function writeJson(opts: { readonly payload: unknown }): void { + process.stdout.write(`${JSON.stringify(opts.payload, null, 2)}\n`); +} + +async function renderProjectUsageTable(opts: { + readonly report: UsageReport; +}): Promise { + if (opts.report.projects.length === 0) { + return; + } + + await display.table({ + columns: ["Project", "CPU", "Memory", "PIDs", "Containers"], + rows: opts.report.projects.map((project) => [ + project.project, + formatPercent({ percent: project.cpuPercent }), + formatMemoryLabel({ + used: project.memUsedBytes, + limit: project.memLimitBytes, + percent: project.memPercent, + }), + project.pids !== null ? String(project.pids) : "n/a", + String(project.containers), + ]), + }); +} + +async function renderHostUsageTable(opts: { + readonly hostReport: HostUsageReport; +}): Promise { + if (opts.hostReport.rows.length === 0) { + return; + } + + await display.table({ + columns: ["Host", "CPU", "Memory", "PIDs", "Processes"], + rows: opts.hostReport.rows.map((row) => [ + row.name, + formatPercent({ percent: row.cpuPercent }), + formatBytesMaybe({ bytes: row.memBytes }), + row.pids.length > 0 ? row.pids.join(",") : "n/a", + String(row.processes), + ]), + }); +} + +async function renderTotalUsagePanels(opts: { + readonly report: UsageReport; + readonly hostReport: HostUsageReport; +}): Promise { + if (opts.report.total) { + await display.panel({ + title: "Total", + tone: "info", + lines: [ + `CPU: ${formatPercent({ percent: opts.report.total.cpuPercent })}`, + `Memory: ${formatMemoryLabel({ + used: opts.report.total.memUsedBytes, + limit: opts.report.total.memLimitBytes, + percent: opts.report.total.memPercent, + })}`, + `PIDs: ${opts.report.total.pids ?? "n/a"}`, + `Containers: ${opts.report.total.containers}`, + ], + }); + } + + if (opts.hostReport.total) { + await display.panel({ + title: "Host total", + tone: "info", + lines: [ + `CPU: ${formatPercent({ percent: opts.hostReport.total.cpuPercent })}`, + `Memory: ${formatBytesMaybe({ bytes: opts.hostReport.total.memBytes })}`, + `Processes: ${opts.hostReport.total.processes}`, + ], + }); + } } function buildUsageReport(opts: { diff --git a/src/commands/x.ts b/src/commands/x.ts index d8a48288..14041293 100644 --- a/src/commands/x.ts +++ b/src/commands/x.ts @@ -46,7 +46,35 @@ async function handleX({ throw new CliUsageError("Unable to parse extension command."); } - const loaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); + const loaded = await loadAndLogExtensionManager({ cwd: ctx.cwd }); + + const dispatcherResult = await handleDispatcherInvocation({ + loaded, + invocation, + }); + if (dispatcherResult !== null) { + return dispatcherResult; + } + + const namespace = invocation.namespace ?? ""; + const extension = loaded.manager.getExtensionByNamespace({ namespace }); + if (!extension) { + logger.error({ message: `Unknown extension namespace: ${namespace}` }); + return 1; + } + + return await dispatchExtensionCommand({ + ctx, + loaded, + extension, + invocation, + }); +} + +async function loadAndLogExtensionManager(opts: { + readonly cwd: string; +}): Promise>> { + const loaded = await loadExtensionManagerForCli({ cwd: opts.cwd }); if (loaded.configError) { logger.warn({ message: `Control plane config error: ${loaded.configError}`, @@ -55,130 +83,162 @@ async function handleX({ for (const warning of loaded.warnings) { logger.warn({ message: warning }); } + return loaded; +} - if (!invocation.namespace) { - await renderDispatcherHelp({ extensions: loaded.manager.listExtensions() }); +async function handleDispatcherInvocation(opts: { + readonly loaded: Awaited>; + readonly invocation: ExtensionInvocation; +}): Promise { + if (!opts.invocation.namespace) { + await renderDispatcherHelp({ + extensions: opts.loaded.manager.listExtensions(), + }); return 1; } - if (invocation.namespace === "list") { - await renderExtensionList({ extensions: loaded.manager.listExtensions() }); + if (opts.invocation.namespace === "list") { + await renderExtensionList({ + extensions: opts.loaded.manager.listExtensions(), + }); return 0; } - if (invocation.namespace === "resolve") { - const commandId = invocation.command ?? ""; - if (!commandId) { - throw new CliUsageError( - "Missing commandId for `hack x resolve `" - ); - } - const resolved = loaded.manager.resolveCommandId({ commandId }); - if (!resolved) { - logger.error({ message: `Unknown commandId: ${commandId}` }); - return 1; - } - process.stdout.write( - `hack x ${resolved.namespace} ${resolved.commandName}\n` + if (opts.invocation.namespace === "resolve") { + return handleResolveCommandId({ + loaded: opts.loaded, + invocation: opts.invocation, + }); + } + + return null; +} + +function handleResolveCommandId(opts: { + readonly loaded: Awaited>; + readonly invocation: ExtensionInvocation; +}): number { + const commandId = opts.invocation.command ?? ""; + if (!commandId) { + throw new CliUsageError( + "Missing commandId for `hack x resolve `" ); - return 0; } - const extension = loaded.manager.getExtensionByNamespace({ - namespace: invocation.namespace, - }); - if (!extension) { - logger.error({ - message: `Unknown extension namespace: ${invocation.namespace}`, - }); + const resolved = opts.loaded.manager.resolveCommandId({ commandId }); + if (!resolved) { + logger.error({ message: `Unknown commandId: ${commandId}` }); return 1; } - if (!extension.enabled) { - const instructions = buildEnableInstructions({ - extension, - namespace: invocation.namespace ?? "", - command: invocation.command, - args: invocation.args, - }); - await display.panel({ - title: "Extension disabled", - tone: "warn", - lines: instructions.lines, - }); + process.stdout.write( + `hack x ${resolved.namespace} ${resolved.commandName}\n` + ); + return 0; +} - const didEnable = await maybeEnableExtension({ - extension, - namespace: invocation.namespace ?? "", - command: invocation.command, - args: invocation.args, - projectDir: loaded.context.project?.projectDir, +async function dispatchExtensionCommand(opts: { + readonly ctx: CliContext; + readonly loaded: Awaited>; + readonly extension: ResolvedExtension; + readonly invocation: ExtensionInvocation; +}): Promise { + if (opts.extension.enabled) { + return await dispatchEnabledExtensionCommand({ + loaded: opts.loaded, + extension: opts.extension, + invocation: opts.invocation, }); + } - if (didEnable) { - const reloaded = await loadExtensionManagerForCli({ cwd: ctx.cwd }); - const nextExtension = reloaded.manager.getExtensionByNamespace({ - namespace: invocation.namespace, - }); - if (!nextExtension?.enabled) { - logger.warn({ - message: "Extension still disabled after enable attempt.", - }); - return 1; - } - - if (!invocation.command || invocation.command === "help") { - await renderExtensionHelp({ - extension: nextExtension, - commands: reloaded.manager.listCommands({ - namespace: nextExtension.namespace, - }), - }); - return 0; - } - - const resolved = reloaded.manager.resolveCommand({ - namespace: nextExtension.namespace, - commandName: invocation.command, - }); - if (!resolved) { - logger.error({ - message: `Unknown command "${invocation.command}" for ${nextExtension.namespace}`, - }); - return 1; - } - - return await resolved.command.handler({ - ctx: reloaded.context, - args: invocation.args, - }); - } + const didEnable = await promptEnableExtension({ + loaded: opts.loaded, + extension: opts.extension, + invocation: opts.invocation, + }); + if (!didEnable) { + return 1; + } + const reloaded = await loadAndLogExtensionManager({ cwd: opts.ctx.cwd }); + const nextExtension = reloaded.manager.getExtensionByNamespace({ + namespace: opts.extension.namespace, + }); + if (!nextExtension?.enabled) { + logger.warn({ + message: "Extension still disabled after enable attempt.", + }); return 1; } - if (!invocation.command || invocation.command === "help") { + return await dispatchEnabledExtensionCommand({ + loaded: reloaded, + extension: nextExtension, + invocation: opts.invocation, + }); +} + +async function promptEnableExtension(opts: { + readonly loaded: Awaited>; + readonly extension: ResolvedExtension; + readonly invocation: ExtensionInvocation; +}): Promise { + const instructions = buildEnableInstructions({ + extension: opts.extension, + namespace: opts.invocation.namespace ?? "", + command: opts.invocation.command, + args: opts.invocation.args, + }); + await display.panel({ + title: "Extension disabled", + tone: "warn", + lines: instructions.lines, + }); + + return await maybeEnableExtension({ + extension: opts.extension, + namespace: opts.invocation.namespace ?? "", + command: opts.invocation.command, + args: opts.invocation.args, + projectDir: opts.loaded.context.project?.projectDir, + }); +} + +async function dispatchEnabledExtensionCommand(opts: { + readonly loaded: Awaited>; + readonly extension: ResolvedExtension; + readonly invocation: ExtensionInvocation; +}): Promise { + if (!opts.invocation.command || opts.invocation.command === "help") { await renderExtensionHelp({ - extension, - commands: loaded.manager.listCommands({ namespace: extension.namespace }), + extension: opts.extension, + commands: opts.loaded.manager.listCommands({ + namespace: opts.extension.namespace, + }), }); return 0; } - const resolved = loaded.manager.resolveCommand({ - namespace: extension.namespace, - commandName: invocation.command, + const resolved = opts.loaded.manager.resolveCommand({ + namespace: opts.extension.namespace, + commandName: opts.invocation.command, }); if (!resolved) { logger.error({ - message: `Unknown command "${invocation.command}" for ${extension.namespace}`, + message: `Unknown command "${opts.invocation.command}" for ${opts.extension.namespace}`, + }); + await renderExtensionHelp({ + extension: opts.extension, + commands: opts.loaded.manager.listCommands({ + namespace: opts.extension.namespace, + }), }); return 1; } return await resolved.command.handler({ - ctx: loaded.context, - args: invocation.args, + ctx: opts.loaded.context, + args: opts.invocation.args, }); } diff --git a/src/constants.ts b/src/constants.ts index 193ab081..dd9823e4 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -64,6 +64,7 @@ export const GLOBAL_GRAFANA_DASHBOARD_FILENAME = export const PROJECT_COMPOSE_FILENAME = "docker-compose.yml" as const; export const PROJECT_ENV_FILENAME = ".env" as const; +export const PROJECT_ENV_CONTRACT_FILENAME = "hack.env.json" as const; export const PROJECT_CONFIG_FILENAME = "hack.config.json" as const; export const PROJECT_CONFIG_LEGACY_FILENAME = "hack.toml" as const; export const PROJECT_BRANCHES_FILENAME = "hack.branches.json" as const; diff --git a/src/control-plane/extensions/cloudflare/commands.ts b/src/control-plane/extensions/cloudflare/commands.ts index be5deff5..9a43cfce 100644 --- a/src/control-plane/extensions/cloudflare/commands.ts +++ b/src/control-plane/extensions/cloudflare/commands.ts @@ -21,7 +21,7 @@ import { display } from "../../../ui/display.ts"; import type { ControlPlaneConfig } from "../../sdk/config.ts"; import { readControlPlaneConfig } from "../../sdk/config.ts"; import { resolveGatewayConfig } from "../gateway/config.ts"; -import type { ExtensionCommand } from "../types.ts"; +import type { ExtensionCommand, ExtensionCommandContext } from "../types.ts"; type CloudflareExtensionConfig = { readonly hostname?: string; @@ -150,120 +150,7 @@ export const CLOUDFLARE_COMMANDS: readonly ExtensionCommand[] = [ name: "tunnel-setup", summary: "Create a Cloudflare tunnel and write config", scope: "global", - handler: async ({ ctx, args }) => { - const parsed = parseTunnelSetupArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const defaultOrigin = await resolveDefaultOrigin(); - const globalConfig = (await readControlPlaneConfig({})).config; - const config = resolveTunnelConfig({ - controlPlaneConfig: globalConfig, - overrides: parsed.value, - defaultOrigin, - }); - if (!config.hostname) { - ctx.logger.error({ - message: - "Missing hostname. Use --hostname or set global config: hack config set --global 'controlPlane.extensions[\"dance.hack.cloudflare\"].config.hostname' .", - }); - return 1; - } - - const outPath = resolveOutPath({ - cwd: ctx.cwd, - raw: config.out ?? DEFAULT_CONFIG_PATH, - }); - const check = await ensureCloudflared(); - if (!check.ok) { - ctx.logger.error({ message: check.error }); - return 1; - } - - if (!parsed.value.skipLogin) { - const login = await runCloudflared({ - args: ["tunnel", "login"], - inherit: true, - }); - if (!login.ok) { - ctx.logger.error({ message: "cloudflared login failed." }); - return 1; - } - } - - let tunnelId = await findTunnelId({ name: config.tunnel }); - if (!(tunnelId || parsed.value.skipCreate)) { - const created = await runCloudflared({ - args: ["tunnel", "create", config.tunnel], - inherit: true, - }); - if (!created.ok) { - ctx.logger.error({ message: "cloudflared tunnel create failed." }); - return 1; - } - tunnelId = await findTunnelId({ name: config.tunnel }); - } - - if (!tunnelId) { - ctx.logger.error({ message: `Tunnel "${config.tunnel}" not found.` }); - return 1; - } - - if (!parsed.value.skipRoute) { - const routed = await runCloudflared({ - args: ["tunnel", "route", "dns", config.tunnel, config.hostname], - inherit: true, - }); - if (!routed.ok) { - ctx.logger.warn({ - message: "cloudflared route dns failed (it may already exist).", - }); - } - if (config.sshHostname) { - const routedSsh = await runCloudflared({ - args: ["tunnel", "route", "dns", config.tunnel, config.sshHostname], - inherit: true, - }); - if (!routedSsh.ok) { - ctx.logger.warn({ - message: - "cloudflared route dns failed for SSH hostname (it may already exist).", - }); - } - } - } - - const credentialsFile = - config.credentialsFile ?? (await resolveCredentialsFile({ tunnelId })); - const yaml = renderCloudflaredConfig({ - tunnel: tunnelId, - hostname: config.hostname, - origin: config.origin, - ...(config.sshHostname ? { sshHostname: config.sshHostname } : {}), - ...(config.sshOrigin ? { sshOrigin: config.sshOrigin } : {}), - ...(credentialsFile ? { credentialsFile } : {}), - }); - - const result = await writeTextFileIfChanged(outPath, `${yaml}\n`); - ctx.logger.success({ - message: result.changed - ? `Wrote ${outPath}` - : `No changes needed: ${outPath}`, - }); - - const nextSteps = [ - `Run tunnel: cloudflared tunnel --config ${outPath} run ${config.tunnel}`, - "Optional: use Cloudflare Access policies to protect the hostname.", - ]; - await display.panel({ - title: "Next steps", - tone: "info", - lines: nextSteps, - }); - return 0; - }, + handler: handleTunnelSetup, }, { name: "tunnel-start", @@ -444,137 +331,326 @@ export function parseTunnelPrintArgs(opts: { }): ParseResult { const out: TunnelPrintArgs = {}; - const takeValue = ( - _token: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; - }; - for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; if (token === "--") { return { ok: true, value: out }; } - if (token.startsWith("--hostname=")) { - out.hostname = normalizeValue(token.slice("--hostname=".length)); - continue; + const parsed = parseTunnelPrintValueFlag({ + token, + next: opts.args[i + 1], + }); + if (!parsed.ok) { + return parsed; } - if (token === "--hostname") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--hostname requires a value." }; - } - out.hostname = normalizeValue(value); - i += 1; + if (parsed.value) { + out[parsed.value.key] = normalizeValue(parsed.value.rawValue); + i += parsed.value.consume; continue; } - if (token.startsWith("--tunnel=")) { - out.tunnel = normalizeValue(token.slice("--tunnel=".length)); - continue; + if (token.startsWith("-")) { + return { ok: false, error: `Unknown option: ${token}` }; } - if (token === "--tunnel") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--tunnel requires a value." }; - } - out.tunnel = normalizeValue(value); - i += 1; - continue; - } + return { ok: false, error: `Unexpected argument: ${token}` }; + } - if (token.startsWith("--origin=")) { - out.origin = normalizeValue(token.slice("--origin=".length)); - continue; - } + return { ok: true, value: out }; +} - if (token === "--origin") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--origin requires a value." }; - } - out.origin = normalizeValue(value); - i += 1; - continue; - } +type TunnelPrintValueFlag = { + readonly key: keyof TunnelPrintArgs; + readonly rawValue: string; + readonly consume: number; +}; - if (token.startsWith("--ssh-hostname=")) { - out.sshHostname = normalizeValue(token.slice("--ssh-hostname=".length)); - continue; - } +type TunnelPrintValueFlagResult = + | { readonly ok: true; readonly value: TunnelPrintValueFlag | null } + | { readonly ok: false; readonly error: string }; - if (token === "--ssh-hostname") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--ssh-hostname requires a value." }; - } - out.sshHostname = normalizeValue(value); - i += 1; - continue; - } +const TUNNEL_PRINT_VALUE_FLAGS: Record = { + "--hostname": "hostname", + "--tunnel": "tunnel", + "--origin": "origin", + "--ssh-hostname": "sshHostname", + "--ssh-origin": "sshOrigin", + "--credentials-file": "credentialsFile", + "--out": "out", +}; - if (token.startsWith("--ssh-origin=")) { - out.sshOrigin = normalizeValue(token.slice("--ssh-origin=".length)); - continue; - } +function parseTunnelPrintValueFlag(opts: { + readonly token: string; + readonly next: string | undefined; +}): TunnelPrintValueFlagResult { + const split = splitFlagToken({ token: opts.token }); + const key = TUNNEL_PRINT_VALUE_FLAGS[split.name]; + if (!key) { + return { ok: true, value: null }; + } - if (token === "--ssh-origin") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--ssh-origin requires a value." }; - } - out.sshOrigin = normalizeValue(value); - i += 1; - continue; - } + if (split.inlineValue !== null) { + return { + ok: true, + value: { key, rawValue: split.inlineValue, consume: 0 }, + }; + } - if (token.startsWith("--credentials-file=")) { - out.credentialsFile = normalizeValue( - token.slice("--credentials-file=".length) - ); - continue; - } + const value = takeValueFromNextToken({ value: opts.next }); + if (value === null) { + return { ok: false, error: `${split.name} requires a value.` }; + } + return { ok: true, value: { key, rawValue: value, consume: 1 } }; +} - if (token === "--credentials-file") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--credentials-file requires a value." }; - } - out.credentialsFile = normalizeValue(value); - i += 1; - continue; - } +function splitFlagToken(opts: { readonly token: string }): { + readonly name: string; + readonly inlineValue: string | null; +} { + const idx = opts.token.indexOf("="); + if (idx === -1) { + return { name: opts.token, inlineValue: null }; + } + return { + name: opts.token.slice(0, idx), + inlineValue: opts.token.slice(idx + 1), + }; +} - if (token.startsWith("--out=")) { - out.out = normalizeValue(token.slice("--out=".length)); - continue; - } +function takeValueFromNextToken(opts: { + readonly value: string | undefined; +}): string | null { + if (!opts.value || opts.value.startsWith("-")) { + return null; + } + return opts.value; +} - if (token === "--out") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--out requires a value." }; - } - out.out = normalizeValue(value); - i += 1; - continue; +async function handleTunnelSetup({ + ctx, + args, +}: { + readonly ctx: ExtensionCommandContext; + readonly args: readonly string[]; +}): Promise { + const parsed = parseTunnelSetupArgs({ args }); + if (!parsed.ok) { + ctx.logger.error({ message: parsed.error }); + return 1; + } + + const configResult = await resolveTunnelSetupConfig({ + ctx, + overrides: parsed.value, + }); + if (!configResult.ok) { + ctx.logger.error({ message: configResult.error }); + return 1; + } + + const check = await ensureCloudflared(); + if (!check.ok) { + ctx.logger.error({ message: check.error }); + return 1; + } + + const login = await maybeCloudflaredLogin({ + skipLogin: parsed.value.skipLogin, + }); + if (!login.ok) { + ctx.logger.error({ message: login.error }); + return 1; + } + + const tunnelIdResult = await resolveOrCreateTunnelId({ + tunnel: configResult.value.config.tunnel, + skipCreate: parsed.value.skipCreate, + }); + if (!tunnelIdResult.ok) { + ctx.logger.error({ message: tunnelIdResult.error }); + return 1; + } + + await maybeRouteDns({ + skipRoute: parsed.value.skipRoute, + tunnel: configResult.value.config.tunnel, + hostname: configResult.value.config.hostname, + sshHostname: configResult.value.config.sshHostname, + logger: ctx.logger, + }); + + const credentialsFile = + configResult.value.config.credentialsFile ?? + (await resolveCredentialsFile({ tunnelId: tunnelIdResult.value.tunnelId })); + const yaml = renderCloudflaredConfig({ + tunnel: tunnelIdResult.value.tunnelId, + hostname: configResult.value.config.hostname, + origin: configResult.value.config.origin, + ...(configResult.value.config.sshHostname + ? { sshHostname: configResult.value.config.sshHostname } + : {}), + ...(configResult.value.config.sshOrigin + ? { sshOrigin: configResult.value.config.sshOrigin } + : {}), + ...(credentialsFile ? { credentialsFile } : {}), + }); + + const result = await writeTextFileIfChanged( + configResult.value.outPath, + `${yaml}\n` + ); + ctx.logger.success({ + message: result.changed + ? `Wrote ${configResult.value.outPath}` + : `No changes needed: ${configResult.value.outPath}`, + }); + + await display.panel({ + title: "Next steps", + tone: "info", + lines: [ + `Run tunnel: cloudflared tunnel --config ${configResult.value.outPath} run ${configResult.value.config.tunnel}`, + "Optional: use Cloudflare Access policies to protect the hostname.", + ], + }); + return 0; +} + +type TunnelSetupConfigResult = + | { + readonly ok: true; + readonly value: { + readonly config: Required> & + Required> & + CloudflareExtensionConfig & { readonly out?: string }; + readonly outPath: string; + }; } + | { readonly ok: false; readonly error: string }; - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; +async function resolveTunnelSetupConfig(opts: { + readonly ctx: ExtensionCommandContext; + readonly overrides: TunnelPrintArgs; +}): Promise { + const defaultOrigin = await resolveDefaultOrigin(); + const config = resolveTunnelConfig({ + controlPlaneConfig: opts.ctx.controlPlaneConfig, + overrides: opts.overrides, + defaultOrigin, + }); + if (!config.hostname) { + return { + ok: false, + error: + "Missing hostname. Use --hostname or set global config: hack config set --global 'controlPlane.extensions[\"dance.hack.cloudflare\"].config.hostname' .", + }; + } + + const outPath = resolveOutPath({ + cwd: opts.ctx.cwd, + raw: config.out ?? DEFAULT_CONFIG_PATH, + }); + + return { + ok: true, + value: { + config: { + ...config, + hostname: config.hostname, + }, + outPath, + }, + }; +} + +async function maybeCloudflaredLogin(opts: { + readonly skipLogin: boolean; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + if (opts.skipLogin) { + return { ok: true }; + } + + const login = await runCloudflared({ + args: ["tunnel", "login"], + inherit: true, + }); + if (!login.ok) { + return { ok: false, error: "cloudflared login failed." }; + } + return { ok: true }; +} + +async function resolveOrCreateTunnelId(opts: { + readonly tunnel: string; + readonly skipCreate: boolean; +}): Promise< + | { readonly ok: true; readonly value: { readonly tunnelId: string } } + | { readonly ok: false; readonly error: string } +> { + let tunnelId = await findTunnelId({ name: opts.tunnel }); + if (!(tunnelId || opts.skipCreate)) { + const created = await runCloudflared({ + args: ["tunnel", "create", opts.tunnel], + inherit: true, + }); + if (!created.ok) { + return { ok: false, error: "cloudflared tunnel create failed." }; } + tunnelId = await findTunnelId({ name: opts.tunnel }); + } - return { ok: false, error: `Unexpected argument: ${token}` }; + if (!tunnelId) { + return { ok: false, error: `Tunnel "${opts.tunnel}" not found.` }; } - return { ok: true, value: out }; + return { ok: true, value: { tunnelId } }; +} + +async function maybeRouteDns(opts: { + readonly skipRoute: boolean; + readonly tunnel: string; + readonly hostname: string; + readonly sshHostname: string | undefined; + readonly logger: ExtensionCommandContext["logger"]; +}): Promise { + if (opts.skipRoute) { + return; + } + + await routeDnsForHostname({ + tunnel: opts.tunnel, + hostname: opts.hostname, + logger: opts.logger, + }); + + if (!opts.sshHostname) { + return; + } + await routeDnsForHostname({ + tunnel: opts.tunnel, + hostname: opts.sshHostname, + logger: opts.logger, + }); +} + +async function routeDnsForHostname(opts: { + readonly tunnel: string; + readonly hostname: string; + readonly logger: ExtensionCommandContext["logger"]; +}): Promise { + const routed = await runCloudflared({ + args: ["tunnel", "route", "dns", opts.tunnel, opts.hostname], + inherit: true, + }); + if (!routed.ok) { + opts.logger.warn({ + message: `cloudflared route dns failed for ${opts.hostname} (it may already exist).`, + }); + } } function parseTunnelSetupArgs(opts: { @@ -622,64 +698,23 @@ export function parseTunnelStartArgs(opts: { }): StartParseResult { const out: TunnelStartArgs = {}; - const takeValue = ( - _token: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; - }; - for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; if (token === "--") { return { ok: true, value: out }; } - if (token.startsWith("--config=")) { - out.config = normalizeValue(token.slice("--config=".length)); - continue; + const parsed = parseTunnelStartValueFlag({ + token, + next: opts.args[i + 1], + }); + if (!parsed.ok) { + return parsed; } - if (token === "--config") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--config requires a value." }; - } - out.config = normalizeValue(value); - i += 1; - continue; - } - - if (token.startsWith("--out=")) { - out.config = normalizeValue(token.slice("--out=".length)); - continue; - } - - if (token === "--out") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--out requires a value." }; - } - out.config = normalizeValue(value); - i += 1; - continue; - } - - if (token.startsWith("--tunnel=")) { - out.tunnel = normalizeValue(token.slice("--tunnel=".length)); - continue; - } - - if (token === "--tunnel") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--tunnel requires a value." }; - } - out.tunnel = normalizeValue(value); - i += 1; + if (parsed.value) { + out[parsed.value.key] = normalizeValue(parsed.value.rawValue); + i += parsed.value.consume; continue; } @@ -693,6 +728,46 @@ export function parseTunnelStartArgs(opts: { return { ok: true, value: out }; } +type TunnelStartValueFlag = { + readonly key: keyof TunnelStartArgs; + readonly rawValue: string; + readonly consume: number; +}; + +type TunnelStartValueFlagResult = + | { readonly ok: true; readonly value: TunnelStartValueFlag | null } + | { readonly ok: false; readonly error: string }; + +const TUNNEL_START_VALUE_FLAGS: Record = { + "--config": "config", + "--out": "config", + "--tunnel": "tunnel", +}; + +function parseTunnelStartValueFlag(opts: { + readonly token: string; + readonly next: string | undefined; +}): TunnelStartValueFlagResult { + const split = splitFlagToken({ token: opts.token }); + const key = TUNNEL_START_VALUE_FLAGS[split.name]; + if (!key) { + return { ok: true, value: null }; + } + + if (split.inlineValue !== null) { + return { + ok: true, + value: { key, rawValue: split.inlineValue, consume: 0 }, + }; + } + + const value = takeValueFromNextToken({ value: opts.next }); + if (value === null) { + return { ok: false, error: `${split.name} requires a value.` }; + } + return { ok: true, value: { key, rawValue: value, consume: 1 } }; +} + export function parseAccessSetupArgs(opts: { readonly args: readonly string[]; }): AccessSetupParseResult { diff --git a/src/control-plane/extensions/gateway/commands.ts b/src/control-plane/extensions/gateway/commands.ts index 12464e98..b4b3a2e7 100644 --- a/src/control-plane/extensions/gateway/commands.ts +++ b/src/control-plane/extensions/gateway/commands.ts @@ -115,95 +115,151 @@ type TokenCreateParseResult = | { readonly ok: true; readonly value: TokenCreateArgs } | { readonly ok: false; readonly error: string }; +type ParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + function parseTokenCreateArgs(opts: { readonly args: readonly string[]; }): TokenCreateParseResult { - let label: string | undefined; - let scope: GatewayTokenScope = "read"; - - const takeValue = ( - _token: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; - }; + const state: { label?: string; scope: GatewayTokenScope } = { scope: "read" }; for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; + if (token === "--") { const rest = opts.args.slice(i + 1); - if (rest.length > 0 && !label) { - label = normalizeLabel(rest[0] ?? ""); + if (rest.length > 0 && !state.label) { + state.label = normalizeLabel(rest[0] ?? ""); } break; } - if (token === "--write") { - scope = "write"; - continue; + const parsed = parseTokenCreateToken({ + token, + next: opts.args[i + 1], + state, + }); + if (!parsed.ok) { + return { ok: false, error: parsed.error }; } - if (token.startsWith("--scope=")) { - const value = token.slice("--scope=".length).trim(); - const parsed = parseScope(value); - if (!parsed) { - return { ok: false, error: "Invalid --scope (use read|write)." }; - } - scope = parsed; - continue; - } + state.label = parsed.value.state.label; + state.scope = parsed.value.state.scope; + i += parsed.value.consume; + } - if (token === "--scope") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--scope requires a value." }; - } - const parsed = parseScope(value); - if (!parsed) { - return { ok: false, error: "Invalid --scope (use read|write)." }; - } - scope = parsed; - i += 1; - continue; - } + return { + ok: true, + value: { + ...(state.label ? { label: state.label } : {}), + scope: state.scope, + }, + }; +} - if (token.startsWith("--label=")) { - label = normalizeLabel(token.slice("--label=".length)); - continue; - } +type TokenCreateState = { + readonly label?: string; + readonly scope: GatewayTokenScope; +}; - if (token === "--label") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--label requires a value." }; - } - label = normalizeLabel(value); - i += 1; - continue; +function parseTokenCreateToken(opts: { + readonly token: string; + readonly next: string | undefined; + readonly state: TokenCreateState; +}): ParseResult<{ + readonly state: TokenCreateState; + readonly consume: number; +}> { + if (opts.token === "--write") { + return { + ok: true, + value: { state: { ...opts.state, scope: "write" }, consume: 0 }, + }; + } + + const scopeInline = parseInlineFlag({ token: opts.token, name: "--scope" }); + if (scopeInline) { + const scope = parseScope(scopeInline); + if (!scope) { + return { ok: false, error: "Invalid --scope (use read|write)." }; } + return { ok: true, value: { state: { ...opts.state, scope }, consume: 0 } }; + } - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; + if (opts.token === "--scope") { + const scopeValue = takeFlagValue({ token: opts.token, value: opts.next }); + if (!scopeValue) { + return { ok: false, error: "--scope requires a value." }; } + const scope = parseScope(scopeValue); + if (!scope) { + return { ok: false, error: "Invalid --scope (use read|write)." }; + } + return { ok: true, value: { state: { ...opts.state, scope }, consume: 1 } }; + } - if (!label) { - label = normalizeLabel(token); - continue; + const labelInline = parseInlineFlag({ token: opts.token, name: "--label" }); + if (labelInline !== null) { + return { + ok: true, + value: { + state: { ...opts.state, label: normalizeLabel(labelInline) }, + consume: 0, + }, + }; + } + + if (opts.token === "--label") { + const labelValue = takeFlagValue({ token: opts.token, value: opts.next }); + if (!labelValue) { + return { ok: false, error: "--label requires a value." }; } + return { + ok: true, + value: { + state: { ...opts.state, label: normalizeLabel(labelValue) }, + consume: 1, + }, + }; + } - return { ok: false, error: `Unexpected argument: ${token}` }; + if (opts.token.startsWith("-")) { + return { ok: false, error: `Unknown option: ${opts.token}` }; } - return { - ok: true, - value: { - ...(label ? { label } : {}), - scope, - }, - }; + if (!opts.state.label) { + return { + ok: true, + value: { + state: { ...opts.state, label: normalizeLabel(opts.token) }, + consume: 0, + }, + }; + } + + return { ok: false, error: `Unexpected argument: ${opts.token}` }; +} + +function parseInlineFlag(opts: { + readonly token: string; + readonly name: string; +}): string | null { + const prefix = `${opts.name}=`; + if (!opts.token.startsWith(prefix)) { + return null; + } + return opts.token.slice(prefix.length).trim(); +} + +function takeFlagValue(opts: { + readonly token: string; + readonly value: string | undefined; +}): string | null { + if (!opts.value || opts.value.startsWith("-")) { + return null; + } + return opts.value; } function parseScope(value: string): GatewayTokenScope | null { diff --git a/src/control-plane/extensions/supervisor/shell-service.ts b/src/control-plane/extensions/supervisor/shell-service.ts index eaaacb0a..9e2c4bdd 100644 --- a/src/control-plane/extensions/supervisor/shell-service.ts +++ b/src/control-plane/extensions/supervisor/shell-service.ts @@ -91,89 +91,54 @@ export function createShellService(opts?: { const createShell = (input: ShellCreateInput): ShellCreateResult => { const shellId = randomUUID(); - const shellRaw = (input.shell ?? process.env.SHELL ?? "/bin/bash").trim(); - const shell = shellRaw.length > 0 ? shellRaw : "/bin/bash"; - const cwd = - input.cwd && input.cwd.trim().length > 0 ? input.cwd : input.projectRoot; + const shell = resolveShellCommand({ input }); + const cwd = resolveShellCwd({ input }); const cols = normalizeDimension(input.cols, DEFAULT_COLS); const rows = normalizeDimension(input.rows, DEFAULT_ROWS); const createdAt = new Date().toISOString(); const listeners = new Set(); - let terminal: Bun.Terminal; - try { - terminal = new Bun.Terminal({ - cols, - rows, - data: (_term, data) => { - for (const listener of listeners) { - listener.onData(data); - } - }, - exit: (_term, exitCode) => { - if (exitCode !== 0) { - logger.warn({ message: `Shell PTY closed with code ${exitCode}` }); - } - }, - }); - terminal.setRawMode(true); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to create PTY"; - return { ok: false, error: message }; + const terminalResult = createShellTerminal({ + cols, + rows, + listeners, + logger, + }); + if (!terminalResult.ok) { + return { ok: false, error: terminalResult.error }; } - let proc: ReturnType; - try { - proc = Bun.spawn([shell], { - cwd, - env: buildShellEnv({ env: input.env }), - terminal, - }); - } catch (error: unknown) { - const message = - error instanceof Error ? error.message : "Failed to start shell"; - terminal.close(); - return { ok: false, error: message }; + const procResult = spawnShellProcess({ + shell, + cwd, + env: input.env, + terminal: terminalResult.terminal, + }); + if (!procResult.ok) { + terminalResult.terminal.close(); + return { ok: false, error: procResult.error }; } - const meta: ShellMeta = { + const meta = buildShellMeta({ + input, shellId, - status: "running", createdAt, - updatedAt: createdAt, - ...(input.projectId ? { projectId: input.projectId } : {}), - ...(input.projectName ? { projectName: input.projectName } : {}), cwd, shell, cols, rows, - ...(Number.isFinite(proc.pid) ? { pid: proc.pid } : {}), - }; + pid: procResult.proc.pid, + }); const session: ShellSession = { meta, - terminal, - proc, + terminal: terminalResult.terminal, + proc: procResult.proc, listeners, }; shells.set(shellId, session); - proc.exited - .then((exitCode) => { - handleShellExit({ - shells, - session, - exitCode, - }); - }) - .catch(() => { - handleShellExit({ - shells, - session, - exitCode: 1, - }); - }); + attachShellExitHandlers({ shells, session }); return { ok: true, shell: meta }; }; @@ -256,6 +221,128 @@ export function createShellService(opts?: { }; } +function resolveShellCommand(opts: { + readonly input: ShellCreateInput; +}): string { + const shellRaw = ( + opts.input.shell ?? + process.env.SHELL ?? + "/bin/bash" + ).trim(); + return shellRaw.length > 0 ? shellRaw : "/bin/bash"; +} + +function resolveShellCwd(opts: { readonly input: ShellCreateInput }): string { + const raw = (opts.input.cwd ?? "").trim(); + return raw.length > 0 ? raw : opts.input.projectRoot; +} + +type TerminalCreateResult = + | { readonly ok: true; readonly terminal: Bun.Terminal } + | { readonly ok: false; readonly error: string }; + +function createShellTerminal(opts: { + readonly cols: number; + readonly rows: number; + readonly listeners: Set; + readonly logger: Logger; +}): TerminalCreateResult { + try { + const terminal = new Bun.Terminal({ + cols: opts.cols, + rows: opts.rows, + data: (_term, data) => { + for (const listener of opts.listeners) { + listener.onData(data); + } + }, + exit: (_term, exitCode) => { + if (exitCode !== 0) { + opts.logger.warn({ + message: `Shell PTY closed with code ${exitCode}`, + }); + } + }, + }); + terminal.setRawMode(true); + return { ok: true, terminal }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Failed to create PTY"; + return { ok: false, error: message }; + } +} + +type SpawnShellResult = + | { readonly ok: true; readonly proc: ReturnType } + | { readonly ok: false; readonly error: string }; + +function spawnShellProcess(opts: { + readonly shell: string; + readonly cwd: string; + readonly env: Record | undefined; + readonly terminal: Bun.Terminal; +}): SpawnShellResult { + try { + const proc = Bun.spawn([opts.shell], { + cwd: opts.cwd, + env: buildShellEnv({ env: opts.env }), + terminal: opts.terminal, + }); + return { ok: true, proc }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "Failed to start shell"; + return { ok: false, error: message }; + } +} + +function buildShellMeta(opts: { + readonly input: ShellCreateInput; + readonly shellId: string; + readonly createdAt: string; + readonly cwd: string; + readonly shell: string; + readonly cols: number; + readonly rows: number; + readonly pid: number; +}): ShellMeta { + return { + shellId: opts.shellId, + status: "running", + createdAt: opts.createdAt, + updatedAt: opts.createdAt, + ...(opts.input.projectId ? { projectId: opts.input.projectId } : {}), + ...(opts.input.projectName ? { projectName: opts.input.projectName } : {}), + cwd: opts.cwd, + shell: opts.shell, + cols: opts.cols, + rows: opts.rows, + ...(Number.isFinite(opts.pid) ? { pid: opts.pid } : {}), + }; +} + +function attachShellExitHandlers(opts: { + readonly shells: Map; + readonly session: ShellSession; +}): void { + opts.session.proc.exited + .then((exitCode) => { + handleShellExit({ + shells: opts.shells, + session: opts.session, + exitCode, + }); + }) + .catch(() => { + handleShellExit({ + shells: opts.shells, + session: opts.session, + exitCode: 1, + }); + }); +} + function normalizeDimension( value: number | undefined, fallback: number @@ -281,7 +368,7 @@ function buildShellEnv(opts: { env[key] = value; } } - env.TERM = process.env.TERM ?? DEFAULT_TERM; + env.TERM = env.TERM || process.env.TERM || DEFAULT_TERM; return env; } diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index d12c2636..afb008af 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -186,88 +186,7 @@ export function createTicketsStore(opts: { const tickets = new Map(); for (const event of opts.events) { - if (event.type === "ticket.created") { - const title = - typeof event.payload.title === "string" ? event.payload.title : ""; - const body = - typeof event.payload.body === "string" - ? event.payload.body - : undefined; - const dependsOn = parseDependencyList({ - value: event.payload.dependsOn, - }); - const blocks = parseDependencyList({ value: event.payload.blocks }); - - tickets.set(event.ticketId, { - ticketId: event.ticketId, - title, - body, - status: "open", - createdAt: event.tsIso, - updatedAt: event.tsIso, - dependsOn, - blocks, - ...(event.projectId ? { projectId: event.projectId } : {}), - ...(event.projectName ? { projectName: event.projectName } : {}), - }); - continue; - } - - if (event.type === "ticket.status_changed") { - const current = tickets.get(event.ticketId); - if (!current) { - continue; - } - - const next = - typeof event.payload.status === "string" ? event.payload.status : ""; - if ( - next === "open" || - next === "in_progress" || - next === "blocked" || - next === "done" - ) { - tickets.set(event.ticketId, { - ...current, - status: next, - updatedAt: event.tsIso, - }); - } - continue; - } - - if (event.type === "ticket.updated") { - const current = tickets.get(event.ticketId); - if (!current) { - continue; - } - - const title = - typeof event.payload.title === "string" - ? event.payload.title - : undefined; - const body = - typeof event.payload.body === "string" - ? event.payload.body - : undefined; - const dependsOn = readDependencyUpdate({ - payload: event.payload, - key: "dependsOn", - }); - const blocks = readDependencyUpdate({ - payload: event.payload, - key: "blocks", - }); - - tickets.set(event.ticketId, { - ...current, - ...(title ? { title } : {}), - ...(body !== undefined ? { body } : {}), - ...(dependsOn !== null ? { dependsOn } : {}), - ...(blocks !== null ? { blocks } : {}), - updatedAt: event.tsIso, - }); - } + applyTicketEvent({ tickets, event }); } return applyDerivedBlocks(tickets); @@ -447,6 +366,142 @@ export function createTicketsStore(opts: { }; } +function applyTicketEvent(opts: { + readonly tickets: Map; + readonly event: TicketEvent; +}): void { + switch (opts.event.type) { + case "ticket.created": { + applyTicketCreatedEvent({ + tickets: opts.tickets, + event: opts.event, + }); + break; + } + case "ticket.status_changed": { + applyTicketStatusChangedEvent({ + tickets: opts.tickets, + event: opts.event, + }); + break; + } + case "ticket.updated": { + applyTicketUpdatedEvent({ + tickets: opts.tickets, + event: opts.event, + }); + break; + } + default: { + break; + } + } +} + +function applyTicketCreatedEvent(opts: { + readonly tickets: Map; + readonly event: TicketEvent; +}): void { + const title = + typeof opts.event.payload.title === "string" + ? opts.event.payload.title + : ""; + const body = + typeof opts.event.payload.body === "string" + ? opts.event.payload.body + : undefined; + const dependsOn = parseDependencyList({ + value: opts.event.payload.dependsOn, + }); + const blocks = parseDependencyList({ + value: opts.event.payload.blocks, + }); + + opts.tickets.set(opts.event.ticketId, { + ticketId: opts.event.ticketId, + title, + body, + status: "open", + createdAt: opts.event.tsIso, + updatedAt: opts.event.tsIso, + dependsOn, + blocks, + ...(opts.event.projectId ? { projectId: opts.event.projectId } : {}), + ...(opts.event.projectName ? { projectName: opts.event.projectName } : {}), + }); +} + +function applyTicketStatusChangedEvent(opts: { + readonly tickets: Map; + readonly event: TicketEvent; +}): void { + const current = opts.tickets.get(opts.event.ticketId); + if (!current) { + return; + } + + const status = parseTicketStatus({ value: opts.event.payload.status }); + if (!status) { + return; + } + + opts.tickets.set(opts.event.ticketId, { + ...current, + status, + updatedAt: opts.event.tsIso, + }); +} + +function applyTicketUpdatedEvent(opts: { + readonly tickets: Map; + readonly event: TicketEvent; +}): void { + const current = opts.tickets.get(opts.event.ticketId); + if (!current) { + return; + } + + const title = + typeof opts.event.payload.title === "string" + ? opts.event.payload.title + : undefined; + const body = + typeof opts.event.payload.body === "string" + ? opts.event.payload.body + : undefined; + const dependsOn = readDependencyUpdate({ + payload: opts.event.payload, + key: "dependsOn", + }); + const blocks = readDependencyUpdate({ + payload: opts.event.payload, + key: "blocks", + }); + + opts.tickets.set(opts.event.ticketId, { + ...current, + ...(title ? { title } : {}), + ...(body !== undefined ? { body } : {}), + ...(dependsOn !== null ? { dependsOn } : {}), + ...(blocks !== null ? { blocks } : {}), + updatedAt: opts.event.tsIso, + }); +} + +function parseTicketStatus(opts: { + readonly value: unknown; +}): TicketStatus | null { + if ( + opts.value === "open" || + opts.value === "in_progress" || + opts.value === "blocked" || + opts.value === "done" + ) { + return opts.value; + } + return null; +} + function parseDependencyList(opts: { readonly value: unknown }): string[] { if (!Array.isArray(opts.value)) { return []; diff --git a/src/daemon/routes/env.ts b/src/daemon/routes/env.ts new file mode 100644 index 00000000..f3c50918 --- /dev/null +++ b/src/daemon/routes/env.ts @@ -0,0 +1,405 @@ +import { resolve } from "node:path"; +import { secrets } from "bun"; + +import { PROJECT_ENV_FILENAME } from "../../constants.ts"; +import { isRecord } from "../../lib/guards.ts"; +import { + removeDotEnvKey, + resolveHackEnv, + resolveKeychainServiceName, + upsertDotEnvValue, +} from "../../lib/hack-env.ts"; +import type { RegisteredProject } from "../../lib/projects-registry.ts"; +import { + readProjectsRegistry, + resolveRegisteredProjectById, + resolveRegisteredProjectByName, +} from "../../lib/projects-registry.ts"; + +const ENV_KEY_PATTERN = /^[A-Z_][A-Z0-9_]*$/; + +type ParseResult = + | { readonly ok: true; readonly value: T } + | { readonly ok: false; readonly error: string }; + +export type EnvValueState = { + readonly key: string; + readonly required: boolean; + readonly source: "plain_env" | "keychain"; + readonly services: readonly string[] | null; + readonly description?: string; + readonly resolvedFrom: "dotenv" | "process" | "keychain" | null; + readonly hasValue: boolean; +}; + +export type EnvGetResponse = { + readonly project: { + readonly id: string; + readonly name: string; + readonly repoRoot: string; + readonly projectDir: string; + }; + readonly contract: { + readonly path: string; + readonly exists: boolean; + readonly parseError?: string; + readonly version: number; + readonly vars: ReadonlyArray<{ + readonly key: string; + readonly required: boolean; + readonly source: "plain_env" | "keychain"; + readonly services: readonly string[] | null; + readonly description?: string; + }>; + }; + readonly values: readonly EnvValueState[]; + readonly missingRequired: readonly string[]; +}; + +type EnvSetBody = { + readonly project?: string; + readonly projectId?: string; + readonly key: string; + readonly value: string; + readonly secret?: boolean; +}; + +type EnvUnsetBody = { + readonly project?: string; + readonly projectId?: string; + readonly key: string; +}; + +/** + * Handles env API routes. + * + * Routes: + * - GET /v1/env?project=&project_id= - Get env contract + resolution state (redacted values). + * - POST /v1/env/set - Set env (.hack/.env) or secret (keychain) + * - POST /v1/env/unset - Unset env + keychain entry + * + * @returns Response if route matched, null otherwise + */ +export async function handleEnvRoutes(opts: { + readonly req: Request; + readonly url: URL; +}): Promise { + const segments = opts.url.pathname.split("/").filter(Boolean); + if (segments[0] !== "v1" || segments[1] !== "env") { + return null; + } + + if (segments.length === 2 && opts.req.method === "GET") { + return await handleGetEnv({ url: opts.url }); + } + + if (segments[2] === "set" && opts.req.method === "POST") { + return await handleSetEnv({ req: opts.req }); + } + + if (segments[2] === "unset" && opts.req.method === "POST") { + return await handleUnsetEnv({ req: opts.req }); + } + + return jsonResponse({ error: "not_found" }, 404); +} + +async function handleGetEnv(opts: { readonly url: URL }): Promise { + const projectId = normalizeQueryParam({ + value: opts.url.searchParams.get("project_id"), + }); + const projectName = normalizeQueryParam({ + value: opts.url.searchParams.get("project"), + }); + + const resolvedProject = await resolveProjectFromParams({ + projectId, + projectName, + }); + if (!resolvedProject.ok) { + return jsonResponse({ error: resolvedProject.error }, 400); + } + + const { project, registration } = resolvedProject.value; + const resolved = await resolveHackEnv({ + projectDir: project.projectDir, + projectName: registration.name, + }); + + const valuesByKey = new Map(resolved.values.map((v) => [v.key, v] as const)); + const contractVars = resolved.contract.vars.map((v) => ({ + key: v.key, + required: v.required, + source: v.source, + services: v.services, + ...(v.description ? { description: v.description } : {}), + })); + + const values: EnvValueState[] = []; + for (const v of resolved.contract.vars) { + const state = valuesByKey.get(v.key) ?? null; + values.push({ + key: v.key, + required: v.required, + source: v.source, + services: v.services, + ...(v.description ? { description: v.description } : {}), + resolvedFrom: state?.resolvedFrom ?? null, + hasValue: Boolean(state?.value), + }); + } + + const body: EnvGetResponse = { + project: { + id: registration.id, + name: registration.name, + repoRoot: registration.repoRoot, + projectDir: registration.projectDir, + }, + contract: { + path: resolved.contractPath, + exists: resolved.contractExists, + ...(resolved.contractParseError + ? { parseError: resolved.contractParseError } + : {}), + version: resolved.contract.version, + vars: contractVars, + }, + values, + missingRequired: resolved.missingRequired.map((v) => v.key), + }; + + return jsonResponse(body as unknown as Record); +} + +async function handleSetEnv(opts: { + readonly req: Request; +}): Promise { + const body = await readJsonBody(opts.req); + if (!body) { + return jsonResponse({ error: "invalid_json" }, 400); + } + + const parsed = parseEnvSetBody(body); + if (!parsed.ok) { + return jsonResponse({ error: parsed.error }, 400); + } + + const resolvedProject = await resolveProjectFromParams({ + projectId: parsed.value.projectId ?? null, + projectName: parsed.value.project ?? null, + }); + if (!resolvedProject.ok) { + return jsonResponse({ error: resolvedProject.error }, 400); + } + + const { project, registration } = resolvedProject.value; + const keychainService = resolveKeychainServiceName({ + projectName: registration.name, + }); + + const secret = parsed.value.secret === true; + if (secret) { + try { + await secrets.set({ + service: keychainService, + name: parsed.value.key, + value: parsed.value.value, + }); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Keychain error"; + return jsonResponse( + { error: "keychain_error", message, service: keychainService }, + 500 + ); + } + return jsonResponse({ status: "ok", stored: "keychain" }, 200); + } + + const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME); + await upsertDotEnvValue({ + envFile, + key: parsed.value.key, + value: parsed.value.value, + }); + return jsonResponse({ status: "ok", stored: "dotenv" }, 200); +} + +async function handleUnsetEnv(opts: { + readonly req: Request; +}): Promise { + const body = await readJsonBody(opts.req); + if (!body) { + return jsonResponse({ error: "invalid_json" }, 400); + } + + const parsed = parseEnvUnsetBody(body); + if (!parsed.ok) { + return jsonResponse({ error: parsed.error }, 400); + } + + const resolvedProject = await resolveProjectFromParams({ + projectId: parsed.value.projectId ?? null, + projectName: parsed.value.project ?? null, + }); + if (!resolvedProject.ok) { + return jsonResponse({ error: resolvedProject.error }, 400); + } + + const { project, registration } = resolvedProject.value; + const keychainService = resolveKeychainServiceName({ + projectName: registration.name, + }); + + const envFile = resolve(project.projectDir, PROJECT_ENV_FILENAME); + const dotenvResult = await removeDotEnvKey({ + envFile, + key: parsed.value.key, + }); + let keychainDeleted: boolean | null = null; + try { + keychainDeleted = await secrets.delete({ + service: keychainService, + name: parsed.value.key, + }); + } catch { + keychainDeleted = null; + } + + return jsonResponse( + { + status: "ok", + dotenvChanged: dotenvResult.changed, + keychainDeleted, + }, + 200 + ); +} + +async function resolveProjectFromParams(opts: { + readonly projectId: string | null; + readonly projectName: string | null; +}): Promise< + ParseResult<{ + readonly project: NonNullable< + Awaited> + >; + readonly registration: RegisteredProject; + }> +> { + if (opts.projectId) { + const byId = await resolveRegisteredProjectById({ id: opts.projectId }); + if (!byId) { + return { ok: false, error: "project_not_found" }; + } + return { ok: true, value: byId }; + } + + const name = opts.projectName?.trim() ?? ""; + if (name.length === 0) { + return { ok: false, error: "missing_project" }; + } + + const registry = await readProjectsRegistry(); + const registration = registry.projects.find((p) => p.name === name) ?? null; + if (!registration) { + return { ok: false, error: "project_not_found" }; + } + + const project = await resolveRegisteredProjectByName({ name }); + if (!project) { + return { ok: false, error: "project_not_found" }; + } + + return { ok: true, value: { project, registration } }; +} + +function parseEnvSetBody( + body: Record +): ParseResult { + const key = body.key; + const value = body.value; + if (typeof key !== "string" || key.trim().length === 0) { + return { ok: false, error: "missing_key" }; + } + const trimmedKey = key.trim(); + if (!ENV_KEY_PATTERN.test(trimmedKey)) { + return { ok: false, error: "invalid_key" }; + } + if (typeof value !== "string" || value.length === 0) { + return { ok: false, error: "missing_value" }; + } + + const project = + typeof body.project === "string" ? body.project.trim() : undefined; + const projectId = + typeof body.projectId === "string" ? body.projectId.trim() : undefined; + const secret = body.secret === true ? true : undefined; + + return { + ok: true, + value: { + ...(project && project.length > 0 ? { project } : {}), + ...(projectId && projectId.length > 0 ? { projectId } : {}), + key: trimmedKey, + value, + ...(secret ? { secret } : {}), + }, + }; +} + +function parseEnvUnsetBody( + body: Record +): ParseResult { + const key = body.key; + if (typeof key !== "string" || key.trim().length === 0) { + return { ok: false, error: "missing_key" }; + } + const trimmedKey = key.trim(); + if (!ENV_KEY_PATTERN.test(trimmedKey)) { + return { ok: false, error: "invalid_key" }; + } + + const project = + typeof body.project === "string" ? body.project.trim() : undefined; + const projectId = + typeof body.projectId === "string" ? body.projectId.trim() : undefined; + + return { + ok: true, + value: { + ...(project && project.length > 0 ? { project } : {}), + ...(projectId && projectId.length > 0 ? { projectId } : {}), + key: trimmedKey, + }, + }; +} + +function normalizeQueryParam(opts: { + readonly value: string | null; +}): string | null { + const trimmed = (opts.value ?? "").trim(); + return trimmed.length > 0 ? trimmed : null; +} + +async function readJsonBody( + req: Request +): Promise | null> { + try { + const parsed: unknown = await req.json(); + return isRecord(parsed) ? parsed : null; + } catch { + return null; + } +} + +function jsonResponse(body: Record, status = 200): Response { + const payload = JSON.stringify(body, null, 2); + return new Response(payload, { + status, + headers: { + "content-type": "application/json", + "content-length": `${Buffer.byteLength(payload)}`, + }, + }); +} diff --git a/src/daemon/routes/sessions.ts b/src/daemon/routes/sessions.ts index 674408a2..7f61ce52 100644 --- a/src/daemon/routes/sessions.ts +++ b/src/daemon/routes/sessions.ts @@ -3,20 +3,19 @@ import { buildTailscaleSshCommand, getTailscaleStatus, } from "../../lib/tailscale.ts"; +import type { MuxBackendName, MuxSession } from "../../mux/mux-backend.ts"; +import { + resolveDefaultBackendName, + resolveMux, +} from "../../mux/mux-resolver.ts"; -/** Valid session name pattern: alphanumeric, dash, underscore, or dot */ -const SESSION_NAME_PATTERN = /^[\w.-]+$/; +/** Valid session name pattern: alphanumeric, dash, underscore */ +const SESSION_NAME_PATTERN = /^[\w-]+$/; /** - * Parsed tmux session info. + * Parsed mux session info. */ -export interface TmuxSession { - readonly name: string; - readonly attached: boolean; - readonly path: string | null; - readonly windows: number; - readonly createdAt: string | null; -} +export type DaemonSession = MuxSession; /** * Session create input. @@ -24,6 +23,7 @@ export interface TmuxSession { export interface SessionCreateInput { readonly name: string; readonly cwd?: string; + readonly backend?: MuxBackendName; } /** @@ -62,7 +62,7 @@ type ParseResult = * Handles session API routes. * * Routes: - * - GET /v1/sessions - List all tmux sessions + * - GET /v1/sessions - List all sessions * - POST /v1/sessions - Create a new session * - GET /v1/sessions/:id - Get session details * - POST /v1/sessions/:id/stop - Stop (kill) session @@ -121,22 +121,28 @@ export async function handleSessionRoutes(opts: { } /** - * List all tmux sessions. + * List all sessions. */ async function handleListSessions(): Promise { + const mux = await resolveMux({ project: null }); const [sessions, connectionInfo] = await Promise.all([ - listTmuxSessions(), + mux.mode === "none" ? Promise.resolve([] as const) : listSessions({ mux }), getConnectionInfo(), ]); return jsonResponse({ sessions, connection: connectionInfo }); } /** - * Create a new tmux session. + * Create a new session. */ async function handleCreateSession(opts: { readonly req: Request; }): Promise { + const mux = await resolveMux({ project: null }); + if (mux.mode === "none") { + return jsonResponse({ error: "sessions_disabled" }, 503); + } + const body = await readJsonBody(opts.req); if (!body) { return jsonResponse({ error: "invalid_json" }, 400); @@ -147,30 +153,33 @@ async function handleCreateSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - const { name, cwd } = parsed.value; + const { name, cwd, backend: backendRequested } = parsed.value; + const backendName = + backendRequested ?? + resolveDefaultBackendName({ mode: mux.mode, backends: mux.backends }); + if (!backendName) { + return jsonResponse({ error: "no_backend_available" }, 503); + } + const backend = mux.backends.get(backendName); + if (!backend?.available) { + return jsonResponse({ error: "backend_unavailable" }, 503); + } // Check if session already exists - const existing = await findSession({ name }); + const existing = await findSession({ mux, name }); if (existing) { return jsonResponse({ error: "session_exists", session: existing }, 409); } - // Create session - uses array form for safety (no shell interpolation) - const args = ["tmux", "new-session", "-d", "-s", name]; - if (cwd) { - args.push("-c", cwd); - } - - const result = await exec(args, { stdin: "ignore" }); - if (result.exitCode !== 0) { + const create = await backend.createSession({ name, cwd }); + if (!create.ok) { return jsonResponse( - { error: "create_failed", message: result.stderr.trim() }, + { error: create.error, message: create.stderr ?? "" }, 500 ); } - // Return created session - const session = await findSession({ name }); + const session = create.session ?? (await findSession({ mux, name })); return jsonResponse({ session }, 201); } @@ -180,8 +189,9 @@ async function handleCreateSession(opts: { async function handleGetSession(opts: { readonly sessionId: string; }): Promise { + const mux = await resolveMux({ project: null }); const [session, connectionInfo] = await Promise.all([ - findSession({ name: opts.sessionId }), + findSession({ mux, name: opts.sessionId }), getConnectionInfo({ sessionName: opts.sessionId }), ]); if (!session) { @@ -191,19 +201,22 @@ async function handleGetSession(opts: { } /** - * Stop (kill) a tmux session. + * Stop (kill) a session. */ async function handleStopSession(opts: { readonly sessionId: string; }): Promise { - const session = await findSession({ name: opts.sessionId }); + const mux = await resolveMux({ project: null }); + const session = await findSession({ mux, name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } - const result = await exec(["tmux", "kill-session", "-t", opts.sessionId], { - stdin: "ignore", - }); + const backend = mux.backends.get(session.backend); + if (!backend?.available) { + return jsonResponse({ error: "backend_unavailable" }, 503); + } + const result = await backend.killSession({ name: opts.sessionId }); if (result.exitCode !== 0) { return jsonResponse( @@ -216,14 +229,15 @@ async function handleStopSession(opts: { } /** - * Execute a command in a tmux session. + * Execute a command in a session. * Sends the command followed by Enter. */ async function handleExecSession(opts: { readonly req: Request; readonly sessionId: string; }): Promise { - const session = await findSession({ name: opts.sessionId }); + const mux = await resolveMux({ project: null }); + const session = await findSession({ mux, name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } @@ -238,11 +252,14 @@ async function handleExecSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - // Uses array form - command is a separate argument, not interpolated - const result = await exec( - ["tmux", "send-keys", "-t", opts.sessionId, parsed.value.command, "Enter"], - { stdin: "ignore" } - ); + const backend = mux.backends.get(session.backend); + if (!backend?.available) { + return jsonResponse({ error: "backend_unavailable" }, 503); + } + const result = await backend.execInSession({ + name: opts.sessionId, + command: parsed.value.command, + }); if (result.exitCode !== 0) { return jsonResponse( @@ -255,7 +272,7 @@ async function handleExecSession(opts: { } /** - * Send raw input/keystrokes to a tmux session. + * Send raw input/keystrokes to a session. * Does NOT automatically append Enter - allows sending key sequences like: * - "C-c" (Ctrl+C) * - "C-d" (Ctrl+D) @@ -268,7 +285,8 @@ async function handleInputSession(opts: { readonly req: Request; readonly sessionId: string; }): Promise { - const session = await findSession({ name: opts.sessionId }); + const mux = await resolveMux({ project: null }); + const session = await findSession({ mux, name: opts.sessionId }); if (!session) { return jsonResponse({ error: "session_not_found" }, 404); } @@ -283,11 +301,14 @@ async function handleInputSession(opts: { return jsonResponse({ error: parsed.error }, 400); } - // send-keys without trailing Enter - uses array form for safety - const result = await exec( - ["tmux", "send-keys", "-t", opts.sessionId, parsed.value.keys], - { stdin: "ignore" } - ); + const backend = mux.backends.get(session.backend); + if (!backend?.available) { + return jsonResponse({ error: "backend_unavailable" }, 503); + } + const result = await backend.sendInput({ + name: opts.sessionId, + keys: parsed.value.keys, + }); if (result.exitCode !== 0) { return jsonResponse( @@ -300,57 +321,29 @@ async function handleInputSession(opts: { } /** - * List all tmux sessions with detailed info. + * Find a session by name. */ -async function listTmuxSessions(): Promise { - const format = [ - "#{session_name}", - "#{session_attached}", - "#{session_path}", - "#{session_windows}", - "#{session_created}", - ].join(":"); - - const result = await exec(["tmux", "list-sessions", "-F", format], { - stdin: "ignore", - }); - - if (result.exitCode !== 0) { - return []; - } +async function findSession(opts: { + readonly mux: Awaited>; + readonly name: string; +}): Promise { + const sessions = await listSessions({ mux: opts.mux }); + return sessions.find((s) => s.name === opts.name) ?? null; +} - const sessions: TmuxSession[] = []; - for (const line of result.stdout.trim().split("\n")) { - if (!line) { +async function listSessions(opts: { + readonly mux: Awaited>; +}): Promise { + const sessions: DaemonSession[] = []; + for (const backend of opts.mux.backends.values()) { + if (!backend?.available) { continue; } - const [name, attached, path, windows, created] = line.split(":"); - if (name) { - sessions.push({ - name, - attached: attached === "1", - path: path || null, - windows: Number.parseInt(windows ?? "1", 10), - createdAt: created - ? new Date(Number.parseInt(created, 10) * 1000).toISOString() - : null, - }); - } + sessions.push(...(await backend.listSessions())); } - return sessions; } -/** - * Find a session by name. - */ -async function findSession(opts: { - readonly name: string; -}): Promise { - const sessions = await listTmuxSessions(); - return sessions.find((s) => s.name === opts.name) ?? null; -} - /** * Get connection info for SSH access. * @@ -396,23 +389,28 @@ function parseSessionCreateInput( return { ok: false, error: "missing_name" }; } - // Validate session name (tmux restrictions) + // Validate session name (alphanumeric, dash, underscore, dot) const trimmedName = name.trim(); if (!SESSION_NAME_PATTERN.test(trimmedName)) { return { ok: false, error: - "invalid_name: must contain only alphanumeric, dash, underscore, or dot", + "invalid_name: must contain only alphanumeric, dash, or underscore", }; } const cwd = typeof body.cwd === "string" ? body.cwd.trim() : undefined; + const backend = + body.backend === "tmux" || body.backend === "zellij" + ? (body.backend as MuxBackendName) + : undefined; return { ok: true, value: { name: trimmedName, ...(cwd && cwd.length > 0 ? { cwd } : {}), + ...(backend ? { backend } : {}), }, }; } diff --git a/src/daemon/runtime-cache.ts b/src/daemon/runtime-cache.ts index aef8938c..1a61353b 100644 --- a/src/daemon/runtime-cache.ts +++ b/src/daemon/runtime-cache.ts @@ -1,3 +1,6 @@ +import { resolve } from "node:path"; +import { PROJECT_COMPOSE_FILENAME } from "../constants.ts"; +import { resolveProjectMeta } from "../lib/project-meta.ts"; import { buildProjectViews, serializeProjectView, @@ -35,6 +38,7 @@ export type ProjectsPayload = { readonly filter: string | null; readonly include_global: boolean; readonly include_unregistered: boolean; + readonly include_meta: boolean; readonly runtime_ok: boolean; readonly runtime_error: string | null; readonly runtime_checked_at: string | null; @@ -70,6 +74,7 @@ export interface RuntimeCache { readonly filter: string | null; readonly includeGlobal: boolean; readonly includeUnregistered: boolean; + readonly includeMeta: boolean; }): Promise; getPsPayload(opts: { readonly composeProject: string; @@ -81,7 +86,22 @@ export interface RuntimeCache { export function createRuntimeCache(opts: { readonly onRefresh?: (snapshot: RuntimeSnapshot) => void; + readonly deps?: { + readonly readProjectsRegistry?: typeof readProjectsRegistry; + readonly buildProjectViews?: typeof buildProjectViews; + readonly serializeProjectView?: typeof serializeProjectView; + readonly resolveProjectMeta?: typeof resolveProjectMeta; + }; }): RuntimeCache { + const deps = { + readProjectsRegistry: + opts.deps?.readProjectsRegistry ?? readProjectsRegistry, + buildProjectViews: opts.deps?.buildProjectViews ?? buildProjectViews, + serializeProjectView: + opts.deps?.serializeProjectView ?? serializeProjectView, + resolveProjectMeta: opts.deps?.resolveProjectMeta ?? resolveProjectMeta, + } as const; + let snapshot: RuntimeSnapshot | null = null; let refreshTask: Promise | null = null; let pending = false; @@ -180,20 +200,22 @@ export function createRuntimeCache(opts: { filter, includeGlobal, includeUnregistered, + includeMeta, }: { readonly filter: string | null; readonly includeGlobal: boolean; readonly includeUnregistered: boolean; + readonly includeMeta: boolean; }): Promise => { if (!snapshot) { await refresh({ reason: "projects" }); } - const registry = await readProjectsRegistry(); + const registry = await deps.readProjectsRegistry(); const runtime = filterRuntimeProjects({ runtime: snapshot?.runtime ?? [], includeGlobal, }); - const views = await buildProjectViews({ + const views = await deps.buildProjectViews({ registryProjects: registry.projects, runtime, runtimeOk: health.ok, @@ -202,18 +224,49 @@ export function createRuntimeCache(opts: { }); const runtimeMeta = serializeRuntimeHealth({ health }); + + const registryByName = new Map( + registry.projects.map((p) => [p.name, p] as const) + ); + const metas = includeMeta + ? await Promise.all( + views.map(async (view) => { + if (view.kind !== "registered") { + return null; + } + const reg = registryByName.get(view.name) ?? null; + if (!reg) { + return null; + } + try { + return await deps.resolveProjectMeta({ + projectName: reg.name, + repoRoot: reg.repoRoot, + projectDir: reg.projectDir, + composeFile: resolve(reg.projectDir, PROJECT_COMPOSE_FILENAME), + }); + } catch { + return null; + } + }) + ) + : []; return { generated_at: new Date().toISOString(), filter, include_global: includeGlobal, include_unregistered: includeUnregistered, + include_meta: includeMeta, runtime_ok: runtimeMeta.ok, runtime_error: runtimeMeta.error, runtime_checked_at: runtimeMeta.checkedAt, runtime_last_ok_at: runtimeMeta.lastOkAt, runtime_reset_at: runtimeMeta.lastResetAt, runtime_reset_count: runtimeMeta.resetCount, - projects: views.map(serializeProjectView), + projects: views.map((view, i) => ({ + ...deps.serializeProjectView(view), + ...(includeMeta ? { meta: metas[i] ?? null } : {}), + })), }; }; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index 233c0a6c..36949e36 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -23,6 +23,7 @@ import { startDockerEventWatcher } from "./docker-events.ts"; import { createDaemonLogger } from "./logger.ts"; import type { DaemonPaths } from "./paths.ts"; import { removeFileIfExists, writeDaemonPid } from "./process.ts"; +import { handleEnvRoutes } from "./routes/env.ts"; import { handleSessionRoutes } from "./routes/sessions.ts"; import type { RuntimeHealth } from "./runtime-cache.ts"; import { createRuntimeCache } from "./runtime-cache.ts"; @@ -185,72 +186,7 @@ export async function runDaemon({ ws: ServerWebSocket, message: string | Uint8Array ) => { - const state = ws.data as ControlPlaneWsState | undefined; - if (!state) { - ws.close(1008, "missing_state"); - return; - } - if (state.kind === "job") { - const parsed = parseWsMessage({ message }); - if (!parsed) { - ws.send( - JSON.stringify({ type: "error", message: "invalid_message" }) - ); - return; - } - if (parsed.type !== "hello") { - ws.send(JSON.stringify({ type: "error", message: "expected_hello" })); - return; - } - await startJobStream({ - ws, - state, - logsFrom: parsed.logsFrom, - eventsFrom: parsed.eventsFrom, - }); - return; - } - - const attachment = state.attachment; - if (!attachment) { - ws.close(1008, "shell_detached"); - return; - } - - const parsed = parseShellClientMessage({ message }); - if (!parsed) { - if (typeof message === "string" && message.length > 0) { - attachment.write(message); - } else if (message instanceof Uint8Array) { - attachment.write(message); - } - return; - } - - if (parsed.type === "hello" || parsed.type === "resize") { - if ( - typeof parsed.cols === "number" && - typeof parsed.rows === "number" - ) { - attachment.resize(parsed.cols, parsed.rows); - } - return; - } - - if (parsed.type === "input") { - attachment.write(parsed.data); - return; - } - - if (parsed.type === "signal") { - attachment.signal(parsed.signal); - return; - } - - if (parsed.type === "close") { - attachment.close(); - return; - } + await handleWebSocketMessage({ ws, message }); }, close: (ws: ServerWebSocket) => { const state = ws.data as ControlPlaneWsState | undefined; @@ -364,12 +300,18 @@ async function handleRequest({ return controlPlaneResponse; } - // Session routes (tmux session management) + // Session routes (mux session management) const sessionResponse = await handleSessionRoutes({ req, url }); if (sessionResponse) { return sessionResponse; } + // Env routes (contract + secrets state) + const envResponse = await handleEnvRoutes({ req, url }); + if (envResponse) { + return envResponse; + } + if (url.pathname === "/v1/status") { return jsonResponse({ status: "ok", @@ -423,10 +365,14 @@ async function handleRequest({ const includeUnregistered = parseBoolean({ value: url.searchParams.get("include_unregistered"), }); + const includeMeta = parseBoolean({ + value: url.searchParams.get("include_meta"), + }); const payload = await cache.getProjectsPayload({ filter, includeGlobal, includeUnregistered, + includeMeta, }); return jsonResponse(payload); } @@ -606,10 +552,14 @@ async function handleGatewayRequest(opts: { const includeGlobal = parseBoolean({ value: url.searchParams.get("include_global"), }); + const includeMeta = parseBoolean({ + value: url.searchParams.get("include_meta"), + }); const payload = await opts.cache.getProjectsPayload({ filter, includeGlobal, includeUnregistered: false, + includeMeta, }); const filtered = payload.projects.filter((project) => { if (!project || typeof project !== "object") { @@ -1044,6 +994,96 @@ function parseWsMessage(opts: { }; } +async function handleWebSocketMessage(opts: { + readonly ws: ServerWebSocket; + readonly message: string | Uint8Array; +}): Promise { + const state = opts.ws.data as ControlPlaneWsState | undefined; + if (!state) { + opts.ws.close(1008, "missing_state"); + return; + } + + if (state.kind === "job") { + await handleJobStreamWsMessage({ + ws: opts.ws, + state, + message: opts.message, + }); + return; + } + + const attachment = state.attachment; + if (!attachment) { + opts.ws.close(1008, "shell_detached"); + return; + } + + handleShellStreamWsMessage({ + attachment, + message: opts.message, + }); +} + +async function handleJobStreamWsMessage(opts: { + readonly ws: ServerWebSocket; + readonly state: JobStreamState; + readonly message: string | Uint8Array; +}): Promise { + const parsed = parseWsMessage({ message: opts.message }); + if (!parsed) { + opts.ws.send(JSON.stringify({ type: "error", message: "invalid_message" })); + return; + } + + await startJobStream({ + ws: opts.ws, + state: opts.state, + logsFrom: parsed.logsFrom, + eventsFrom: parsed.eventsFrom, + }); +} + +function handleShellStreamWsMessage(opts: { + readonly attachment: ShellAttachment; + readonly message: string | Uint8Array; +}): void { + const parsed = parseShellClientMessage({ message: opts.message }); + if (!parsed) { + if (typeof opts.message === "string" && opts.message.length > 0) { + opts.attachment.write(opts.message); + } else if (opts.message instanceof Uint8Array) { + opts.attachment.write(opts.message); + } + return; + } + + switch (parsed.type) { + case "hello": + case "resize": { + if (typeof parsed.cols === "number" && typeof parsed.rows === "number") { + opts.attachment.resize(parsed.cols, parsed.rows); + } + return; + } + case "input": { + opts.attachment.write(parsed.data); + return; + } + case "signal": { + opts.attachment.signal(parsed.signal); + return; + } + case "close": { + opts.attachment.close(); + return; + } + default: { + return; + } + } +} + type ShellClientMessage = | { readonly type: "hello"; readonly cols?: number; readonly rows?: number } | { readonly type: "input"; readonly data: string } diff --git a/src/lib/config.ts b/src/lib/config.ts index 908c1013..249f7b19 100644 --- a/src/lib/config.ts +++ b/src/lib/config.ts @@ -74,86 +74,121 @@ function parseJsonSafe(text: string): Record { } function parseKeyPath(opts: { readonly raw: string }): readonly string[] { - const parts: string[] = []; - let buffer = ""; - let escaped = false; - let inBracket = false; - let quote: '"' | "'" | null = null; - - const pushBuffer = () => { - const trimmed = buffer.trim(); - if (trimmed.length > 0) { - parts.push(trimmed); - } - buffer = ""; - }; + const state = createKeyPathParserState(); for (let i = 0; i < opts.raw.length; i += 1) { const ch = opts.raw[i] ?? ""; - if (inBracket) { - if (escaped) { - buffer += ch; - escaped = false; - continue; - } - if (ch === "\\") { - escaped = true; - continue; - } - if (quote) { - if (ch === quote) { - quote = null; - continue; - } - buffer += ch; - continue; - } - if (ch === "'" || ch === '"') { - quote = ch; - continue; - } - if (ch === "]") { - inBracket = false; - pushBuffer(); - continue; - } - buffer += ch; + if (state.inBracket) { + handleBracketChar(state, ch); continue; } + handlePathChar(state, ch); + } - if (escaped) { - buffer += ch; - escaped = false; - continue; - } - if (ch === "\\") { - escaped = true; - continue; - } - if (ch === ".") { - pushBuffer(); - continue; + finalizeKeyPath(state); + return state.parts; +} + +type KeyPathParserState = { + parts: string[]; + buffer: string; + escaped: boolean; + inBracket: boolean; + quote: '"' | "'" | null; +}; + +function createKeyPathParserState(): KeyPathParserState { + return { + parts: [], + buffer: "", + escaped: false, + inBracket: false, + quote: null, + }; +} + +function handleBracketChar(state: KeyPathParserState, ch: string): void { + if (state.escaped) { + state.buffer += ch; + state.escaped = false; + return; + } + + if (ch === "\\") { + state.escaped = true; + return; + } + + if (state.quote) { + if (ch === state.quote) { + state.quote = null; + return; } - if (ch === "[") { - if (buffer.trim().length > 0) { - pushBuffer(); - } else { - buffer = ""; - } - inBracket = true; - continue; + state.buffer += ch; + return; + } + + if (ch === "'" || ch === '"') { + state.quote = ch; + return; + } + + if (ch === "]") { + state.inBracket = false; + pushKeyPathBuffer(state); + return; + } + + state.buffer += ch; +} + +function handlePathChar(state: KeyPathParserState, ch: string): void { + if (state.escaped) { + state.buffer += ch; + state.escaped = false; + return; + } + + if (ch === "\\") { + state.escaped = true; + return; + } + + if (ch === ".") { + pushKeyPathBuffer(state); + return; + } + + if (ch === "[") { + if (state.buffer.trim().length > 0) { + pushKeyPathBuffer(state); + } else { + state.buffer = ""; } - buffer += ch; + state.inBracket = true; + return; } - if (escaped) { - buffer += "\\"; + state.buffer += ch; +} + +function pushKeyPathBuffer(state: KeyPathParserState): void { + const trimmed = state.buffer.trim(); + if (trimmed.length > 0) { + state.parts.push(trimmed); } - if (buffer.length > 0) { - pushBuffer(); + state.buffer = ""; +} + +function finalizeKeyPath(state: KeyPathParserState): void { + if (state.escaped) { + state.buffer += "\\"; + state.escaped = false; } - return parts; + if (state.buffer.length > 0) { + pushKeyPathBuffer(state); + } } function getPathValue(opts: { diff --git a/src/lib/hack-env.ts b/src/lib/hack-env.ts new file mode 100644 index 00000000..9b7b7964 --- /dev/null +++ b/src/lib/hack-env.ts @@ -0,0 +1,301 @@ +import { resolve } from "node:path"; +import { secrets } from "bun"; + +import { + PROJECT_ENV_CONTRACT_FILENAME, + PROJECT_ENV_FILENAME, +} from "../constants.ts"; +import { parseDotEnv } from "./env.ts"; +import { readTextFile, writeTextFileIfChanged } from "./fs.ts"; +import { getString, isRecord } from "./guards.ts"; + +export const HACK_ENV_VERSION = 1 as const; + +export type HackEnvSource = "plain_env" | "keychain"; + +export type HackEnvVar = { + readonly key: string; + readonly required: boolean; + readonly source: HackEnvSource; + readonly services: readonly string[] | null; + readonly description?: string; +}; + +export type HackEnvContract = { + readonly $schema?: string; + readonly version: typeof HACK_ENV_VERSION; + readonly vars: readonly HackEnvVar[]; +}; + +export type HackEnvReadResult = { + readonly path: string; + readonly exists: boolean; + readonly contract: HackEnvContract; + readonly parseError?: string; +}; + +export type HackEnvValueState = { + readonly key: string; + readonly required: boolean; + readonly source: HackEnvSource; + readonly services: readonly string[] | null; + readonly value: string | null; + readonly resolvedFrom: "dotenv" | "process" | "keychain" | null; +}; + +export type HackEnvResolveResult = { + readonly contractPath: string; + readonly contractExists: boolean; + readonly contractParseError?: string; + readonly contract: HackEnvContract; + readonly values: readonly HackEnvValueState[]; + readonly missingRequired: readonly HackEnvValueState[]; + readonly envForCompose: Readonly>; +}; + +export async function readHackEnvContract(opts: { + readonly projectDir: string; +}): Promise { + const path = resolve(opts.projectDir, PROJECT_ENV_CONTRACT_FILENAME); + const text = await readTextFile(path); + if (text === null) { + return { path, exists: false, contract: defaultHackEnvContract() }; + } + + let parsed: unknown; + try { + parsed = JSON.parse(text); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : "Invalid JSON"; + return { + path, + exists: true, + contract: defaultHackEnvContract(), + parseError: message, + }; + } + + const contract = parseHackEnvContract(parsed); + if (!contract) { + return { + path, + exists: true, + contract: defaultHackEnvContract(), + parseError: "Invalid hack.env.json format", + }; + } + + return { path, exists: true, contract }; +} + +export async function resolveHackEnv(opts: { + readonly projectDir: string; + readonly projectName: string; +}): Promise { + const read = await readHackEnvContract({ projectDir: opts.projectDir }); + const contract = read.contract; + + const envPath = resolve(opts.projectDir, PROJECT_ENV_FILENAME); + const envText = await readTextFile(envPath); + const dotenv = envText ? parseDotEnv(envText) : {}; + + const envForCompose: Record = {}; + const values: HackEnvValueState[] = []; + for (const v of contract.vars) { + const key = v.key; + + if (v.source === "keychain") { + const value = await secrets.get({ + service: resolveKeychainServiceName({ projectName: opts.projectName }), + name: key, + }); + const resolvedFrom = value === null ? null : "keychain"; + values.push({ + key, + required: v.required, + source: v.source, + services: v.services, + value, + resolvedFrom, + }); + if (value !== null) { + envForCompose[key] = value; + } + continue; + } + + const fromDotenv = dotenv[key]; + if (typeof fromDotenv === "string" && fromDotenv.length > 0) { + values.push({ + key, + required: v.required, + source: v.source, + services: v.services, + value: fromDotenv, + resolvedFrom: "dotenv", + }); + envForCompose[key] = fromDotenv; + continue; + } + + const fromProcess = process.env[key]; + if (typeof fromProcess === "string" && fromProcess.length > 0) { + values.push({ + key, + required: v.required, + source: v.source, + services: v.services, + value: fromProcess, + resolvedFrom: "process", + }); + envForCompose[key] = fromProcess; + continue; + } + + values.push({ + key, + required: v.required, + source: v.source, + services: v.services, + value: null, + resolvedFrom: null, + }); + } + + const missingRequired = values.filter((v) => v.required && v.value === null); + return { + contractPath: read.path, + contractExists: read.exists, + ...(read.parseError ? { contractParseError: read.parseError } : {}), + contract, + values, + missingRequired, + envForCompose, + }; +} + +export async function upsertDotEnvValue(opts: { + readonly envFile: string; + readonly key: string; + readonly value: string; +}): Promise<{ readonly changed: boolean }> { + const existingText = (await readTextFile(opts.envFile)) ?? ""; + const env = parseDotEnv(existingText); + const nextEnv: Record = { ...env, [opts.key]: opts.value }; + const nextText = serializeDotEnvStable(nextEnv); + const result = await writeTextFileIfChanged(opts.envFile, nextText); + return { changed: result.changed }; +} + +export async function removeDotEnvKey(opts: { + readonly envFile: string; + readonly key: string; +}): Promise<{ readonly changed: boolean }> { + const existingText = (await readTextFile(opts.envFile)) ?? ""; + const env = parseDotEnv(existingText); + if (!(opts.key in env)) { + return { changed: false }; + } + const nextEnv: Record = {}; + for (const [key, value] of Object.entries(env)) { + if (key === opts.key) { + continue; + } + nextEnv[key] = value; + } + const nextText = serializeDotEnvStable(nextEnv); + const result = await writeTextFileIfChanged(opts.envFile, nextText); + return { changed: result.changed }; +} + +export function resolveKeychainServiceName(opts: { + readonly projectName: string; +}): string { + return `hack-${opts.projectName}`; +} + +function defaultHackEnvContract(): HackEnvContract { + return { version: HACK_ENV_VERSION, vars: [] }; +} + +function parseHackEnvContract(value: unknown): HackEnvContract | null { + if (!isRecord(value)) { + return null; + } + const versionRaw = value.version; + const version = typeof versionRaw === "number" ? versionRaw : null; + if (version !== HACK_ENV_VERSION) { + return null; + } + + const varsRaw = value.vars; + if (!Array.isArray(varsRaw)) { + return null; + } + + const vars: HackEnvVar[] = []; + for (const entry of varsRaw) { + const parsed = parseHackEnvVar(entry); + if (parsed) { + vars.push(parsed); + } + } + + return { + $schema: getString(value, "$schema") ?? undefined, + version: HACK_ENV_VERSION, + vars, + }; +} + +function parseHackEnvVar(value: unknown): HackEnvVar | null { + if (!isRecord(value)) { + return null; + } + const key = getString(value, "key"); + if (!key) { + return null; + } + const required = value.required === true; + const sourceRaw = getString(value, "source") ?? "plain_env"; + const source: HackEnvSource = + sourceRaw === "keychain" ? "keychain" : "plain_env"; + + const servicesRaw = value.services; + const services = Array.isArray(servicesRaw) + ? servicesRaw + .map((v) => (typeof v === "string" ? v.trim() : "")) + .filter((v) => v.length > 0) + : null; + + const description = getString(value, "description") ?? undefined; + + return { + key, + required, + source, + services, + ...(description ? { description } : {}), + }; +} + +function serializeDotEnvStable(env: Record): string { + const lines: string[] = []; + for (const key of Object.keys(env).sort((a, b) => a.localeCompare(b))) { + const value = env[key]; + if (typeof value !== "string") { + continue; + } + lines.push(`${key}=${escapeEnvValue(value)}`); + } + return `${lines.join("\n")}\n`; +} + +function escapeEnvValue(value: string): string { + const needsQuotes = + value.includes(" ") || value.includes("\n") || value.includes('"'); + if (!needsQuotes) { + return value; + } + return `"${value.replaceAll('"', '\\"')}"`; +} diff --git a/src/lib/project-meta.ts b/src/lib/project-meta.ts new file mode 100644 index 00000000..3d571462 --- /dev/null +++ b/src/lib/project-meta.ts @@ -0,0 +1,435 @@ +import { dirname, resolve } from "node:path"; +import { YAML } from "bun"; +import type { MuxSession } from "../mux/mux-backend.ts"; +import { getMuxBackends } from "../mux/mux-resolver.ts"; +import { parseSessionBase } from "../mux/session-names.ts"; +import type { BranchEntry } from "./branches.ts"; +import { readBranchesFile } from "./branches.ts"; +import { pathExists, readTextFile } from "./fs.ts"; +import { isRecord } from "./guards.ts"; +import type { HackEnvSource } from "./hack-env.ts"; +import { resolveHackEnv } from "./hack-env.ts"; +import { exec } from "./shell.ts"; + +export type GitWorktreeMeta = { + readonly path: string; + readonly head: string | null; + readonly branch: string | null; + readonly detached: boolean; +}; + +export type GitMeta = { + readonly isRepo: boolean; + readonly head: string | null; + readonly branch: string | null; + readonly detached: boolean | null; + readonly dirty: boolean | null; + readonly localBranchCount: number | null; + readonly worktrees: readonly GitWorktreeMeta[] | null; + readonly error: string | null; +}; + +export type HackBranchesMeta = { + readonly path: string; + readonly parseError: string | null; + readonly branches: readonly BranchEntry[]; +}; + +export type EnvVarMeta = { + readonly key: string; + readonly required: boolean; + readonly source: HackEnvSource; + readonly services: readonly string[] | null; + readonly description?: string; + readonly resolvedFrom: "dotenv" | "process" | "keychain" | null; + readonly hasValue: boolean; +}; + +export type EnvMeta = { + readonly contractPath: string; + readonly contractExists: boolean; + readonly contractParseError: string | null; + readonly vars: readonly EnvVarMeta[]; + readonly missingRequired: readonly string[]; +}; + +export type SessionsMeta = { + readonly sessions: readonly MuxSession[]; +}; + +export type ComposeBuildServiceMeta = { + readonly service: string; + readonly build: boolean; + readonly context: string | null; + readonly dockerfile: string | null; + readonly dockerfilePath: string | null; + readonly dockerfileExists: boolean | null; +}; + +export type ComposeBuildMeta = { + readonly services: readonly ComposeBuildServiceMeta[]; +}; + +export type ProjectMeta = { + readonly git: GitMeta; + readonly hackBranches: HackBranchesMeta; + readonly env: EnvMeta; + readonly sessions: SessionsMeta; + readonly composeBuild: ComposeBuildMeta; +}; + +export async function resolveProjectMeta(opts: { + readonly projectName: string; + readonly repoRoot: string; + readonly projectDir: string; + readonly composeFile: string; +}): Promise { + const [git, hackBranches, env, sessions, composeBuild] = await Promise.all([ + resolveGitMeta({ repoRoot: opts.repoRoot }), + resolveHackBranchesMeta({ projectDir: opts.projectDir }), + resolveEnvMeta({ + projectDir: opts.projectDir, + projectName: opts.projectName, + }), + resolveSessionsMeta({ projectName: opts.projectName }), + resolveComposeBuildMeta({ composeFile: opts.composeFile }), + ]); + + return { + git, + hackBranches, + env, + sessions, + composeBuild, + }; +} + +export async function resolveGitMeta(opts: { + readonly repoRoot: string; +}): Promise { + const inside = await exec( + ["git", "-C", opts.repoRoot, "rev-parse", "--is-inside-work-tree"], + { stdin: "ignore" } + ); + if (inside.exitCode !== 0 || inside.stdout.trim() !== "true") { + return { + isRepo: false, + head: null, + branch: null, + detached: null, + dirty: null, + localBranchCount: null, + worktrees: null, + error: null, + }; + } + + const [headRes, branchRes, statusRes, branchesRes, worktreesRes] = + await Promise.all([ + exec(["git", "-C", opts.repoRoot, "rev-parse", "HEAD"], { + stdin: "ignore", + }), + exec(["git", "-C", opts.repoRoot, "branch", "--show-current"], { + stdin: "ignore", + }), + exec(["git", "-C", opts.repoRoot, "status", "--porcelain"], { + stdin: "ignore", + }), + exec( + [ + "git", + "-C", + opts.repoRoot, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + ], + { stdin: "ignore" } + ), + exec(["git", "-C", opts.repoRoot, "worktree", "list", "--porcelain"], { + stdin: "ignore", + }), + ]); + + const head = headRes.exitCode === 0 ? headRes.stdout.trim() : null; + const branch = branchRes.exitCode === 0 ? branchRes.stdout.trim() : ""; + const detached = branch.length === 0; + const dirty = + statusRes.exitCode === 0 ? statusRes.stdout.trim().length > 0 : null; + + const localBranchCount = + branchesRes.exitCode === 0 + ? branchesRes.stdout + .split("\n") + .map((l) => l.trim()) + .filter((l) => l.length > 0).length + : null; + + const worktrees = + worktreesRes.exitCode === 0 + ? parseGitWorktreePorcelain(worktreesRes.stdout) + : null; + + const error = + headRes.exitCode !== 0 || + branchRes.exitCode !== 0 || + statusRes.exitCode !== 0 || + branchesRes.exitCode !== 0 + ? "git_command_failed" + : null; + + return { + isRepo: true, + head, + branch: branch.length > 0 ? branch : null, + detached, + dirty, + localBranchCount, + worktrees, + error, + }; +} + +function parseGitWorktreePorcelain(text: string): readonly GitWorktreeMeta[] { + const lines = text.split("\n"); + const out: GitWorktreeMeta[] = []; + + let current: { + path: string | null; + head: string | null; + branch: string | null; + detached: boolean; + } = { path: null, head: null, branch: null, detached: false }; + + const flush = () => { + if (current.path) { + out.push({ + path: current.path, + head: current.head, + branch: current.branch, + detached: current.detached, + }); + } + current = { path: null, head: null, branch: null, detached: false }; + }; + + for (const line of lines) { + const trimmed = line.trim(); + if (trimmed.length === 0) { + flush(); + continue; + } + if (trimmed.startsWith("worktree ")) { + current.path = trimmed.slice("worktree ".length).trim(); + continue; + } + if (trimmed.startsWith("HEAD ")) { + current.head = trimmed.slice("HEAD ".length).trim(); + continue; + } + if (trimmed.startsWith("branch ")) { + const ref = trimmed.slice("branch ".length).trim(); + const prefix = "refs/heads/"; + current.branch = ref.startsWith(prefix) ? ref.slice(prefix.length) : ref; + continue; + } + if (trimmed === "detached") { + current.detached = true; + } + } + flush(); + + return out; +} + +export async function resolveHackBranchesMeta(opts: { + readonly projectDir: string; +}): Promise { + const read = await readBranchesFile({ projectDir: opts.projectDir }); + return { + path: read.path, + parseError: read.parseError ?? null, + branches: read.file.branches, + }; +} + +export async function resolveEnvMeta(opts: { + readonly projectDir: string; + readonly projectName: string; +}): Promise { + const resolved = await resolveHackEnv({ + projectDir: opts.projectDir, + projectName: opts.projectName, + }); + + const valuesByKey = new Map(resolved.values.map((v) => [v.key, v] as const)); + const vars: EnvVarMeta[] = []; + for (const v of resolved.contract.vars) { + const state = valuesByKey.get(v.key) ?? null; + vars.push({ + key: v.key, + required: v.required, + source: v.source, + services: v.services, + ...(v.description ? { description: v.description } : {}), + resolvedFrom: state?.resolvedFrom ?? null, + hasValue: Boolean(state?.value), + }); + } + + return { + contractPath: resolved.contractPath, + contractExists: resolved.contractExists, + contractParseError: resolved.contractParseError ?? null, + vars, + missingRequired: resolved.missingRequired.map((v) => v.key), + }; +} + +export async function resolveSessionsMeta(opts: { + readonly projectName: string; +}): Promise { + const backends = getMuxBackends(); + const sessions: MuxSession[] = []; + + for (const backend of backends.values()) { + if (!backend.available) { + continue; + } + const listed = await backend.listSessions(); + for (const s of listed) { + const base = parseSessionBase({ name: s.name }); + if (base === opts.projectName) { + sessions.push(s); + } + } + } + + return { + sessions: sessions.sort((a, b) => + a.name === b.name + ? a.backend.localeCompare(b.backend) + : a.name.localeCompare(b.name) + ), + }; +} + +export async function resolveComposeBuildMeta(opts: { + readonly composeFile: string; +}): Promise { + const text = await readTextFile(opts.composeFile); + if (!text) { + return { services: [] }; + } + + const parsed = parseComposeYaml({ text }); + if (!parsed) { + return { services: [] }; + } + + const servicesRaw = readComposeServices({ parsed }); + if (!servicesRaw) { + return { services: [] }; + } + + const composeDir = dirname(opts.composeFile); + const out: ComposeBuildServiceMeta[] = []; + + for (const [service, raw] of Object.entries(servicesRaw)) { + const meta = await resolveComposeBuildServiceMeta({ + service, + raw, + composeDir, + }); + if (meta) { + out.push(meta); + } + } + + return { + services: out.sort((a, b) => a.service.localeCompare(b.service)), + }; +} + +function parseComposeYaml(opts: { + readonly text: string; +}): Record | null { + let parsed: unknown; + try { + parsed = YAML.parse(opts.text); + } catch { + return null; + } + return isRecord(parsed) ? parsed : null; +} + +function readComposeServices(opts: { + readonly parsed: Record; +}): Record | null { + const services = opts.parsed.services; + return isRecord(services) ? services : null; +} + +async function resolveComposeBuildServiceMeta(opts: { + readonly service: string; + readonly raw: unknown; + readonly composeDir: string; +}): Promise { + if (!isRecord(opts.raw)) { + return null; + } + + const buildRaw = opts.raw.build; + const build = typeof buildRaw === "string" || isRecord(buildRaw); + if (!build) { + return { + service: opts.service, + build: false, + context: null, + dockerfile: null, + dockerfilePath: null, + dockerfileExists: null, + }; + } + + const resolved = resolveComposeBuildInputs({ buildRaw }); + const dockerfilePath = resolved.context + ? resolve(opts.composeDir, resolved.context, resolved.dockerfile) + : resolve(opts.composeDir, resolved.dockerfile); + const dockerfileExists = await pathExists(dockerfilePath); + + return { + service: opts.service, + build: true, + context: resolved.context, + dockerfile: resolved.dockerfile.length > 0 ? resolved.dockerfile : null, + dockerfilePath, + dockerfileExists, + }; +} + +function resolveComposeBuildInputs(opts: { readonly buildRaw: unknown }): { + readonly context: string | null; + readonly dockerfile: string; +} { + if (typeof opts.buildRaw === "string") { + const context = opts.buildRaw.trim(); + return { + context: context.length > 0 ? context : null, + dockerfile: "Dockerfile", + }; + } + + const contextRaw = isRecord(opts.buildRaw) ? opts.buildRaw.context : null; + const context = typeof contextRaw === "string" ? contextRaw.trim() : ""; + const dockerfileRaw = isRecord(opts.buildRaw) + ? opts.buildRaw.dockerfile + : null; + const dockerfile = + typeof dockerfileRaw === "string" ? dockerfileRaw.trim() : "Dockerfile"; + + return { + context: context.length > 0 ? context : null, + dockerfile, + }; +} diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index 6fe4604f..76716c57 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -60,16 +60,11 @@ export async function buildProjectViews(opts: { const runtimeByName = new Map( opts.runtime.map((p) => [p.project, p] as const) ); - - const names = new Set(); - for (const p of opts.registryProjects) { - names.add(p.name); - } - if (opts.includeUnregistered) { - for (const p of opts.runtime) { - names.add(p.project); - } - } + const names = collectProjectNames({ + registryProjects: opts.registryProjects, + runtime: opts.runtime, + includeUnregistered: opts.includeUnregistered, + }); const out: ProjectView[] = []; for (const name of [...names].sort((a, b) => a.localeCompare(b))) { @@ -81,85 +76,163 @@ export async function buildProjectViews(opts: { const runtime = runtimeByName.get(name) ?? null; if (reg) { - const projectDirOk = await pathExists(reg.projectDir); - const composeFile = resolve(reg.projectDir, PROJECT_COMPOSE_FILENAME); - const composeExists = projectDirOk && (await pathExists(composeFile)); - const definedServices = composeExists - ? await readComposeServices({ composeFile }) - : null; - const serviceHosts = composeExists - ? await readComposeServiceHosts({ composeFile }) - : null; - const running = countRunningServices(runtime); - const runtimeConfigured = composeExists; - const runtimeStatus: ProjectRuntimeStatus = resolveRuntimeStatus({ - projectDirOk, - composeExists, - runtimeOk: opts.runtimeOk, - running, - }); - const status: ProjectView["status"] = resolveProjectStatus({ - projectDirOk, - runtimeOk: opts.runtimeOk, - running, - }); - const branchRuntime = collectBranchRuntime({ - baseName: name, - runtimeProjects: opts.runtime, - }); - const extensions = projectDirOk - ? await resolveProjectExtensions({ projectDir: reg.projectDir }) - : null; - - out.push({ - projectId: reg.id, - name, - devHost: reg.devHost ?? null, - repoRoot: reg.repoRoot, - projectDir: reg.projectDir, - definedServices, - extensionsEnabled: extensions?.enabled ?? null, - features: extensions?.features ?? null, - serviceHosts, - runtimeConfigured, - runtimeStatus, - runtime, - branchRuntime, - kind: "registered", - status, - }); + out.push( + await buildRegisteredProjectView({ + name, + reg, + runtime, + runtimeOk: opts.runtimeOk, + runtimeProjects: opts.runtime, + }) + ); continue; } if (opts.includeUnregistered) { - const running = countRunningServices(runtime); - const runtimeStatus: ProjectRuntimeStatus = - resolveUnregisteredRuntimeStatus({ + out.push( + buildUnregisteredProjectView({ + name, + runtime, runtimeOk: opts.runtimeOk, - running, - }); - out.push({ - name, - devHost: null, - repoRoot: null, - projectDir: null, - definedServices: null, - extensionsEnabled: null, - features: null, - serviceHosts: null, - runtimeConfigured: null, - runtimeStatus, - runtime, - branchRuntime: [], - kind: "unregistered", - status: "unregistered", - }); + }) + ); } } return out; } +function collectProjectNames(opts: { + readonly registryProjects: readonly RegisteredProject[]; + readonly runtime: readonly RuntimeProject[]; + readonly includeUnregistered: boolean; +}): ReadonlySet { + const names = new Set(); + for (const p of opts.registryProjects) { + names.add(p.name); + } + if (!opts.includeUnregistered) { + return names; + } + for (const p of opts.runtime) { + names.add(p.project); + } + return names; +} + +async function buildRegisteredProjectView(opts: { + readonly name: string; + readonly reg: RegisteredProject; + readonly runtime: RuntimeProject | null; + readonly runtimeOk: boolean; + readonly runtimeProjects: readonly RuntimeProject[]; +}): Promise { + const composeMeta = await resolveComposeMeta({ + projectDir: opts.reg.projectDir, + }); + const running = countRunningServices(opts.runtime); + const runtimeStatus = resolveRuntimeStatus({ + projectDirOk: composeMeta.projectDirOk, + composeExists: composeMeta.composeExists, + runtimeOk: opts.runtimeOk, + running, + }); + const status = resolveProjectStatus({ + projectDirOk: composeMeta.projectDirOk, + runtimeOk: opts.runtimeOk, + running, + }); + const branchRuntime = collectBranchRuntime({ + baseName: opts.name, + runtimeProjects: opts.runtimeProjects, + }); + const extensions = composeMeta.projectDirOk + ? await resolveProjectExtensions({ projectDir: opts.reg.projectDir }) + : null; + + return { + projectId: opts.reg.id, + name: opts.name, + devHost: opts.reg.devHost ?? null, + repoRoot: opts.reg.repoRoot, + projectDir: opts.reg.projectDir, + definedServices: composeMeta.definedServices, + extensionsEnabled: extensions?.enabled ?? null, + features: extensions?.features ?? null, + serviceHosts: composeMeta.serviceHosts, + runtimeConfigured: composeMeta.composeExists, + runtimeStatus, + runtime: opts.runtime, + branchRuntime, + kind: "registered", + status, + }; +} + +function buildUnregisteredProjectView(opts: { + readonly name: string; + readonly runtime: RuntimeProject | null; + readonly runtimeOk: boolean; +}): ProjectView { + const running = countRunningServices(opts.runtime); + const runtimeStatus = resolveUnregisteredRuntimeStatus({ + runtimeOk: opts.runtimeOk, + running, + }); + + return { + name: opts.name, + devHost: null, + repoRoot: null, + projectDir: null, + definedServices: null, + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: null, + runtimeStatus, + runtime: opts.runtime, + branchRuntime: [], + kind: "unregistered", + status: "unregistered", + }; +} + +type ComposeMeta = { + readonly projectDirOk: boolean; + readonly composeExists: boolean; + readonly definedServices: readonly string[] | null; + readonly serviceHosts: Readonly> | null; +}; + +async function resolveComposeMeta(opts: { + readonly projectDir: string; +}): Promise { + const projectDirOk = await pathExists(opts.projectDir); + const composeFile = resolve(opts.projectDir, PROJECT_COMPOSE_FILENAME); + const composeExists = projectDirOk && (await pathExists(composeFile)); + if (!composeExists) { + return { + projectDirOk, + composeExists, + definedServices: null, + serviceHosts: null, + }; + } + + const [definedServices, serviceHosts] = await Promise.all([ + readComposeServices({ composeFile }), + readComposeServiceHosts({ composeFile }), + ]); + + return { + projectDirOk, + composeExists, + definedServices, + serviceHosts, + }; +} + export function serializeProjectView( view: ProjectView ): Record { diff --git a/src/lib/project.ts b/src/lib/project.ts index 30acd866..3dc39650 100644 --- a/src/lib/project.ts +++ b/src/lib/project.ts @@ -127,10 +127,46 @@ export interface ProjectConfig { readonly logs?: ProjectLogsConfig; readonly oauth?: ProjectOauthConfig; readonly internal?: ProjectInternalConfig; + readonly sessions?: ProjectSessionsConfig; + readonly lifecycle?: ProjectLifecycleConfig; readonly configPath?: string; readonly parseError?: string; } +export interface ProjectSessionsConfig { + /** + * Mux backend for `hack session`. + * - "auto" (default): prefer tmux, fall back to zellij. + * - "tmux": require tmux. + * - "zellij": require zellij. + * - "none": disable sessions. + */ + readonly mux?: string; +} + +export interface ProjectLifecycleConfig { + readonly up?: ProjectLifecycleHooks; + readonly down?: ProjectLifecycleHooks; + readonly processes?: readonly ProjectLifecycleProcess[]; +} + +export interface ProjectLifecycleHooks { + readonly before?: readonly ProjectLifecycleCommand[]; + readonly after?: readonly ProjectLifecycleCommand[]; +} + +export interface ProjectLifecycleCommand { + readonly name?: string; + readonly command: string; + readonly cwd?: string; +} + +export interface ProjectLifecycleProcess { + readonly name: string; + readonly command: string; + readonly cwd?: string; +} + export type LogsBackend = "compose" | "loki"; export interface ProjectLogsConfig { @@ -265,6 +301,8 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { const logs = parseLogsConfig(getRecord(value, "logs")); const oauth = parseOauthConfig(getRecord(value, "oauth")); const internal = parseInternalConfig(getRecord(value, "internal")); + const sessions = parseSessionsConfig(getRecord(value, "sessions")); + const lifecycle = parseLifecycleConfig(getRecord(value, "lifecycle")); return { ...(name ? { name } : {}), @@ -272,10 +310,139 @@ function parseProjectConfigRecord(value: unknown, path: string): ProjectConfig { ...(logs ? { logs } : {}), ...(oauth ? { oauth } : {}), ...(internal ? { internal } : {}), + ...(sessions ? { sessions } : {}), + ...(lifecycle ? { lifecycle } : {}), configPath: path, }; } +function parseLifecycleConfig( + value: Record | undefined +): ProjectLifecycleConfig | undefined { + if (!value) { + return undefined; + } + + const up = parseLifecycleHooks(getRecord(value, "up")); + const down = parseLifecycleHooks(getRecord(value, "down")); + const processes = parseLifecycleProcesses(value.processes); + + const out: ProjectLifecycleConfig = { + ...(up ? { up } : {}), + ...(down ? { down } : {}), + ...(processes ? { processes } : {}), + }; + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseLifecycleHooks( + value: Record | undefined +): ProjectLifecycleHooks | undefined { + if (!value) { + return undefined; + } + + const before = parseLifecycleCommands(value.before); + const after = parseLifecycleCommands(value.after); + const out: ProjectLifecycleHooks = { + ...(before ? { before } : {}), + ...(after ? { after } : {}), + }; + return Object.keys(out).length > 0 ? out : undefined; +} + +function parseLifecycleCommands( + value: unknown +): readonly ProjectLifecycleCommand[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const out: ProjectLifecycleCommand[] = []; + for (const entry of value) { + const parsed = parseLifecycleCommand(entry); + if (parsed) { + out.push(parsed); + } + } + + return out.length > 0 ? out : undefined; +} + +function parseLifecycleCommand(value: unknown): ProjectLifecycleCommand | null { + if (typeof value === "string") { + const command = value.trim(); + return command.length > 0 ? { command } : null; + } + if (!isRecord(value)) { + return null; + } + + const command = getString(value, "command")?.trim(); + if (!command) { + return null; + } + const name = getString(value, "name")?.trim(); + const cwd = getString(value, "cwd")?.trim(); + + return { + ...(name && name.length > 0 ? { name } : {}), + command, + ...(cwd && cwd.length > 0 ? { cwd } : {}), + }; +} + +function parseLifecycleProcesses( + value: unknown +): readonly ProjectLifecycleProcess[] | undefined { + if (!Array.isArray(value)) { + return undefined; + } + + const out: ProjectLifecycleProcess[] = []; + for (const entry of value) { + const parsed = parseLifecycleProcess(entry); + if (parsed) { + out.push(parsed); + } + } + + return out.length > 0 ? out : undefined; +} + +function parseLifecycleProcess(value: unknown): ProjectLifecycleProcess | null { + if (!isRecord(value)) { + return null; + } + + const name = getString(value, "name")?.trim(); + const command = getString(value, "command")?.trim(); + if (!(name && command)) { + return null; + } + const cwd = getString(value, "cwd")?.trim(); + + return { + name, + command, + ...(cwd && cwd.length > 0 ? { cwd } : {}), + }; +} + +function parseSessionsConfig( + value: Record | undefined +): ProjectSessionsConfig | undefined { + if (!value) { + return undefined; + } + + const mux = getString(value, "mux"); + const out: ProjectSessionsConfig = { + ...(mux ? { mux } : {}), + }; + return Object.keys(out).length > 0 ? out : undefined; +} + function parseLogsConfig( value: Record | undefined ): ProjectLogsConfig | undefined { diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 3a0cb7b2..8e1bd413 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -19,9 +19,18 @@ export type RuntimeContainer = { readonly status: string; readonly name: string; readonly ports: string; + readonly image: string | null; + readonly ip: string | null; + readonly mounts: readonly RuntimeMount[]; + readonly labels: Readonly>; readonly workingDir: string | null; }; +export type RuntimeMount = { + readonly source: string | null; + readonly destination: string | null; +}; + export type RuntimeService = { readonly service: string; readonly containers: readonly RuntimeContainer[]; @@ -69,6 +78,40 @@ export async function readRuntimeProjects(opts: { readonly includeGlobal: boolean; }): Promise { const checkedAtMs = Date.now(); + const psResult = await readDockerComposePs(); + if (!psResult.ok) { + return { ok: false, runtime: [], error: psResult.error, checkedAtMs }; + } + + const globalRoot = resolveGlobalHackRoot(); + const containers = buildRuntimeContainers({ + rows: psResult.rows, + inspectById: psResult.inspectById, + globalRoot, + includeGlobal: opts.includeGlobal, + }); + const out = buildRuntimeProjects({ + containers, + globalRoot, + }); + + return { + ok: true, + runtime: out.sort((a, b) => a.project.localeCompare(b.project)), + error: null, + checkedAtMs, + }; +} + +type DockerComposePsResult = + | { + readonly ok: true; + readonly rows: readonly unknown[]; + readonly inspectById: Map; + } + | { readonly ok: false; readonly error: string }; + +async function readDockerComposePs(): Promise { const res = await exec( [ "docker", @@ -84,65 +127,116 @@ export async function readRuntimeProjects(opts: { if (res.exitCode !== 0) { return { ok: false, - runtime: [], error: formatDockerError({ exitCode: res.exitCode, stdout: res.stdout, stderr: res.stderr, }), - checkedAtMs, }; } - const baseRows = parseJsonLines(res.stdout); - const ids = baseRows + const rows = parseJsonLines(res.stdout); + const ids = rows .map((row) => getString(row, "ID") ?? getString(row, "Id") ?? "") .filter((id) => id.length > 0); - const labelsById = await readContainerLabels({ ids }); + const inspectById = await readContainerInspectMeta({ ids }); + return { ok: true, rows, inspectById }; +} +function resolveGlobalHackRoot(): string { const home = process.env.HOME ?? ""; - const globalRoot = home ? resolve(home, GLOBAL_HACK_DIR_NAME) : ""; + return home ? resolve(home, GLOBAL_HACK_DIR_NAME) : ""; +} +function buildRuntimeContainers(opts: { + readonly rows: readonly unknown[]; + readonly inspectById: Map; + readonly globalRoot: string; + readonly includeGlobal: boolean; +}): RuntimeContainer[] { const containers: RuntimeContainer[] = []; - for (const row of baseRows) { - const id = getString(row, "ID") ?? getString(row, "Id") ?? ""; - const state = getString(row, "State") ?? ""; - const status = getString(row, "Status") ?? ""; - const name = getString(row, "Names") ?? ""; - const ports = getString(row, "Ports") ?? ""; - const labelsRaw = getString(row, "Labels"); - const labels = - (id.length > 0 ? labelsById.get(id) : undefined) ?? - (labelsRaw ? parseLabelString({ raw: labelsRaw }) : {}); - const project = labels["com.docker.compose.project"] ?? null; - const service = labels["com.docker.compose.service"] ?? null; - const oneoff = - (labels["com.docker.compose.oneoff"] ?? "").toLowerCase() === "true"; - if (!(project && service) || oneoff) { + + for (const row of opts.rows) { + const container = parseRuntimeContainerRow({ + row, + inspectById: opts.inspectById, + globalRoot: opts.globalRoot, + }); + if (!container) { continue; } - const workingDir = labels["com.docker.compose.project.working_dir"] ?? null; - const isGlobal = - globalRoot.length > 0 && workingDir - ? workingDir.startsWith(globalRoot) - : false; - if (isGlobal && !opts.includeGlobal) { + if ( + !opts.includeGlobal && + isGlobalWorkingDir({ + globalRoot: opts.globalRoot, + workingDir: container.workingDir, + }) + ) { continue; } - containers.push({ - id, - project, - service, - state, - status, - name, - ports, - workingDir, - }); + containers.push(container); + } + + return containers; +} + +function parseRuntimeContainerRow(opts: { + readonly row: unknown; + readonly inspectById: Map; + readonly globalRoot: string; +}): RuntimeContainer | null { + if (!isRecord(opts.row)) { + return null; + } + const row = opts.row; + + const id = getString(row, "ID") ?? getString(row, "Id") ?? ""; + const imageFromPs = getString(row, "Image") ?? null; + const labelsRaw = getString(row, "Labels"); + const inspect = id.length > 0 ? opts.inspectById.get(id) : undefined; + const labels = + inspect?.labels ?? (labelsRaw ? parseLabelString({ raw: labelsRaw }) : {}); + const project = labels["com.docker.compose.project"] ?? null; + const service = labels["com.docker.compose.service"] ?? null; + const oneoff = + (labels["com.docker.compose.oneoff"] ?? "").toLowerCase() === "true"; + if (!(project && service) || oneoff) { + return null; } + const workingDir = labels["com.docker.compose.project.working_dir"] ?? null; + + return { + id, + project, + service, + state: getString(row, "State") ?? "", + status: getString(row, "Status") ?? "", + name: getString(row, "Names") ?? "", + ports: getString(row, "Ports") ?? "", + image: inspect?.image ?? imageFromPs, + ip: inspect?.ip ?? null, + mounts: inspect?.mounts ?? [], + labels, + workingDir, + }; +} + +function isGlobalWorkingDir(opts: { + readonly globalRoot: string; + readonly workingDir: string | null; +}): boolean { + return opts.globalRoot.length > 0 && opts.workingDir !== null + ? opts.workingDir.startsWith(opts.globalRoot) + : false; +} + +function buildRuntimeProjects(opts: { + readonly containers: readonly RuntimeContainer[]; + readonly globalRoot: string; +}): RuntimeProject[] { const byProject = new Map< string, { @@ -151,20 +245,29 @@ export async function readRuntimeProjects(opts: { isGlobal: boolean; } >(); - for (const c of containers) { - const workingDir = c.workingDir; - const isGlobal = - globalRoot.length > 0 && workingDir - ? workingDir.startsWith(globalRoot) - : false; - const p = byProject.get(c.project) ?? { - workingDir, - byService: new Map(), - isGlobal, - }; - const arr = p.byService.get(c.service) ?? []; - p.byService.set(c.service, [...arr, c]); - byProject.set(c.project, p); + + for (const container of opts.containers) { + const existing = byProject.get(container.project); + if (!existing) { + const byService = new Map(); + byService.set(container.service, [container]); + byProject.set(container.project, { + workingDir: container.workingDir, + byService, + isGlobal: isGlobalWorkingDir({ + globalRoot: opts.globalRoot, + workingDir: container.workingDir, + }), + }); + continue; + } + + const list = existing.byService.get(container.service); + if (list) { + list.push(container); + } else { + existing.byService.set(container.service, [container]); + } } const out: RuntimeProject[] = []; @@ -181,12 +284,7 @@ export async function readRuntimeProjects(opts: { }); } - return { - ok: true, - runtime: out.sort((a, b) => a.project.localeCompare(b.project)), - error: null, - checkedAtMs, - }; + return out; } export async function autoRegisterRuntimeHackProjects(opts: { @@ -233,15 +331,29 @@ export function serializeRuntimeProject( status: container.status, name: container.name, ports: container.ports, + image: container.image, + ip: container.ip, + mounts: container.mounts.map((mount) => ({ + source: mount.source, + destination: mount.destination, + })), + labels: container.labels, working_dir: container.workingDir ?? null, })), })), }; } -export async function readContainerLabels(opts: { +type ContainerInspectMeta = { + readonly labels: Readonly>; + readonly image: string | null; + readonly ip: string | null; + readonly mounts: readonly RuntimeMount[]; +}; + +export async function readContainerInspectMeta(opts: { readonly ids: readonly string[]; -}): Promise>> { +}): Promise> { if (opts.ids.length === 0) { return new Map(); } @@ -251,7 +363,7 @@ export async function readContainerLabels(opts: { "docker", "inspect", "--format", - "{{.Id}}|{{json .Config.Labels}}", + "{{.Id}}\t{{.Config.Image}}\t{{json .Config.Labels}}\t{{json .Mounts}}\t{{json .NetworkSettings.Networks}}", ...opts.ids, ], { stdin: "ignore" } @@ -260,24 +372,31 @@ export async function readContainerLabels(opts: { return new Map(); } - const out = new Map>(); + const out = new Map(); for (const line of res.stdout.split("\n")) { const trimmed = line.trim(); if (trimmed.length === 0) { continue; } - const idx = trimmed.indexOf("|"); - if (idx <= 0) { + + const [idRaw, imageRaw, labelsRaw, mountsRaw, networksRaw] = + trimmed.split("\t"); + const id = (idRaw ?? "").trim(); + if (id.length === 0) { continue; } - const id = trimmed.slice(0, idx).trim(); - const json = trimmed.slice(idx + 1).trim(); - const labels = parseLabelsJson({ raw: json }); - if (id.length > 0) { - out.set(id, labels); - if (id.length >= 12) { - out.set(id.slice(0, 12), labels); - } + + const image = (imageRaw ?? "").trim(); + const meta: ContainerInspectMeta = { + labels: filterLabelsForUi(parseLabelsJson({ raw: labelsRaw ?? "" })), + image: image.length > 0 ? image : null, + ip: parseIpFromNetworksJson({ raw: networksRaw ?? "" }), + mounts: parseMountsJson({ raw: mountsRaw ?? "" }), + }; + + out.set(id, meta); + if (id.length >= 12) { + out.set(id.slice(0, 12), meta); } } @@ -319,6 +438,87 @@ function parseLabelsJson(opts: { return out; } +function filterLabelsForUi( + labels: Record +): Readonly> { + const out: Record = {}; + for (const [key, value] of Object.entries(labels)) { + if (key.startsWith("caddy")) { + out[key] = value; + continue; + } + if (key.startsWith("com.docker.compose.")) { + out[key] = value; + } + } + return out; +} + +function parseMountsJson(opts: { + readonly raw: string; +}): readonly RuntimeMount[] { + const raw = opts.raw.trim(); + if (raw.length === 0 || raw === "null") { + return []; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return []; + } + if (!Array.isArray(parsed)) { + return []; + } + + const out: RuntimeMount[] = []; + for (const entry of parsed) { + if (!isRecord(entry)) { + continue; + } + const source = + getString(entry, "Source") ?? getString(entry, "Name") ?? null; + const destination = getString(entry, "Destination") ?? null; + if (!(source || destination)) { + continue; + } + out.push({ source, destination }); + } + return out; +} + +function parseIpFromNetworksJson(opts: { + readonly raw: string; +}): string | null { + const raw = opts.raw.trim(); + if (raw.length === 0 || raw === "null") { + return null; + } + + let parsed: unknown; + try { + parsed = JSON.parse(raw); + } catch { + return null; + } + if (!isRecord(parsed)) { + return null; + } + + for (const value of Object.values(parsed)) { + if (!isRecord(value)) { + continue; + } + const ip = getString(value, "IPAddress"); + if (ip && ip.length > 0) { + return ip; + } + } + + return null; +} + function parseLabelString(opts: { readonly raw: string; }): Record { diff --git a/src/lib/shell.ts b/src/lib/shell.ts index 4264d352..b6f0de67 100644 --- a/src/lib/shell.ts +++ b/src/lib/shell.ts @@ -10,13 +10,29 @@ export interface ExecOptions { readonly stdin?: "inherit" | "pipe" | "ignore"; } +function buildSpawnEnv( + override: Record | undefined +): Record | undefined { + if (!override) { + return undefined; + } + + const base: Record = {}; + for (const [key, value] of Object.entries(process.env)) { + if (typeof value === "string") { + base[key] = value; + } + } + return { ...base, ...override }; +} + export async function exec( cmd: readonly string[], opts: ExecOptions = {} ): Promise { const proc = Bun.spawn([...cmd], { cwd: opts.cwd, - env: opts.env, + env: buildSpawnEnv(opts.env), stdin: opts.stdin ?? "inherit", stdout: "pipe", stderr: "pipe", @@ -45,7 +61,7 @@ export async function run( ): Promise { const proc = Bun.spawn([...cmd], { cwd: opts.cwd, - env: opts.env, + env: buildSpawnEnv(opts.env), stdin: opts.stdin ?? "inherit", stdout: "inherit", stderr: "inherit", diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index b3ef2535..ad227eb1 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -181,7 +181,7 @@ export function renderAgentDocsSnippet(): string { "Standard workflow:", "- If `.hack/` is missing: `hack init`", "- Start services: `hack up --detach`", - "- Check status: `hack ps` or `hack projects status`", + "- Check status: `hack ps` or `hack status`", "- Open app: `hack open` (use `--json` for machine parsing)", "- Stop services: `hack down`", "", @@ -213,15 +213,15 @@ export function renderAgentDocsSnippet(): string { "- Prefer `hack` commands; they include the right files/networks.", "- Use `docker compose -f .hack/docker-compose.yml exec ` only if you need exec into a running container.", "", - "Sessions (tmux-based):", - "- Interactive picker: `hack session` (fzf picker, works as switcher inside tmux)", + "Sessions (mux-based):", + "- Interactive picker: `hack session` (clack picker; switches inside tmux, attaches outside)", "- Start/attach: `hack session start ` (attaches if exists, switches if in tmux)", "- Force new: `hack session start --new --name agent-1`", "- With infra: `hack session start --up`", "- List: `hack session list`", "- Stop: `hack session stop `", '- Exec in session: `hack session exec ""`', - "- Setup tmux: `hack setup tmux` (installs tmux if missing)", + "- Setup tmux: `hack setup tmux` (adds a keybinding; requires tmux installed)", "", "Agent setup (CLI-first):", "- Cursor rules: `hack setup cursor`", diff --git a/src/mux/mux-backend.ts b/src/mux/mux-backend.ts new file mode 100644 index 00000000..eadb2590 --- /dev/null +++ b/src/mux/mux-backend.ts @@ -0,0 +1,37 @@ +import type { ExecResult } from "../lib/shell.ts"; + +export type MuxBackendName = "tmux" | "zellij"; + +export type MuxSession = { + readonly backend: MuxBackendName; + readonly name: string; + readonly attached: boolean | null; + readonly path: string | null; + readonly windows: number | null; + readonly createdAt: string | null; +}; + +export type MuxSessionCreateResult = + | { readonly ok: true; readonly session: MuxSession | null } + | { readonly ok: false; readonly error: string; readonly stderr?: string }; + +export interface MuxBackend { + readonly name: MuxBackendName; + readonly available: boolean; + + listSessions(): Promise; + createSession(opts: { + readonly name: string; + readonly cwd?: string; + }): Promise; + killSession(opts: { readonly name: string }): Promise; + + execInSession(opts: { + readonly name: string; + readonly command: string; + }): Promise; + sendInput(opts: { + readonly name: string; + readonly keys: string; + }): Promise; +} diff --git a/src/mux/mux-config.ts b/src/mux/mux-config.ts new file mode 100644 index 00000000..fbd70b5c --- /dev/null +++ b/src/mux/mux-config.ts @@ -0,0 +1,51 @@ +import { readGlobalConfig } from "../lib/config.ts"; +import type { ProjectContext } from "../lib/project.ts"; +import { readProjectConfig } from "../lib/project.ts"; + +export type SessionsMuxMode = "auto" | "tmux" | "zellij" | "none"; + +export function parseSessionsMuxMode(value: unknown): SessionsMuxMode | null { + if (typeof value !== "string") { + return null; + } + const v = value.trim().toLowerCase(); + if (v === "auto") { + return "auto"; + } + if (v === "tmux") { + return "tmux"; + } + if (v === "zellij") { + return "zellij"; + } + if (v === "none") { + return "none"; + } + return null; +} + +export async function resolveSessionsMuxMode(opts: { + readonly project?: ProjectContext | null; +}): Promise { + const envRaw = (process.env.HACK_SESSIONS_MUX ?? "").trim(); + const envMode = parseSessionsMuxMode(envRaw); + if (envMode) { + return envMode; + } + + if (opts.project) { + const cfg = await readProjectConfig(opts.project); + const projectMode = parseSessionsMuxMode(cfg.sessions?.mux); + if (projectMode) { + return projectMode; + } + } + + const globalModeRaw = await readGlobalConfig({ path: "sessions.mux" }); + const globalMode = parseSessionsMuxMode(globalModeRaw); + if (globalMode) { + return globalMode; + } + + return "auto"; +} diff --git a/src/mux/mux-resolver.ts b/src/mux/mux-resolver.ts new file mode 100644 index 00000000..cc8667b7 --- /dev/null +++ b/src/mux/mux-resolver.ts @@ -0,0 +1,73 @@ +import type { ProjectContext } from "../lib/project.ts"; + +import type { MuxBackend, MuxBackendName } from "./mux-backend.ts"; +import { resolveSessionsMuxMode, type SessionsMuxMode } from "./mux-config.ts"; +import { createTmuxBackend } from "./tmux-backend.ts"; +import { createZellijBackend } from "./zellij-backend.ts"; + +export type ResolvedMux = { + readonly mode: SessionsMuxMode; + readonly backends: ReadonlyMap; +}; + +export function getMuxBackends(): ReadonlyMap { + const tmux = createTmuxBackend(); + const zellij = createZellijBackend(); + return new Map([ + ["tmux", tmux], + ["zellij", zellij], + ]); +} + +export async function resolveMux(opts: { + readonly project?: ProjectContext | null; +}): Promise { + const mode = await resolveSessionsMuxMode({ project: opts.project }); + return { mode, backends: getMuxBackends() }; +} + +export function resolveMuxCandidates(opts: { + readonly mode: SessionsMuxMode; +}): readonly MuxBackendName[] { + if (opts.mode === "none") { + return []; + } + if (opts.mode === "tmux") { + return ["tmux"]; + } + if (opts.mode === "zellij") { + return ["zellij"]; + } + return ["tmux", "zellij"]; +} + +export function resolveDefaultBackendName(opts: { + readonly mode: SessionsMuxMode; + readonly backends: ReadonlyMap; +}): MuxBackendName | null { + for (const name of resolveMuxCandidates({ mode: opts.mode })) { + const backend = opts.backends.get(name); + if (backend?.available) { + return name; + } + } + return null; +} + +export async function listMuxSessions(opts: { + readonly mode: SessionsMuxMode; + readonly backends: ReadonlyMap; +}): Promise< + readonly Awaited>[number][] +> { + const out: Awaited>[number][] = []; + for (const name of resolveMuxCandidates({ mode: opts.mode })) { + const backend = opts.backends.get(name); + if (!backend?.available) { + continue; + } + const sessions = await backend.listSessions(); + out.push(...sessions); + } + return out; +} diff --git a/src/mux/session-names.ts b/src/mux/session-names.ts new file mode 100644 index 00000000..794b58c2 --- /dev/null +++ b/src/mux/session-names.ts @@ -0,0 +1,39 @@ +const SESSION_DELIMITER = "--" as const; + +export function buildSessionName(opts: { + readonly base: string; + readonly suffix?: string; +}): string { + const base = opts.base.trim(); + const suffix = (opts.suffix ?? "").trim(); + if (suffix.length === 0) { + return base; + } + return `${base}${SESSION_DELIMITER}${suffix}`; +} + +export function parseSessionBase(opts: { readonly name: string }): string { + const name = opts.name.trim(); + const idx = name.indexOf(SESSION_DELIMITER); + if (idx === -1) { + return name; + } + return name.slice(0, idx); +} + +export function getNextNumericSessionSuffix(opts: { + readonly sessions: readonly { readonly name: string }[]; + readonly base: string; +}): number { + const base = opts.base.trim(); + if (base.length === 0) { + return 2; + } + + const names = new Set(opts.sessions.map((s) => s.name)); + let n = 2; + while (names.has(buildSessionName({ base, suffix: String(n) }))) { + n += 1; + } + return n; +} diff --git a/src/mux/tmux-backend.ts b/src/mux/tmux-backend.ts new file mode 100644 index 00000000..c6db7008 --- /dev/null +++ b/src/mux/tmux-backend.ts @@ -0,0 +1,148 @@ +import type { ExecResult, RunOptions } from "../lib/shell.ts"; +import { exec, findExecutableInPath } from "../lib/shell.ts"; + +import type { + MuxBackend, + MuxSession, + MuxSessionCreateResult, +} from "./mux-backend.ts"; + +function parseIntOrNull(value: string | undefined): number | null { + const n = Number.parseInt(value ?? "", 10); + return Number.isFinite(n) ? n : null; +} + +export function createTmuxBackend(): MuxBackend { + const available = Boolean(findExecutableInPath("tmux")); + + const listSessions = async (): Promise => { + if (!available) { + return []; + } + + const format = [ + "#{session_name}", + "#{session_attached}", + "#{session_path}", + "#{session_windows}", + "#{session_created}", + ].join("\t"); + + const result = await exec(["tmux", "list-sessions", "-F", format], { + stdin: "ignore", + }); + + if (result.exitCode !== 0) { + return []; + } + + const sessions: MuxSession[] = []; + for (const line of result.stdout.trim().split("\n")) { + if (!line) { + continue; + } + const [name, attached, path, windows, created] = line.split("\t"); + if (!name) { + continue; + } + + const createdAt = + created && created.length > 0 + ? new Date(Number.parseInt(created, 10) * 1000).toISOString() + : null; + + sessions.push({ + backend: "tmux", + name, + attached: attached === "1", + path: path || null, + windows: parseIntOrNull(windows), + createdAt, + }); + } + + return sessions; + }; + + const createSession = async (opts: { + readonly name: string; + readonly cwd?: string; + }): Promise => { + if (!available) { + return { ok: false, error: "tmux_unavailable" }; + } + + const args = ["tmux", "new-session", "-d", "-s", opts.name]; + if (opts.cwd) { + args.push("-c", opts.cwd); + } + + const result = await exec(args, { stdin: "ignore" }); + if (result.exitCode !== 0) { + return { + ok: false, + error: "create_failed", + stderr: result.stderr.trim(), + }; + } + + const sessions = await listSessions(); + const session = sessions.find((s) => s.name === opts.name) ?? null; + return { ok: true, session }; + }; + + const killSession = async (opts: { + readonly name: string; + }): Promise => { + return await exec(["tmux", "kill-session", "-t", opts.name], { + stdin: "ignore", + }); + }; + + const execInSession = async (opts: { + readonly name: string; + readonly command: string; + }): Promise => { + return await exec( + ["tmux", "send-keys", "-t", opts.name, opts.command, "Enter"], + { + stdin: "ignore", + } + ); + }; + + const sendInput = async (opts: { + readonly name: string; + readonly keys: string; + }): Promise => { + return await exec(["tmux", "send-keys", "-t", opts.name, opts.keys], { + stdin: "ignore", + }); + }; + + return { + name: "tmux", + available, + listSessions, + createSession, + killSession, + execInSession, + sendInput, + }; +} + +export async function attachTmuxSession(opts: { + readonly name: string; + readonly run: (cmd: readonly string[], opts: RunOptions) => Promise; +}): Promise { + const insideTmux = Boolean(process.env.TMUX); + if (insideTmux) { + return await opts.run(["tmux", "switch-client", "-t", opts.name], { + stdin: "inherit", + }); + } + + return await opts.run(["tmux", "attach", "-d", "-t", opts.name], { + stdin: "inherit", + }); +} diff --git a/src/mux/zellij-backend.ts b/src/mux/zellij-backend.ts new file mode 100644 index 00000000..3260c20e --- /dev/null +++ b/src/mux/zellij-backend.ts @@ -0,0 +1,131 @@ +import type { ExecResult, RunOptions } from "../lib/shell.ts"; +import { exec, findExecutableInPath } from "../lib/shell.ts"; + +import type { + MuxBackend, + MuxSession, + MuxSessionCreateResult, +} from "./mux-backend.ts"; + +export function createZellijBackend(): MuxBackend { + const available = Boolean(findExecutableInPath("zellij")); + + const listSessions = async (): Promise => { + if (!available) { + return []; + } + + const result = await exec( + ["zellij", "list-sessions", "--no-formatting", "--short"], + { + stdin: "ignore", + } + ); + if (result.exitCode !== 0) { + return []; + } + + const out: MuxSession[] = []; + for (const line of result.stdout.trim().split("\n")) { + const name = line.trim(); + if (!name) { + continue; + } + out.push({ + backend: "zellij", + name, + attached: null, + path: null, + windows: null, + createdAt: null, + }); + } + return out; + }; + + const createSession = async (opts: { + readonly name: string; + readonly cwd?: string; + }): Promise => { + if (!available) { + return { ok: false, error: "zellij_unavailable" }; + } + + // Create a detached session in the background. + const result = await exec( + ["zellij", "attach", "--create-background", opts.name], + { + stdin: "ignore", + cwd: opts.cwd, + env: opts.name ? { ZELLIJ_SESSION_NAME: opts.name } : undefined, + } + ); + + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + if (stderr.toLowerCase().includes("session already exists")) { + const sessions = await listSessions(); + const session = sessions.find((s) => s.name === opts.name) ?? null; + return { ok: true, session }; + } + return { ok: false, error: "create_failed", stderr }; + } + + const sessions = await listSessions(); + const session = sessions.find((s) => s.name === opts.name) ?? null; + return { ok: true, session }; + }; + + const killSession = async (opts: { + readonly name: string; + }): Promise => { + return await exec(["zellij", "kill-session", opts.name], { + stdin: "ignore", + }); + }; + + const execInSession = async (opts: { + readonly name: string; + readonly command: string; + }): Promise => { + // `zellij run` requires an active session; set env to target the desired session. + return await exec(["zellij", "run", "--", "sh", "-lc", opts.command], { + stdin: "ignore", + env: { ZELLIJ_SESSION_NAME: opts.name }, + }); + }; + + const sendInput = async (opts: { + readonly name: string; + readonly keys: string; + }): Promise => { + // Best-effort. This sends raw characters and does not attempt to encode special key chords. + return await exec(["zellij", "action", "write-chars", opts.keys], { + stdin: "ignore", + env: { ZELLIJ_SESSION_NAME: opts.name }, + }); + }; + + return { + name: "zellij", + available, + listSessions, + createSession, + killSession, + execInSession, + sendInput, + }; +} + +export async function attachZellijSession(opts: { + readonly name: string; + readonly createIfMissing: boolean; + readonly cwd?: string; + readonly run: (cmd: readonly string[], opts: RunOptions) => Promise; +}): Promise { + const args = ["zellij", "attach", opts.name]; + if (opts.createIfMissing) { + args.splice(2, 0, "--create"); + } + return await opts.run(args, { stdin: "inherit", cwd: opts.cwd }); +} diff --git a/src/templates.ts b/src/templates.ts index b5b22602..cfa0a40d 100644 --- a/src/templates.ts +++ b/src/templates.ts @@ -332,6 +332,15 @@ export function renderProjectConfigJson(opts: { return `${JSON.stringify(config, null, 2)}\n`; } +export function renderProjectEnvContractJson(): string { + const contract = { + $schema: `https://${DEFAULT_SCHEMAS_HOST}/hack.env.schema.json`, + version: 1, + vars: [], + }; + return `${JSON.stringify(contract, null, 2)}\n`; +} + export function renderProjectConfigSchemaJson(): string { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", @@ -372,6 +381,116 @@ export function renderProjectConfigSchemaJson(): string { tld: { type: "string" }, }, }, + sessions: { + type: "object", + additionalProperties: true, + properties: { + mux: { type: "string", enum: ["auto", "tmux", "zellij", "none"] }, + }, + }, + lifecycle: { + type: "object", + additionalProperties: true, + properties: { + up: { + type: "object", + additionalProperties: true, + properties: { + before: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: true, + required: ["command"], + properties: { + name: { type: "string" }, + command: { type: "string" }, + cwd: { type: "string" }, + }, + }, + ], + }, + }, + after: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: true, + required: ["command"], + properties: { + name: { type: "string" }, + command: { type: "string" }, + cwd: { type: "string" }, + }, + }, + ], + }, + }, + }, + }, + down: { + type: "object", + additionalProperties: true, + properties: { + before: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: true, + required: ["command"], + properties: { + name: { type: "string" }, + command: { type: "string" }, + cwd: { type: "string" }, + }, + }, + ], + }, + }, + after: { + type: "array", + items: { + anyOf: [ + { type: "string" }, + { + type: "object", + additionalProperties: true, + required: ["command"], + properties: { + name: { type: "string" }, + command: { type: "string" }, + cwd: { type: "string" }, + }, + }, + ], + }, + }, + }, + }, + processes: { + type: "array", + items: { + type: "object", + additionalProperties: true, + required: ["name", "command"], + properties: { + name: { type: "string" }, + command: { type: "string" }, + cwd: { type: "string" }, + }, + }, + }, + }, + }, controlPlane: { type: "object", additionalProperties: true, @@ -466,6 +585,44 @@ export function renderProjectConfigSchemaJson(): string { return `${JSON.stringify(schema, null, 2)}\n`; } +export function renderProjectEnvSchemaJson(): string { + const schema = { + $schema: "https://json-schema.org/draft/2020-12/schema", + title: "hack.env.json", + type: "object", + additionalProperties: true, + required: ["version", "vars"], + properties: { + $schema: { type: "string" }, + version: { type: "integer", const: 1 }, + vars: { + type: "array", + items: { + type: "object", + additionalProperties: true, + required: ["key"], + properties: { + key: { + type: "string", + pattern: "^[A-Z_][A-Z0-9_]*$", + minLength: 1, + }, + required: { type: "boolean" }, + source: { type: "string", enum: ["plain_env", "keychain"] }, + services: { + type: "array", + items: { type: "string" }, + }, + description: { type: "string" }, + }, + }, + }, + }, + } as const; + + return `${JSON.stringify(schema, null, 2)}\n`; +} + export function renderProjectBranchesSchemaJson(): string { const schema = { $schema: "https://json-schema.org/draft/2020-12/schema", diff --git a/tests/daemon-env.test.ts b/tests/daemon-env.test.ts new file mode 100644 index 00000000..d1adcd15 --- /dev/null +++ b/tests/daemon-env.test.ts @@ -0,0 +1,202 @@ +import { afterEach, beforeEach, describe, expect, test } from "bun:test"; +import { mkdtemp, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + PROJECT_COMPOSE_FILENAME, + PROJECT_CONFIG_FILENAME, + PROJECT_ENV_CONTRACT_FILENAME, +} from "../src/constants.ts"; +import { handleEnvRoutes } from "../src/daemon/routes/env.ts"; +import { ensureDir, writeTextFileIfChanged } from "../src/lib/fs.ts"; +import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; + +function mockRequest(opts: { + readonly method: string; + readonly path: string; + readonly body?: Record; +}): Request { + const url = `http://localhost${opts.path}`; + const init: RequestInit = { + method: opts.method, + headers: { "content-type": "application/json" }, + }; + if (opts.body) { + init.body = JSON.stringify(opts.body); + } + return new Request(url, init); +} + +async function parseResponse( + res: Response +): Promise | null> { + const text = await res.text(); + try { + return JSON.parse(text) as Record; + } catch { + return null; + } +} + +describe("handleEnvRoutes", () => { + let tempDir: string; + let repoRoot: string; + let originalConfigPath: string | undefined; + + beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-test-env-")); + + // Isolate ~/.hack state by pointing the global config path to a temp dir. + originalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; + process.env.HACK_GLOBAL_CONFIG_PATH = join(tempDir, "hack.config.json"); + + repoRoot = join(tempDir, "repo"); + const hackDir = join(repoRoot, ".hack"); + await ensureDir(hackDir); + + await writeTextFileIfChanged( + resolve(hackDir, PROJECT_CONFIG_FILENAME), + `${JSON.stringify({ name: "env-test", dev_host: "env-test.hack" }, null, 2)}\n` + ); + + await writeTextFileIfChanged( + resolve(hackDir, PROJECT_COMPOSE_FILENAME), + ["services:", " app:", " image: alpine:3.20", ""].join("\n") + ); + + await writeTextFileIfChanged( + resolve(hackDir, PROJECT_ENV_CONTRACT_FILENAME), + `${JSON.stringify( + { + version: 1, + vars: [{ key: "FOO", required: false, source: "plain_env" }], + }, + null, + 2 + )}\n` + ); + + await upsertProjectRegistration({ + project: { + projectRoot: repoRoot, + projectDirName: ".hack", + projectDir: hackDir, + composeFile: resolve(hackDir, PROJECT_COMPOSE_FILENAME), + envFile: resolve(hackDir, ".env"), + configFile: resolve(hackDir, PROJECT_CONFIG_FILENAME), + }, + }); + }); + + afterEach(async () => { + if (originalConfigPath !== undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = originalConfigPath; + } else { + Reflect.deleteProperty(process.env, "HACK_GLOBAL_CONFIG_PATH"); + } + await rm(tempDir, { recursive: true, force: true }); + }); + + test("returns null for non-env routes", async () => { + const req = mockRequest({ method: "GET", path: "/v1/status" }); + const url = new URL(req.url); + const result = await handleEnvRoutes({ req, url }); + expect(result).toBeNull(); + }); + + test("GET /v1/env without project returns 400", async () => { + const req = mockRequest({ method: "GET", path: "/v1/env" }); + const url = new URL(req.url); + const result = await handleEnvRoutes({ req, url }); + expect(result).not.toBeNull(); + expect(result?.status).toBe(400); + const body = await parseResponse(result!); + expect(body?.error).toBe("missing_project"); + }); + + test("GET /v1/env returns contract and resolution state", async () => { + const req = mockRequest({ + method: "GET", + path: "/v1/env?project=env-test", + }); + const url = new URL(req.url); + const result = await handleEnvRoutes({ req, url }); + expect(result).not.toBeNull(); + expect(result?.status).toBe(200); + const body = await parseResponse(result!); + expect(body?.project).toBeTruthy(); + expect(body?.contract).toBeTruthy(); + expect(Array.isArray(body?.values)).toBe(true); + }); + + test("POST /v1/env/set writes to .hack/.env and shows as resolved", async () => { + const setReq = mockRequest({ + method: "POST", + path: "/v1/env/set", + body: { project: "env-test", key: "FOO", value: "bar" }, + }); + const setUrl = new URL(setReq.url); + const setRes = await handleEnvRoutes({ req: setReq, url: setUrl }); + expect(setRes).not.toBeNull(); + expect(setRes?.status).toBe(200); + + const getReq = mockRequest({ + method: "GET", + path: "/v1/env?project=env-test", + }); + const getUrl = new URL(getReq.url); + const getRes = await handleEnvRoutes({ req: getReq, url: getUrl }); + expect(getRes).not.toBeNull(); + expect(getRes?.status).toBe(200); + const body = await parseResponse(getRes!); + const values = Array.isArray(body?.values) + ? (body?.values as unknown[]) + : []; + const foo = values.find( + (v) => + typeof v === "object" && + v !== null && + (v as Record).key === "FOO" + ) as Record | undefined; + expect(foo?.hasValue).toBe(true); + expect(foo?.resolvedFrom).toBe("dotenv"); + }); + + test("POST /v1/env/unset clears .hack/.env and shows as missing", async () => { + const setReq = mockRequest({ + method: "POST", + path: "/v1/env/set", + body: { project: "env-test", key: "FOO", value: "bar" }, + }); + const setUrl = new URL(setReq.url); + await handleEnvRoutes({ req: setReq, url: setUrl }); + + const unsetReq = mockRequest({ + method: "POST", + path: "/v1/env/unset", + body: { project: "env-test", key: "FOO" }, + }); + const unsetUrl = new URL(unsetReq.url); + const unsetRes = await handleEnvRoutes({ req: unsetReq, url: unsetUrl }); + expect(unsetRes).not.toBeNull(); + expect(unsetRes?.status).toBe(200); + + const getReq = mockRequest({ + method: "GET", + path: "/v1/env?project=env-test", + }); + const getUrl = new URL(getReq.url); + const getRes = await handleEnvRoutes({ req: getReq, url: getUrl }); + const body = await parseResponse(getRes!); + const values = Array.isArray(body?.values) + ? (body?.values as unknown[]) + : []; + const foo = values.find( + (v) => + typeof v === "object" && + v !== null && + (v as Record).key === "FOO" + ) as Record | undefined; + expect(foo?.hasValue).toBe(false); + }); +}); diff --git a/tests/daemon-sessions.test.ts b/tests/daemon-sessions.test.ts index de65e831..cf4dac93 100644 --- a/tests/daemon-sessions.test.ts +++ b/tests/daemon-sessions.test.ts @@ -149,7 +149,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { expect(result?.status).not.toBe(400); }); - test("accepts names with dots", async () => { + test("rejects names with dots", async () => { const req = mockRequest({ method: "POST", path: "/v1/sessions", @@ -157,7 +157,7 @@ describe.skipIf(!hasTmux)("handleSessionRoutes", () => { }); const url = new URL(req.url); const result = await handleSessionRoutes({ req, url }); - expect(result?.status).not.toBe(400); + expect(result?.status).toBe(400); }); test("rejects names with spaces", async () => { diff --git a/tests/project-views.test.ts b/tests/project-views.test.ts index a3f737c6..2c52704a 100644 --- a/tests/project-views.test.ts +++ b/tests/project-views.test.ts @@ -100,6 +100,10 @@ function makeContainer(opts: { status: opts.state === "running" ? "Up 5s" : "Exited (0)", name: opts.name, ports: "", + image: null, + ip: null, + mounts: [], + labels: {}, workingDir: `/tmp/${opts.project}/.hack`, }; } diff --git a/tests/runtime-cache.test.ts b/tests/runtime-cache.test.ts index a628eb6e..40f196fd 100644 --- a/tests/runtime-cache.test.ts +++ b/tests/runtime-cache.test.ts @@ -1,5 +1,9 @@ import { beforeEach, expect, mock, test } from "bun:test"; +import { HACK_PROJECT_DIR_PRIMARY } from "../src/constants.ts"; +import type { ProjectMeta } from "../src/lib/project-meta.ts"; +import type { ProjectView } from "../src/lib/project-views.ts"; +import type { RegisteredProject } from "../src/lib/projects-registry.ts"; import type { RuntimeProject } from "../src/lib/runtime-projects.ts"; const runtimeQueue: Array<{ @@ -109,6 +113,122 @@ test("runtime cache refresh records healthy snapshot", async () => { expect(autoRegisterCalls.length).toBe(1); }); +test("getProjectsPayload keeps working when resolveProjectMeta fails for one project", async () => { + const createdAt = new Date().toISOString(); + + const projects: RegisteredProject[] = [ + { + id: "ok", + name: "ok", + repoRoot: "/tmp/ok", + projectDirName: HACK_PROJECT_DIR_PRIMARY, + projectDir: "/tmp/ok/.hack", + createdAt, + }, + { + id: "bad", + name: "bad", + repoRoot: "/tmp/bad", + projectDirName: HACK_PROJECT_DIR_PRIMARY, + projectDir: "/tmp/bad/.hack", + createdAt, + }, + ]; + + const makeView = (name: string): ProjectView => ({ + name, + devHost: null, + repoRoot: null, + projectDir: null, + definedServices: null, + extensionsEnabled: null, + features: null, + serviceHosts: null, + runtimeConfigured: null, + runtimeStatus: "unknown", + runtime: null, + branchRuntime: [], + kind: "registered", + status: "unknown", + }); + + const cache = createRuntimeCache({ + deps: { + readProjectsRegistry: async () => ({ version: 1, projects }), + buildProjectViews: async () => [makeView("ok"), makeView("bad")], + serializeProjectView: (view) => ({ name: view.name, kind: view.kind }), + resolveProjectMeta: async (opts) => { + if (opts.projectName === "bad") { + throw new Error("boom"); + } + const meta: ProjectMeta = { + git: { + isRepo: false, + head: null, + branch: null, + detached: null, + dirty: null, + localBranchCount: null, + worktrees: null, + error: null, + }, + hackBranches: { path: "", parseError: null, branches: [] }, + env: { + contractPath: "", + contractExists: false, + contractParseError: null, + vars: [], + missingRequired: [], + }, + sessions: { sessions: [] }, + composeBuild: { services: [] }, + }; + return meta; + }, + }, + }); + + await cache.refresh({ reason: "test" }); + + const payload = await cache.getProjectsPayload({ + filter: null, + includeGlobal: true, + includeUnregistered: true, + includeMeta: true, + }); + + expect(payload.projects.length).toBe(2); + expect(payload.projects[0]).toMatchObject({ + name: "ok", + meta: { + git: { + isRepo: false, + head: null, + branch: null, + detached: null, + dirty: null, + localBranchCount: null, + worktrees: null, + error: null, + }, + hackBranches: { path: "", parseError: null, branches: [] }, + env: { + contractPath: "", + contractExists: false, + contractParseError: null, + vars: [], + missingRequired: [], + }, + sessions: { sessions: [] }, + composeBuild: { services: [] }, + }, + }); + expect(payload.projects[1]).toMatchObject({ + name: "bad", + meta: null, + }); +}); + test("runtime cache retains last runtime on failure", async () => { const runtime: RuntimeProject[] = [ { diff --git a/tests/runtime-projects.test.ts b/tests/runtime-projects.test.ts index d1153684..3af00765 100644 --- a/tests/runtime-projects.test.ts +++ b/tests/runtime-projects.test.ts @@ -26,6 +26,10 @@ function makeContainer(opts: { status: opts.status, name: opts.name, ports: opts.ports ?? "", + image: null, + ip: null, + mounts: [], + labels: {}, workingDir: `/tmp/${opts.project}/.hack`, }; }