diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index 2d0b90964..e047d8aea 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -137,6 +137,15 @@ struct CommandPaletteView: View { ) } ) + actions.append( + CommandPaletteAction(title: "Go to AWS Settings", subtitle: "AWS provider + bootstrap controls") { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.aws.rawValue] + ) + } + ) actions.append( CommandPaletteAction(title: "Go to Permissions Settings", subtitle: "Automation + local network access") { NotificationCenter.default.post( diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 250a6830e..f738c5e7d 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -1159,6 +1159,25 @@ public final class DashboardModel { } } + public func bootstrapAwsNode( + request: AwsBootstrapRequest + ) async -> AwsBootstrapResponse? { + await runActionResult(message: "Bootstrapping AWS node…") { + try await self.client.bootstrapAwsNode(request: request) + } + } + + public func inspectAws( + request: AwsBootstrapRequest? = nil + ) async -> AwsInspectResponse? { + do { + return try await client.inspectAws(request: request) + } catch { + errorMessage = error.localizedDescription + return nil + } + } + public func listNodes() async -> NodeRegistryListResponse? { do { return try await client.listNodes() diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift index f2c48499a..bb80b47c7 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift @@ -131,7 +131,7 @@ struct NodeTopologySettingsView: View { Spacer() if isControllerHost { Button { - openRailwaySettings() + openAwsSettings() } label: { Label("Add remote node", systemImage: "plus.circle") } @@ -201,6 +201,9 @@ struct NodeTopologySettingsView: View { ) if isControllerHost { Menu { + Button("Add via AWS") { + openAwsSettings() + } Button("Add via Railway") { openRailwaySettings() } @@ -248,7 +251,7 @@ struct NodeTopologySettingsView: View { .overlay(alignment: .topTrailing) { if isControllerHost { Button { - openRailwaySettings() + openAwsSettings() } label: { Image(systemName: "plus") .font(.system(size: 12, weight: .semibold)) @@ -293,6 +296,10 @@ struct NodeTopologySettingsView: View { Spacer() if isControllerHost { Menu { + Button("Add via AWS") { + topologyExpanded = false + openAwsSettings() + } Button("Add via Railway") { topologyExpanded = false openRailwaySettings() @@ -1151,11 +1158,20 @@ struct NodeTopologySettingsView: View { StatusPill(text: "Unknown", tone: .neutral) } StatusPill(text: "Authorized", tone: .neutral) + if let source = node.source, !source.isEmpty { + StatusPill(text: "Source-backed", tone: .good) + } } Text(node.endpoint) .font(.mono(.caption)) .foregroundStyle(.secondary) .textSelection(.enabled) + if let source = node.source, !source.isEmpty { + Text(source) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } Text(node.id) .font(.mono(.caption2)) .foregroundStyle(.tertiary) @@ -1436,6 +1452,14 @@ struct NodeTopologySettingsView: View { ) } + private func openAwsSettings() { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.aws.rawValue] + ) + } + private func openTailscaleSettings() { NotificationCenter.default.post( name: .hackSettingsRequested, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 072c128ed..316bef7fb 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -842,6 +842,8 @@ struct ProjectDetailView: View { .padding(16) .background(projectSettingsCardBackground) + explicitRemoteActionsCard + linearRoutingSummaryCard if let projectSystemGitIdentity { @@ -975,6 +977,83 @@ struct ProjectDetailView: View { } } + private var explicitRemoteActionsCard: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .top, spacing: 12) { + VStack(alignment: .leading, spacing: 4) { + Text("Explicit remote actions") + .font(.system(size: 15, weight: .semibold)) + Text("Project settings above are defaults. These actions let you use a remote node without changing the saved default behavior.") + .font(.system(size: 13, weight: .medium)) + .foregroundStyle(.secondary) + } + Spacer() + } + + HStack(alignment: .center, spacing: 8) { + StatusPill( + text: executionMode == .local ? "Default: local" : "Default: \(executionMode.title)", + tone: executionMode == .local ? .neutral : .good + ) + StatusPill( + text: explicitRemoteActionNode?.name ?? "No remote node selected", + tone: explicitRemoteActionNode == nil ? .warn : .good + ) + StatusPill( + text: explicitRemoteActionHasSource ? "Source-backed" : "No SSH source", + tone: explicitRemoteActionHasSource ? .good : .warn + ) + Spacer() + } + + HStack(spacing: 10) { + Button { + openExplicitRemoteRun() + } label: { + Label("Run on remote once", systemImage: "play.fill") + } + .adaptiveToolbarButtonProminent() + .disabled(explicitRemoteActionNode == nil) + + Button { + openExplicitRemoteDevcontainer() + } label: { + Label("Start devcontainer", systemImage: "shippingbox.circle") + } + .adaptiveToolbarButton() + .disabled(!explicitRemoteActionHasSource) + + Button { + openExplicitRemoteNodeStatus() + } label: { + Label("Inspect node", systemImage: "stethoscope") + } + .adaptiveToolbarButton() + .disabled(explicitRemoteActionNode == nil) + + Button { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.topology.rawValue] + ) + } label: { + Label("Open topology", systemImage: "point.3.connected.trianglepath.dotted") + } + .adaptiveToolbarButton() + + Spacer() + } + + Text(explicitRemoteActionFootnote) + .font(.system(size: 12, weight: .medium, design: .monospaced)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + .padding(16) + .background(projectSettingsCardBackground) + } + private var defaultLinearRouteTarget: LinearProjectBindingTarget? { guard !linearBoundProjectId.isEmpty else { return nil @@ -1712,6 +1791,41 @@ struct ProjectDetailView: View { return "Inherited (\(inheritedDefaultNodeId))" } + private var dispatchProjectSelector: String { + let candidate = (project.projectId ?? project.name).trimmingCharacters(in: .whitespacesAndNewlines) + if !candidate.isEmpty { + return candidate + } + return project.name + } + + private var explicitRemoteActionNode: NodeRegistryRecord? { + let selectedNodeId = executionTargetNodeId.trimmingCharacters(in: .whitespacesAndNewlines) + if !selectedNodeId.isEmpty { + return executionTargetNodes.first(where: { $0.id == selectedNodeId }) + } + let defaultNodeId = executionDefaultNodeId.trimmingCharacters(in: .whitespacesAndNewlines) + if !defaultNodeId.isEmpty { + return executionTargetNodes.first(where: { $0.id == defaultNodeId }) + } + return executionTargetNodes.first(where: { $0.isDefault == true }) + } + + private var explicitRemoteActionHasSource: Bool { + let source = explicitRemoteActionNode?.source?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return !source.isEmpty + } + + private var explicitRemoteActionFootnote: String { + guard let node = explicitRemoteActionNode else { + return "Pick a remote node above or from Topology to enable one-off remote actions." + } + if explicitRemoteActionHasSource { + return "Using \(node.name) with SSH source \(node.source ?? "")." + } + return "Using \(node.name). Add SSH source metadata if you want source-backed flows like devcontainer attach." + } + private var selectedGitSummary: String { let selectedProfileId = githubProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) guard !selectedProfileId.isEmpty else { @@ -3854,4 +3968,40 @@ struct ProjectDetailView: View { return "'\(value.replacingOccurrences(of: "'", with: "'\"'\"'"))'" } + private func openExplicitRemoteRun() { + guard let node = explicitRemoteActionNode else { + return + } + let command = [ + "hack dispatch run", + "--project \(shellQuote(dispatchProjectSelector))", + "--node \(shellQuote(node.id))", + "--target remote", + "--runner generic", + "--", + "sh -lc \(shellQuote("pwd && uname -a"))", + ].joined(separator: " ") + openTerminal(kind: .shell, command: command, title: "remote run") + } + + private func openExplicitRemoteDevcontainer() { + guard let node = explicitRemoteActionNode, explicitRemoteActionHasSource else { + return + } + let command = [ + "hack node devcontainer up", + "--node \(shellQuote(node.id))", + "--project \(shellQuote(dispatchProjectSelector))", + ].joined(separator: " ") + openTerminal(kind: .shell, command: command, title: "remote devcontainer") + } + + private func openExplicitRemoteNodeStatus() { + guard let node = explicitRemoteActionNode else { + return + } + let command = "hack node status --node \(shellQuote(node.id)) --json" + openTerminal(kind: .shell, command: command, title: "node status") + } + } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index f99c08202..6db7a8f35 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -19,6 +19,7 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { case linear case github case cloudflare + case aws case railway case tailscale case certificates @@ -52,6 +53,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "GitHub" case .cloudflare: return "Cloudflare" + case .aws: + return "AWS" case .railway: return "Railway" case .tailscale: @@ -89,6 +92,8 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { return "chevron.left.forwardslash.chevron.right" case .cloudflare: return "cloud" + case .aws: + return "server.rack" case .railway: return "tram.fill.tunnel" case .tailscale: @@ -205,6 +210,7 @@ struct SettingsOverlayView: View { settingsRow(.linear) settingsRow(.github) settingsRow(.cloudflare) + settingsRow(.aws) settingsRow(.railway) settingsRow(.tailscale) } @@ -250,6 +256,8 @@ struct SettingsOverlayView: View { GitHubExtensionSettingsView() case .cloudflare: CloudflareExtensionSettingsView() + case .aws: + AwsExtensionSettingsView() case .railway: RailwayExtensionSettingsView() case .tailscale: @@ -1811,6 +1819,381 @@ private struct CloudflareExtensionSettingsView: View { } } +private struct AwsExtensionSettingsView: View { + @Environment(DashboardModel.self) private var model + @State private var isLoadingConfig = false + @State private var isSavingConfig = false + @State private var isBootstrapping = false + @State private var isLoadingDiagnostics = false + @State private var suppressEnabledToggleChange = false + @State private var showAdvancedOptions = false + @State private var enabled = false + @State private var diagnostics: AwsInspectResponse? = nil + @State private var bootstrapResult: AwsBootstrapResponse? = nil + @State private var instanceId = "" + @State private var instanceTagKey = "Name" + @State private var instanceTagValue = "" + @State private var region = "" + @State private var profile = "" + @State private var nodeName = "" + @State private var source = "" + @State private var endpoint = "" + @State private var labelsCsv = "aws,linux" + @State private var bootstrapCommand = "" + @State private var defaultNode = false + + var body: some View { + ScrollView { + LazyVStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / AWS", + title: "AWS", + subtitle: "Store EC2 + SSM bootstrap defaults and register nodes without leaving the app" + ) + + GlassCard(title: "Provider status", systemImage: "server.rack") { + HStack(alignment: .center, spacing: 8) { + StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) + StatusPill( + text: hasTargetSelector ? "Target configured" : "Target missing", + tone: hasTargetSelector ? .good : .warn + ) + StatusPill( + text: canBootstrapAwsNode ? "Bootstrap ready" : "Needs required fields", + tone: canBootstrapAwsNode ? .good : .warn + ) + if let diagnostics { + StatusPill( + text: diagnostics.target?.resolved == true ? "Instance resolved" : "Instance unresolved", + tone: diagnostics.target?.resolved == true ? .good : .warn + ) + StatusPill( + text: diagnostics.target?.ssmOnline == true ? "SSM online" : "SSM offline", + tone: diagnostics.target?.ssmOnline == true ? .good : .warn + ) + } + Spacer() + Toggle("Enabled", isOn: $enabled) + .labelsHidden() + .toggleStyle(.switch) + .onChange(of: enabled) { _, newValue in + guard !suppressEnabledToggleChange else { return } + Task { await applyAwsEnabledToggle(newValue) } + } + if isLoadingConfig || isSavingConfig || isBootstrapping || isLoadingDiagnostics { + ProgressView() + .controlSize(.small) + } + } + + Text("This page saves defaults for controlPlane.extensions[\"dance.hack.aws\"] and calls the built-in `hack node provider aws bootstrap` flow.") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + + if !hasExclusiveTargetSelector { + Text("Choose exactly one selector: instance id or tag value.") + .font(.mono(.caption2)) + .foregroundStyle(Color.orange) + } + if let diagnostics { + Text("selector: \(diagnostics.selectorSummary)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let instanceId = diagnostics.target?.instanceId { + Text("instance: \(instanceId)") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + } + if !diagnostics.issues.isEmpty { + ForEach(diagnostics.issues, id: \.self) { issue in + Text(issue) + .font(.mono(.caption2)) + .foregroundStyle(Color.orange) + } + } + } + + HStack(spacing: 10) { + Button { + Task { await refreshAwsDiagnostics() } + } label: { + Label("Refresh diagnostics", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + .disabled(isLoadingDiagnostics || isBootstrapping) + + Spacer() + } + } + + GlassCard(title: "Bootstrap configuration", systemImage: "gearshape.2") { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + TextField("Region", text: $region) + .textFieldStyle(.roundedBorder) + TextField("Profile (optional)", text: $profile) + .textFieldStyle(.roundedBorder) + Toggle("Set as default after bootstrap", isOn: $defaultNode) + .font(.mono(.caption)) + } + + HStack(spacing: 10) { + TextField("Instance id", text: $instanceId) + .textFieldStyle(.roundedBorder) + TextField("Tag key", text: $instanceTagKey) + .textFieldStyle(.roundedBorder) + TextField("Tag value", text: $instanceTagValue) + .textFieldStyle(.roundedBorder) + } + + HStack(spacing: 10) { + TextField("SSH source", text: $source) + .textFieldStyle(.roundedBorder) + TextField("Gateway endpoint", text: $endpoint) + .textFieldStyle(.roundedBorder) + } + + HStack(spacing: 10) { + TextField("Node display name", text: $nodeName) + .textFieldStyle(.roundedBorder) + TextField("Labels (comma-separated)", text: $labelsCsv) + .textFieldStyle(.roundedBorder) + } + + HStack(spacing: 10) { + Button { + Task { await saveAwsDefaults() } + } label: { + Label("Save defaults", systemImage: "square.and.arrow.down") + } + .adaptiveToolbarButtonProminent() + .disabled(isSavingConfig || isBootstrapping) + + Button { + Task { await bootstrapAwsNode() } + } label: { + Label("Bootstrap node now", systemImage: "play.fill") + } + .adaptiveToolbarButton() + .disabled(isBootstrapping || !canBootstrapAwsNode) + + Button { + openGlobalCommandInTerminalPanel( + command: "hack node provider aws bootstrap --help", + title: "hack aws bootstrap help" + ) + } label: { + Label("Open CLI help", systemImage: "questionmark.circle") + } + .adaptiveToolbarButton() + + Button { + openTopologySettings() + } label: { + Label("Open topology", systemImage: "point.3.connected.trianglepath.dotted") + } + .adaptiveToolbarButton() + + Spacer() + } + + DisclosureGroup("Advanced options", isExpanded: $showAdvancedOptions) { + VStack(alignment: .leading, spacing: 10) { + TextField("Bootstrap command (optional)", text: $bootstrapCommand) + .textFieldStyle(.roundedBorder) + Text("Use this for host prep such as starting a systemd unit before `hack node init` runs.") + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } + } + } + + if let bootstrapResult { + GlassCard(title: "Latest bootstrap result", systemImage: "checkmark.seal") { + HStack(alignment: .center, spacing: 8) { + StatusPill( + text: bootstrapResult.created ? "Node registered" : "Node updated", + tone: .good + ) + StatusPill( + text: bootstrapResult.probe.ok ? "Probe healthy" : "Probe failed", + tone: bootstrapResult.probe.ok ? .good : .warn + ) + StatusPill(text: bootstrapResult.aws.region, tone: .neutral) + Spacer() + } + Text("Instance: \(bootstrapResult.aws.instanceId)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text("Node: \(bootstrapResult.node.name) (\(bootstrapResult.node.id))") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + Text("Endpoint: \(bootstrapResult.endpoint)") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + .textSelection(.enabled) + if let error = bootstrapResult.probe.error, !error.isEmpty { + Text(error) + .font(.mono(.caption2)) + .foregroundStyle(Color.orange) + } + } + } + } + .padding(16) + } + .task { + await loadAwsConfigFromDisk() + } + } + + private var hasTargetSelector: Bool { + normalizedOrNil(instanceId) != nil || normalizedOrNil(instanceTagValue) != nil + } + + private var hasExclusiveTargetSelector: Bool { + (normalizedOrNil(instanceId) != nil ? 1 : 0) + (normalizedOrNil(instanceTagValue) != nil ? 1 : 0) == 1 + } + + private var canBootstrapAwsNode: Bool { + hasExclusiveTargetSelector + && normalizedOrNil(region) != nil + && normalizedOrNil(source) != nil + && normalizedOrNil(endpoint) != nil + } + + private func loadAwsConfigFromDisk() async { + isLoadingConfig = true + defer { isLoadingConfig = false } + + let snapshot = GlobalConfigSnapshot.load() + suppressEnabledToggleChange = true + enabled = snapshot.awsExtensionEnabled ?? false + suppressEnabledToggleChange = false + instanceId = snapshot.awsInstanceId ?? "" + instanceTagKey = snapshot.awsInstanceTagKey ?? "Name" + instanceTagValue = snapshot.awsInstanceTagValue ?? "" + region = snapshot.awsRegion ?? "" + profile = snapshot.awsProfile ?? "" + nodeName = snapshot.awsNodeName ?? "" + source = snapshot.awsSource ?? "" + endpoint = snapshot.awsEndpoint ?? "" + labelsCsv = snapshot.awsLabelsCsv ?? "aws,linux" + bootstrapCommand = snapshot.awsBootstrapCommand ?? "" + await refreshAwsDiagnostics() + } + + private func applyAwsEnabledToggle(_ isEnabled: Bool) async { + isSavingConfig = true + defer { isSavingConfig = false } + + let didUpdate = await model.setGlobalConfig( + key: "controlPlane.extensions[\"dance.hack.aws\"].enabled", + value: isEnabled ? "true" : "false" + ) + guard didUpdate else { + await loadAwsConfigFromDisk() + return + } + await loadAwsConfigFromDisk() + } + + private func refreshAwsDiagnostics() async { + isLoadingDiagnostics = true + defer { isLoadingDiagnostics = false } + diagnostics = await model.inspectAws( + request: AwsBootstrapRequest( + instanceId: normalizedOrNil(instanceId), + instanceTagKey: normalizedOrNil(instanceTagKey), + instanceTagValue: normalizedOrNil(instanceTagValue), + region: normalizedOrNil(region), + profile: normalizedOrNil(profile), + bootstrapCommand: normalizedOrNil(bootstrapCommand), + source: normalizedOrNil(source), + endpoint: normalizedOrNil(endpoint), + nodeName: normalizedOrNil(nodeName), + labels: parseCSV(labelsCsv), + defaultNode: defaultNode + ) + ) + } + + private func saveAwsDefaults() async { + isSavingConfig = true + defer { isSavingConfig = false } + + let writes: [(String, String)] = [ + ("controlPlane.extensions[\"dance.hack.aws\"].enabled", enabled ? "true" : "false"), + ("controlPlane.extensions[\"dance.hack.aws\"].config.instanceId", instanceId), + ("controlPlane.extensions[\"dance.hack.aws\"].config.instanceTagKey", instanceTagKey), + ("controlPlane.extensions[\"dance.hack.aws\"].config.instanceTagValue", instanceTagValue), + ("controlPlane.extensions[\"dance.hack.aws\"].config.region", region), + ("controlPlane.extensions[\"dance.hack.aws\"].config.profile", profile), + ("controlPlane.extensions[\"dance.hack.aws\"].config.nodeName", nodeName), + ("controlPlane.extensions[\"dance.hack.aws\"].config.source", source), + ("controlPlane.extensions[\"dance.hack.aws\"].config.endpoint", endpoint), + ("controlPlane.extensions[\"dance.hack.aws\"].config.labelsCsv", labelsCsv), + ("controlPlane.extensions[\"dance.hack.aws\"].config.bootstrapCommand", bootstrapCommand), + ] + + for (key, value) in writes { + _ = await model.setGlobalConfig(key: key, value: value) + } + + await loadAwsConfigFromDisk() + } + + private func bootstrapAwsNode() async { + guard canBootstrapAwsNode else { + return + } + isBootstrapping = true + defer { isBootstrapping = false } + + let request = AwsBootstrapRequest( + instanceId: normalizedOrNil(instanceId), + instanceTagKey: normalizedOrNil(instanceTagKey), + instanceTagValue: normalizedOrNil(instanceTagValue), + region: normalizedOrNil(region), + profile: normalizedOrNil(profile), + bootstrapCommand: normalizedOrNil(bootstrapCommand), + source: normalizedOrNil(source), + endpoint: normalizedOrNil(endpoint), + nodeName: normalizedOrNil(nodeName), + labels: parseCSV(labelsCsv), + defaultNode: defaultNode + ) + bootstrapResult = await model.bootstrapAwsNode(request: request) + await refreshAwsDiagnostics() + } + + private func normalizedOrNil(_ value: String) -> String? { + let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) + return trimmed.isEmpty ? nil : trimmed + } + + private func parseCSV(_ value: String) -> [String] { + value + .split(separator: ",") + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } + } + + private func openTopologySettings() { + NotificationCenter.default.post( + name: .hackSettingsRequested, + object: nil, + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.topology.rawValue] + ) + } +} + /// Railway provider diagnostics and bootstrap workflow for remote node onboarding. private struct RailwayExtensionSettingsView: View { @Environment(DashboardModel.self) private var model @@ -5867,6 +6250,7 @@ private struct GlobalConfigSnapshot { let linearExtensionEnabled: Bool? let githubExtensionEnabled: Bool? let cloudflareExtensionEnabled: Bool? + let awsExtensionEnabled: Bool? let railwayExtensionEnabled: Bool? let tailscaleExtensionEnabled: Bool? let linearDefaultProfile: String? @@ -5875,6 +6259,16 @@ private struct GlobalConfigSnapshot { let linearSyncDependencies: Bool? let linearSyncProjects: Bool? let githubDefaultProfile: String? + let awsInstanceId: String? + let awsInstanceTagKey: String? + let awsInstanceTagValue: String? + let awsRegion: String? + let awsProfile: String? + let awsBootstrapCommand: String? + let awsSource: String? + let awsEndpoint: String? + let awsNodeName: String? + let awsLabelsCsv: String? let railwayProject: String? let railwayService: String? let railwayEnvironment: String? @@ -5939,12 +6333,14 @@ private struct GlobalConfigSnapshot { let linearExt = dictionary(extensions, key: "dance.hack.linear") let githubExt = dictionary(extensions, key: "dance.hack.github") let cloudflareExt = dictionary(extensions, key: "dance.hack.cloudflare") + let awsExt = dictionary(extensions, key: "dance.hack.aws") let railwayExt = dictionary(extensions, key: "dance.hack.railway") let tailscaleExt = dictionary(extensions, key: "dance.hack.tailscale") let linearConfig = dictionary(linearExt, key: "config") let linearSync = dictionary(linearConfig, key: "sync") let githubConfig = dictionary(githubExt, key: "config") let cloudflareConfig = dictionary(cloudflareExt, key: "config") + let awsConfig = dictionary(awsExt, key: "config") let railwayConfig = dictionary(railwayExt, key: "config") let tailscaleConfig = dictionary(tailscaleExt, key: "config") @@ -5953,6 +6349,7 @@ private struct GlobalConfigSnapshot { linearExtensionEnabled: linearExt["enabled"] as? Bool, githubExtensionEnabled: githubExt["enabled"] as? Bool, cloudflareExtensionEnabled: cloudflareExt["enabled"] as? Bool, + awsExtensionEnabled: awsExt["enabled"] as? Bool, railwayExtensionEnabled: railwayExt["enabled"] as? Bool, tailscaleExtensionEnabled: tailscaleExt["enabled"] as? Bool, linearDefaultProfile: linearConfig["defaultProfile"] as? String, @@ -5961,6 +6358,16 @@ private struct GlobalConfigSnapshot { linearSyncDependencies: linearSync["dependencies"] as? Bool, linearSyncProjects: linearSync["projects"] as? Bool, githubDefaultProfile: githubConfig["defaultProfile"] as? String, + awsInstanceId: awsConfig["instanceId"] as? String, + awsInstanceTagKey: awsConfig["instanceTagKey"] as? String, + awsInstanceTagValue: awsConfig["instanceTagValue"] as? String, + awsRegion: awsConfig["region"] as? String, + awsProfile: awsConfig["profile"] as? String, + awsBootstrapCommand: awsConfig["bootstrapCommand"] as? String, + awsSource: awsConfig["source"] as? String, + awsEndpoint: awsConfig["endpoint"] as? String, + awsNodeName: awsConfig["nodeName"] as? String, + awsLabelsCsv: awsConfig["labelsCsv"] as? String, railwayProject: railwayConfig["project"] as? String, railwayService: railwayConfig["service"] as? String, railwayEnvironment: railwayConfig["environment"] as? String, @@ -5998,6 +6405,7 @@ private struct GlobalConfigSnapshot { linearExtensionEnabled: nil, githubExtensionEnabled: nil, cloudflareExtensionEnabled: nil, + awsExtensionEnabled: nil, railwayExtensionEnabled: nil, tailscaleExtensionEnabled: nil, linearDefaultProfile: nil, @@ -6006,6 +6414,16 @@ private struct GlobalConfigSnapshot { linearSyncDependencies: nil, linearSyncProjects: nil, githubDefaultProfile: nil, + awsInstanceId: nil, + awsInstanceTagKey: nil, + awsInstanceTagValue: nil, + awsRegion: nil, + awsProfile: nil, + awsBootstrapCommand: nil, + awsSource: nil, + awsEndpoint: nil, + awsNodeName: nil, + awsLabelsCsv: nil, railwayProject: nil, railwayService: nil, railwayEnvironment: nil, diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 6ac79cc19..f34c4c6c1 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -1206,6 +1206,87 @@ public actor HackCLIClient { return try decodeJsonOrThrow(RailwayBootstrapResponse.self, result: result) } + public func bootstrapAwsNode( + request: AwsBootstrapRequest + ) async throws -> AwsBootstrapResponse { + var args = [ + "node", + "provider", + "aws", + "bootstrap", + "--json", + ] + + if let value = normalized(request.instanceId) { + args.append(contentsOf: ["--instance-id", value]) + } + if let value = normalized(request.instanceTagKey) { + args.append(contentsOf: ["--instance-tag-key", value]) + } + if let value = normalized(request.instanceTagValue) { + args.append(contentsOf: ["--instance-tag-value", value]) + } + if let value = normalized(request.region) { + args.append(contentsOf: ["--region", value]) + } + if let value = normalized(request.profile) { + args.append(contentsOf: ["--profile", value]) + } + if let value = normalized(request.bootstrapCommand) { + args.append(contentsOf: ["--bootstrap-command", value]) + } + if let value = normalized(request.source) { + args.append(contentsOf: ["--source", value]) + } + if let value = normalized(request.endpoint) { + args.append(contentsOf: ["--endpoint", value]) + } + if let value = normalized(request.nodeName) { + args.append(contentsOf: ["--name", value]) + } + if !request.labels.isEmpty { + args.append(contentsOf: ["--labels", request.labels.joined(separator: ",")]) + } + if request.defaultNode { + args.append("--default") + } + + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(AwsBootstrapResponse.self, result: result) + } + + public func inspectAws( + request: AwsBootstrapRequest? = nil + ) async throws -> AwsInspectResponse { + var args = ["node", "provider", "aws", "inspect", "--json"] + if let request { + if let value = normalized(request.instanceId) { + args.append(contentsOf: ["--instance-id", value]) + } + if let value = normalized(request.instanceTagKey) { + args.append(contentsOf: ["--instance-tag-key", value]) + } + if let value = normalized(request.instanceTagValue) { + args.append(contentsOf: ["--instance-tag-value", value]) + } + if let value = normalized(request.region) { + args.append(contentsOf: ["--region", value]) + } + if let value = normalized(request.profile) { + args.append(contentsOf: ["--profile", value]) + } + if let value = normalized(request.source) { + args.append(contentsOf: ["--source", value]) + } + if let value = normalized(request.endpoint) { + args.append(contentsOf: ["--endpoint", value]) + } + } + + let result = try await run(args, allowNonZeroExit: true) + return try decodeJsonOrThrow(AwsInspectResponse.self, result: result) + } + public func listNodes() async throws -> NodeRegistryListResponse { let result = try await run(["node", "list", "--json"], allowNonZeroExit: true) do { diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index eb6106e45..307aad70f 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -620,6 +620,7 @@ public enum NodeRegistryStatus: String, Decodable { public struct NodeRegistryRecord: Decodable, Identifiable, Hashable { public let id: String public let name: String + public let source: String? public let labels: [String] public let capabilities: [String] public let endpoint: String @@ -636,6 +637,7 @@ public struct NodeRegistryRecord: Decodable, Identifiable, Hashable { public init( id: String, name: String, + source: String?, labels: [String], capabilities: [String], endpoint: String, @@ -651,6 +653,7 @@ public struct NodeRegistryRecord: Decodable, Identifiable, Hashable { ) { self.id = id self.name = name + self.source = source self.labels = labels self.capabilities = capabilities self.endpoint = endpoint @@ -2049,6 +2052,198 @@ public struct RailwayBootstrapResponse: Decodable, Hashable { } } +public struct AwsBootstrapRequest: Hashable { + public let instanceId: String? + public let instanceTagKey: String? + public let instanceTagValue: String? + public let region: String? + public let profile: String? + public let bootstrapCommand: String? + public let source: String? + public let endpoint: String? + public let nodeName: String? + public let labels: [String] + public let defaultNode: Bool + + public init( + instanceId: String?, + instanceTagKey: String?, + instanceTagValue: String?, + region: String?, + profile: String?, + bootstrapCommand: String?, + source: String?, + endpoint: String?, + nodeName: String?, + labels: [String], + defaultNode: Bool + ) { + self.instanceId = instanceId + self.instanceTagKey = instanceTagKey + self.instanceTagValue = instanceTagValue + self.region = region + self.profile = profile + self.bootstrapCommand = bootstrapCommand + self.source = source + self.endpoint = endpoint + self.nodeName = nodeName + self.labels = labels + self.defaultNode = defaultNode + } +} + +public struct AwsBootstrapProviderTag: Decodable, Hashable { + public let key: String + public let value: String + + public init(key: String, value: String) { + self.key = key + self.value = value + } +} + +public struct AwsBootstrapProviderMetadata: Decodable, Hashable { + public let instanceId: String + public let region: String + public let profile: String? + public let tag: AwsBootstrapProviderTag? + + public init( + instanceId: String, + region: String, + profile: String?, + tag: AwsBootstrapProviderTag? + ) { + self.instanceId = instanceId + self.region = region + self.profile = profile + self.tag = tag + } +} + +public struct AwsBootstrapProbe: Decodable, Hashable { + public let ok: Bool + public let status: NodeRegistryStatus? + public let error: String? + + public init(ok: Bool, status: NodeRegistryStatus?, error: String?) { + self.ok = ok + self.status = status + self.error = error + } +} + +public struct AwsBootstrapResponse: Decodable, Hashable { + public let provider: String + public let aws: AwsBootstrapProviderMetadata + public let node: NodeRegistryRecord + public let endpoint: String + public let created: Bool + public let probe: AwsBootstrapProbe + + public init( + provider: String, + aws: AwsBootstrapProviderMetadata, + node: NodeRegistryRecord, + endpoint: String, + created: Bool, + probe: AwsBootstrapProbe + ) { + self.provider = provider + self.aws = aws + self.node = node + self.endpoint = endpoint + self.created = created + self.probe = probe + } +} + +public struct AwsInspectTarget: Decodable, Hashable { + public let resolved: Bool + public let instanceId: String? + public let state: String? + public let ssmOnline: Bool? + public let error: String? + + public init( + resolved: Bool, + instanceId: String?, + state: String?, + ssmOnline: Bool?, + error: String? + ) { + self.resolved = resolved + self.instanceId = instanceId + self.state = state + self.ssmOnline = ssmOnline + self.error = error + } + + private enum CodingKeys: String, CodingKey { + case resolved + case instanceId + case state + case ssmOnline + case error + } +} + +public struct AwsInspectConfig: Decodable, Hashable { + public let instanceId: String? + public let instanceTagKey: String? + public let instanceTagValue: String? + public let region: String? + public let profile: String? + public let source: String? + public let endpoint: String? + public let endpointValid: Bool + + public init( + instanceId: String?, + instanceTagKey: String?, + instanceTagValue: String?, + region: String?, + profile: String?, + source: String?, + endpoint: String?, + endpointValid: Bool + ) { + self.instanceId = instanceId + self.instanceTagKey = instanceTagKey + self.instanceTagValue = instanceTagValue + self.region = region + self.profile = profile + self.source = source + self.endpoint = endpoint + self.endpointValid = endpointValid + } +} + +public struct AwsInspectResponse: Decodable, Hashable { + public let provider: String + public let selectorSummary: String + public let config: AwsInspectConfig + public let canBootstrap: Bool + public let issues: [String] + public let target: AwsInspectTarget? + + public init( + provider: String, + selectorSummary: String, + config: AwsInspectConfig, + canBootstrap: Bool, + issues: [String], + target: AwsInspectTarget? + ) { + self.provider = provider + self.selectorSummary = selectorSummary + self.config = config + self.canBootstrap = canBootstrap + self.issues = issues + self.target = target + } +} + public struct TailscaleInspectResponse: Decodable { public let installed: Bool public let binaryPath: String? diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/AwsBootstrapRequestTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/AwsBootstrapRequestTests.swift new file mode 100644 index 000000000..c8c282073 --- /dev/null +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/AwsBootstrapRequestTests.swift @@ -0,0 +1,48 @@ +import XCTest + +@testable import HackDesktopModels + +final class AwsBootstrapRequestTests: XCTestCase { + func testAwsBootstrapRequestAllowsInstanceIdSelection() { + let request = AwsBootstrapRequest( + instanceId: "i-1234567890", + instanceTagKey: nil, + instanceTagValue: nil, + region: "us-east-1", + profile: nil, + bootstrapCommand: nil, + source: "ec2-user@example.internal", + endpoint: "https://node.example.internal", + nodeName: "aws-node-a", + labels: ["aws", "linux"], + defaultNode: true + ) + + XCTAssertEqual(request.instanceId, "i-1234567890") + XCTAssertNil(request.instanceTagValue) + XCTAssertEqual(request.labels, ["aws", "linux"]) + XCTAssertTrue(request.defaultNode) + } + + func testAwsBootstrapRequestRetainsTagSelectorWhenProvided() { + let request = AwsBootstrapRequest( + instanceId: nil, + instanceTagKey: "Name", + instanceTagValue: "qa-runner", + region: "us-east-2", + profile: "sandbox", + bootstrapCommand: "sudo systemctl restart hack-node", + source: "ubuntu@qa-runner.internal", + endpoint: "https://qa-runner.internal", + nodeName: nil, + labels: [], + defaultNode: false + ) + + XCTAssertNil(request.instanceId) + XCTAssertEqual(request.instanceTagKey, "Name") + XCTAssertEqual(request.instanceTagValue, "qa-runner") + XCTAssertEqual(request.bootstrapCommand, "sudo systemctl restart hack-node") + XCTAssertFalse(request.defaultNode) + } +} diff --git a/bun.lock b/bun.lock index 76263be72..c19130994 100644 --- a/bun.lock +++ b/bun.lock @@ -5,6 +5,9 @@ "": { "name": "hack-cli", "dependencies": { + "@aws-sdk/client-ec2": "^3.1005.0", + "@aws-sdk/client-ssm": "^3.1005.0", + "@aws-sdk/credential-providers": "^3.1005.0", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", "@modelcontextprotocol/sdk": "1.26.0", @@ -81,6 +84,74 @@ "@actions/io": ["@actions/io@2.0.0", "", {}, "sha512-Jv33IN09XLO+0HS79aaODsvIRyduiF7NY/F6LYeK5oeUmrsz7aFdRphQjFoESF4jS7lMauDOttKALcpapVDIAg=="], + "@aws-crypto/sha256-browser": ["@aws-crypto/sha256-browser@5.2.0", "", { "dependencies": { "@aws-crypto/sha256-js": "^5.2.0", "@aws-crypto/supports-web-crypto": "^5.2.0", "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "@aws-sdk/util-locate-window": "^3.0.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-AXfN/lGotSQwu6HNcEsIASo7kWXZ5HYWvfOmSNKDsEqC4OashTp8alTmaz+F7TC2L083SFv5RdB+qU3Vs1kZqw=="], + + "@aws-crypto/sha256-js": ["@aws-crypto/sha256-js@5.2.0", "", { "dependencies": { "@aws-crypto/util": "^5.2.0", "@aws-sdk/types": "^3.222.0", "tslib": "^2.6.2" } }, "sha512-FFQQyu7edu4ufvIZ+OadFpHHOt+eSTBaYaki44c+akjg7qZg9oOQeLlk77F6tSYqjDAFClrHJk9tMf0HdVyOvA=="], + + "@aws-crypto/supports-web-crypto": ["@aws-crypto/supports-web-crypto@5.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-iAvUotm021kM33eCdNfwIN//F77/IADDSs58i+MDaOqFrVjZo9bAal0NK7HurRuWLLpF1iLX7gbWrjHjeo+YFg=="], + + "@aws-crypto/util": ["@aws-crypto/util@5.2.0", "", { "dependencies": { "@aws-sdk/types": "^3.222.0", "@smithy/util-utf8": "^2.0.0", "tslib": "^2.6.2" } }, "sha512-4RkU9EsI6ZpBve5fseQlGNUWKMa1RLPQ1dnjnQoe07ldfIzcsGb5hC5W0Dm7u423KWzawlrpbjXBrXCEv9zazQ=="], + + "@aws-sdk/client-cognito-identity": ["@aws-sdk/client-cognito-identity@3.1005.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-vYZCaQPvawj2W76UtQCu2VCV0vEQnHxL1MOKri4jICLSRiFPlHTosUuwJDzJIIrvTuFgR2DolbcBLRSCLBo/oQ=="], + + "@aws-sdk/client-ec2": ["@aws-sdk/client-ec2@3.1005.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-sdk-ec2": "^3.972.14", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-5ll4ameoqPPqLcka72Z8WmlNIWqcj1ID6lZfT/rUT1DTQ4CagPzy+tRYe/lNLWrdllTpDmVRHSkfqeWZigILZQ=="], + + "@aws-sdk/client-ssm": ["@aws-sdk/client-ssm@3.1005.0", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-node": "^3.972.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "@smithy/util-waiter": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-4+fEslOguhf9Oc4CDOk1OAJFFoNTWyXYUwFxPwaBMp6xpEhBjztYBImYYve++2OC4Zqg7o8uK+G8MYb5090ncg=="], + + "@aws-sdk/core": ["@aws-sdk/core@3.973.19", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/xml-builder": "^3.972.10", "@smithy/core": "^3.23.9", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-56KePyOcZnKTWCd89oJS1G6j3HZ9Kc+bh/8+EbvtaCCXdP6T7O7NzCiPuHRhFLWnzXIaXX3CxAz0nI5My9spHQ=="], + + "@aws-sdk/credential-provider-cognito-identity": ["@aws-sdk/credential-provider-cognito-identity@3.972.11", "", { "dependencies": { "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-yHBjinYauxSvikf15EtgXyZ9TBIMVHUSWFPycQtPltTINpK+uv6K22zKkVsbxpB0gvsdRdIWP0UG5gejM+jPuQ=="], + + "@aws-sdk/credential-provider-env": ["@aws-sdk/credential-provider-env@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-MBAMW6YELzE1SdkOniqr51mrjapQUv8JXSGxtwRjQV0mwVDutVsn22OPAUt4RcLRvdiHQmNBDEFP9iTeSVCOlA=="], + + "@aws-sdk/credential-provider-http": ["@aws-sdk/credential-provider-http@3.972.19", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-9EJROO8LXll5a7eUFqu48k6BChrtokbmgeMWmsH7lBb6lVbtjslUYz/ShLi+SHkYzTomiGBhmzTW7y+H4BxsnA=="], + + "@aws-sdk/credential-provider-ini": ["@aws-sdk/credential-provider-ini@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-login": "^3.972.18", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vthIAXJISZnj2576HeyLBj4WTeX+I7PwWeRkbOa0mVX39K13SCGxCgOFuKj2ytm9qTlLOmXe4cdEnroteFtJfw=="], + + "@aws-sdk/credential-provider-login": ["@aws-sdk/credential-provider-login@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-kINzc5BBxdYBkPZ0/i1AMPMOk5b5QaFNbYMElVw5QTX13AKj6jcxnv/YNl9oW9mg+Y08ti19hh01HhyEAxsSJQ=="], + + "@aws-sdk/credential-provider-node": ["@aws-sdk/credential-provider-node@3.972.19", "", { "dependencies": { "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.18", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/types": "^3.973.5", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-yDWQ9dFTr+IMxwanFe7+tbN5++q8psZBjlUwOiCXn1EzANoBgtqBwcpYcHaMGtn0Wlfj4NuXdf2JaEx1lz5RaQ=="], + + "@aws-sdk/credential-provider-process": ["@aws-sdk/credential-provider-process@3.972.17", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-c8G8wT1axpJDgaP3xzcy+q8Y1fTi9A2eIQJvyhQ9xuXrUZhlCfXbC0vM9bM1CUXiZppFQ1p7g0tuUMvil/gCPg=="], + + "@aws-sdk/credential-provider-sso": ["@aws-sdk/credential-provider-sso@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/token-providers": "3.1005.0", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-YHYEfj5S2aqInRt5ub8nDOX8vAxgMvd84wm2Y3WVNfFa/53vOv9T7WOAqXI25qjj3uEcV46xxfqdDQk04h5XQA=="], + + "@aws-sdk/credential-provider-web-identity": ["@aws-sdk/credential-provider-web-identity@3.972.18", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-OqlEQpJ+J3T5B96qtC1zLLwkBloechP+fezKbCH0sbd2cCc0Ra55XpxWpk/hRj69xAOYtHvoC4orx6eTa4zU7g=="], + + "@aws-sdk/credential-providers": ["@aws-sdk/credential-providers@3.1005.0", "", { "dependencies": { "@aws-sdk/client-cognito-identity": "3.1005.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/credential-provider-cognito-identity": "^3.972.11", "@aws-sdk/credential-provider-env": "^3.972.17", "@aws-sdk/credential-provider-http": "^3.972.19", "@aws-sdk/credential-provider-ini": "^3.972.18", "@aws-sdk/credential-provider-login": "^3.972.18", "@aws-sdk/credential-provider-node": "^3.972.19", "@aws-sdk/credential-provider-process": "^3.972.17", "@aws-sdk/credential-provider-sso": "^3.972.18", "@aws-sdk/credential-provider-web-identity": "^3.972.18", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-H6Prb37+8TdTkGsYH1WkWjupWdM4aHTmF6VLdyTwOWmg4WFkKAb1OJcMrxNCJ8Yy/UvxOca4jk6z2ej+T1pjtQ=="], + + "@aws-sdk/middleware-host-header": ["@aws-sdk/middleware-host-header@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-aHQZgztBFEpDU1BB00VWCIIm85JjGjQW1OG9+98BdmaOpguJvzmXBGbnAiYcciCd+IS4e9BEq664lhzGnWJHgQ=="], + + "@aws-sdk/middleware-logger": ["@aws-sdk/middleware-logger@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-LXhiWlWb26txCU1vcI9PneESSeRp/RYY/McuM4SpdrimQR5NgwaPb4VJCadVeuGWgh6QmqZ6rAKSoL1ob16W6w=="], + + "@aws-sdk/middleware-recursion-detection": ["@aws-sdk/middleware-recursion-detection@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws/lambda-invoke-store": "^0.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-l2VQdcBcYLzIzykCHtXlbpiVCZ94/xniLIkAj0jpnpjY4xlgZx7f56Ypn+uV1y3gG0tNVytJqo3K9bfMFee7SQ=="], + + "@aws-sdk/middleware-sdk-ec2": ["@aws-sdk/middleware-sdk-ec2@3.972.14", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-format-url": "^3.972.7", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/protocol-http": "^5.3.11", "@smithy/signature-v4": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-oQYo61SwCUl4+0B749bnJpZjOTRl3ukdaFZo7A2Ntbxo0nQ8/CvXWOju9MVjHWtnhd+0iyIMTCteieFV4hWyXA=="], + + "@aws-sdk/middleware-user-agent": ["@aws-sdk/middleware-user-agent@3.972.20", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@smithy/core": "^3.23.9", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-retry": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-3kNTLtpUdeahxtnJRnj/oIdLAUdzTfr9N40KtxNhtdrq+Q1RPMdCJINRXq37m4t5+r3H70wgC3opW46OzFcZYA=="], + + "@aws-sdk/nested-clients": ["@aws-sdk/nested-clients@3.996.8", "", { "dependencies": { "@aws-crypto/sha256-browser": "5.2.0", "@aws-crypto/sha256-js": "5.2.0", "@aws-sdk/core": "^3.973.19", "@aws-sdk/middleware-host-header": "^3.972.7", "@aws-sdk/middleware-logger": "^3.972.7", "@aws-sdk/middleware-recursion-detection": "^3.972.7", "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/region-config-resolver": "^3.972.7", "@aws-sdk/types": "^3.973.5", "@aws-sdk/util-endpoints": "^3.996.4", "@aws-sdk/util-user-agent-browser": "^3.972.7", "@aws-sdk/util-user-agent-node": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/core": "^3.23.9", "@smithy/fetch-http-handler": "^5.3.13", "@smithy/hash-node": "^4.2.11", "@smithy/invalid-dependency": "^4.2.11", "@smithy/middleware-content-length": "^4.2.11", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-retry": "^4.4.40", "@smithy/middleware-serde": "^4.2.12", "@smithy/middleware-stack": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/node-http-handler": "^4.4.14", "@smithy/protocol-http": "^5.3.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-body-length-node": "^4.2.3", "@smithy/util-defaults-mode-browser": "^4.3.39", "@smithy/util-defaults-mode-node": "^4.2.42", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-6HlLm8ciMW8VzfB80kfIx16PBA9lOa9Dl+dmCBi78JDhvGlx3I7Rorwi5PpVRkL31RprXnYna3yBf6UKkD/PqA=="], + + "@aws-sdk/region-config-resolver": ["@aws-sdk/region-config-resolver@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/config-resolver": "^4.4.10", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-/Ev/6AI8bvt4HAAptzSjThGUMjcWaX3GX8oERkB0F0F9x2dLSBdgFDiyrRz3i0u0ZFZFQ1b28is4QhyqXTUsVA=="], + + "@aws-sdk/token-providers": ["@aws-sdk/token-providers@3.1005.0", "", { "dependencies": { "@aws-sdk/core": "^3.973.19", "@aws-sdk/nested-clients": "^3.996.8", "@aws-sdk/types": "^3.973.5", "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-vMxd+ivKqSxU9bHx5vmAlFKDAkjGotFU56IOkDa5DaTu1WWwbcse0yFHEm9I537oVvodaiwMl3VBwgHfzQ2rvw=="], + + "@aws-sdk/types": ["@aws-sdk/types@3.973.5", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hl7BGwDCWsjH8NkZfx+HgS7H2LyM2lTMAI7ba9c8O0KqdBLTdNJivsHpqjg9rNlAlPyREb6DeDRXUl0s8uFdmQ=="], + + "@aws-sdk/util-endpoints": ["@aws-sdk/util-endpoints@3.996.4", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-endpoints": "^3.3.2", "tslib": "^2.6.2" } }, "sha512-Hek90FBmd4joCFj+Vc98KLJh73Zqj3s2W56gjAcTkrNLMDI5nIFkG9YpfcJiVI1YlE2Ne1uOQNe+IgQ/Vz2XRA=="], + + "@aws-sdk/util-format-url": ["@aws-sdk/util-format-url@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-V+PbnWfUl93GuFwsOHsAq7hY/fnm9kElRqR8IexIJr5Rvif9e614X5sGSyz3mVSf1YAZ+VTy63W1/pGdA55zyA=="], + + "@aws-sdk/util-locate-window": ["@aws-sdk/util-locate-window@3.965.5", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-WhlJNNINQB+9qtLtZJcpQdgZw3SCDCpXdUJP7cToGwHbCWCnRckGlc6Bx/OhWwIYFNAn+FIydY8SZ0QmVu3xTQ=="], + + "@aws-sdk/util-user-agent-browser": ["@aws-sdk/util-user-agent-browser@3.972.7", "", { "dependencies": { "@aws-sdk/types": "^3.973.5", "@smithy/types": "^4.13.0", "bowser": "^2.11.0", "tslib": "^2.6.2" } }, "sha512-7SJVuvhKhMF/BkNS1n0QAJYgvEwYbK2QLKBrzDiwQGiTRU6Yf1f3nehTzm/l21xdAOtWSfp2uWSddPnP2ZtsVw=="], + + "@aws-sdk/util-user-agent-node": ["@aws-sdk/util-user-agent-node@3.973.5", "", { "dependencies": { "@aws-sdk/middleware-user-agent": "^3.972.20", "@aws-sdk/types": "^3.973.5", "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" }, "peerDependencies": { "aws-crt": ">=1.0.0" }, "optionalPeers": ["aws-crt"] }, "sha512-Dyy38O4GeMk7UQ48RupfHif//gqnOPbq/zlvRssc11E2mClT+aUfc3VS2yD8oLtzqO3RsqQ9I3gOBB4/+HjPOw=="], + + "@aws-sdk/xml-builder": ["@aws-sdk/xml-builder@3.972.10", "", { "dependencies": { "@smithy/types": "^4.13.0", "fast-xml-parser": "5.4.1", "tslib": "^2.6.2" } }, "sha512-OnejAIVD+CxzyAUrVic7lG+3QRltyja9LoNqCE/1YVs8ichoTbJlVSaZ9iSMcnHLyzrSNtvaOGjSDRP+d/ouFA=="], + + "@aws/lambda-invoke-store": ["@aws/lambda-invoke-store@0.2.3", "", {}, "sha512-oLvsaPMTBejkkmHhjf09xTgk71mOqyr/409NKhRIL08If7AhVfUsJhVsx386uJaqNd42v9kWamQ9lFbkoC2dYw=="], + "@babel/code-frame": ["@babel/code-frame@7.27.1", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.27.1", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.28.5", "", {}, "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q=="], @@ -407,6 +478,88 @@ "@sindresorhus/merge-streams": ["@sindresorhus/merge-streams@4.0.0", "", {}, "sha512-tlqY9xq5ukxTUZBmoOp+m61cqwQD5pHJtFY3Mn8CA8ps6yghLH/Hw8UPdqg4OLmFW3IFlcXnQNmo/dh8HzXYIQ=="], + "@smithy/abort-controller": ["@smithy/abort-controller@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-Hj4WoYWMJnSpM6/kchsm4bUNTL9XiSyhvoMb2KIq4VJzyDt7JpGHUZHkVNPZVC7YE1tf8tPeVauxpFBKGW4/KQ=="], + + "@smithy/config-resolver": ["@smithy/config-resolver@4.4.10", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "@smithy/util-config-provider": "^4.2.2", "@smithy/util-endpoints": "^3.3.2", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-IRTkd6ps0ru+lTWnfnsbXzW80A8Od8p3pYiZnW98K2Hb20rqfsX7VTlfUwhrcOeSSy68Gn9WBofwPuw3e5CCsg=="], + + "@smithy/core": ["@smithy/core@3.23.9", "", { "dependencies": { "@smithy/middleware-serde": "^4.2.12", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-body-length-browser": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-stream": "^4.5.17", "@smithy/util-utf8": "^4.2.2", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-1Vcut4LEL9HZsdpI0vFiRYIsaoPwZLjAxnVQDUMQK8beMS+EYPLDQCXtbzfxmM5GzSgjfe2Q9M7WaXwIMQllyQ=="], + + "@smithy/credential-provider-imds": ["@smithy/credential-provider-imds@4.2.11", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-lBXrS6ku0kTj3xLmsJW0WwqWbGQ6ueooYyp/1L9lkyT0M02C+DWwYwc5aTyXFbRaK38ojALxNixg+LxKSHZc0g=="], + + "@smithy/fetch-http-handler": ["@smithy/fetch-http-handler@5.3.13", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "tslib": "^2.6.2" } }, "sha512-U2Hcfl2s3XaYjikN9cT4mPu8ybDbImV3baXR0PkVlC0TTx808bRP3FaPGAzPtB8OByI+JqJ1kyS+7GEgae7+qQ=="], + + "@smithy/hash-node": ["@smithy/hash-node@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-T+p1pNynRkydpdL015ruIoyPSRw9e/SQOWmSAMmmprfswMrd5Ow5igOWNVlvyVFZlxXqGmyH3NQwfwy8r5Jx0A=="], + + "@smithy/invalid-dependency": ["@smithy/invalid-dependency@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-cGNMrgykRmddrNhYy1yBdrp5GwIgEkniS7k9O1VLB38yxQtlvrxpZtUVvo6T4cKpeZsriukBuuxfJcdZQc/f/g=="], + + "@smithy/is-array-buffer": ["@smithy/is-array-buffer@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-n6rQ4N8Jj4YTQO3YFrlgZuwKodf4zUFs7EJIWH86pSCWBaAtAGBFfCM7Wx6D2bBJ2xqFNxGBSrUWswT3M0VJow=="], + + "@smithy/middleware-content-length": ["@smithy/middleware-content-length@4.2.11", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-UvIfKYAKhCzr4p6jFevPlKhQwyQwlJ6IeKLDhmV1PlYfcW3RL4ROjNEDtSik4NYMi9kDkH7eSwyTP3vNJ/u/Dw=="], + + "@smithy/middleware-endpoint": ["@smithy/middleware-endpoint@4.4.23", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-serde": "^4.2.12", "@smithy/node-config-provider": "^4.3.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "@smithy/url-parser": "^4.2.11", "@smithy/util-middleware": "^4.2.11", "tslib": "^2.6.2" } }, "sha512-UEFIejZy54T1EJn2aWJ45voB7RP2T+IRzUqocIdM6GFFa5ClZncakYJfcYnoXt3UsQrZZ9ZRauGm77l9UCbBLw=="], + + "@smithy/middleware-retry": ["@smithy/middleware-retry@4.4.40", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/protocol-http": "^5.3.11", "@smithy/service-error-classification": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "@smithy/util-middleware": "^4.2.11", "@smithy/util-retry": "^4.2.11", "@smithy/uuid": "^1.1.2", "tslib": "^2.6.2" } }, "sha512-YhEMakG1Ae57FajERdHNZ4ShOPIY7DsgV+ZoAxo/5BT0KIe+f6DDU2rtIymNNFIj22NJfeeI6LWIifrwM0f+rA=="], + + "@smithy/middleware-serde": ["@smithy/middleware-serde@4.2.12", "", { "dependencies": { "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-W9g1bOLui7Xn5FABRVS0o3rXL0gfN37d/8I/W7i0N7oxjx9QecUmXEMSUMADTODwdtka9cN43t5BI2CodLJpng=="], + + "@smithy/middleware-stack": ["@smithy/middleware-stack@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-s+eenEPW6RgliDk2IhjD2hWOxIx1NKrOHxEwNUaUXxYBxIyCcDfNULZ2Mu15E3kwcJWBedTET/kEASPV1A1Akg=="], + + "@smithy/node-config-provider": ["@smithy/node-config-provider@4.3.11", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/shared-ini-file-loader": "^4.4.6", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-xD17eE7kaLgBBGf5CZQ58hh2YmwK1Z0O8YhffwB/De2jsL0U3JklmhVYJ9Uf37OtUDLF2gsW40Xwwag9U869Gg=="], + + "@smithy/node-http-handler": ["@smithy/node-http-handler@4.4.14", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/querystring-builder": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-DamSqaU8nuk0xTJDrYnRzZndHwwRnyj/n/+RqGGCcBKB4qrQem0mSDiWdupaNWdwxzyMU91qxDmHOCazfhtO3A=="], + + "@smithy/property-provider": ["@smithy/property-provider@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-14T1V64o6/ndyrnl1ze1ZhyLzIeYNN47oF/QU6P5m82AEtyOkMJTb0gO1dPubYjyyKuPD6OSVMPDKe+zioOnCg=="], + + "@smithy/protocol-http": ["@smithy/protocol-http@5.3.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-hI+barOVDJBkNt4y0L2mu3Ugc0w7+BpJ2CZuLwXtSltGAAwCb3IvnalGlbDV/UCS6a9ZuT3+exd1WxNdLb5IlQ=="], + + "@smithy/querystring-builder": ["@smithy/querystring-builder@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "@smithy/util-uri-escape": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-7spdikrYiljpket6u0up2Ck2mxhy7dZ0+TDd+S53Dg2DHd6wg+YNJrTCHiLdgZmEXZKI7LJZcwL3721ZRDFiqA=="], + + "@smithy/querystring-parser": ["@smithy/querystring-parser@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-nE3IRNjDltvGcoThD2abTozI1dkSy8aX+a2N1Rs55en5UsdyyIXgGEmevUL3okZFoJC77JgRGe99xYohhsjivQ=="], + + "@smithy/service-error-classification": ["@smithy/service-error-classification@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0" } }, "sha512-HkMFJZJUhzU3HvND1+Yw/kYWXp4RPDLBWLcK1n+Vqw8xn4y2YiBhdww8IxhkQjP/QlZun5bwm3vcHc8AqIU3zw=="], + + "@smithy/shared-ini-file-loader": ["@smithy/shared-ini-file-loader@4.4.6", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-IB/M5I8G0EeXZTHsAxpx51tMQ5R719F3aq+fjEB6VtNcCHDc0ajFDIGDZw+FW9GxtEkgTduiPpjveJdA/CX7sw=="], + + "@smithy/signature-v4": ["@smithy/signature-v4@5.3.11", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-middleware": "^4.2.11", "@smithy/util-uri-escape": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-V1L6N9aKOBAN4wEHLyqjLBnAz13mtILU0SeDrjOaIZEeN6IFa6DxwRt1NNpOdmSpQUfkBj0qeD3m6P77uzMhgQ=="], + + "@smithy/smithy-client": ["@smithy/smithy-client@4.12.3", "", { "dependencies": { "@smithy/core": "^3.23.9", "@smithy/middleware-endpoint": "^4.4.23", "@smithy/middleware-stack": "^4.2.11", "@smithy/protocol-http": "^5.3.11", "@smithy/types": "^4.13.0", "@smithy/util-stream": "^4.5.17", "tslib": "^2.6.2" } }, "sha512-7k4UxjSpHmPN2AxVhvIazRSzFQjWnud3sOsXcFStzagww17j1cFQYqTSiQ8xuYK3vKLR1Ni8FzuT3VlKr3xCNw=="], + + "@smithy/types": ["@smithy/types@4.13.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-COuLsZILbbQsdrwKQpkkpyep7lCsByxwj7m0Mg5v66/ZTyenlfBc40/QFQ5chO0YN/PNEH1Bi3fGtfXPnYNeDw=="], + + "@smithy/url-parser": ["@smithy/url-parser@4.2.11", "", { "dependencies": { "@smithy/querystring-parser": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-oTAGGHo8ZYc5VZsBREzuf5lf2pAurJQsccMusVZ85wDkX66ojEc/XauiGjzCj50A61ObFTPe6d7Pyt6UBYaing=="], + + "@smithy/util-base64": ["@smithy/util-base64@4.3.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-XRH6b0H/5A3SgblmMa5ErXQ2XKhfbQB+Fm/oyLZ2O2kCUrwgg55bU0RekmzAhuwOjA9qdN5VU2BprOvGGUkOOQ=="], + + "@smithy/util-body-length-browser": ["@smithy/util-body-length-browser@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-JKCrLNOup3OOgmzeaKQwi4ZCTWlYR5H4Gm1r2uTMVBXoemo1UEghk5vtMi1xSu2ymgKVGW631e2fp9/R610ZjQ=="], + + "@smithy/util-body-length-node": ["@smithy/util-body-length-node@4.2.3", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-ZkJGvqBzMHVHE7r/hcuCxlTY8pQr1kMtdsVPs7ex4mMU+EAbcXppfo5NmyxMYi2XU49eqaz56j2gsk4dHHPG/g=="], + + "@smithy/util-buffer-from": ["@smithy/util-buffer-from@4.2.2", "", { "dependencies": { "@smithy/is-array-buffer": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-FDXD7cvUoFWwN6vtQfEta540Y/YBe5JneK3SoZg9bThSoOAC/eGeYEua6RkBgKjGa/sz6Y+DuBZj3+YEY21y4Q=="], + + "@smithy/util-config-provider": ["@smithy/util-config-provider@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-dWU03V3XUprJwaUIFVv4iOnS1FC9HnMHDfUrlNDSh4315v0cWyaIErP8KiqGVbf5z+JupoVpNM7ZB3jFiTejvQ=="], + + "@smithy/util-defaults-mode-browser": ["@smithy/util-defaults-mode-browser@4.3.39", "", { "dependencies": { "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-ui7/Ho/+VHqS7Km2wBw4/Ab4RktoiSshgcgpJzC4keFPs6tLJS4IQwbeahxQS3E/w98uq6E1mirCH/id9xIXeQ=="], + + "@smithy/util-defaults-mode-node": ["@smithy/util-defaults-mode-node@4.2.42", "", { "dependencies": { "@smithy/config-resolver": "^4.4.10", "@smithy/credential-provider-imds": "^4.2.11", "@smithy/node-config-provider": "^4.3.11", "@smithy/property-provider": "^4.2.11", "@smithy/smithy-client": "^4.12.3", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-QDA84CWNe8Akpj15ofLO+1N3Rfg8qa2K5uX0y6HnOp4AnRYRgWrKx/xzbYNbVF9ZsyJUYOfcoaN3y93wA/QJ2A=="], + + "@smithy/util-endpoints": ["@smithy/util-endpoints@3.3.2", "", { "dependencies": { "@smithy/node-config-provider": "^4.3.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-+4HFLpE5u29AbFlTdlKIT7jfOzZ8PDYZKTb3e+AgLz986OYwqTourQ5H+jg79/66DB69Un1+qKecLnkZdAsYcA=="], + + "@smithy/util-hex-encoding": ["@smithy/util-hex-encoding@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-Qcz3W5vuHK4sLQdyT93k/rfrUwdJ8/HZ+nMUOyGdpeGA1Wxt65zYwi3oEl9kOM+RswvYq90fzkNDahPS8K0OIg=="], + + "@smithy/util-middleware": ["@smithy/util-middleware@4.2.11", "", { "dependencies": { "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-r3dtF9F+TpSZUxpOVVtPfk09Rlo4lT6ORBqEvX3IBT6SkQAdDSVKR5GcfmZbtl7WKhKnmb3wbDTQ6ibR2XHClw=="], + + "@smithy/util-retry": ["@smithy/util-retry@4.2.11", "", { "dependencies": { "@smithy/service-error-classification": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-XSZULmL5x6aCTTii59wJqKsY1l3eMIAomRAccW7Tzh9r8s7T/7rdo03oektuH5jeYRlJMPcNP92EuRDvk9aXbw=="], + + "@smithy/util-stream": ["@smithy/util-stream@4.5.17", "", { "dependencies": { "@smithy/fetch-http-handler": "^5.3.13", "@smithy/node-http-handler": "^4.4.14", "@smithy/types": "^4.13.0", "@smithy/util-base64": "^4.3.2", "@smithy/util-buffer-from": "^4.2.2", "@smithy/util-hex-encoding": "^4.2.2", "@smithy/util-utf8": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-793BYZ4h2JAQkNHcEnyFxDTcZbm9bVybD0UV/LEWmZ5bkTms7JqjfrLMi2Qy0E5WFcCzLwCAPgcvcvxoeALbAQ=="], + + "@smithy/util-uri-escape": ["@smithy/util-uri-escape@4.2.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-2kAStBlvq+lTXHyAZYfJRb/DfS3rsinLiwb+69SstC9Vb0s9vNWkRwpnj918Pfi85mzi42sOqdV72OLxWAISnw=="], + + "@smithy/util-utf8": ["@smithy/util-utf8@4.2.2", "", { "dependencies": { "@smithy/util-buffer-from": "^4.2.2", "tslib": "^2.6.2" } }, "sha512-75MeYpjdWRe8M5E3AW0O4Cx3UadweS+cwdXjwYGBW5h/gxxnbeZ877sLPX/ZJA9GVTlL/qG0dXP29JWFCD1Ayw=="], + + "@smithy/util-waiter": ["@smithy/util-waiter@4.2.11", "", { "dependencies": { "@smithy/abort-controller": "^4.2.11", "@smithy/types": "^4.13.0", "tslib": "^2.6.2" } }, "sha512-x7Rh2azQPs3XxbvCzcttRErKKvLnbZfqRf/gOjw2pb+ZscX88e5UkRPCB67bVnsFHxayvMvmePfKTqsRb+is1A=="], + + "@smithy/uuid": ["@smithy/uuid@1.1.2", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-O/IEdcCUKkubz60tFbGA7ceITTAJsty+lBjNoorP4Z6XRqaFb/OjQjZODophEcuq68nKm6/0r+6/lLQ+XVpk8g=="], + "@standard-schema/spec": ["@standard-schema/spec@1.1.0", "", {}, "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w=="], "@tokenizer/token": ["@tokenizer/token@0.3.0", "", {}, "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A=="], @@ -487,6 +640,8 @@ "bottleneck": ["bottleneck@2.19.5", "", {}, "sha512-VHiNCbI1lKdl44tGrhNfU3lup0Tj/ZBMJB5/2ZbNXRCPuRCO7ed2mgcK4r17y+KB2EfuYuRaVlwNbAeaWGSpbw=="], + "bowser": ["bowser@2.14.1", "", {}, "sha512-tzPjzCxygAKWFOJP011oxFHs57HzIhOEracIgAePE4pqB3LikALKnSzUyU4MGs9/iCEUuHlAJTjTc5M+u7YEGg=="], + "brace-expansion": ["brace-expansion@1.1.12", "", { "dependencies": { "balanced-match": "^1.0.0", "concat-map": "0.0.1" } }, "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg=="], "braces": ["braces@3.0.3", "", { "dependencies": { "fill-range": "^7.1.1" } }, "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA=="], @@ -729,6 +884,10 @@ "fast-uri": ["fast-uri@3.1.0", "", {}, "sha512-iPeeDKJSWf4IEOasVVrknXpaBV0IApz/gp7S2bb7Z4Lljbl2MGJRqInZiUrQwV16cpzw/D3S5j5Julj/gT52AA=="], + "fast-xml-builder": ["fast-xml-builder@1.0.0", "", {}, "sha512-fpZuDogrAgnyt9oDDz+5DBz0zgPdPZz6D4IR7iESxRXElrlGTRkHJ9eEt+SACRJwT0FNFrt71DFQIUFBJfX/uQ=="], + + "fast-xml-parser": ["fast-xml-parser@5.4.1", "", { "dependencies": { "fast-xml-builder": "^1.0.0", "strnum": "^2.1.2" }, "bin": { "fxparser": "src/cli/cli.js" } }, "sha512-BQ30U1mKkvXQXXkAGcuyUA/GA26oEB7NzOtsxCDtyu62sjGw5QraKFhx2Em3WQNjPw9PG6MQ9yuIIgkSDfGu5A=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "figures": ["figures@6.1.0", "", { "dependencies": { "is-unicode-supported": "^2.0.0" } }, "sha512-d+l3qxjSesT4V7v2fh+QnmFnUWv9lSpjarhShNTgBOfA0ttejbQUAlHLitbjkoRiDulW0OPoQPYIGhIC8ohejg=="], @@ -1341,6 +1500,8 @@ "strip-json-comments": ["strip-json-comments@3.1.1", "", {}, "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig=="], + "strnum": ["strnum@2.2.0", "", {}, "sha512-Y7Bj8XyJxnPAORMZj/xltsfo55uOiyHcU2tnAVzHUnSJR/KsEX+9RoDeXEnsXtl/CX4fAcrt64gZ13aGaWPeBg=="], + "strtok3": ["strtok3@6.3.0", "", { "dependencies": { "@tokenizer/token": "^0.3.0", "peek-readable": "^4.1.0" } }, "sha512-fZtbhtvI9I48xDSywd/somNqgUHl2L2cstmXCCif0itOf96jeW18MBSyrLuNicYQVkvpOxkZtkzujiTJ9LW5Jw=="], "super-regex": ["super-regex@1.1.0", "", { "dependencies": { "function-timeout": "^1.0.1", "make-asynchronous": "^1.0.1", "time-span": "^5.1.0" } }, "sha512-WHkws2ZflZe41zj6AolvvmaTrWds/VuyeYr9iPVv/oQeaIoVxMKaushfFWpOGDT+GuBrM/sVqF8KUCYQlSSTdQ=="], @@ -1495,6 +1656,10 @@ "@actions/http-client/undici": ["undici@5.29.0", "", { "dependencies": { "@fastify/busboy": "^2.0.0" } }, "sha512-raqeBD6NQK4SkWhQzeYKd1KmIG6dllBOTt55Rmkt4HtI9mwdWtJljnrXjAFUBLTSN67HWrOIZ3EPF4kjUw80Bg=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + + "@aws-crypto/util/@smithy/util-utf8": ["@smithy/util-utf8@2.3.0", "", { "dependencies": { "@smithy/util-buffer-from": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-R8Rdn8Hy72KKcebgLiv8jQcQkXoLMOGGv5uI1/k0l+snqkOzQ1R0ChUBCxWMlBsFMekWjq0wRudIweFs7sKT5A=="], + "@better-auth/core/better-call": ["better-call@1.1.8", "", { "dependencies": { "@better-auth/utils": "^0.3.0", "@better-fetch/fetch": "^1.1.4", "rou3": "^0.7.10", "set-cookie-parser": "^2.7.1" }, "peerDependencies": { "zod": "^4.0.0" }, "optionalPeers": ["zod"] }, "sha512-XMQ2rs6FNXasGNfMjzbyroSwKwYbZ/T3IxruSS6U2MJRsSYh3wYtG3o6H00ZlKZ/C/UPOAD97tqgQJNsxyeTXw=="], "@better-auth/kysely-adapter/@better-auth/core": ["@better-auth/core@1.5.4", "", { "dependencies": { "@standard-schema/spec": "^1.1.0", "zod": "^4.3.6" }, "peerDependencies": { "@better-auth/utils": "0.3.1", "@better-fetch/fetch": "1.1.21", "@cloudflare/workers-types": ">=4", "better-call": "1.3.2", "jose": "^6.1.0", "kysely": "^0.28.5", "nanostores": "^1.0.1" }, "optionalPeers": ["@cloudflare/workers-types"] }, "sha512-k5AdwPRQETZn0vdB60EB9CDxxfllpJXKqVxTjyXIUSRz7delNGlU0cR/iRP3VfVJwvYR1NbekphBDNo+KGoEzQ=="], @@ -2027,6 +2192,10 @@ "wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from": ["@smithy/util-buffer-from@2.2.0", "", { "dependencies": { "@smithy/is-array-buffer": "^2.2.0", "tslib": "^2.6.2" } }, "sha512-IJdWBbTcMQ6DA0gdNhh/BwrLkDR+ADW5Kr1aZmd4k3DIF6ezMV4R2NIAmT08wQJ3yUK82thHWmC/TnK/wpMMIA=="], + "@better-auth/core/better-call/set-cookie-parser": ["set-cookie-parser@2.7.2", "", {}, "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw=="], "@better-auth/kysely-adapter/@better-auth/core/@better-auth/utils": ["@better-auth/utils@0.3.1", "", {}, "sha512-+CGp4UmZSUrHHnpHhLPYu6cV+wSUSvVbZbNykxhUDocpVNTo9uFFxw/NqJlh1iC4wQ9HKKWGCKuZ5wUgS0v6Kg=="], @@ -2217,6 +2386,10 @@ "wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@aws-crypto/sha256-browser/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + + "@aws-crypto/util/@smithy/util-utf8/@smithy/util-buffer-from/@smithy/is-array-buffer": ["@smithy/is-array-buffer@2.2.0", "", { "dependencies": { "tslib": "^2.6.2" } }, "sha512-GGP3O9QFD24uGeAXYUjwSTXARoqpZykHadOmA8G5vfJPK0/DC67qa//0qvqrJzL1xc8WQWX7/yc7fwudjPHPhA=="], + "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], "@semantic-release/github/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], diff --git a/docs/README.md b/docs/README.md index e493be242..c37d45f3f 100644 --- a/docs/README.md +++ b/docs/README.md @@ -30,6 +30,7 @@ Quick diagnostics: - Start here (first remote node in ~10 minutes): - [Remote node quickstart](guides/remote-node-quickstart.md) - [Run laptop-to-laptop node pairing e2e](guides/remote-node-laptop-e2e.md) + - [Bootstrap a remote node on AWS](guides/remote-node-aws.md) - [Bootstrap a remote node on Railway](guides/remote-node-railway.md) - Remote setup (one command): `hack remote setup` - [Initialize a project](guides/init-project.md) @@ -40,6 +41,7 @@ Quick diagnostics: - [Expose the gateway with Tailscale](guides/remote-tailscale.md) - [Run remote supervisor jobs](guides/remote-supervisor.md) - [Run a remote node via container image](guides/remote-node-container.md) +- [Bootstrap a remote node on AWS](guides/remote-node-aws.md) - [Bootstrap a remote node on Railway](guides/remote-node-railway.md) - [Bootstrap auth-broker with Neon + Railway](guides/auth-broker-neon-bootstrap.md) - [Create a new extension](guides/create-extension.md) diff --git a/docs/cli.md b/docs/cli.md index d46d6c85c..2f32bb596 100644 --- a/docs/cli.md +++ b/docs/cli.md @@ -851,6 +851,29 @@ hack node provider railway bootstrap \ --railway-private \ --default +# AWS provider bootstrap (explicit instance id) +hack node provider aws bootstrap \ + --instance-id "i-0123456789abcdef0" \ + --region us-east-1 \ + --source "ec2-user@runner.internal" \ + --endpoint "https://runner.example.internal" \ + --name "aws-runner-1" \ + --labels aws,linux \ + --default + +# AWS provider bootstrap (tag discovery + optional pre-init command) +hack node provider aws bootstrap \ + --instance-tag-value "event-agent-qa-runner" \ + --instance-tag-key Name \ + --region us-east-1 \ + --profile qa \ + --source "ec2-user@runner.internal" \ + --endpoint "https://runner.example.internal" \ + --bootstrap-command "sudo systemctl start hack-node" + +# AWS provider e2e validation +bun run test:e2e:aws + hack node devcontainer up --node --project my-project --branch feature/foo hack node devcontainer attach --node --id --ide vscode --ssh-host node.example.com --ssh-alias hack-node-dev ``` @@ -936,6 +959,7 @@ Options: | `--ticket ` | string | - | Ticket id to associate with run metadata | | `--runner ` | string | `generic` | Runner identity for policy and audit | | `--approve` | boolean | false | Approve high/critical risk commands non-interactively | +| `--local` | boolean | false | Execute the dispatch run in the local project workspace instead of a remote node | | `--pr` | boolean | false | Push branch + create/update GitHub PR on successful run | | `--pr-base ` | string | `main` | Base branch used with `--pr` | | `--pr-title ` | string | auto | Override PR title | @@ -945,6 +969,20 @@ Options: Note: when the target node does not already have the project workspace, dispatch sends bootstrap metadata (git origin + project name) to `/v1/node/workspaces/ensure` so the node can clone/register the repo automatically. +Local dispatch notes: + +- `--local` cannot be combined with `--node`, `--provider`, `--profile`, or `--bootstrap-if-needed`. +- `--local --pr` emits typed terminal states in JSON and persisted artifacts: + - `completed` + - `no_diff` + - `no_commit` + - `pr_created` + - `pr_failed` +- Dispatch exit codes for PR terminal states: + - `20` = `no_diff` + - `21` = `no_commit` + - `22` = `pr_failed` + Route precedence for dispatch: 1. Command flags (`--node`, `--provider`, `--profile`). @@ -967,6 +1005,26 @@ Usage: `hack dispatch status <run-id> [--json]` Usage: `hack dispatch logs <run-id> [--follow] [--tail <n>] [--json]` +### hack workspace + +Usage: `hack workspace <subcommand>` + +Subcommands: + +| Subcommand | Summary | +| --- | --- | +| `reset` | Reset a workspace to a base ref and clean untracked files | + +#### hack workspace reset + +Usage: `hack workspace reset --base <remote/branch|ref> [--path <dir>|--project <name>] [--exclude <csv>] [--json]` + +Notes: + +- Removes stale `.git/index.lock` before reset when present. +- Resets tracked changes and cleans untracked files while preserving explicit excludes. +- Emits a deterministic JSON summary with removed paths, preserved excludes, and before/after status details. + ### hack remote Usage: `hack remote <subcommand>` diff --git a/docs/guides/remote-node-aws.md b/docs/guides/remote-node-aws.md new file mode 100644 index 000000000..14c6c0c82 --- /dev/null +++ b/docs/guides/remote-node-aws.md @@ -0,0 +1,206 @@ +# AWS Node Bootstrap Guide + +Use this guide to register an EC2-hosted runtime as a `hack` remote node using the built-in AWS provider flow. + +If this is your first remote node, start with [Remote node quickstart](remote-node-quickstart.md) for core concepts. + +## Quick Start + +### Fastest path (macOS app) + +1. Open **Settings → Extensions → AWS**. +2. Save defaults for: + - region + - instance id or tag selector + - SSH source + - gateway endpoint +3. Click **Bootstrap node now**. +4. Open **Settings → System → Topology** and verify the node is registered. + +### CLI path (explicit instance id) + +```bash +hack node provider aws bootstrap \ + --instance-id "i-0123456789abcdef0" \ + --region us-east-1 \ + --source "ec2-user@runner.internal" \ + --endpoint "https://runner.example.internal" \ + --name "aws-runner-1" \ + --labels aws,linux \ + --default +``` + +### CLI path (tag lookup + profile) + +```bash +hack node provider aws bootstrap \ + --instance-tag-value "event-agent-qa-runner" \ + --instance-tag-key Name \ + --region us-east-1 \ + --profile qa \ + --source "ec2-user@runner.internal" \ + --endpoint "https://runner.example.internal" \ + --bootstrap-command "sudo systemctl start hack-node" +``` + +### CLI path (uses saved AWS defaults) + +If your AWS defaults are already stored in global config: + +```bash +hack node provider aws bootstrap --default +``` + +## macOS App Workflow + +In Hack Desktop: + +1. Open **Settings → Extensions → AWS**. +2. Confirm the provider is enabled. +3. Set defaults: + - AWS region + - instance id, or tag key + tag value + - SSH source + - gateway endpoint + - optional node name, labels, and bootstrap command +4. Click **Save defaults**. +5. Click **Bootstrap node now**. +6. Validate the result card. +7. Open **Topology** and confirm the node is visible, healthy, and optionally marked as default. + +## What The Command Does + +`hack node provider aws bootstrap` performs these steps: + +1. Resolves the target EC2 instance by instance id or tag selector. +2. Starts the instance if it is stopped. +3. Waits for EC2 state + SSM readiness. +4. Optionally runs `--bootstrap-command` over SSM for host preparation. +5. Executes `hack node init --json` over SSM on the remote host. +6. Registers the returned node bundle on the controller. +7. Probes node health and stores endpoint + SSH source metadata for parity flows. + +## Required Prerequisites + +1. The controller has AWS credentials that can access EC2 + SSM in the target account/region. +2. The EC2 instance has SSM enabled and is online. +3. The remote host already has `hack` installed and the gateway available, unless your bootstrap command installs or starts it. +4. The controller can reach the provided gateway endpoint. +5. The controller can SSH to the provided `source` when you want Mutagen sync or devcontainer attach parity. + +## Flags + +- `--instance-id <id>`: target a specific EC2 instance. +- `--instance-tag-key <key>`: tag key used with tag lookup (defaults to `Name`). +- `--instance-tag-value <value>`: tag value used to resolve an instance. +- `--region <region>`: AWS region to query. +- `--profile <profile>`: optional shared AWS config profile. +- `--bootstrap-command <cmd>`: optional SSM command to prepare the host before `hack node init`. +- `--source <user@host>`: required SSH source for source-backed parity flows. +- `--endpoint <url>`: required gateway endpoint the controller can reach. +- `--name <name>`: optional node display name. +- `--labels <csv>`: optional node labels. +- `--default`: set the resulting node as the default controller node. +- `--json`: machine-readable output. + +## Validation Checklist + +After bootstrap: + +```bash +hack node list +hack node status --node <node-id> +hack dispatch run --node <node-id> --project <project> --branch <branch> --runner generic -- "pwd" +``` + +Pin a project to remote workspace offload: + +```bash +hack config set "controlPlane.execution.mode" "local_edit_remote_run" +hack config set "controlPlane.execution.nodeId" "<node-id>" +hack up --target auto +``` + +Optional source-backed parity checks: + +```bash +hack node devcontainer up --node <node-id> --project <project> --branch <branch> +hack node devcontainer attach --node <node-id> --id <session-id> --ide vscode --ssh-host <ssh-host> +mutagen sync list +``` + +## E2E Test Matrix (AWS + EC2 + SSM) + +Run this matrix before marking AWS bootstrap stable: + +1. **Bootstrap** + - run `bun run test:e2e:aws` + - confirm node id and endpoint are returned +2. **Registry + probe** + - `hack node list` + - `hack node status --node <node-id>` +3. **Dispatch execution** + - `hack dispatch run --node <node-id> --project <project> --branch <branch> --runner generic -- "uname -a"` +4. **Remote workspace offload** + - set `controlPlane.execution.mode=local_edit_remote_run` + - run `hack up --target auto` + - confirm run artifacts include sync metadata when Mutagen is active +5. **Devcontainer smoke** + - `hack node devcontainer up --node <node-id> --project <project> --branch <branch>` + - attach from local IDE and verify shell commands execute + +### Automated E2E Runner + +Use the bundled runner for repeatable CLI validation: + +```bash +bun run test:e2e:aws +``` + +Required environment: + +- `HACK_AWS_E2E_REGION` +- `HACK_AWS_E2E_SOURCE` +- `HACK_AWS_E2E_ENDPOINT` +- exactly one of: + - `HACK_AWS_E2E_INSTANCE_ID` + - `HACK_AWS_E2E_INSTANCE_TAG_VALUE` + +Optional overrides: + +- `HACK_AWS_E2E_INSTANCE_TAG_KEY=Name` +- `HACK_AWS_E2E_PROFILE=<aws-profile>` +- `HACK_AWS_E2E_BOOTSTRAP_COMMAND=<command>` +- `HACK_AWS_E2E_NODE_NAME=<node-name>` +- `HACK_AWS_E2E_LABELS=aws,e2e` +- `HACK_AWS_E2E_DEFAULT_NODE=true` +- `HACK_AWS_E2E_PROJECT=<project-name-or-id>` +- `HACK_AWS_E2E_BRANCH=<branch>` +- `HACK_AWS_E2E_RUN_COMMAND=<shell-command>` +- `HACK_AWS_E2E_DEVCONTAINER=true` + +If `HACK_AWS_E2E_PROJECT` is set, the runner also exercises a dispatch command against the bootstrapped node. If `HACK_AWS_E2E_DEVCONTAINER=true`, it also runs `hack node devcontainer up`. + +## Troubleshooting + +1. `AWS bootstrap requires exactly one of --instance-id or --instance-tag-value` + - Cause: both selectors were set, or neither was set. + - Fix: choose one target selector. +2. `SSM readiness timed out` + - Cause: the instance is not online in SSM yet, or the instance role/agent is broken. + - Fix: verify SSM agent health and IAM permissions on the instance. +3. `bootstrap command failed` + - Cause: the pre-init command returned non-zero. + - Fix: inspect the surfaced stdout/stderr in the JSON output and rerun with a corrected command. +4. Node appears in the registry but Mutagen/devcontainer flows do not work + - Cause: `source` metadata is missing or not reachable over SSH. + - Fix: bootstrap again with a valid `--source`, then verify `hack node ssh setup --source <user@host>`. +5. Node is healthy but the project still runs locally + - Cause: project default behavior is still `local`. + - Fix: set `controlPlane.execution.mode=local_edit_remote_run`, or use explicit remote actions/flags when available. + +## Current Limitations + +1. v1 AWS support targets existing EC2 instances over SSM only. +2. `hack` does not provision EC2, IAM, security groups, or AWS networking for you. +3. Project-specific env hydration and service orchestration still belong in repo-owned automation. diff --git a/docs/guides/remote-node-quickstart.md b/docs/guides/remote-node-quickstart.md index d4af4bc71..40b0a4ecd 100644 --- a/docs/guides/remote-node-quickstart.md +++ b/docs/guides/remote-node-quickstart.md @@ -2,6 +2,10 @@ Use this guide to connect one remote machine as a `hack` execution node and run a project on it. +Provider-specific bootstrap flows: +- [Bootstrap a remote node on AWS](remote-node-aws.md) +- [Bootstrap a remote node on Railway](remote-node-railway.md) + ## What you need 1. A host machine (controller) with `hack` installed. diff --git a/docs/plans/2026-03-10-remote-modes-aws-runner-foundation.md b/docs/plans/2026-03-10-remote-modes-aws-runner-foundation.md new file mode 100644 index 000000000..802002421 --- /dev/null +++ b/docs/plans/2026-03-10-remote-modes-aws-runner-foundation.md @@ -0,0 +1,123 @@ +# Remote Modes, AWS Nodes, and Runner Foundation + +## Summary + +`hack` currently has enough remote functionality to prove the model, but the product language and implementation boundaries are still too blurry. We need to intentionally separate three different use cases: + +1. Managed standalone runtime +2. Remote workspace offload +3. Explicit remote run / instance + +This plan uses `event-agent` as the reference deployment for managed standalone runtime and AWS-backed remote execution. + +## Product Model + +### Managed standalone runtime + +A long-lived remote machine runs `hack` for itself and orchestrates a project much like a developer laptop would. Controller visibility is useful, but not required for operation. + +Reference shape: +- `event-agent` QA runner on EC2 +- remote host owns project bootstrap, env hydration, and service orchestration +- `hack` should provide generic runner primitives, not project-specific shell glue + +### Remote workspace offload + +A developer keeps a local primary workspace and offloads lifecycle/runtime work to a controller-known node. This is the current `local_edit_remote_run` shape and is where Mutagen sync and optional devcontainer attach belong. + +Key requirements: +- controller-visible node +- gateway endpoint +- SSH `source` metadata for sync and attach parity +- branch-aware workspaces and route bridging + +### Explicit remote run / instance + +A project should be able to launch a remote action without forcing a permanent project-mode change. This includes: + +- one-off remote runs +- branch-scoped remote instances +- optional attach/open actions for those instances + +Project execution mode should remain a default, not the only expression of remote behavior. + +## Terminology + +- Node: controller-known machine with gateway reachability and optional SSH source metadata +- Instance: per-project or per-branch runtime running on a node +- Run: dispatched job targeting a workspace or instance + +Topology should stay focused on nodes and connectivity. +Project detail should own default behavior and explicit remote actions. + +## Event-Agent Lessons + +- Managed standalone runtime is a valid first-class mode and should not be forced into the same UX path as local workspace offload. +- `hack` should own node/runtime bootstrap, registration, auth verification, cleanup primitives, and dispatch contracts. +- Project-specific pieces should remain repo-owned: + - env hydration + - secrets shaping + - service orchestration + - EC2 wake/start/stop policies + - application-specific automation +- AWS parity is incomplete unless the node can also participate in source-backed flows like Mutagen sync and devcontainer attach. + +## Implementation Tracks + +### Runner primitives + +Umbrella: +- `T-00199` Add unattended runner primitives for node registration and auth + +Implementation tickets: +- `T-00210` Add `hack workspace reset` for unattended runners +- `T-00211` Add `hack dispatch run --local` and typed PR terminal states +- `T-00214` Migrate `event-agent` QA runner to new `hack` runner primitives + +Implemented in this pass: +- `hack node auth verify` +- `hack node ensure` + +### Remote mode foundation + +Implementation ticket: +- `T-00209` Define remote mode taxonomy and explicit remote actions UX + +This work should drive CLI docs, Desktop project settings IA, and Topology language before larger app changes land. + +### AWS node backend + +Umbrella: +- `T-00198` Add AWS provider bootstrap for remote hack nodes + +Implementation tickets: +- `T-00212` Implement AWS EC2 + SSM bootstrap for remote nodes +- `T-00213` Add AWS Desktop/Topology integration and source-backed parity flows + +## UX Direction + +### Keep project defaults + +Projects still need a saved default execution behavior. + +### Add explicit remote actions + +Users should also be able to: +- start a remote instance +- run on remote once +- open a remote instance +- attach a devcontainer + +Those actions should work without mutating the saved project default. + +### Preserve branch instances + +Branch instances are the right isolation model for parallel remote work. The product should make them more visible rather than inventing a second parallel concept. + +## Acceptance Bar + +This initiative is complete when: + +- managed standalone runtime, remote workspace offload, and explicit remote run are documented and reflected in product surfaces +- AWS-backed nodes can be bootstrapped and used with the same parity expectations as other source-backed nodes +- `event-agent` can adopt the generic runner primitives without reintroducing bespoke shell behavior diff --git a/package.json b/package.json index db30b0558..b8d3fb02c 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,7 @@ "install:bin": "bun run build && bun run --cwd packages/cli install:bin", "install:status": "bun run --cwd packages/cli install:status", "lint": "bun run check", + "lint:check": "bun run check", "macos:project-gen": "bun run --cwd packages/cli macos:project-gen", "macos:open": "bun run --cwd packages/cli macos:open", "macos:dev": "bun run --cwd packages/cli macos:dev", @@ -54,12 +55,14 @@ "check": "bun run privacy:check && bun run turbo:check", "prepare": "husky", "release:prepare": "bunx semantic-release --no-ci", + "type-check": "bun run typecheck", "typecheck": "bun run turbo:typecheck", "uninstall:local": "bun run --cwd packages/cli uninstall:local", "test": "bun run turbo:test", "test:e2e:gateway": "bun run scripts/gateway-e2e.ts", "test:e2e:dispatch": "bun run scripts/dispatch-e2e.ts", - "test:e2e:railway": "bun run scripts/railway-node-e2e.ts" + "test:e2e:railway": "bun run scripts/railway-node-e2e.ts", + "test:e2e:aws": "bun run scripts/aws-node-e2e.ts" }, "devDependencies": { "@biomejs/biome": "2.3.11", @@ -90,11 +93,14 @@ "typescript": "^5" }, "dependencies": { + "@aws-sdk/client-ec2": "^3.1005.0", + "@aws-sdk/client-ssm": "^3.1005.0", + "@aws-sdk/credential-providers": "^3.1005.0", "@charmland/lipgloss": "2.0.0-beta.3-0e280f3", "@clack/prompts": "1.0.0-alpha.9", - "elysia": "^1.4.9", "@modelcontextprotocol/sdk": "1.26.0", "@opentui/core": "^0.1.74", + "elysia": "^1.4.9", "qrcode": "^1.5.4", "qrcode-terminal": "^0.12.0", "zod": "4.3.5" diff --git a/scripts/aws-node-e2e.ts b/scripts/aws-node-e2e.ts new file mode 100644 index 000000000..3620de56d --- /dev/null +++ b/scripts/aws-node-e2e.ts @@ -0,0 +1,295 @@ +type ProcessResult = { + readonly stdout: string; + readonly stderr: string; + readonly exitCode: number; +}; + +type AwsBootstrapPayload = { + readonly node?: { + readonly id?: string; + readonly name?: string; + }; + readonly endpoint?: string; + readonly probe?: { + readonly ok?: boolean; + readonly status?: string; + readonly error?: string; + }; + readonly aws?: { + readonly region?: string; + readonly instanceId?: string; + }; +}; + +type NodeStatusPayload = { + readonly nodes?: ReadonlyArray<{ + readonly ok?: boolean; + readonly status?: string; + readonly error?: string; + readonly input?: { + readonly id?: string; + readonly name?: string; + }; + }>; +}; + +const boolFromEnv = ({ + value, + defaultValue, +}: { + readonly value: string | undefined; + readonly defaultValue: boolean; +}): boolean => { + if (!value) { + return defaultValue; + } + const normalized = value.trim().toLowerCase(); + if (!normalized) { + return defaultValue; + } + return ["1", "true", "yes", "on"].includes(normalized); +}; + +const parseCsv = (value: string | undefined): string[] => { + if (!value) { + return []; + } + return value + .split(",") + .map((entry) => entry.trim()) + .filter((entry) => entry.length > 0); +}; + +const requireEnv = ({ key }: { readonly key: string }): string => { + const value = (process.env[key] ?? "").trim(); + if (!value) { + throw new Error(`Missing required environment variable ${key}`); + } + return value; +}; + +const runHack = async ({ + args, +}: { + readonly args: readonly string[]; +}): Promise<ProcessResult> => { + const proc = Bun.spawn({ + cmd: ["hack", ...args], + env: { + ...process.env, + }, + stdout: "pipe", + stderr: "pipe", + }); + const [stdoutBytes, stderrBytes, exitCode] = await Promise.all([ + proc.stdout.text(), + proc.stderr.text(), + proc.exited, + ]); + return { + stdout: stdoutBytes, + stderr: stderrBytes, + exitCode, + }; +}; + +const extractJsonObject = <T>({ + text, +}: { + readonly text: string; +}): T | null => { + const trimmed = text.trim(); + if (!trimmed) { + return null; + } + try { + return JSON.parse(trimmed) as T; + } catch { + // fall through + } + const start = trimmed.indexOf("{"); + const end = trimmed.lastIndexOf("}"); + if (start < 0 || end <= start) { + return null; + } + const candidate = trimmed.slice(start, end + 1); + try { + return JSON.parse(candidate) as T; + } catch { + return null; + } +}; + +const instanceId = (process.env.HACK_AWS_E2E_INSTANCE_ID ?? "").trim(); +const instanceTagKey = ( + process.env.HACK_AWS_E2E_INSTANCE_TAG_KEY ?? "Name" +).trim(); +const instanceTagValue = ( + process.env.HACK_AWS_E2E_INSTANCE_TAG_VALUE ?? "" +).trim(); +const region = requireEnv({ key: "HACK_AWS_E2E_REGION" }); +const profile = (process.env.HACK_AWS_E2E_PROFILE ?? "").trim(); +const source = requireEnv({ key: "HACK_AWS_E2E_SOURCE" }); +const endpoint = requireEnv({ key: "HACK_AWS_E2E_ENDPOINT" }); +const bootstrapCommand = ( + process.env.HACK_AWS_E2E_BOOTSTRAP_COMMAND ?? "" +).trim(); +const nodeName = (process.env.HACK_AWS_E2E_NODE_NAME ?? "").trim(); +const labels = parseCsv(process.env.HACK_AWS_E2E_LABELS ?? "aws,e2e"); +const defaultNode = boolFromEnv({ + value: process.env.HACK_AWS_E2E_DEFAULT_NODE, + defaultValue: false, +}); +const project = (process.env.HACK_AWS_E2E_PROJECT ?? "").trim(); +const branch = (process.env.HACK_AWS_E2E_BRANCH ?? "").trim(); +const runCommand = (process.env.HACK_AWS_E2E_RUN_COMMAND ?? "pwd").trim(); +const runDevcontainer = boolFromEnv({ + value: process.env.HACK_AWS_E2E_DEVCONTAINER, + defaultValue: false, +}); + +if ((instanceId ? 1 : 0) + (instanceTagValue ? 1 : 0) !== 1) { + throw new Error( + "Set exactly one of HACK_AWS_E2E_INSTANCE_ID or HACK_AWS_E2E_INSTANCE_TAG_VALUE." + ); +} + +const bootstrapArgs: string[] = [ + "node", + "provider", + "aws", + "bootstrap", + "--json", + "--region", + region, + "--source", + source, + "--endpoint", + endpoint, +]; + +if (instanceId) { + bootstrapArgs.push("--instance-id", instanceId); +} +if (instanceTagValue) { + bootstrapArgs.push("--instance-tag-key", instanceTagKey); + bootstrapArgs.push("--instance-tag-value", instanceTagValue); +} +if (profile) { + bootstrapArgs.push("--profile", profile); +} +if (bootstrapCommand) { + bootstrapArgs.push("--bootstrap-command", bootstrapCommand); +} +if (nodeName) { + bootstrapArgs.push("--name", nodeName); +} +if (labels.length > 0) { + bootstrapArgs.push("--labels", labels.join(",")); +} +if (defaultNode) { + bootstrapArgs.push("--default"); +} + +console.log("==> aws bootstrap"); +const bootstrap = await runHack({ args: bootstrapArgs }); +if (bootstrap.exitCode !== 0) { + console.error(bootstrap.stdout); + console.error(bootstrap.stderr); + throw new Error(`Bootstrap failed with exit code ${bootstrap.exitCode}`); +} + +const bootstrapPayload = extractJsonObject<AwsBootstrapPayload>({ + text: bootstrap.stdout, +}); +if (!bootstrapPayload?.node?.id) { + console.error(bootstrap.stdout); + throw new Error("Bootstrap succeeded but node id was not returned."); +} + +const nodeId = bootstrapPayload.node.id; +console.log( + `Node registered: ${bootstrapPayload.node.name ?? nodeId} (${nodeId}) endpoint=${bootstrapPayload.endpoint ?? "unknown"}` +); +console.log( + `Bootstrap aws_region=${bootstrapPayload.aws?.region ?? "unknown"} instance_id=${bootstrapPayload.aws?.instanceId ?? "unknown"}` +); + +console.log("==> node status probe"); +const status = await runHack({ + args: ["node", "status", "--node", nodeId, "--json"], +}); +if (status.exitCode !== 0) { + console.error(status.stdout); + console.error(status.stderr); + throw new Error(`Node status failed with exit code ${status.exitCode}`); +} +const statusPayload = extractJsonObject<NodeStatusPayload>({ + text: status.stdout, +}); +const snapshot = + statusPayload?.nodes?.find((entry) => entry.input?.id === nodeId) ?? + statusPayload?.nodes?.[0]; +if (!snapshot) { + console.error(status.stdout); + throw new Error("Node status did not return a snapshot."); +} + +console.log( + `Probe result: ok=${snapshot.ok ?? false} status=${snapshot.status ?? "unknown"} error=${snapshot.error ?? "none"}` +); +if (snapshot.ok !== true) { + throw new Error("E2E failed: node probe is not healthy."); +} + +if (project) { + console.log("==> dispatch run"); + const dispatchArgs = [ + "dispatch", + "run", + "--project", + project, + "--node", + nodeId, + "--runner", + "generic", + ...(branch ? ["--branch", branch] : []), + "--", + "sh", + "-lc", + runCommand, + ]; + const dispatch = await runHack({ args: dispatchArgs }); + if (dispatch.exitCode !== 0) { + console.error(dispatch.stdout); + console.error(dispatch.stderr); + throw new Error(`Dispatch run failed with exit code ${dispatch.exitCode}`); + } + console.log("Dispatch run passed."); +} + +if (project && runDevcontainer) { + console.log("==> devcontainer up"); + const devcontainerArgs = [ + "node", + "devcontainer", + "up", + "--node", + nodeId, + "--project", + project, + ...(branch ? ["--branch", branch] : []), + "--json", + ]; + const devcontainer = await runHack({ args: devcontainerArgs }); + if (devcontainer.exitCode !== 0) { + console.error(devcontainer.stdout); + console.error(devcontainer.stderr); + throw new Error( + `Devcontainer up failed with exit code ${devcontainer.exitCode}` + ); + } + console.log("Devcontainer up passed."); +} + +console.log("AWS node e2e passed."); diff --git a/services/auth-broker/src/flow-store.ts b/services/auth-broker/src/flow-store.ts index f5990b176..c09697f54 100644 --- a/services/auth-broker/src/flow-store.ts +++ b/services/auth-broker/src/flow-store.ts @@ -371,6 +371,7 @@ function parsePersistedPayload(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Persisted OAuth flows intentionally deserialize many optional legacy fields while rejecting malformed required state. function parsePersistedFlow(input: { readonly value: unknown; }): OAuthFlow | null { diff --git a/services/auth-broker/src/modules/github-oauth/service.ts b/services/auth-broker/src/modules/github-oauth/service.ts index 6a709d5a3..1f1e1f0d5 100644 --- a/services/auth-broker/src/modules/github-oauth/service.ts +++ b/services/auth-broker/src/modules/github-oauth/service.ts @@ -144,6 +144,7 @@ export async function refreshFlowInstallationsIfNeeded(input: { /** * Complete OAuth callback flow and return browser-facing HTML response. */ +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The callback handler must preserve the full OAuth/browser recovery contract across missing state, exchange failures, identity lookup, and install-required flows. export async function handleGitHubCallback(input: { readonly config: BrokerConfig; readonly flowStore: FlowStore; diff --git a/services/auth-broker/src/modules/linear-agent/plugin.ts b/services/auth-broker/src/modules/linear-agent/plugin.ts index a13dfdc7f..8f7286c4b 100644 --- a/services/auth-broker/src/modules/linear-agent/plugin.ts +++ b/services/auth-broker/src/modules/linear-agent/plugin.ts @@ -167,6 +167,7 @@ async function prepareWebhookRequest(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Webhook persistence intentionally bundles ownership resolution, delivery recording, and autosync subscription lookup into one transactional response path. async function persistWebhookDelivery(input: { readonly prepared: PreparedWebhookRequest; readonly syncStore: LinearSyncStore; diff --git a/services/auth-broker/src/modules/linear-connections/local-access.ts b/services/auth-broker/src/modules/linear-connections/local-access.ts index 290238f8d..2ca76176f 100644 --- a/services/auth-broker/src/modules/linear-connections/local-access.ts +++ b/services/auth-broker/src/modules/linear-connections/local-access.ts @@ -65,6 +65,7 @@ export async function persistLinearLocalAccessCustody(input: { } } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Local-access seeding preserves the full refresh-and-rehydrate flow, including custody persistence and operator-facing error mapping. export async function seedLinearLocalAccess(input: { readonly config: { readonly providerTokenEncryptionKey?: string; diff --git a/services/auth-broker/src/modules/linear-oauth/service.ts b/services/auth-broker/src/modules/linear-oauth/service.ts index 1d870cfc0..0da018b65 100644 --- a/services/auth-broker/src/modules/linear-oauth/service.ts +++ b/services/auth-broker/src/modules/linear-oauth/service.ts @@ -118,6 +118,7 @@ export function createFlow(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The callback flow coordinates OAuth exchange, Better Auth linking, management token issuance, secure custody, and browser recovery paths. export async function handleLinearCallback(input: { readonly config: BrokerConfig; readonly flowStore: FlowStore; diff --git a/src/cli/spec.ts b/src/cli/spec.ts index 079084d87..16f4bc34a 100644 --- a/src/cli/spec.ts +++ b/src/cli/spec.ts @@ -38,6 +38,7 @@ import { tuiCommand } from "../commands/tui.ts"; import { updateCommand } from "../commands/update.ts"; import { usageCommand } from "../commands/usage.ts"; import { versionCommand } from "../commands/version.ts"; +import { workspaceCommand } from "../commands/workspace.ts"; import { xCommand } from "../commands/x.ts"; import { defineCli } from "./command.ts"; @@ -86,6 +87,7 @@ export const CLI_SPEC = defineCli({ ticketsCommand, agentCommand, gatewayCommand, + workspaceCommand, nodeCommand, remoteCommand, internalCommand, diff --git a/src/commands/dispatch.ts b/src/commands/dispatch.ts index daf3877e5..703ce73c3 100644 --- a/src/commands/dispatch.ts +++ b/src/commands/dispatch.ts @@ -10,6 +10,7 @@ import { } from "../control-plane/extensions/github/auth.ts"; import { createGitHubAppClient, + type GitHubAppClient, parseGitHubRepoRef, } from "../control-plane/extensions/github/client.ts"; import type { JobMeta } from "../control-plane/extensions/supervisor/job-store.ts"; @@ -40,6 +41,7 @@ import { createDispatchRunRecord, type DispatchRunRecord, type DispatchRunStatus, + type DispatchRunTerminalState, readDispatchRunLogTail, readDispatchRunRecord, updateDispatchRunRecord, @@ -142,6 +144,13 @@ const optApprove = defineOption({ description: "Approve high/critical command risk without interactive prompt", } as const); +const optLocal = defineOption({ + name: "local", + type: "boolean", + long: "--local", + description: "Execute the dispatch run in the local project workspace", +} as const); + const optPr = defineOption({ name: "pr", type: "boolean", @@ -191,6 +200,7 @@ const runOptions = [ optTicket, optRunner, optApprove, + optLocal, optPr, optPrBase, optPrTitle, @@ -252,6 +262,19 @@ type DispatchStatusArgs = CommandArgs< >; type DispatchLogsArgs = CommandArgs<typeof logsOptions, typeof logsPositionals>; +type ProviderBootstrapCliArgsResult = + | { + readonly ok: true; + readonly provider: "railway" | "aws"; + readonly cliArgs: string[]; + } + | { readonly ok: false; readonly error: string }; + +type DispatchPrProfileContext = { + readonly profileId: string; + readonly profileSource: string; +}; + export const dispatchCommand = withHandler( defineCommand({ ...dispatchSpec, @@ -267,6 +290,7 @@ export const dispatchCommand = withHandler( tone: "info", lines: [ "hack dispatch run --project <name|id> [--node <id|default|auto>] [--provider <name>] [--profile <id>] [--bootstrap-if-needed] [--branch <name>] [--runner <runner>] [--pr --github-profile <id>] -- <command...>", + "hack dispatch run --project <name|id> --local -- <command...>", "hack dispatch status <run-id>", "hack dispatch logs <run-id> [--follow]", ], @@ -275,6 +299,7 @@ export const dispatchCommand = withHandler( } ); +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Dispatch run is the top-level controller entrypoint that intentionally sequences project resolution, routing, node/bootstrap handling, execution, artifact storage, and PR automation. async function handleDispatchRun({ args, }: { @@ -312,6 +337,19 @@ async function handleDispatchRun({ logger.warn({ message: controlPlane.parseError }); } + if (args.options.local === true) { + return await handleDispatchRunLocal({ + args, + project, + controlPlaneConfig: controlPlane.config, + actor, + command, + runner, + ticketId, + branch, + }); + } + const requestedNode = normalizeOptionalString(args.options.node); const route = resolveDispatchRoute({ config: controlPlane.config, @@ -649,6 +687,17 @@ async function handleDispatchRun({ runId, patch: { status, + ...(resolveDispatchTerminalState({ + status, + prOutcome: null, + }) + ? { + terminalState: resolveDispatchTerminalState({ + status, + prOutcome: null, + }), + } + : {}), jobStatus: outcome.job.status, logOffset: outcome.logsOffset, eventsSeq: outcome.eventsSeq, @@ -711,6 +760,10 @@ async function handleDispatchRun({ const summary = buildSummaryMarkdown({ runId, runStatus: status, + terminalState: resolveDispatchTerminalState({ + status, + prOutcome, + }), node: selectedNode.node, workspace: workspace.data.workspace, job: outcome.job, @@ -732,6 +785,17 @@ async function handleDispatchRun({ runner, command, status, + ...(resolveDispatchTerminalState({ + status, + prOutcome, + }) + ? { + terminalState: resolveDispatchTerminalState({ + status, + prOutcome, + }), + } + : {}), jobStatus: outcome.job.status, ...(outcome.exitCode !== undefined ? { exitCode: outcome.exitCode } @@ -747,6 +811,17 @@ async function handleDispatchRun({ jobStatus: outcome.job.status, runner, command, + ...(resolveDispatchTerminalState({ + status, + prOutcome, + }) + ? { + terminalState: resolveDispatchTerminalState({ + status, + prOutcome, + }), + } + : {}), riskLevel: policy.level, riskReasons: policy.reasons, route: routeMetadata, @@ -784,17 +859,30 @@ async function handleDispatchRun({ actor, }); - const exitCode = - status === "completed" && - (args.options.pr !== true || prOutcome === null || prOutcome.ok) - ? 0 - : 1; + const exitCode = runStatusToExitCode({ + status, + terminalState: resolveDispatchTerminalState({ + status, + prOutcome, + }), + }); if (args.options.json) { process.stdout.write( `${JSON.stringify( { runId, status, + ...(resolveDispatchTerminalState({ + status, + prOutcome, + }) + ? { + terminalState: resolveDispatchTerminalState({ + status, + prOutcome, + }), + } + : {}), job: outcome.job, node: selectedNode.node, workspace: workspace.data.workspace, @@ -819,6 +907,10 @@ async function handleDispatchRun({ entries: [ ["run_id", runId], ["status", status], + [ + "terminal_state", + resolveDispatchTerminalState({ status, prOutcome }) ?? "", + ], ["job_status", outcome.job.status], ["job_id", outcome.job.jobId], ["route_source", routeMetadata.nodeSource], @@ -977,7 +1069,10 @@ async function handleDispatchLogs({ if (args.options.json) { process.stdout.write(`${JSON.stringify({ run: updated }, null, 2)}\n`); } - return runStatusToExitCode({ status }); + return runStatusToExitCode({ + status, + terminalState: updated?.terminalState, + }); } type DispatchProjectResolution = { @@ -990,6 +1085,358 @@ type DispatchProjectResolution = { readonly projectRoot?: string; }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Local dispatch mirrors the remote pipeline, including workspace checks, artifact handling, and PR-state outcomes. +async function handleDispatchRunLocal(input: { + readonly args: DispatchRunArgs; + readonly project: DispatchProjectResolution; + readonly controlPlaneConfig: ControlPlaneConfig; + readonly actor: string; + readonly command: readonly string[]; + readonly runner: string; + readonly ticketId?: string; + readonly branch?: string; +}): Promise<number> { + if (!input.project.projectRoot) { + logger.error({ + message: + "Local dispatch requires a registered project workspace. Re-run against a local project path or registered project.", + }); + return 1; + } + if ( + input.args.options.node || + input.args.options.provider || + input.args.options.profile || + input.args.options.bootstrapIfNeeded === true + ) { + logger.error({ + message: + "--local cannot be combined with --node, --provider, --profile, or --bootstrap-if-needed.", + }); + return 1; + } + + const risk = assessCommandRisk({ + command: input.command, + runner: input.runner, + }); + const policy = await resolvePolicyDecision({ + level: risk.level, + reasons: risk.reasons, + requiresApproval: risk.requiresApproval, + approveFlag: input.args.options.approve === true, + actor: input.actor, + promptLabel: "primary command", + }); + + const runId = randomUUID(); + const run = await createDispatchRunRecord({ + runId, + nodeId: "local", + nodeName: "local", + nodeEndpoint: "local://workspace", + projectSelector: input.project.selector, + ...(input.project.projectName + ? { projectName: input.project.projectName } + : {}), + ...(input.project.projectRoot + ? { projectRoot: input.project.projectRoot } + : {}), + ...(input.project.controllerProjectId + ? { projectId: input.project.controllerProjectId } + : {}), + ...(input.branch ? { branch: input.branch } : {}), + ...(input.ticketId ? { ticketId: input.ticketId } : {}), + runner: input.runner, + command: input.command, + policy: { + level: policy.level, + requiresApproval: policy.requiresApproval, + approved: policy.approved, + rationale: [...policy.reasons], + actor: input.actor, + decidedAt: new Date().toISOString(), + mode: policy.mode, + }, + }); + await appendDispatchRunEvent({ + runId, + event: { + type: "run.created", + mode: "local", + project: input.project.projectName ?? input.project.selector, + branch: input.branch ?? null, + runner: input.runner, + ticketId: input.ticketId ?? null, + }, + }); + await appendDispatchRunEvent({ + runId, + event: { + type: "policy.decision", + level: policy.level, + requiresApproval: policy.requiresApproval, + approved: policy.approved, + mode: policy.mode, + reasons: policy.reasons, + actor: input.actor, + }, + }); + await appendPolicyAuditEvent({ + actor: input.actor, + operation: "dispatch.run.local", + level: policy.level, + requiresApproval: policy.requiresApproval, + approved: policy.approved, + mode: policy.mode, + reasons: policy.reasons, + command: input.command, + runner: input.runner, + runId, + ...(input.ticketId ? { ticketId: input.ticketId } : {}), + nodeId: "local", + projectSelector: input.project.selector, + ...(policy.approved ? {} : { error: policy.error }), + }); + if (!policy.approved) { + await finalizeFailedRun({ + run, + errorMessage: policy.error, + status: "cancelled", + reason: "policy_denied", + controlPlaneConfig: input.controlPlaneConfig, + actor: input.actor, + }); + logger.error({ message: policy.error }); + return 1; + } + + await updateDispatchRunRecord({ + runId, + patch: { + status: "running", + startedAt: new Date().toISOString(), + }, + }); + + if (!input.args.options.json) { + await display.kv({ + title: "Dispatch run started", + entries: [ + ["run_id", runId], + ["mode", "local"], + ["project", input.project.projectName ?? input.project.selector], + ["branch", input.branch ?? ""], + ["runner", input.runner], + ], + }); + } + + const result = await exec([...input.command], { + cwd: input.project.projectRoot, + stdin: "ignore", + }); + const combinedLogs = [result.stdout, result.stderr] + .filter((value) => value.length > 0) + .join(""); + if (combinedLogs.length > 0) { + await appendDispatchRunLog({ + runId, + text: combinedLogs, + }); + if (!input.args.options.json) { + process.stdout.write(result.stdout); + process.stderr.write(result.stderr); + } + } + + const status: DispatchRunStatus = + result.exitCode === 0 ? "completed" : "failed"; + let prOutcome: DispatchPrOutcome | null = null; + let terminalState = resolveDispatchTerminalState({ + status, + prOutcome: null, + }); + if (status === "completed" && input.args.options.pr === true) { + const localPr = await runLocalDispatchPrAutomation({ + actor: input.actor, + approveFlag: input.args.options.approve === true, + command: input.command, + controlPlaneConfig: input.controlPlaneConfig, + projectRoot: input.project.projectRoot, + prBase: (input.args.options.prBase ?? "main").trim() || "main", + prTitle: input.args.options.prTitle?.trim(), + prBody: input.args.options.prBody, + githubProfile: normalizeOptionalString(input.args.options.githubProfile), + run, + }); + terminalState = localPr.terminalState; + prOutcome = localPr.prOutcome; + let prEventType = "run.pr.skipped"; + if (terminalState === "pr_created") { + prEventType = "run.pr.upserted"; + } else if (terminalState === "pr_failed") { + prEventType = "run.pr.failed"; + } + await appendDispatchRunEvent({ + runId, + event: { + type: prEventType, + terminalState, + ...(prOutcome?.ok + ? { + number: prOutcome.pull.number, + url: prOutcome.pull.htmlUrl, + base: prOutcome.pull.baseRef, + head: prOutcome.pull.headRef, + profileId: prOutcome.profileId, + profileSource: prOutcome.profileSource, + } + : {}), + ...(prOutcome && !prOutcome.ok + ? { + error: prOutcome.error, + profileId: prOutcome.profileId, + profileSource: prOutcome.profileSource, + } + : {}), + }, + }); + } + await updateDispatchRunRecord({ + runId, + patch: { + status, + ...(terminalState ? { terminalState } : {}), + exitCode: result.exitCode, + finishedAt: new Date().toISOString(), + }, + }); + await appendDispatchRunEvent({ + runId, + event: { + type: "run.completed", + mode: "local", + runStatus: status, + terminalState: terminalState ?? null, + exitCode: result.exitCode, + }, + }); + + const summary = buildLocalDispatchSummaryMarkdown({ + runId, + status, + terminalState, + project: input.project, + command: input.command, + runner: input.runner, + riskLevel: policy.level, + riskReasons: policy.reasons, + exitCode: result.exitCode, + prOutcome, + }); + await writeDispatchRunArtifacts({ + runId, + summaryMarkdown: summary, + patchDiff: "# no diff captured for this run", + testsManifest: { + mode: "local", + command: input.command, + status, + ...(terminalState ? { terminalState } : {}), + exitCode: result.exitCode, + }, + manifest: { + runId, + mode: "local", + status, + ...(terminalState ? { terminalState } : {}), + projectName: input.project.projectName ?? null, + projectRoot: input.project.projectRoot, + branch: input.branch ?? null, + runner: input.runner, + command: input.command, + riskLevel: policy.level, + riskReasons: policy.reasons, + ...(input.ticketId ? { ticketId: input.ticketId } : {}), + exitCode: result.exitCode, + ...(prOutcome + ? { + pr: prOutcome.ok + ? { + status: "ok", + profileId: prOutcome.profileId, + profileSource: prOutcome.profileSource, + number: prOutcome.pull.number, + url: prOutcome.pull.htmlUrl, + base: prOutcome.pull.baseRef, + head: prOutcome.pull.headRef, + } + : { + status: "error", + profileId: prOutcome.profileId, + profileSource: prOutcome.profileSource, + error: prOutcome.error, + }, + } + : {}), + }, + }); + + const currentRun = (await readDispatchRunRecord({ runId })) ?? run; + await persistRunArtifactsToCanonicalTickets({ + run: currentRun, + controlPlaneConfig: input.controlPlaneConfig, + actor: input.actor, + }); + + const exitCode = runStatusToExitCode({ + status, + terminalState, + }); + if (input.args.options.json) { + process.stdout.write( + `${JSON.stringify( + { + runId, + status, + ...(terminalState ? { terminalState } : {}), + mode: "local", + artifacts: run.artifacts, + ...(prOutcome ? { pr: prOutcome } : {}), + exitCode: result.exitCode, + }, + null, + 2 + )}\n` + ); + return exitCode; + } + + await display.kv({ + title: "Dispatch run completed", + entries: [ + ["run_id", runId], + ["mode", "local"], + ["status", status], + ["terminal_state", terminalState ?? ""], + ["exit_code", String(result.exitCode)], + ...(prOutcome + ? ([["github_profile", prOutcome.profileId]] as const) + : []), + ["logs", run.artifacts.logPath], + ["summary", run.artifacts.summaryPath], + ...(prOutcome?.ok ? ([["pr", prOutcome.pull.htmlUrl]] as const) : []), + ], + }); + emitDispatchTerminalStateGuidance({ + terminalState, + prOutcome, + jsonMode: false, + }); + return exitCode; +} + type WorkspaceEnsureRequest = { readonly project?: string; readonly projectId?: string; @@ -1122,7 +1569,7 @@ async function resolveWorkspaceBootstrap(input: { const token = await resolveGitHubAppToken({ controlPlaneConfig: input.controlPlaneConfig, - preferEnvTokenOnly: shouldPreferEnvTokenOnlyForBootstrap(), + preferEnvTokenOnly: shouldPreferEnvTokenOnlyForGitHubOperations(), }); if (!token.ok) { return { @@ -1143,11 +1590,21 @@ async function resolveWorkspaceBootstrap(input: { } /** - * Uses env-only token lookup for unattended invocations to avoid interactive - * keychain prompts; interactive terminals can leverage keychain-backed tokens. + * Uses env-only token lookup for unattended/agent invocations to avoid + * interactive keychain prompts; normal human CLI sessions can still use + * keychain-backed tokens. */ -function shouldPreferEnvTokenOnlyForBootstrap(): boolean { - return !(process.stdin.isTTY && process.stdout.isTTY); +function shouldPreferEnvTokenOnlyForGitHubOperations(): boolean { + if (!(process.stdin.isTTY && process.stdout.isTTY)) { + return true; + } + if (process.env.CODEX_CI === "1" || process.env.CODEX_SHELL === "1") { + return true; + } + if ((process.env.__CFBundleIdentifier ?? "").trim() === "com.openai.codex") { + return true; + } + return false; } /** @@ -1775,171 +2232,324 @@ async function bootstrapNodeFromProviderRoute(input: { }): Promise< | { readonly ok: true; - readonly provider: "railway"; + readonly provider: "railway" | "aws"; readonly created: boolean; readonly nodeId: string; } | { readonly ok: false; readonly error: string } > { - if (input.route.providerRoute.provider !== "railway") { - return { - ok: false, - error: `Bootstrap handoff is not implemented for provider "${input.route.providerRoute.provider}".`, - }; + const provider = input.route.providerRoute.provider; + const cliArgs = buildProviderBootstrapCliArgs({ + provider, + config: input.route.providerRoute.effectiveConfig, + project: input.project, + privateNetworking: input.route.providerRoute.privateNetworking, + }); + if (!cliArgs.ok) { + return cliArgs; } - const config = input.route.providerRoute.effectiveConfig; - const railwayProject = resolveConfigString({ - config, - key: "project", + const result = await exec(cliArgs.cliArgs, { + stdin: "ignore", + env: { + NO_COLOR: "1", + }, }); - if (!railwayProject) { + if (result.exitCode !== 0) { return { ok: false, error: - "Provider route is missing railway project. Set controlPlane.providers.profiles.<id>.config.project.", + result.stderr.trim() || + result.stdout.trim() || + `Provider bootstrap failed (exit ${result.exitCode}).`, }; } - - const cliArgs = [ - "bun", - resolve(import.meta.dir, "../index.ts"), - "node", - "provider", - "railway", - "bootstrap", - "--json", - "--railway-project", - railwayProject, - ]; - - const railwayService = resolveConfigString({ - config, - key: "service", - }); - if (railwayService) { - cliArgs.push("--railway-service", railwayService); - } - const createService = - parseConfigBoolean({ config, key: "createService" }) ?? - railwayService === undefined; - if (createService) { - cliArgs.push("--create-service"); + const payload = parseEmbeddedJson(result.stdout); + if (!(payload && isRecord(payload))) { + return { + ok: false, + error: "Provider bootstrap did not return JSON payload.", + }; } - const railwayEnvironment = resolveConfigString({ - config, - key: "environment", - }); - if (railwayEnvironment) { - cliArgs.push("--railway-environment", railwayEnvironment); + const node = isRecord(payload.node) ? payload.node : null; + const nodeId = node ? getString(node, "id") : undefined; + if (!nodeId) { + return { + ok: false, + error: "Provider bootstrap JSON is missing node.id.", + }; } - const railwayWorkspace = resolveConfigString({ - config, - key: "workspace", - }); - if (railwayWorkspace) { - cliArgs.push("--railway-workspace", railwayWorkspace); + const created = payload.created === true; + if (!input.jsonMode) { + logger.info({ + message: `Bootstrap handoff registered node ${nodeId} via provider ${cliArgs.provider}.`, + }); } - const image = resolveConfigString({ - config, - key: "image", + return { + ok: true, + provider: cliArgs.provider, + created, + nodeId, + }; +} + +function buildProviderBootstrapCliArgs(input: { + readonly provider: string; + readonly config: Record<string, unknown>; + readonly project: DispatchProjectResolution; + readonly privateNetworking: boolean; +}): ProviderBootstrapCliArgsResult { + if (!(input.provider === "railway" || input.provider === "aws")) { + return { + ok: false, + error: `Bootstrap handoff is not implemented for provider "${input.provider}".`, + }; + } + + const cliArgs = [ + "bun", + resolve(import.meta.dir, "../index.ts"), + "node", + "provider", + input.provider, + "bootstrap", + "--json", + ]; + const providerArgs = + input.provider === "railway" + ? buildRailwayProviderBootstrapCliArgs({ + config: input.config, + privateNetworking: input.privateNetworking, + }) + : buildAwsProviderBootstrapCliArgs({ + config: input.config, + }); + if (!providerArgs.ok) { + return providerArgs; + } + cliArgs.push(...providerArgs.args); + appendOptionalCliArg({ + cliArgs, + flag: "--name", + value: + resolveConfigString({ + config: input.config, + key: "nodeName", + }) ?? + input.project.projectName ?? + input.project.selector, }); - if (image) { - cliArgs.push("--railway-image", image); + appendOptionalCliArg({ + cliArgs, + flag: "--labels", + value: resolveConfigString({ + config: input.config, + key: "labelsCsv", + }), + }); + return { + ok: true, + provider: input.provider, + cliArgs, + }; +} + +function buildRailwayProviderBootstrapCliArgs(input: { + readonly config: Record<string, unknown>; + readonly privateNetworking: boolean; +}): + | { readonly ok: true; readonly args: string[] } + | { readonly ok: false; readonly error: string } { + const railwayProject = resolveConfigString({ + config: input.config, + key: "project", + }); + if (!railwayProject) { + return { + ok: false, + error: + "Provider route is missing railway project. Set controlPlane.providers.profiles.<id>.config.project.", + }; } - const endpoint = resolveConfigString({ - config, - key: "endpoint", + + const args = ["--railway-project", railwayProject]; + const railwayService = resolveConfigString({ + config: input.config, + key: "service", + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--railway-service", + value: railwayService, + }); + if ( + parseConfigBoolean({ config: input.config, key: "createService" }) ?? + railwayService === undefined + ) { + args.push("--create-service"); + } + appendOptionalCliArg({ + cliArgs: args, + flag: "--railway-environment", + value: resolveConfigString({ + config: input.config, + key: "environment", + }), + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--railway-workspace", + value: resolveConfigString({ + config: input.config, + key: "workspace", + }), + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--railway-image", + value: resolveConfigString({ + config: input.config, + key: "image", + }), + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--endpoint", + value: resolveConfigString({ + config: input.config, + key: "endpoint", + }), }); - if (endpoint) { - cliArgs.push("--endpoint", endpoint); - } - const nodeName = - resolveConfigString({ - config, - key: "nodeName", - }) ?? - input.project.projectName ?? - input.project.selector; - if (nodeName) { - cliArgs.push("--name", nodeName); - } - const labelsCsv = resolveConfigString({ - config, - key: "labelsCsv", - }); - if (labelsCsv) { - cliArgs.push("--labels", labelsCsv); - } - - if (input.route.providerRoute.privateNetworking) { - cliArgs.push("--railway-private"); - const auth = isRecord(config.auth) ? config.auth : {}; - const tailscaleAuthKey = resolveConfigString({ - config: { ...auth, ...config }, - key: "tailscaleAuthKey", + + if (input.privateNetworking) { + args.push("--railway-private"); + const authConfig = isRecord(input.config.auth) ? input.config.auth : {}; + const privateConfig = { ...authConfig, ...input.config }; + appendOptionalCliArg({ + cliArgs: args, + flag: "--tailscale-auth-key", + value: resolveConfigString({ + config: privateConfig, + key: "tailscaleAuthKey", + }), }); - if (tailscaleAuthKey) { - cliArgs.push("--tailscale-auth-key", tailscaleAuthKey); - } - const hostname = resolveConfigString({ - config: { ...auth, ...config }, - key: "tailscaleHostname", + appendOptionalCliArg({ + cliArgs: args, + flag: "--tailscale-hostname", + value: resolveConfigString({ + config: privateConfig, + key: "tailscaleHostname", + }), }); - if (hostname) { - cliArgs.push("--tailscale-hostname", hostname); - } - const tags = resolveConfigString({ - config: { ...auth, ...config }, - key: "tailscaleTagsCsv", + appendOptionalCliArg({ + cliArgs: args, + flag: "--tailscale-tags", + value: resolveConfigString({ + config: privateConfig, + key: "tailscaleTagsCsv", + }), }); - if (tags) { - cliArgs.push("--tailscale-tags", tags); - } } + return { ok: true, args }; +} - const result = await exec(cliArgs, { - stdin: "ignore", - env: { - NO_COLOR: "1", - }, +function buildAwsProviderBootstrapCliArgs(input: { + readonly config: Record<string, unknown>; +}): + | { readonly ok: true; readonly args: string[] } + | { readonly ok: false; readonly error: string } { + const instanceId = resolveConfigString({ + config: input.config, + key: "instanceId", }); - if (result.exitCode !== 0) { + const instanceTagValue = resolveConfigString({ + config: input.config, + key: "instanceTagValue", + }); + if ((instanceId ? 1 : 0) + (instanceTagValue ? 1 : 0) !== 1) { return { ok: false, error: - result.stderr.trim() || - result.stdout.trim() || - `Provider bootstrap failed (exit ${result.exitCode}).`, + "Provider route must define exactly one of config.instanceId or config.instanceTagValue for AWS bootstrap.", }; } - const payload = parseEmbeddedJson(result.stdout); - if (!(payload && isRecord(payload))) { + + const region = resolveConfigString({ + config: input.config, + key: "region", + }); + if (!region) { return { ok: false, - error: "Provider bootstrap did not return JSON payload.", + error: + "Provider route is missing aws region. Set controlPlane.providers.profiles.<id>.config.region.", }; } - const node = isRecord(payload.node) ? payload.node : null; - const nodeId = node ? getString(node, "id") : undefined; - if (!nodeId) { + const endpoint = resolveConfigString({ + config: input.config, + key: "endpoint", + }); + if (!endpoint) { return { ok: false, - error: "Provider bootstrap JSON is missing node.id.", + error: + "Provider route is missing aws endpoint. Set controlPlane.providers.profiles.<id>.config.endpoint.", }; } - const created = payload.created === true; - if (!input.jsonMode) { - logger.info({ - message: `Bootstrap handoff registered node ${nodeId} via provider railway.`, - }); + const source = resolveConfigString({ + config: input.config, + key: "source", + }); + if (!source) { + return { + ok: false, + error: + "Provider route is missing aws source. Set controlPlane.providers.profiles.<id>.config.source.", + }; + } + + const args = ["--region", region, "--endpoint", endpoint, "--source", source]; + if (instanceId) { + args.push("--instance-id", instanceId); + } + if (instanceTagValue) { + args.push("--instance-tag-value", instanceTagValue); + } + appendOptionalCliArg({ + cliArgs: args, + flag: "--instance-tag-key", + value: resolveConfigString({ + config: input.config, + key: "instanceTagKey", + }), + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--profile", + value: resolveConfigString({ + config: input.config, + key: "profile", + }), + }); + appendOptionalCliArg({ + cliArgs: args, + flag: "--bootstrap-command", + value: resolveConfigString({ + config: input.config, + key: "bootstrapCommand", + }), + }); + return { ok: true, args }; +} + +function appendOptionalCliArg(input: { + readonly cliArgs: string[]; + readonly flag: string; + readonly value?: string; +}): void { + if (input.value) { + input.cliArgs.push(input.flag, input.value); } - return { - ok: true, - provider: "railway", - created, - nodeId, - }; } async function resolveNodeClient(input: { @@ -2154,13 +2764,36 @@ function mapJobStatusToRunStatus(input: { function runStatusToExitCode(input: { readonly status: DispatchRunStatus; + readonly terminalState?: DispatchRunTerminalState; }): number { + if (input.terminalState === "no_diff") { + return 20; + } + if (input.terminalState === "no_commit") { + return 21; + } + if (input.terminalState === "pr_failed") { + return 22; + } if (input.status === "running" || input.status === "completed") { return 0; } return 1; } +function resolveDispatchTerminalState(input: { + readonly status: DispatchRunStatus; + readonly prOutcome: DispatchPrOutcome | null; +}): DispatchRunTerminalState | undefined { + if (input.status !== "completed") { + return undefined; + } + if (!input.prOutcome) { + return "completed"; + } + return input.prOutcome.ok ? "pr_created" : "pr_failed"; +} + type DispatchPrOutcome = | { readonly ok: true; @@ -2181,6 +2814,31 @@ type DispatchPrOutcome = readonly error: string; }; +type DispatchGitHubContext = + | { + readonly ok: true; + readonly client: GitHubAppClient; + readonly profileId: string; + readonly profileSource: string; + readonly repo: { + readonly owner: string; + readonly repo: string; + }; + } + | { + readonly ok: false; + readonly profileId: string; + readonly profileSource: string; + readonly error: string; + }; + +type LocalDispatchPrAutomationResult = { + readonly terminalState: DispatchRunTerminalState; + readonly prOutcome: DispatchPrOutcome | null; + readonly branch?: string; + readonly message?: string; +}; + async function persistRunArtifactsToCanonicalTickets(input: { readonly run: DispatchRunRecord; readonly controlPlaneConfig: ControlPlaneConfig; @@ -2234,34 +2892,232 @@ async function runDispatchPrAutomation(input: { readonly prBody?: string; readonly githubProfile?: string; }): Promise<DispatchPrOutcome> { - const requestedGitHubProfile = normalizeOptionalString(input.githubProfile); - const selectedGitHubProfile = - requestedGitHubProfile ?? - resolveGitHubAuthSettings({ - controlPlaneConfig: input.controlPlaneConfig, - }).profileId; - const selectedGitHubProfileSource = requestedGitHubProfile - ? "command_flags" - : "project_or_global"; - + const githubProfile = resolveDispatchPrProfileContext({ + controlPlaneConfig: input.controlPlaneConfig, + githubProfile: input.githubProfile, + }); const branch = (input.workspace.branch ?? input.run.branch ?? "").trim(); if (!branch) { return { - ok: false, - profileId: selectedGitHubProfile, - profileSource: selectedGitHubProfileSource, - error: "Cannot open PR without a resolved workspace branch.", + ok: false, + profileId: githubProfile.profileId, + profileSource: githubProfile.profileSource, + error: "Cannot open PR without a resolved workspace branch.", + }; + } + + const pushCommand = [ + "git", + "-C", + input.workspace.projectRoot, + "push", + "-u", + "origin", + branch, + ] as const; + const pushPolicy = await resolveDispatchPrPushPolicy({ + actor: input.actor, + approveFlag: input.approveFlag, + command: pushCommand, + }); + await appendPolicyAuditEvent({ + actor: input.actor, + operation: "dispatch.pr.push", + level: pushPolicy.level, + requiresApproval: pushPolicy.requiresApproval, + approved: pushPolicy.approved, + mode: pushPolicy.mode, + reasons: pushPolicy.reasons, + command: pushCommand, + runner: "generic", + runId: input.run.runId, + ...(input.run.ticketId ? { ticketId: input.run.ticketId } : {}), + nodeId: input.run.nodeId, + projectSelector: input.run.projectSelector, + ...(pushPolicy.approved ? {} : { error: pushPolicy.error }), + }); + if (!pushPolicy.approved) { + return { + ok: false, + profileId: githubProfile.profileId, + profileSource: githubProfile.profileSource, + error: pushPolicy.error, + }; + } + + const pushOutcome = await runRemoteDispatchPushJob({ + runId: input.run.runId, + client: input.selectedNode.client, + projectId: input.workspace.projectId, + pushCommand, + }); + if (!pushOutcome.ok) { + return { + ok: false, + profileId: githubProfile.profileId, + profileSource: githubProfile.profileSource, + error: pushOutcome.error, + }; + } + + const githubContext = await resolveDispatchGitHubContext({ + controlPlaneConfig: input.controlPlaneConfig, + githubProfile: normalizeOptionalString(input.githubProfile), + projectRoot: input.project.projectRoot ?? input.run.projectRoot, + }); + if (!githubContext.ok) { + return githubContext; + } + + return await upsertDispatchPullRequest({ + branch, + command: input.command, + github: githubContext, + prBase: input.prBase, + prBody: input.prBody, + prTitle: input.prTitle, + run: input.run, + pushed: true, + }); +} + +function resolveDispatchPrProfileContext(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly githubProfile?: string; +}): DispatchPrProfileContext { + const explicitGitHubProfile = normalizeOptionalString(input.githubProfile); + return { + profileId: + explicitGitHubProfile ?? + resolveGitHubAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + }).profileId, + profileSource: explicitGitHubProfile + ? "command_flags" + : "project_or_global", + }; +} + +async function resolveDispatchPrPushPolicy(input: { + readonly actor: string; + readonly approveFlag: boolean; + readonly command: readonly string[]; +}): Promise<Awaited<ReturnType<typeof resolvePolicyDecision>>> { + const pushRisk = assessCommandRisk({ + command: input.command, + runner: "generic", + }); + return await resolvePolicyDecision({ + level: pushRisk.level, + reasons: pushRisk.reasons, + requiresApproval: pushRisk.requiresApproval, + approveFlag: input.approveFlag, + actor: input.actor, + promptLabel: "git push for --pr", + }); +} + +async function runRemoteDispatchPushJob(input: { + readonly runId: string; + readonly client: GatewayClient; + readonly projectId: string; + readonly pushCommand: readonly string[]; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + const createdPush = await input.client.createJob({ + projectId: input.projectId, + runner: "generic", + command: [...input.pushCommand], + }); + if (!createdPush.ok) { + return { + ok: false, + error: `Failed to create push job (${createdPush.status}): ${createdPush.error.message}`, + }; + } + + const pushOutcome = await streamRemoteJob({ + runId: input.runId, + client: input.client, + projectId: input.projectId, + jobId: createdPush.data.job.jobId, + logsFrom: 0, + eventsFrom: 0, + printLogs: false, + }); + if (!pushOutcome.job) { + return { + ok: false, + error: "Push job ended without terminal state.", + }; + } + if (pushOutcome.job.status !== "completed") { + return { + ok: false, + error: `Push job failed with status ${pushOutcome.job.status}`, + }; + } + return { ok: true }; +} + +async function runLocalDispatchPrAutomation(input: { + readonly run: DispatchRunRecord; + readonly actor: string; + readonly approveFlag: boolean; + readonly command: readonly string[]; + readonly controlPlaneConfig: ControlPlaneConfig; + readonly projectRoot: string; + readonly prBase: string; + readonly prTitle?: string; + readonly prBody?: string; + readonly githubProfile?: string; +}): Promise<LocalDispatchPrAutomationResult> { + const inspection = await inspectLocalDispatchPrState({ + branch: input.run.branch, + controlPlaneConfig: input.controlPlaneConfig, + githubProfile: normalizeOptionalString(input.githubProfile), + prBase: input.prBase, + projectRoot: input.projectRoot, + }); + if (!inspection.ok) { + return { + terminalState: "pr_failed", + message: inspection.error, + prOutcome: { + ok: false, + profileId: inspection.profileId, + profileSource: inspection.profileSource, + error: inspection.error, + }, + }; + } + if (inspection.terminalState === "no_diff") { + return { + terminalState: "no_diff", + branch: inspection.branch, + message: `Branch ${inspection.branch} has no committed diff ahead of ${input.prBase}.`, + prOutcome: null, + }; + } + if (inspection.terminalState === "no_commit") { + return { + terminalState: "no_commit", + branch: inspection.branch, + message: + "Dispatch run completed with local changes still uncommitted. Commit the branch before opening a PR.", + prOutcome: null, }; } const pushCommand = [ "git", "-C", - input.workspace.projectRoot, + input.projectRoot, "push", "-u", "origin", - branch, + inspection.branch, ] as const; const pushRisk = assessCommandRisk({ command: pushCommand, @@ -2293,55 +3149,211 @@ async function runDispatchPrAutomation(input: { }); if (!pushPolicy.approved) { return { - ok: false, - profileId: selectedGitHubProfile, - profileSource: selectedGitHubProfileSource, - error: pushPolicy.error, + terminalState: "pr_failed", + branch: inspection.branch, + message: pushPolicy.error, + prOutcome: { + ok: false, + profileId: inspection.profileId, + profileSource: inspection.profileSource, + error: pushPolicy.error, + }, }; } - const createdPush = await input.selectedNode.client.createJob({ - projectId: input.workspace.projectId, - runner: "generic", - command: [...pushCommand], + const pushed = await exec(pushCommand, { + stdin: "ignore", }); - if (!createdPush.ok) { + if (pushed.exitCode !== 0) { + return { + terminalState: "pr_failed", + branch: inspection.branch, + message: `git push failed (${pushed.exitCode}): ${(pushed.stderr || pushed.stdout).trim() || "unknown error"}`, + prOutcome: { + ok: false, + profileId: inspection.profileId, + profileSource: inspection.profileSource, + error: `git push failed (${pushed.exitCode}): ${(pushed.stderr || pushed.stdout).trim() || "unknown error"}`, + }, + }; + } + + const githubContext = await resolveDispatchGitHubContext({ + controlPlaneConfig: input.controlPlaneConfig, + githubProfile: normalizeOptionalString(input.githubProfile), + projectRoot: input.projectRoot, + }); + if (!githubContext.ok) { + return { + terminalState: "pr_failed", + branch: inspection.branch, + message: githubContext.error, + prOutcome: githubContext, + }; + } + + const prOutcome = await upsertDispatchPullRequest({ + branch: inspection.branch, + command: input.command, + github: githubContext, + prBase: input.prBase, + prBody: input.prBody, + prTitle: input.prTitle, + run: input.run, + pushed: true, + }); + return { + terminalState: prOutcome.ok ? "pr_created" : "pr_failed", + prOutcome, + branch: inspection.branch, + ...(prOutcome.ok ? {} : { message: prOutcome.error }), + }; +} + +async function inspectLocalDispatchPrState(input: { + readonly projectRoot: string; + readonly prBase: string; + readonly branch?: string; + readonly controlPlaneConfig: ControlPlaneConfig; + readonly githubProfile?: string; +}): Promise< + | { + readonly ok: true; + readonly terminalState: "ready" | "no_diff" | "no_commit"; + readonly branch: string; + readonly profileId: string; + readonly profileSource: string; + } + | { + readonly ok: false; + readonly profileId: string; + readonly profileSource: string; + readonly error: string; + } +> { + const requestedGitHubProfile = normalizeOptionalString(input.githubProfile); + const profileId = + requestedGitHubProfile ?? + resolveGitHubAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + }).profileId; + const profileSource = requestedGitHubProfile + ? "command_flags" + : "project_or_global"; + const branch = + normalizeOptionalString(input.branch) ?? + (await resolveLocalGitBranch({ projectRoot: input.projectRoot })); + if (!branch) { return { ok: false, - profileId: selectedGitHubProfile, - profileSource: selectedGitHubProfileSource, - error: `Failed to create push job (${createdPush.status}): ${createdPush.error.message}`, + profileId, + profileSource, + error: "Cannot open PR without a resolved local branch.", }; } - const pushOutcome = await streamRemoteJob({ - runId: input.run.runId, - client: input.selectedNode.client, - projectId: input.workspace.projectId, - jobId: createdPush.data.job.jobId, - logsFrom: 0, - eventsFrom: 0, - printLogs: false, + const status = await exec( + [ + "git", + "-C", + input.projectRoot, + "status", + "--porcelain=v1", + "--untracked-files=all", + ], + { + stdin: "ignore", + } + ); + if (status.exitCode !== 0) { + return { + ok: false, + profileId, + profileSource, + error: `Failed to inspect git status (${status.exitCode}).`, + }; + } + if (status.stdout.trim().length > 0) { + return { + ok: true, + terminalState: "no_commit", + branch, + profileId, + profileSource, + }; + } + + await exec( + ["git", "-C", input.projectRoot, "fetch", "origin", input.prBase], + { + stdin: "ignore", + } + ); + const baseRef = await resolveLocalDispatchPrBaseRef({ + prBase: input.prBase, + projectRoot: input.projectRoot, }); - if (!pushOutcome.job) { + if (!baseRef) { return { ok: false, - profileId: selectedGitHubProfile, - profileSource: selectedGitHubProfileSource, - error: "Push job ended without terminal state.", + profileId, + profileSource, + error: `Unable to resolve PR base ref ${input.prBase}.`, }; } - if (pushOutcome.job.status !== "completed") { + + const ahead = await exec( + ["git", "-C", input.projectRoot, "rev-list", "--count", `${baseRef}..HEAD`], + { + stdin: "ignore", + } + ); + if (ahead.exitCode !== 0) { return { ok: false, - profileId: selectedGitHubProfile, - profileSource: selectedGitHubProfileSource, - error: `Push job failed with status ${pushOutcome.job.status}`, + profileId, + profileSource, + error: `Failed to inspect git history against ${baseRef}.`, + }; + } + + const aheadCount = Number.parseInt(ahead.stdout.trim() || "0", 10); + if (!Number.isFinite(aheadCount) || aheadCount <= 0) { + return { + ok: true, + terminalState: "no_diff", + branch, + profileId, + profileSource, }; } + return { + ok: true, + terminalState: "ready", + branch, + profileId, + profileSource, + }; +} + +async function resolveDispatchGitHubContext(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly githubProfile?: string; + readonly projectRoot?: string; +}): Promise<DispatchGitHubContext> { + const requestedGitHubProfile = normalizeOptionalString(input.githubProfile); + const selectedGitHubProfile = + requestedGitHubProfile ?? + resolveGitHubAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + }).profileId; + const selectedGitHubProfileSource = requestedGitHubProfile + ? "command_flags" + : "project_or_global"; + const repo = await resolveDispatchGitHubRepo({ - projectRoot: input.project.projectRoot ?? input.run.projectRoot, + projectRoot: input.projectRoot, }); if (!repo) { return { @@ -2357,6 +3369,7 @@ async function runDispatchPrAutomation(input: { controlPlaneConfig: input.controlPlaneConfig, ...(requestedGitHubProfile ? { profileId: requestedGitHubProfile } : {}), allowProjectOverride: !requestedGitHubProfile, + preferEnvTokenOnly: shouldPreferEnvTokenOnlyForGitHubOperations(), }); if (!token.ok) { return { @@ -2367,55 +3380,73 @@ async function runDispatchPrAutomation(input: { }; } - const client = createGitHubAppClient({ - token: token.token, - userAgent: "hack-cli", - }); - const found = await client.findOpenPullRequest({ + return { + ok: true, + client: createGitHubAppClient({ + token: token.token, + userAgent: "hack-cli", + }), + profileId: token.profileId, + profileSource: token.profileSource, repo, - headRef: branch, + }; +} + +async function upsertDispatchPullRequest(input: { + readonly run: DispatchRunRecord; + readonly command: readonly string[]; + readonly github: Extract<DispatchGitHubContext, { readonly ok: true }>; + readonly branch: string; + readonly prBase: string; + readonly prTitle?: string; + readonly prBody?: string; + readonly pushed: true; +}): Promise<DispatchPrOutcome> { + const found = await input.github.client.findOpenPullRequest({ + repo: input.github.repo, + headRef: input.branch, }); if (!found.ok) { return { ok: false, - profileId: token.profileId, - profileSource: token.profileSource, + profileId: input.github.profileId, + profileSource: input.github.profileSource, error: `GitHub lookup failed (${found.status}): ${found.error}`, }; } const title = input.prTitle || - `hack: ${input.run.projectName ?? input.run.projectSelector} (${branch})`; + `hack: ${input.run.projectName ?? input.run.projectSelector} (${input.branch})`; const body = input.prBody ?? `Automated by hack dispatch run.\n\n- run_id: ${input.run.runId}\n- node: ${input.run.nodeName} (${input.run.nodeId})`; const upserted = found.data - ? await client.updatePullRequest({ - repo, + ? await input.github.client.updatePullRequest({ + repo: input.github.repo, number: found.data.number, title, body, }) - : await client.createPullRequest({ - repo, + : await input.github.client.createPullRequest({ + repo: input.github.repo, title, body, - headRef: branch, + headRef: input.branch, baseRef: input.prBase, }); if (!upserted.ok) { return { ok: false, - profileId: token.profileId, - profileSource: token.profileSource, + profileId: input.github.profileId, + profileSource: input.github.profileSource, error: `GitHub PR upsert failed (${upserted.status}): ${upserted.error}`, }; } - const comment = await client.createIssueComment({ - repo, + const comment = await input.github.client.createIssueComment({ + repo: input.github.repo, issueNumber: upserted.data.number, body: `Dispatch run \`${input.run.runId}\` completed.\n\nCommand: \`${input.command.join(" ")}\``, }); @@ -2427,34 +3458,109 @@ async function runDispatchPrAutomation(input: { return { ok: true, - profileId: token.profileId, - profileSource: token.profileSource, + profileId: input.github.profileId, + profileSource: input.github.profileSource, pull: { number: upserted.data.number, htmlUrl: upserted.data.htmlUrl, baseRef: upserted.data.baseRef, headRef: upserted.data.headRef, }, - pushed: true, + pushed: input.pushed, }; } +async function resolveLocalGitBranch(input: { + readonly projectRoot: string; +}): Promise<string | null> { + const branch = await exec( + ["git", "-C", input.projectRoot, "branch", "--show-current"], + { + stdin: "ignore", + } + ); + if (branch.exitCode !== 0) { + return null; + } + const value = branch.stdout.trim(); + return value.length > 0 ? value : null; +} + +async function resolveLocalDispatchPrBaseRef(input: { + readonly projectRoot: string; + readonly prBase: string; +}): Promise<string | null> { + const candidates = [`origin/${input.prBase}`, input.prBase] as const; + for (const candidate of candidates) { + const resolved = await exec( + ["git", "-C", input.projectRoot, "rev-parse", "--verify", candidate], + { + stdin: "ignore", + } + ); + if (resolved.exitCode === 0) { + return candidate; + } + } + return null; +} + +function emitDispatchTerminalStateGuidance(input: { + readonly terminalState?: DispatchRunTerminalState; + readonly prOutcome: DispatchPrOutcome | null; + readonly jsonMode: boolean; +}): void { + if (input.jsonMode) { + return; + } + if (input.terminalState === "no_diff") { + logger.info({ + message: + "PR automation skipped: no committed diff exists versus the selected base branch.", + }); + return; + } + if (input.terminalState === "no_commit") { + logger.warn({ + message: + "PR automation skipped: the workspace has uncommitted changes. Commit them and retry.", + }); + return; + } + if ( + input.terminalState === "pr_failed" && + input.prOutcome && + !input.prOutcome.ok + ) { + logger.warn({ + message: `PR automation failed: ${input.prOutcome.error}`, + }); + } +} + async function resolveDispatchGitHubRepo(input: { readonly projectRoot?: string; }): Promise<{ readonly owner: string; readonly repo: string } | null> { if (!input.projectRoot) { return null; } - const remote = await exec( + const candidates = [ + ["git", "-C", input.projectRoot, "config", "--get", "remote.origin.url"], ["git", "-C", input.projectRoot, "remote", "get-url", "origin"], - { + ] as const; + for (const command of candidates) { + const remote = await exec(command, { stdin: "ignore", + }); + if (remote.exitCode !== 0) { + continue; + } + const parsed = parseGitHubRepoRef({ remoteUrl: remote.stdout.trim() }); + if (parsed) { + return parsed; } - ); - if (remote.exitCode !== 0) { - return null; } - return parseGitHubRepoRef({ remoteUrl: remote.stdout.trim() }); + return null; } type StreamOutcome = { @@ -2746,6 +3852,7 @@ function parseEmbeddedJson(text: string): unknown { } } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: JSON block extraction must scan mixed stdout/stderr text while tracking nesting and string escaping safely. function extractFirstJsonBlock(text: string): unknown { let start = -1; for (let i = 0; i < text.length; i += 1) { @@ -2796,9 +3903,11 @@ function extractFirstJsonBlock(text: string): unknown { return null; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Run summaries intentionally fold together command context, status, artifacts, and optional PR metadata for a single durable handoff record. function buildSummaryMarkdown(input: { readonly runId: string; readonly runStatus: DispatchRunStatus; + readonly terminalState?: DispatchRunTerminalState; readonly node: NodeRecord; readonly workspace: { readonly projectId: string; @@ -2841,6 +3950,9 @@ function buildSummaryMarkdown(input: { "", "## Summary", `- status: ${input.runStatus}`, + ...(input.terminalState + ? [`- terminal_state: ${input.terminalState}`] + : []), `- node: ${input.node.name} (${input.node.id})`, `- project: ${input.workspace.projectName} (${input.workspace.projectId})`, `- branch: ${input.workspace.branch ?? "detached/unknown"}`, @@ -2936,3 +4048,72 @@ function buildSummaryMarkdown(input: { "", ].join("\n"); } + +function buildLocalDispatchSummaryMarkdown(input: { + readonly runId: string; + readonly status: DispatchRunStatus; + readonly terminalState?: DispatchRunTerminalState; + readonly project: DispatchProjectResolution; + readonly command: readonly string[]; + readonly runner: string; + readonly riskLevel: string; + readonly riskReasons: readonly string[]; + readonly exitCode: number; + readonly prOutcome: DispatchPrOutcome | null; +}): string { + let prLines: string[] = ["- status: not requested"]; + if (input.terminalState === "no_diff") { + prLines = ["- status: skipped", "- reason: no committed diff versus base"]; + } else if (input.terminalState === "no_commit") { + prLines = [ + "- status: skipped", + "- reason: workspace has uncommitted changes", + ]; + } else if (input.prOutcome?.ok) { + prLines = [ + "- status: upserted", + `- profile: ${input.prOutcome.profileId}`, + `- profile_source: ${input.prOutcome.profileSource}`, + `- number: ${input.prOutcome.pull.number}`, + `- url: ${input.prOutcome.pull.htmlUrl}`, + `- base: ${input.prOutcome.pull.baseRef}`, + `- head: ${input.prOutcome.pull.headRef}`, + ]; + } else if (input.prOutcome && !input.prOutcome.ok) { + prLines = [ + "- status: failed", + `- profile: ${input.prOutcome.profileId}`, + `- profile_source: ${input.prOutcome.profileSource}`, + `- error: ${input.prOutcome.error}`, + ]; + } + return [ + `# Dispatch Run ${input.runId}`, + "", + "## Summary", + `- status: ${input.status}`, + ...(input.terminalState + ? [`- terminal_state: ${input.terminalState}`] + : []), + "- mode: local", + `- project: ${input.project.projectName ?? input.project.selector}`, + `- root: ${input.project.projectRoot ?? "unknown"}`, + `- runner: ${input.runner}`, + `- exit_code: ${input.exitCode}`, + "", + "## Command", + "```sh", + input.command.join(" "), + "```", + "", + "## Policy", + `- risk_level: ${input.riskLevel}`, + ...(input.riskReasons.length > 0 + ? input.riskReasons.map((reason) => `- rationale: ${reason}`) + : ["- rationale: none"]), + "", + "## Pull Request", + ...prLines, + "", + ].join("\n"); +} diff --git a/src/commands/env.ts b/src/commands/env.ts index 844d0f8da..d787d7014 100644 --- a/src/commands/env.ts +++ b/src/commands/env.ts @@ -417,102 +417,214 @@ const handleEnvBackendStatus: CommandHandlerFor< const handleEnvBackendUse: CommandHandlerFor<typeof backendUseSpec> = async ({ args, }): Promise<number> => { - const backend = args.positionals.backend.trim(); + const backend = resolveSecretBackend({ + value: args.positionals.backend, + }); + const providerRaw = resolveCloudProvider({ + value: args.options.provider, + }); + + await updateGlobalConfig({ + path: "controlPlane.secrets.backend", + value: backend, + }); + await persistSecretBackendConfig({ + backend, + providerRaw, + storePath: args.options.storePath, + keyPath: args.options.keyPath, + secretProject: args.options.secretProject, + secretPrefix: args.options.secretPrefix, + }); + + const controlPlane = await readControlPlaneConfig({}); + const secretsConfig = controlPlane.config.secrets; + const provisionedKey = await maybeProvisionEncryptedFileKey({ + backend, + shouldProvision: args.options.provisionKey === true, + secretsConfig, + }); + if (args.options.json) { + writeEnvBackendUseJson({ + secretsConfig, + provisionedKey, + }); + return 0; + } + + await displayEnvBackendUseSummary({ + backend, + provisionedKey, + secretsConfig, + }); + return 0; +}; + +async function maybeProvisionEncryptedFileKey(input: { + readonly backend: (typeof SECRET_BACKEND_VALUES)[number]; + readonly shouldProvision: boolean; + readonly secretsConfig: Awaited< + ReturnType<typeof readControlPlaneConfig> + >["config"]["secrets"]; +}): Promise<{ + readonly keyPath: string; + readonly source: "env" | "file" | "keychain" | "generated"; +} | null> { + if (!(input.backend === "encrypted_file" && input.shouldProvision)) { + return null; + } + return await provisionEncryptedFileKey({ + keyPath: input.secretsConfig.encryptedFile.keyPath, + storePath: input.secretsConfig.encryptedFile.path, + }); +} + +function writeEnvBackendUseJson(input: { + readonly secretsConfig: Awaited< + ReturnType<typeof readControlPlaneConfig> + >["config"]["secrets"]; + readonly provisionedKey: { + readonly keyPath: string; + readonly source: "env" | "file" | "keychain" | "generated"; + } | null; +}): void { + process.stdout.write( + `${JSON.stringify( + { + backend: input.secretsConfig.backend, + allow_env_auth_refs: input.secretsConfig.allowEnvAuthRefs, + encrypted_file: input.secretsConfig.encryptedFile, + encrypted_file_key_env: ENCRYPTED_FILE_KEY_ENV, + encrypted_file_key_provisioned: input.provisionedKey, + cloud: input.secretsConfig.cloud, + }, + null, + 2 + )}\n` + ); +} + +function resolveSecretBackend(input: { + readonly value: string; +}): (typeof SECRET_BACKEND_VALUES)[number] { + const backend = input.value.trim(); if ( - !SECRET_BACKEND_VALUES.includes( + SECRET_BACKEND_VALUES.includes( backend as (typeof SECRET_BACKEND_VALUES)[number] ) ) { - throw new CliUsageError( - `Invalid backend "${backend}". Expected one of: ${SECRET_BACKEND_VALUES.join(", ")}` - ); + return backend as (typeof SECRET_BACKEND_VALUES)[number]; } + throw new CliUsageError( + `Invalid backend "${backend}". Expected one of: ${SECRET_BACKEND_VALUES.join(", ")}` + ); +} - const providerRaw = args.options.provider?.trim(); +function resolveCloudProvider(input: { + readonly value?: string; +}): (typeof CLOUD_PROVIDER_VALUES)[number] | null { + const providerRaw = input.value?.trim(); + if (!providerRaw) { + return null; + } if ( - providerRaw && - !CLOUD_PROVIDER_VALUES.includes( + CLOUD_PROVIDER_VALUES.includes( providerRaw as (typeof CLOUD_PROVIDER_VALUES)[number] ) ) { - throw new CliUsageError( - `Invalid --provider "${providerRaw}". Expected one of: ${CLOUD_PROVIDER_VALUES.join(", ")}` - ); + return providerRaw as (typeof CLOUD_PROVIDER_VALUES)[number]; } + throw new CliUsageError( + `Invalid --provider "${providerRaw}". Expected one of: ${CLOUD_PROVIDER_VALUES.join(", ")}` + ); +} - await updateGlobalConfig({ - path: "controlPlane.secrets.backend", - value: backend, - }); +async function persistSecretBackendConfig(input: { + readonly backend: (typeof SECRET_BACKEND_VALUES)[number]; + readonly providerRaw: (typeof CLOUD_PROVIDER_VALUES)[number] | null; + readonly storePath?: string; + readonly keyPath?: string; + readonly secretProject?: string; + readonly secretPrefix?: string; +}): Promise<void> { + await persistEncryptedFileBackendConfig(input); + await persistCloudBackendConfig(input); +} + +async function persistEncryptedFileBackendConfig(input: { + readonly backend: (typeof SECRET_BACKEND_VALUES)[number]; + readonly storePath?: string; + readonly keyPath?: string; +}): Promise<void> { + if (input.backend !== "encrypted_file") { + return; + } - if (backend === "encrypted_file" && args.options.storePath?.trim()) { + const storePath = input.storePath?.trim(); + if (storePath) { await updateGlobalConfig({ path: "controlPlane.secrets.encryptedFile.path", - value: args.options.storePath.trim(), + value: storePath, }); } - if (backend === "encrypted_file" && args.options.keyPath?.trim()) { + const keyPath = input.keyPath?.trim(); + if (keyPath) { await updateGlobalConfig({ path: "controlPlane.secrets.encryptedFile.keyPath", - value: args.options.keyPath.trim(), + value: keyPath, }); } +} - if (backend === "cloud") { - if (!providerRaw) { - throw new CliUsageError( - "Cloud backend requires --provider <aws|gcp|azure|vault>." - ); - } +async function persistCloudBackendConfig(input: { + readonly backend: (typeof SECRET_BACKEND_VALUES)[number]; + readonly providerRaw: (typeof CLOUD_PROVIDER_VALUES)[number] | null; + readonly secretProject?: string; + readonly secretPrefix?: string; +}): Promise<void> { + if (input.backend !== "cloud") { + return; + } + if (!input.providerRaw) { + throw new CliUsageError( + "Cloud backend requires --provider <aws|gcp|azure|vault>." + ); + } + + await updateGlobalConfig({ + path: "controlPlane.secrets.cloud.provider", + value: input.providerRaw, + }); + + const secretProject = input.secretProject?.trim(); + if (secretProject) { await updateGlobalConfig({ - path: "controlPlane.secrets.cloud.provider", - value: providerRaw, + path: "controlPlane.secrets.cloud.project", + value: secretProject, }); - if (args.options.secretProject?.trim()) { - await updateGlobalConfig({ - path: "controlPlane.secrets.cloud.project", - value: args.options.secretProject.trim(), - }); - } - if (args.options.secretPrefix?.trim()) { - await updateGlobalConfig({ - path: "controlPlane.secrets.cloud.secretPrefix", - value: args.options.secretPrefix.trim(), - }); - } } - const controlPlane = await readControlPlaneConfig({}); - const secretsConfig = controlPlane.config.secrets; - let provisionedKey: { - readonly keyPath: string; - readonly source: "env" | "file" | "keychain" | "generated"; - } | null = null; - if (backend === "encrypted_file" && args.options.provisionKey) { - provisionedKey = await provisionEncryptedFileKey({ - keyPath: secretsConfig.encryptedFile.keyPath, - storePath: secretsConfig.encryptedFile.path, + const secretPrefix = input.secretPrefix?.trim(); + if (secretPrefix) { + await updateGlobalConfig({ + path: "controlPlane.secrets.cloud.secretPrefix", + value: secretPrefix, }); } - if (args.options.json) { - process.stdout.write( - `${JSON.stringify( - { - backend: secretsConfig.backend, - allow_env_auth_refs: secretsConfig.allowEnvAuthRefs, - encrypted_file: secretsConfig.encryptedFile, - encrypted_file_key_env: ENCRYPTED_FILE_KEY_ENV, - encrypted_file_key_provisioned: provisionedKey, - cloud: secretsConfig.cloud, - }, - null, - 2 - )}\n` - ); - return 0; - } +} +async function displayEnvBackendUseSummary(input: { + readonly backend: (typeof SECRET_BACKEND_VALUES)[number]; + readonly provisionedKey: { + readonly keyPath: string; + readonly source: "env" | "file" | "keychain" | "generated"; + } | null; + readonly secretsConfig: Awaited< + ReturnType<typeof readControlPlaneConfig> + >["config"]["secrets"]; +}): Promise<void> { + const { backend, provisionedKey, secretsConfig } = input; await display.kv({ title: "Env/secret backend updated", entries: [ @@ -526,20 +638,21 @@ const handleEnvBackendUse: CommandHandlerFor<typeof backendUseSpec> = async ({ ["cloud_secret_prefix", secretsConfig.cloud.secretPrefix], ], }); + if (backend === "encrypted_file") { logger.info({ message: provisionedKey ? `Provisioned stable encrypted backend key at ${provisionedKey.keyPath} (${provisionedKey.source}).` : `Set ${ENCRYPTED_FILE_KEY_ENV} or provision ${secretsConfig.encryptedFile.keyPath} to avoid repeated macOS keychain prompts for encrypted file backend key access.`, }); - } else { - logger.info({ - message: - "Secret writes now use the configured backend for keychain-sourced env values.", - }); + return; } - return 0; -}; + + logger.info({ + message: + "Secret writes now use the configured backend for keychain-sourced env values.", + }); +} function parseKeyValueSpec(spec: string): readonly [string, string | null] { const trimmed = spec.trim(); diff --git a/src/commands/global.ts b/src/commands/global.ts index 08ed8fc9e..2c2bd1c22 100644 --- a/src/commands/global.ts +++ b/src/commands/global.ts @@ -480,47 +480,49 @@ function networkHasSubnet(raw: string, subnet: string): boolean { } async function globalInstall(): Promise<number> { - const s = spinner(); - s.start("Ensuring gum…"); - const gum = await ensureBundledGumInstalled(); - if (gum.ok) { - s.stop(gum.installed ? "Installed bundled gum" : "gum already installed"); - } else { - const systemGum = Bun.which("gum"); - s.stop( - systemGum ? "gum available on PATH" : "gum not installed (optional)" - ); - if (gum.reason === "failed") { + await prepareGlobalInstallFiles(); + logger.success({ message: "Global files ready in ~/.hack/" }); + await globalUp(); + await ensureGlobalDnsReady(); + showGlobalInstallNextSteps(); + return 0; +} + +const s = spinner(); + +async function ensureGlobalOptionalDependencies(): Promise<void> { + await ensureOptionalCliDependency({ + label: "gum", + install: () => ensureBundledGumInstalled(), + resolveSystemPath: () => Bun.which("gum"), + installedMessage: "Installed bundled gum", + readyMessage: "gum already installed", + missingMessage: "gum not installed (optional)", + warnOnFailure: ({ message }) => { logger.warn({ - message: `gum install failed: ${gum.message ?? "unknown error"}`, + message: `gum install failed: ${message ?? "unknown error"}`, }); - } - } + }, + }); - s.start("Ensuring mutagen…"); - const mutagen = await ensureBundledMutagenInstalled(); - if (mutagen.ok) { - s.stop( - mutagen.installed - ? "Installed managed mutagen" - : "mutagen already installed" - ); - } else { - const systemMutagen = getMutagenPath(); - s.stop( - systemMutagen ? "mutagen available on PATH" : "mutagen not installed" - ); - if (!systemMutagen) { - const detail = mutagen.message ? `: ${mutagen.message}` : ""; + await ensureOptionalCliDependency({ + label: "mutagen", + install: () => ensureBundledMutagenInstalled(), + resolveSystemPath: () => getMutagenPath(), + installedMessage: "Installed managed mutagen", + readyMessage: "mutagen already installed", + missingMessage: "mutagen not installed", + warnOnMissing: ({ reason, message }) => { + const detail = message ? `: ${message}` : ""; logger.warn({ - message: `mutagen install skipped (${mutagen.reason}${detail})`, + message: `mutagen install skipped (${reason}${detail})`, }); logger.warn({ message: "Remote sync may fail without mutagen. Repair with: hack doctor --fix", }); - } - } + }, + }); if (isMac()) { await ensureMacChafa(); @@ -532,11 +534,50 @@ async function globalInstall(): Promise<number> { } await warnIfSessionsMuxUnavailable(); +} - s.start("Checking Docker…"); - await ensureDockerRunning(); - s.stop("Docker is running"); +async function ensureOptionalCliDependency(input: { + readonly label: string; + readonly install: () => Promise<{ + readonly ok: boolean; + readonly installed?: boolean; + readonly reason?: string; + readonly message?: string; + }>; + readonly resolveSystemPath: () => string | null; + readonly installedMessage: string; + readonly readyMessage: string; + readonly missingMessage: string; + readonly warnOnFailure?: (input: { readonly message?: string }) => void; + readonly warnOnMissing?: (input: { + readonly reason?: string; + readonly message?: string; + }) => void; +}): Promise<void> { + s.start(`Ensuring ${input.label}…`); + const result = await input.install(); + if (result.ok) { + s.stop(result.installed ? input.installedMessage : input.readyMessage); + return; + } + + const systemPath = input.resolveSystemPath(); + s.stop( + systemPath ? `${input.label} available on PATH` : input.missingMessage + ); + if (result.reason === "failed") { + input.warnOnFailure?.({ message: result.message }); + return; + } + if (!systemPath) { + input.warnOnMissing?.({ + reason: result.reason, + message: result.message, + }); + } +} +async function ensureGlobalNetworksReady(): Promise<boolean> { s.start("Ensuring shared networks…"); const ingressNetwork = await ensureNetwork(DEFAULT_INGRESS_NETWORK, { subnet: DEFAULT_INGRESS_SUBNET, @@ -546,15 +587,50 @@ async function globalInstall(): Promise<number> { s.stop( `Networks ready (${DEFAULT_INGRESS_NETWORK}, ${DEFAULT_LOGGING_NETWORK})` ); - const useStaticIps = ingressNetwork.hasSubnet; - if (!useStaticIps) { + if (!ingressNetwork.hasSubnet) { logger.warn({ message: "hack-dev network has no subnet; CoreDNS will resolve via dynamic IP.", }); } + return ingressNetwork.hasSubnet; +} +async function prepareGlobalInstallFiles(): Promise< + ReturnType<typeof getGlobalPaths> +> { + await ensureGlobalOptionalDependencies(); + await ensureGlobalDockerReady(); + const useStaticIps = await ensureGlobalNetworksReady(); const paths = getGlobalPaths(); + await ensureGlobalPathsReady(paths); + await writeGlobalManagedFiles({ + paths, + useStaticIps, + }); + return paths; +} + +async function ensureGlobalDockerReady(): Promise<void> { + s.start("Checking Docker…"); + await ensureDockerRunning(); + s.stop("Docker is running"); +} + +function showGlobalInstallNextSteps(): void { + note( + [ + "Next:", + "- Open https://logs.hack", + "- Start a repo with: hack init && hack up", + ].join("\n"), + "Global install" + ); +} + +async function ensureGlobalPathsReady( + paths: ReturnType<typeof getGlobalPaths> +): Promise<void> { await ensureDir(paths.caddyDir); await ensureDir(paths.loggingDir); await ensureDir(paths.schemasDir); @@ -563,7 +639,13 @@ async function globalInstall(): Promise<number> { await ensureDir(dirname(paths.grafanaDashboard)); await ensureDir(dirname(paths.alloyConfig)); await ensureDir(dirname(paths.lokiConfig)); +} +async function writeGlobalManagedFiles(input: { + readonly paths: ReturnType<typeof getGlobalPaths>; + readonly useStaticIps: boolean; +}): Promise<void> { + const { paths, useStaticIps } = input; await writeWithPromptIfDifferent( paths.caddyCompose, renderGlobalCaddyCompose({ @@ -611,37 +693,26 @@ async function globalInstall(): Promise<number> { paths.branchesSchema, renderProjectBranchesSchemaJson() ); +} - logger.success({ message: "Global files ready in ~/.hack/" }); - await globalUp(); - +async function ensureGlobalDnsReady(): Promise<void> { if (isMac()) { const hostDnsTarget = await resolvePreferredMacHostDnsTarget(); await ensureMacHackDns({ targetIp: hostDnsTarget }); await ensureMacTrustCaddyLocalCa(); - } else { - logger.warn({ - message: "Skipping DNS bootstrap (only implemented for macOS for now).", - }); - note( - [ - `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, - "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", - ].join("\n"), - "DNS setup" - ); + return; } + logger.warn({ + message: "Skipping DNS bootstrap (only implemented for macOS for now).", + }); note( [ - "Next:", - "- Open https://logs.hack", - "- Start a repo with: hack init && hack up", + `You need wildcard DNS for *.hack pointing to ${DEFAULT_HOST_DNS_IP}.`, + "Recommended: dnsmasq + OS resolver config for the 'hack' TLD.", ].join("\n"), - "Global install" + "DNS setup" ); - - return 0; } async function globalLogsReset(): Promise<number> { @@ -894,6 +965,16 @@ async function findIngressIpConflicts(opts: { } async function inspectIngressNetworkSnapshot(): Promise<IngressNetworkSnapshot | null> { + const entries = await readIngressNetworkInspectEntries(); + if (!entries) { + return null; + } + return buildIngressNetworkSnapshot({ entries }); +} + +async function readIngressNetworkInspectEntries(): Promise< + readonly unknown[] | null +> { const inspect = await exec( ["docker", "network", "inspect", DEFAULT_INGRESS_NETWORK], { @@ -903,69 +984,44 @@ async function inspectIngressNetworkSnapshot(): Promise<IngressNetworkSnapshot | if (inspect.exitCode !== 0) { return null; } + return parseIngressNetworkInspectEntries({ stdout: inspect.stdout }); +} +function parseIngressNetworkInspectEntries(input: { + readonly stdout: string; +}): readonly unknown[] | null { let parsed: unknown; try { - parsed = JSON.parse(inspect.stdout); + parsed = JSON.parse(input.stdout); } catch { return null; } - if (!Array.isArray(parsed)) { - return null; - } + return Array.isArray(parsed) ? parsed : null; +} +function buildIngressNetworkSnapshot(input: { + readonly entries: readonly unknown[]; +}): IngressNetworkSnapshot { let subnet: string | null = null; let gateway: string | null = null; const usedIps = new Set<string>(); const containerIpByName = new Map<string, string>(); - for (const entry of parsed) { + for (const entry of input.entries) { if (!entry || typeof entry !== "object") { continue; } + const ipam = extractIngressNetworkIpam({ + entry, + subnet, + gateway, + }); if (subnet === null) { - const ipamConfig = ( - entry as { - IPAM?: { - Config?: Array<{ Subnet?: unknown; Gateway?: unknown }>; - }; - } - ).IPAM?.Config; - if (Array.isArray(ipamConfig)) { - for (const config of ipamConfig) { - if (subnet === null && typeof config?.Subnet === "string") { - subnet = config.Subnet; - } - if (gateway === null && typeof config?.Gateway === "string") { - gateway = config.Gateway; - } - if (subnet !== null && gateway !== null) { - break; - } - } - } + subnet = ipam.subnet; } - - const containers = (entry as { Containers?: Record<string, unknown> }) - .Containers; - if (!containers || typeof containers !== "object") { - continue; + if (gateway === null) { + gateway = ipam.gateway; } - - for (const info of Object.values(containers)) { - if (!info || typeof info !== "object") { - 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 (ip.length === 0) { - continue; - } + for (const { name, ip } of extractIngressNetworkContainers({ entry })) { usedIps.add(ip); containerIpByName.set(name, ip); } @@ -979,6 +1035,89 @@ async function inspectIngressNetworkSnapshot(): Promise<IngressNetworkSnapshot | }; } +function extractIngressNetworkIpam(input: { + readonly entry: object; + readonly subnet: string | null; + readonly gateway: string | null; +}): { readonly subnet: string | null; readonly gateway: string | null } { + if (!(input.subnet === null || input.gateway === null)) { + return { + subnet: input.subnet, + gateway: input.gateway, + }; + } + + const ipamConfig = ( + input.entry as { + IPAM?: { + Config?: Array<{ Subnet?: unknown; Gateway?: unknown }>; + }; + } + ).IPAM?.Config; + if (!Array.isArray(ipamConfig)) { + return { + subnet: input.subnet, + gateway: input.gateway, + }; + } + + let subnet = input.subnet; + let gateway = input.gateway; + for (const config of ipamConfig) { + if (subnet === null && typeof config?.Subnet === "string") { + subnet = config.Subnet; + } + if (gateway === null && typeof config?.Gateway === "string") { + gateway = config.Gateway; + } + if (subnet !== null && gateway !== null) { + break; + } + } + + return { subnet, gateway }; +} + +function extractIngressNetworkContainers(input: { + readonly entry: object; +}): Array<{ readonly name: string; readonly ip: string }> { + const containers = (input.entry as { Containers?: Record<string, unknown> }) + .Containers; + if (!containers || typeof containers !== "object") { + return []; + } + + const resolved: Array<{ readonly name: string; readonly ip: string }> = []; + for (const info of Object.values(containers)) { + const record = parseIngressNetworkContainerRecord({ value: info }); + if (!record) { + continue; + } + resolved.push(record); + } + return resolved; +} + +function parseIngressNetworkContainerRecord(input: { + readonly value: unknown; +}): { readonly name: string; readonly ip: string } | null { + if (!input.value || typeof input.value !== "object") { + return null; + } + const record = input.value as { Name?: unknown; IPv4Address?: unknown }; + const name = typeof record.Name === "string" ? record.Name : ""; + const ipRaw = + typeof record.IPv4Address === "string" ? record.IPv4Address : ""; + if (!(name && ipRaw)) { + return null; + } + const ip = extractIpv4Address({ raw: ipRaw }); + if (ip.length === 0) { + return null; + } + return { name, ip }; +} + async function reassignIngressIpConflicts(opts: { readonly conflicts: readonly IngressIpConflict[]; readonly reservedIps: readonly string[]; diff --git a/src/commands/node.ts b/src/commands/node.ts index 61fa2d1aa..6a9f262d2 100644 --- a/src/commands/node.ts +++ b/src/commands/node.ts @@ -9,6 +9,17 @@ import { } from "node:fs/promises"; import { homedir, hostname, tmpdir } from "node:os"; import { join, resolve } from "node:path"; +import { + DescribeInstancesCommand, + EC2Client, + StartInstancesCommand, +} from "@aws-sdk/client-ec2"; +import { + DescribeInstanceInformationCommand, + GetCommandInvocationCommand, + SendCommandCommand, + SSMClient, +} from "@aws-sdk/client-ssm"; import { confirm, isCancel, text } from "@clack/prompts"; import type { CliContext, CommandArgs } from "../cli/command.ts"; import { defineCommand, defineOption, withHandler } from "../cli/command.ts"; @@ -71,6 +82,8 @@ const DEFAULT_RAILWAY_GATEWAY_PORT = DEFAULT_NODE_GATEWAY_PORT; const DEFAULT_RAILWAY_BOOTSTRAP_RETRIES = 6; const DEFAULT_RAILWAY_BOOTSTRAP_DELAY_MS = 5000; const DEFAULT_RAILWAY_TAILSCALE_SOCKET = "/tmp/tailscaled.sock"; +const DEFAULT_AWS_BOOTSTRAP_RETRIES = 40; +const DEFAULT_AWS_BOOTSTRAP_DELAY_MS = 250; const RAILWAY_SERVICE_NAME_PATTERN = /[^a-z0-9-]+/g; const TRAILING_DOT_PATTERN = /\.$/; const NODE_AUTH_LOOKUP_TTL_MS = 60_000; @@ -129,6 +142,14 @@ const optBundle = defineOption({ description: "Enrollment bundle path, or - for stdin", } as const); +const optAuthRef = defineOption({ + name: "authRef", + type: "string", + long: "--auth-ref", + valueHint: "<ref>", + description: "Stored auth token reference (supports env:NAME)", +} as const); + const optSource = defineOption({ name: "source", type: "string", @@ -377,6 +398,54 @@ const optRailwayPrivate = defineOption({ "Use private networking bootstrap (skip public domain generation; prefer Tailscale endpoint)", } as const); +const optAwsInstanceId = defineOption({ + name: "instanceId", + type: "string", + long: "--instance-id", + valueHint: "<id>", + description: "EC2 instance id to bootstrap", +} as const); + +const optAwsInstanceTagKey = defineOption({ + name: "instanceTagKey", + type: "string", + long: "--instance-tag-key", + valueHint: "<key>", + description: "EC2 tag key used with --instance-tag-value (default: Name)", +} as const); + +const optAwsInstanceTagValue = defineOption({ + name: "instanceTagValue", + type: "string", + long: "--instance-tag-value", + valueHint: "<value>", + description: "EC2 tag value used to discover the bootstrap target", +} as const); + +const optAwsRegion = defineOption({ + name: "region", + type: "string", + long: "--region", + valueHint: "<region>", + description: "AWS region for EC2 + SSM operations", +} as const); + +const optAwsProfile = defineOption({ + name: "profile", + type: "string", + long: "--profile", + valueHint: "<profile>", + description: "Optional AWS shared credentials profile", +} as const); + +const optAwsBootstrapCommand = defineOption({ + name: "bootstrapCommand", + type: "string", + long: "--bootstrap-command", + valueHint: "<shell-command>", + description: "Optional shell command to run over SSM before node init", +} as const); + const optTailscaleAuthKey = defineOption({ name: "tailscaleAuthKey", type: "string", @@ -431,6 +500,15 @@ const addSpec = defineCommand({ subcommands: [], } as const); +const ensureSpec = defineCommand({ + name: "ensure", + summary: "Ensure a local node registration exists for this host", + group: "Extensions", + options: [optAuthRef, optName, optEndpoint, optLabels, optDefault, optJson], + positionals: [], + subcommands: [], +} as const); + const pairSpec = defineCommand({ name: "pair", summary: "Pair node with one-command or expiring verification-code flow", @@ -584,6 +662,24 @@ const sshSetupSpec = defineCommand({ subcommands: [], } as const); +const authSpec = defineCommand({ + name: "auth", + summary: "Verify and inspect node auth state", + group: "Extensions", + options: [], + positionals: [], + subcommands: [], +} as const); + +const authVerifySpec = defineCommand({ + name: "verify", + summary: "Verify the stored auth token for a node", + group: "Extensions", + options: [optNode, optAuthRef, optJson], + positionals: [], + subcommands: [], +} as const); + const listSpec = defineCommand({ name: "list", summary: "List registered nodes", @@ -755,6 +851,15 @@ const providerRailwaySpec = defineCommand({ subcommands: [], } as const); +const providerAwsSpec = defineCommand({ + name: "aws", + summary: "Bootstrap and register nodes hosted on AWS", + group: "Extensions", + options: [], + positionals: [], + subcommands: [], +} as const); + const providerRailwayBootstrapSpec = defineCommand({ name: "bootstrap", summary: "Create/configure Railway service and register it as a node", @@ -783,11 +888,55 @@ const providerRailwayBootstrapSpec = defineCommand({ subcommands: [], } as const); +const providerAwsBootstrapSpec = defineCommand({ + name: "bootstrap", + summary: "Bootstrap an EC2 instance over SSM and register it as a node", + group: "Extensions", + options: [ + optAwsInstanceId, + optAwsInstanceTagKey, + optAwsInstanceTagValue, + optAwsRegion, + optAwsProfile, + optAwsBootstrapCommand, + optSource, + optName, + optEndpoint, + optLabels, + optDefault, + optJson, + ], + positionals: [], + subcommands: [], +} as const); + +const providerAwsInspectSpec = defineCommand({ + name: "inspect", + summary: "Inspect AWS bootstrap defaults and target instance readiness", + group: "Extensions", + options: [ + optAwsInstanceId, + optAwsInstanceTagKey, + optAwsInstanceTagValue, + optAwsRegion, + optAwsProfile, + optSource, + optEndpoint, + optJson, + ], + positionals: [], + subcommands: [], +} as const); + type InitArgs = CommandArgs< typeof initSpec.options, typeof initSpec.positionals >; type AddArgs = CommandArgs<typeof addSpec.options, typeof addSpec.positionals>; +type EnsureArgs = CommandArgs< + typeof ensureSpec.options, + typeof ensureSpec.positionals +>; type PairArgs = CommandArgs< typeof pairSpec.options, typeof pairSpec.positionals @@ -828,6 +977,10 @@ type SshSetupArgs = CommandArgs< typeof sshSetupSpec.options, typeof sshSetupSpec.positionals >; +type AuthVerifyArgs = CommandArgs< + typeof authVerifySpec.options, + typeof authVerifySpec.positionals +>; type ListArgs = CommandArgs< typeof listSpec.options, typeof listSpec.positionals @@ -881,6 +1034,14 @@ type ProviderRailwayBootstrapArgs = CommandArgs< typeof providerRailwayBootstrapSpec.options, typeof providerRailwayBootstrapSpec.positionals >; +type ProviderAwsBootstrapArgs = CommandArgs< + typeof providerAwsBootstrapSpec.options, + typeof providerAwsBootstrapSpec.positionals +>; +type ProviderAwsInspectArgs = CommandArgs< + typeof providerAwsInspectSpec.options, + typeof providerAwsInspectSpec.positionals +>; type NodeEnrollmentBundle = { readonly version: 1; @@ -908,6 +1069,7 @@ export const nodeCommand = withHandler( ...nodeSpec, subcommands: [ withHandler(initSpec, handleNodeInit), + withHandler(ensureSpec, handleNodeEnsure), withHandler( defineCommand({ ...pairSpec, @@ -941,6 +1103,20 @@ export const nodeCommand = withHandler( return 0; } ), + withHandler( + defineCommand({ + ...authSpec, + subcommands: [withHandler(authVerifySpec, handleNodeAuthVerify)], + } as const), + async () => { + await display.panel({ + title: "Node auth commands", + tone: "info", + lines: ["hack node auth verify --node <id> [--auth-ref <ref>]"], + }); + return 0; + } + ), withHandler(addSpec, handleNodeAdd), withHandler(listSpec, handleNodeList), withHandler(statusSpec, handleNodeStatus), @@ -1019,6 +1195,34 @@ export const nodeCommand = withHandler( return 0; } ), + withHandler( + defineCommand({ + ...providerAwsSpec, + subcommands: [ + withHandler( + providerAwsBootstrapSpec, + handleNodeProviderAwsBootstrap + ), + withHandler( + providerAwsInspectSpec, + handleNodeProviderAwsInspect + ), + ], + } as const), + async () => { + await display.panel({ + title: "Node provider aws commands", + tone: "info", + lines: [ + "hack node provider aws bootstrap --instance-id <i-...> --region <region> --source <user@host> --endpoint <url> [--name <node-name>] [--default]", + "hack node provider aws bootstrap --instance-tag-value <value> --instance-tag-key Name --region <region> --source <user@host> --endpoint <url>", + "hack node provider aws bootstrap --instance-id <i-...> --region <region> --bootstrap-command 'sudo systemctl start hack-node'", + "hack node provider aws inspect --instance-id <i-...> --region <region> --json", + ], + }); + return 0; + } + ), ], } as const), async () => { @@ -1027,6 +1231,8 @@ export const nodeCommand = withHandler( tone: "info", lines: [ "hack node provider railway bootstrap --railway-project <id|name> [--railway-service <service>|--create-service] [--railway-private --tailscale-auth-key <key>]", + "hack node provider aws bootstrap --instance-id <i-...>|--instance-tag-value <value> --region <region> --source <user@host> --endpoint <url>", + "hack node provider aws inspect [--instance-id <i-...>|--instance-tag-value <value>] [--region <region>]", ], }); return 0; @@ -1062,6 +1268,7 @@ export const nodeCommand = withHandler( tone: "info", lines: [ "hack node init --name <name> --endpoint <url>", + "hack node ensure --auth-ref <ref> [--name <name>] [--default]", "hack node pair --host <host> [--name <name>] [--labels a,b] [--default]", "hack node pair --source <user@host> --endpoint <url> [--name <name>] [--labels a,b] [--default]", "hack node pair request --controller <user@host> --source <user@host> [--endpoint <url>]", @@ -1069,6 +1276,7 @@ export const nodeCommand = withHandler( "hack node pair list [--status pending|consumed|cancelled|expired|all]", "hack node pair fulfill --session <id> --code <code> [--default]", "hack node ssh setup --source <user@host> [--ssh-port <port>]", + "hack node auth verify --node <id> [--auth-ref <ref>]", "hack node add --bundle <file|->", "hack node list", "hack node status [--node <id>] [--watch]", @@ -1230,6 +1438,193 @@ async function handleNodeAdd({ return 0; } +async function handleNodeEnsure({ + args, +}: { + readonly ctx: CliContext; + readonly args: EnsureArgs; +}): Promise<number> { + const resolved = await resolveNodeEnsureInputs({ args }); + if (!resolved.ok) { + logger.error({ message: resolved.error }); + return 1; + } + + const registry = await readNodesRegistry(); + const existing = + registry.nodes.find((node) => node.authRef === resolved.authRef) ?? null; + const prepared = await upsertNodeRecord({ + id: existing?.id, + name: resolved.name, + source: existing?.source, + labels: + args.options.labels !== undefined + ? parseCsv(args.options.labels) + : (existing?.labels ?? []), + capabilities: existing?.capabilities ?? [...DEFAULT_NODE_CAPABILITIES], + endpoint: resolved.endpoint, + authRef: resolved.authRef, + status: existing?.status ?? "unknown", + version: existing?.version, + platform: existing?.platform, + arch: existing?.arch, + }); + + const repaired = await ensureNodeVerification({ + node: prepared.node, + authRef: resolved.authRef, + name: resolved.name, + }); + if (!repaired.ok) { + logger.error({ message: repaired.error }); + return 1; + } + + if (args.options.defaultNode === true) { + await setDefaultNode({ id: prepared.node.id }); + } + + return await renderNodeEnsureResult({ + args, + created: prepared.created, + verification: repaired.verification, + node: prepared.node, + token: repaired.token, + }); +} + +async function resolveNodeEnsureInputs(input: { + readonly args: EnsureArgs; +}): Promise< + | { + readonly ok: true; + readonly authRef: string; + readonly name: string; + readonly endpoint: string; + } + | { readonly ok: false; readonly error: string } +> { + const authRef = (input.args.options.authRef ?? "").trim(); + if (!authRef) { + return { ok: false, error: "Missing --auth-ref <ref>." }; + } + const name = (input.args.options.name ?? hostname()).trim(); + if (!name) { + return { ok: false, error: "Missing node name. Pass --name." }; + } + + const gateway = await resolveGatewayConfig(); + if (!gateway.config.enabled) { + return { + ok: false, + error: + "Gateway is not enabled on this host. Run `hack gateway enable` in a project first.", + }; + } + + const endpointRaw = + input.args.options.endpoint?.trim() || + buildDefaultEndpoint({ + bind: gateway.config.bind, + port: gateway.config.port, + }); + const endpoint = endpointRaw.replace(TRAILING_SLASH_PATTERN, ""); + if (!isHttpUrl(endpoint)) { + return { + ok: false, + error: "Invalid --endpoint. Expected http(s) URL.", + }; + } + + return { + ok: true, + authRef, + name, + endpoint, + }; +} + +async function ensureNodeVerification(input: { + readonly node: NodeRecord; + readonly authRef: string; + readonly name: string; +}): Promise< + | { + readonly ok: true; + readonly verification: Awaited<ReturnType<typeof verifyNodeAuth>>; + readonly token?: string; + } + | { readonly ok: false; readonly error: string } +> { + let mintedToken: string | undefined; + let verification = await verifyNodeAuth({ + node: input.node, + }); + if (!(verification.ok || verification.state === "unreachable")) { + const issued = await issueNodeAuthToken({ + authRef: input.authRef, + name: input.name, + }); + if (!issued.ok) { + return { ok: false, error: issued.error }; + } + mintedToken = issued.token; + verification = await verifyNodeAuth({ + node: input.node, + }); + } + + return { + ok: true, + verification, + ...(mintedToken ? { token: mintedToken } : {}), + }; +} + +async function renderNodeEnsureResult(input: { + readonly args: EnsureArgs; + readonly created: boolean; + readonly verification: Awaited<ReturnType<typeof verifyNodeAuth>>; + readonly node: NodeRecord; + readonly token?: string; +}): Promise<number> { + const ok = input.verification.ok; + const nodeForOutput = input.verification.ok + ? input.verification.node + : input.node; + const payload = { + ok, + created: input.created, + node: nodeForOutput, + probe: { + ok, + state: input.verification.state, + ...(input.verification.ok ? {} : { error: input.verification.error }), + }, + ...(input.token ? { token: input.token } : {}), + }; + + if (input.args.options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return ok ? 0 : 1; + } + + await display.kv({ + title: input.created ? "Node ensured" : "Node reused", + entries: [ + ["id", nodeForOutput.id], + ["name", nodeForOutput.name], + ["endpoint", nodeForOutput.endpoint], + ["status", input.verification.state], + ["default", input.args.options.defaultNode ? "yes" : "no"], + ], + }); + if (!(ok || !input.verification.error)) { + logger.error({ message: input.verification.error }); + } + return ok ? 0 : 1; +} + async function handleNodePair({ args, }: { @@ -2516,6 +2911,58 @@ async function handleNodeStatus({ } } +async function handleNodeAuthVerify({ + args, +}: { + readonly ctx: CliContext; + readonly args: AuthVerifyArgs; +}): Promise<number> { + const nodeId = (args.options.node ?? "").trim(); + if (!nodeId) { + logger.error({ message: "Missing --node <id>." }); + return 1; + } + const registry = await readNodesRegistry(); + const node = registry.nodes.find((candidate) => candidate.id === nodeId); + if (!node) { + logger.error({ message: `Unknown node id: ${nodeId}` }); + return 1; + } + + const verification = await verifyNodeAuth({ + node, + authRef: args.options.authRef, + }); + const nodeForOutput = verification.ok ? verification.node : node; + const payload = { + ok: verification.ok, + state: verification.state, + authRef: verification.authRef, + node: nodeForOutput, + ...(verification.ok ? {} : { error: verification.error }), + }; + + if (args.options.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + return verification.ok ? 0 : 1; + } + + await display.kv({ + title: verification.ok ? "Node auth verified" : "Node auth failed", + entries: [ + ["id", nodeForOutput.id], + ["name", nodeForOutput.name], + ["state", verification.state], + ["auth_ref", verification.authRef], + ["endpoint", nodeForOutput.endpoint], + ], + }); + if (!(verification.ok || !verification.error)) { + logger.error({ message: verification.error }); + } + return verification.ok ? 0 : 1; +} + type NodeStatusSnapshot = Awaited<ReturnType<typeof probeNode>>; function resolveStatusTargets(input: { @@ -3275,6 +3722,19 @@ type RailwayBootstrapConfigDefaults = { readonly initRetries?: number; }; +type AwsBootstrapConfigDefaults = { + readonly instanceId?: string; + readonly instanceTagKey?: string; + readonly instanceTagValue?: string; + readonly region?: string; + readonly profile?: string; + readonly bootstrapCommand?: string; + readonly endpoint?: string; + readonly source?: string; + readonly nodeName?: string; + readonly labelsCsv?: string; +}; + async function resolveRailwayBootstrapConfigDefaults(input: { readonly cwd: string; }): Promise<RailwayBootstrapConfigDefaults> { @@ -3411,30 +3871,1025 @@ function resolveRailwayConfigInteger(input: { ); } -async function handleNodeProviderRailwayBootstrap({ - ctx, - args, -}: { - readonly ctx: CliContext; - readonly args: ProviderRailwayBootstrapArgs; -}): Promise<number> { - const defaults = await resolveRailwayBootstrapConfigDefaults({ - cwd: ctx.cwd, +async function resolveAwsBootstrapConfigDefaults(input: { + readonly cwd: string; +}): Promise<AwsBootstrapConfigDefaults> { + const project = await findProjectContext(input.cwd); + const controlPlane = await readControlPlaneConfig({ + ...(project ? { projectDir: project.projectDir } : {}), }); - const railwayProject = ( - args.options.railwayProject ?? - defaults.project ?? - "" - ).trim(); - if (!railwayProject) { - logger.error({ - message: - 'Missing Railway project. Pass --railway-project <id|name> or set controlPlane.providers profile config / controlPlane.extensions["dance.hack.railway"].config.project.', - }); - return 1; - } - const railwayBin = (args.options.railwayBin ?? "railway").trim(); - if (!railwayBin) { + const route = resolveDispatchRoute({ + config: controlPlane.config, + commandProvider: "aws", + commandBootstrapIfNeeded: true, + }); + const routeConfig = + route.providerRoute.provider === "aws" + ? route.providerRoute.effectiveConfig + : {}; + + const extensions = controlPlane.config.extensions; + const awsExtension = extensions["dance.hack.aws"]; + let extensionConfig: Record<string, unknown> = {}; + if (isRecord(awsExtension) && isRecord(awsExtension.config)) { + extensionConfig = awsExtension.config; + } + + return { + instanceId: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "instanceId", + }), + instanceTagKey: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "instanceTagKey", + }), + instanceTagValue: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "instanceTagValue", + }), + region: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "region", + }), + profile: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "profile", + }), + bootstrapCommand: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "bootstrapCommand", + }), + endpoint: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "endpoint", + }), + source: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "source", + }), + nodeName: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "nodeName", + }), + labelsCsv: resolveAwsConfigString({ + routeConfig, + extensionConfig, + key: "labelsCsv", + }), + }; +} + +function resolveAwsConfigString(input: { + readonly routeConfig: Record<string, unknown>; + readonly extensionConfig: Record<string, unknown>; + readonly key: string; +}): string | undefined { + return ( + getOptionalString(input.routeConfig[input.key]) ?? + getOptionalString(input.extensionConfig[input.key]) + ); +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: AWS diagnostics intentionally resolve defaults, credentials, target selection, and SSM readiness in one operator-facing probe. +async function inspectAwsBootstrap(input: { + readonly cwd: string; + readonly options: { + readonly instanceId?: string; + readonly instanceTagKey?: string; + readonly instanceTagValue?: string; + readonly region?: string; + readonly profile?: string; + readonly source?: string; + readonly endpoint?: string; + }; +}): Promise<{ + readonly provider: "aws"; + readonly selectorSummary: string; + readonly config: { + readonly instanceId?: string; + readonly instanceTagKey?: string; + readonly instanceTagValue?: string; + readonly region?: string; + readonly profile?: string; + readonly source?: string; + readonly endpoint?: string; + readonly endpointValid: boolean; + }; + readonly canBootstrap: boolean; + readonly issues: readonly string[]; + readonly target?: { + readonly resolved: boolean; + readonly instanceId?: string; + readonly state?: string; + readonly ssmOnline?: boolean; + readonly error?: string; + }; +}> { + const defaults = await resolveAwsBootstrapConfigDefaults({ + cwd: input.cwd, + }); + const instanceId = + getOptionalString(input.options.instanceId)?.trim() ?? defaults.instanceId; + const instanceTagKey = + getOptionalString(input.options.instanceTagKey)?.trim() ?? + defaults.instanceTagKey ?? + "Name"; + const instanceTagValue = + getOptionalString(input.options.instanceTagValue)?.trim() ?? + defaults.instanceTagValue; + const region = + getOptionalString(input.options.region)?.trim() ?? defaults.region; + const profile = + getOptionalString(input.options.profile)?.trim() ?? defaults.profile; + const source = + getOptionalString(input.options.source)?.trim() ?? defaults.source; + const endpoint = + getOptionalString(input.options.endpoint)?.trim() ?? defaults.endpoint; + const endpointValid = !!endpoint && isHttpUrl(endpoint); + const selectorCount = (instanceId ? 1 : 0) + (instanceTagValue ? 1 : 0); + const issues: string[] = []; + + if (selectorCount !== 1) { + issues.push( + "Configure exactly one selector: instance id or instance tag value." + ); + } + if (!region) { + issues.push("Missing region."); + } + if (!source) { + issues.push("Missing SSH source."); + } + if (!endpoint) { + issues.push("Missing gateway endpoint."); + } else if (!endpointValid) { + issues.push("Gateway endpoint is not a valid http(s) URL."); + } + + let selectorSummary = "missing"; + if (instanceId) { + selectorSummary = `instance-id:${instanceId}`; + } else if (instanceTagValue) { + selectorSummary = `tag:${instanceTagKey}=${instanceTagValue}`; + } + + const config = { + ...(instanceId ? { instanceId } : {}), + ...(instanceTagKey ? { instanceTagKey } : {}), + ...(instanceTagValue ? { instanceTagValue } : {}), + ...(region ? { region } : {}), + ...(profile ? { profile } : {}), + ...(source ? { source } : {}), + ...(endpoint ? { endpoint } : {}), + endpointValid, + }; + + if (!(region && selectorCount === 1)) { + return { + provider: "aws", + selectorSummary, + config, + canBootstrap: issues.length === 0, + issues, + }; + } + + const credentials = profile + ? (await import("@aws-sdk/credential-providers")).fromIni({ + profile, + }) + : undefined; + const ec2 = new EC2Client({ + region, + ...(credentials ? { credentials } : {}), + }); + const ssm = new SSMClient({ + region, + ...(credentials ? { credentials } : {}), + }); + + const instance = await resolveAwsBootstrapInstance({ + ec2, + instanceId: instanceId ?? "", + instanceTagKey, + instanceTagValue: instanceTagValue ?? "", + }); + if (!instance.ok) { + issues.push(instance.error); + return { + provider: "aws", + selectorSummary, + config, + canBootstrap: issues.length === 0, + issues, + target: { + resolved: false, + error: instance.error, + }, + }; + } + + const ssmStatus = await inspectAwsSsmStatus({ + ssm, + instanceId: instance.instance.instanceId, + }); + if (!ssmStatus.ok) { + issues.push(ssmStatus.error); + } else if (!ssmStatus.online) { + issues.push( + `SSM is not online for instance ${instance.instance.instanceId}.` + ); + } + + return { + provider: "aws", + selectorSummary, + config, + canBootstrap: issues.length === 0, + issues, + target: { + resolved: true, + instanceId: instance.instance.instanceId, + state: instance.instance.state, + ...(ssmStatus.ok ? { ssmOnline: ssmStatus.online } : {}), + ...(ssmStatus.ok ? {} : { error: ssmStatus.error }), + }, + }; +} + +async function handleNodeProviderAwsBootstrap({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: ProviderAwsBootstrapArgs; +}): Promise<number> { + const resolved = await resolveAwsBootstrapInput({ + cwd: ctx.cwd, + args, + }); + if (!resolved.ok) { + logger.error({ message: resolved.error }); + return 1; + } + + const credentials = resolved.profile + ? (await import("@aws-sdk/credential-providers")).fromIni({ + profile: resolved.profile, + }) + : undefined; + const ec2 = new EC2Client({ + region: resolved.region, + ...(credentials ? { credentials } : {}), + }); + const ssm = new SSMClient({ + region: resolved.region, + ...(credentials ? { credentials } : {}), + }); + + const instance = await resolveAwsBootstrapInstance({ + ec2, + instanceId: resolved.instanceId, + instanceTagKey: resolved.instanceTagKey, + instanceTagValue: resolved.instanceTagValue, + }); + if (!instance.ok) { + logger.error({ message: instance.error }); + return 1; + } + + const prepared = await ensureAwsInstanceReady({ + ec2, + ssm, + instance: instance.instance, + bootstrapCommand: resolved.bootstrapCommand, + }); + if (!prepared.ok) { + logger.error({ message: prepared.error }); + return 1; + } + + const initCommand = renderRemoteHackCommand({ + remoteHack: undefined, + args: [ + "node", + "init", + "--name", + resolved.name, + "--endpoint", + resolved.endpoint, + ...(resolved.labels.length > 0 + ? ["--labels", resolved.labels.join(",")] + : []), + "--json", + ], + }); + const nodeInit = await runAwsSsmShellCommand({ + ssm, + instanceId: prepared.instance.instanceId, + command: initCommand, + retries: DEFAULT_AWS_BOOTSTRAP_RETRIES, + delayMs: DEFAULT_AWS_BOOTSTRAP_DELAY_MS, + }); + if (!nodeInit.ok) { + logger.error({ message: nodeInit.error }); + return 1; + } + + const parsed = parseEnrollmentBundleFromRemoteOutput({ + text: nodeInit.stdout, + }); + if (!parsed.ok) { + logger.error({ message: parsed.error }); + return 1; + } + + const registered = await registerBundleOnController({ + bundle: parsed.bundle, + makeDefault: args.options.defaultNode === true, + source: resolved.source, + }); + if (!registered.ok) { + logger.error({ message: registered.error }); + return 1; + } + const stabilizedProbe = await stabilizeNodeProbe({ + snapshot: registered.probe, + node: registered.nodeForOutput, + retries: DEFAULT_AWS_BOOTSTRAP_RETRIES, + delayMs: DEFAULT_AWS_BOOTSTRAP_DELAY_MS, + jsonMode: args.options.json === true, + }); + + if (args.options.json) { + process.stdout.write( + `${JSON.stringify( + { + provider: "aws", + aws: { + instanceId: prepared.instance.instanceId, + region: resolved.region, + ...(resolved.profile ? { profile: resolved.profile } : {}), + ...(resolved.instanceTagValue + ? { + tag: { + key: resolved.instanceTagKey, + value: resolved.instanceTagValue, + }, + } + : {}), + }, + created: registered.created, + endpoint: resolved.endpoint, + node: registered.nodeForOutput, + probe: { + ok: stabilizedProbe.ok, + status: stabilizedProbe.status, + error: stabilizedProbe.error, + }, + }, + null, + 2 + )}\n` + ); + return 0; + } + + await display.kv({ + title: registered.created ? "AWS node registered" : "AWS node updated", + entries: [ + ["provider", "aws"], + ["instance_id", prepared.instance.instanceId], + ["region", resolved.region], + ["node_id", registered.nodeForOutput.id], + ["endpoint", resolved.endpoint], + ["status", stabilizedProbe.status], + ["default", args.options.defaultNode ? "yes" : "no"], + ], + }); + if (!stabilizedProbe.ok && stabilizedProbe.error) { + logger.warn({ + message: `Node registered but probe failed: ${stabilizedProbe.error}`, + }); + } + return 0; +} + +async function handleNodeProviderAwsInspect({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: ProviderAwsInspectArgs; +}): Promise<number> { + const diagnostics = await inspectAwsBootstrap({ + cwd: ctx.cwd, + options: { + instanceId: args.options.instanceId, + instanceTagKey: args.options.instanceTagKey, + instanceTagValue: args.options.instanceTagValue, + region: args.options.region, + profile: args.options.profile, + source: args.options.source, + endpoint: args.options.endpoint, + }, + }); + + if (args.options.json) { + process.stdout.write(`${JSON.stringify(diagnostics, null, 2)}\n`); + return 0; + } + + await display.kv({ + title: "AWS provider inspect", + entries: [ + ["region", diagnostics.config.region ?? ""], + ["profile", diagnostics.config.profile ?? ""], + ["selector", diagnostics.selectorSummary], + ["source", diagnostics.config.source ?? ""], + ["endpoint", diagnostics.config.endpoint ?? ""], + ["endpoint_valid", diagnostics.config.endpointValid ? "yes" : "no"], + ["can_bootstrap", diagnostics.canBootstrap ? "yes" : "no"], + [ + "instance_resolved", + diagnostics.target?.resolved === true ? "yes" : "no", + ], + ["instance_state", diagnostics.target?.state ?? ""], + ["ssm_online", diagnostics.target?.ssmOnline === true ? "yes" : "no"], + ], + }); + if (diagnostics.issues.length > 0) { + await display.panel({ + title: "AWS inspect issues", + tone: diagnostics.canBootstrap ? "info" : "warn", + lines: diagnostics.issues, + }); + } + return 0; +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: AWS bootstrap input resolution intentionally merges CLI flags, saved defaults, validation, and parity requirements in one place. +async function resolveAwsBootstrapInput(input: { + readonly cwd: string; + readonly args: ProviderAwsBootstrapArgs; +}): Promise< + | { + readonly ok: true; + readonly bootstrapCommand: string; + readonly endpoint: string; + readonly instanceId: string; + readonly instanceTagKey: string; + readonly instanceTagValue: string; + readonly labels: string[]; + readonly name: string; + readonly profile: string; + readonly region: string; + readonly source: string; + } + | { readonly ok: false; readonly error: string } +> { + const defaults = await resolveAwsBootstrapConfigDefaults({ + cwd: input.cwd, + }); + const instanceId = ( + input.args.options.instanceId ?? + defaults.instanceId ?? + "" + ).trim(); + const instanceTagValue = ( + input.args.options.instanceTagValue ?? + defaults.instanceTagValue ?? + "" + ).trim(); + if ((instanceId ? 1 : 0) + (instanceTagValue ? 1 : 0) !== 1) { + return { + ok: false, + error: + "Pass exactly one of --instance-id <id> or --instance-tag-value <value>.", + }; + } + + const region = (input.args.options.region ?? defaults.region ?? "").trim(); + if (!region) { + return { + ok: false, + error: + 'Missing AWS region. Pass --region <region> or set controlPlane.providers profile config / controlPlane.extensions["dance.hack.aws"].config.region.', + }; + } + + const source = (input.args.options.source ?? defaults.source ?? "").trim(); + if (!source) { + return { + ok: false, + error: + "Missing --source <user@host>. AWS bootstrap requires SSH source metadata for remote workflow parity.", + }; + } + + const endpoint = ( + input.args.options.endpoint ?? + defaults.endpoint ?? + "" + ).trim(); + if (!endpoint) { + return { + ok: false, + error: + "Missing --endpoint <url>. AWS bootstrap requires a reachable node gateway URL.", + }; + } + if (!isHttpUrl(endpoint)) { + return { + ok: false, + error: "Invalid --endpoint. Expected http(s) URL.", + }; + } + + const name = ( + input.args.options.name ?? + defaults.nodeName ?? + hostname() + ).trim(); + if (!name) { + return { ok: false, error: "Missing node name. Pass --name." }; + } + + return { + ok: true, + bootstrapCommand: ( + input.args.options.bootstrapCommand ?? + defaults.bootstrapCommand ?? + "" + ).trim(), + endpoint, + instanceId, + instanceTagKey: + ( + input.args.options.instanceTagKey ?? + defaults.instanceTagKey ?? + "Name" + ).trim() || "Name", + instanceTagValue, + labels: parseCsv(input.args.options.labels ?? defaults.labelsCsv), + name, + profile: (input.args.options.profile ?? defaults.profile ?? "").trim(), + region, + source, + }; +} + +async function ensureAwsInstanceReady(input: { + readonly bootstrapCommand: string; + readonly ec2: EC2Client; + readonly instance: AwsBootstrapInstance; + readonly ssm: SSMClient; +}): Promise< + | { readonly ok: true; readonly instance: AwsBootstrapInstance } + | { readonly ok: false; readonly error: string } +> { + const running = await ensureAwsInstanceRunning({ + ec2: input.ec2, + instance: input.instance, + }); + if (!running.ok) { + return running; + } + + const ssmReady = await waitForAwsSsmOnline({ + ssm: input.ssm, + instanceId: running.instance.instanceId, + retries: DEFAULT_AWS_BOOTSTRAP_RETRIES, + delayMs: DEFAULT_AWS_BOOTSTRAP_DELAY_MS, + }); + if (!ssmReady.ok) { + return ssmReady; + } + + if (!input.bootstrapCommand) { + return running; + } + + const bootstrap = await runAwsSsmShellCommand({ + ssm: input.ssm, + instanceId: running.instance.instanceId, + command: input.bootstrapCommand, + retries: DEFAULT_AWS_BOOTSTRAP_RETRIES, + delayMs: DEFAULT_AWS_BOOTSTRAP_DELAY_MS, + }); + if (!bootstrap.ok) { + return bootstrap; + } + + return running; +} + +async function ensureAwsInstanceRunning(input: { + readonly ec2: EC2Client; + readonly instance: AwsBootstrapInstance; +}): Promise< + | { readonly ok: true; readonly instance: AwsBootstrapInstance } + | { readonly ok: false; readonly error: string } +> { + if (input.instance.state === "stopped") { + const started = await startAwsBootstrapInstance({ + ec2: input.ec2, + instanceId: input.instance.instanceId, + }); + if (!started.ok) { + return started; + } + } + + if (input.instance.state === "running") { + return { ok: true, instance: input.instance }; + } + + return await waitForAwsInstanceState({ + ec2: input.ec2, + instanceId: input.instance.instanceId, + targetState: "running", + retries: DEFAULT_AWS_BOOTSTRAP_RETRIES, + delayMs: DEFAULT_AWS_BOOTSTRAP_DELAY_MS, + }); +} + +type AwsBootstrapInstance = { + readonly instanceId: string; + readonly state: string; +}; + +async function resolveAwsBootstrapInstance(input: { + readonly ec2: EC2Client; + readonly instanceId: string; + readonly instanceTagKey: string; + readonly instanceTagValue: string; +}): Promise< + | { readonly ok: true; readonly instance: AwsBootstrapInstance } + | { readonly ok: false; readonly error: string } +> { + try { + const response = await input.ec2.send( + new DescribeInstancesCommand( + input.instanceId + ? { + InstanceIds: [input.instanceId], + } + : { + Filters: [ + { + Name: `tag:${input.instanceTagKey}`, + Values: [input.instanceTagValue], + }, + ], + } + ) + ); + const instance = extractAwsBootstrapInstance({ response }); + if (!instance) { + return { + ok: false, + error: input.instanceId + ? `EC2 instance ${input.instanceId} was not found.` + : `No EC2 instances matched tag ${input.instanceTagKey}=${input.instanceTagValue}.`, + }; + } + return { ok: true, instance }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "EC2 DescribeInstances failed"; + return { ok: false, error: `Failed to resolve EC2 instance: ${message}` }; + } +} + +function extractAwsBootstrapInstance(input: { + readonly response: { + readonly Reservations?: readonly { + readonly Instances?: readonly { + readonly InstanceId?: string; + readonly State?: { + readonly Name?: string; + }; + }[]; + }[]; + }; +}): AwsBootstrapInstance | null { + for (const reservation of input.response.Reservations ?? []) { + for (const instance of reservation.Instances ?? []) { + if (!instance.InstanceId) { + continue; + } + return { + instanceId: instance.InstanceId, + state: (instance.State?.Name ?? "").trim().toLowerCase(), + }; + } + } + return null; +} + +async function startAwsBootstrapInstance(input: { + readonly ec2: EC2Client; + readonly instanceId: string; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + try { + await input.ec2.send( + new StartInstancesCommand({ + InstanceIds: [input.instanceId], + }) + ); + return { ok: true }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "EC2 StartInstances failed"; + return { ok: false, error: `Failed to start EC2 instance: ${message}` }; + } +} + +async function waitForAwsInstanceState(input: { + readonly ec2: EC2Client; + readonly instanceId: string; + readonly targetState: string; + readonly retries: number; + readonly delayMs: number; +}): Promise< + | { readonly ok: true; readonly instance: AwsBootstrapInstance } + | { readonly ok: false; readonly error: string } +> { + for (let attempt = 1; attempt <= input.retries; attempt += 1) { + const resolved = await resolveAwsBootstrapInstance({ + ec2: input.ec2, + instanceId: input.instanceId, + instanceTagKey: "", + instanceTagValue: "", + }); + if (!resolved.ok) { + return resolved; + } + if (resolved.instance.state === input.targetState) { + return resolved; + } + if (attempt < input.retries) { + await Bun.sleep(input.delayMs); + } + } + return { + ok: false, + error: `Timed out waiting for EC2 instance ${input.instanceId} to reach state ${input.targetState}.`, + }; +} + +async function waitForAwsSsmOnline(input: { + readonly ssm: SSMClient; + readonly instanceId: string; + readonly retries: number; + readonly delayMs: number; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + for (let attempt = 1; attempt <= input.retries; attempt += 1) { + try { + const response = await input.ssm.send( + new DescribeInstanceInformationCommand({ + Filters: [ + { + Key: "InstanceIds", + Values: [input.instanceId], + }, + ], + }) + ); + const online = (response.InstanceInformationList ?? []).some( + (entry) => + entry.InstanceId === input.instanceId && entry.PingStatus === "Online" + ); + if (online) { + return { ok: true }; + } + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "SSM DescribeInstanceInformation failed"; + return { + ok: false, + error: `Failed to verify SSM readiness: ${message}`, + }; + } + if (attempt < input.retries) { + await Bun.sleep(input.delayMs); + } + } + return { + ok: false, + error: `Timed out waiting for SSM on instance ${input.instanceId}.`, + }; +} + +async function inspectAwsSsmStatus(input: { + readonly ssm: SSMClient; + readonly instanceId: string; +}): Promise< + | { readonly ok: true; readonly online: boolean } + | { readonly ok: false; readonly error: string } +> { + try { + const response = await input.ssm.send( + new DescribeInstanceInformationCommand({ + Filters: [ + { + Key: "InstanceIds", + Values: [input.instanceId], + }, + ], + }) + ); + const online = (response.InstanceInformationList ?? []).some( + (entry) => + entry.InstanceId === input.instanceId && entry.PingStatus === "Online" + ); + return { ok: true, online }; + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "SSM DescribeInstanceInformation failed"; + return { + ok: false, + error: `Failed to inspect SSM readiness: ${message}`, + }; + } +} + +async function runAwsSsmShellCommand(input: { + readonly ssm: SSMClient; + readonly instanceId: string; + readonly command: string; + readonly retries: number; + readonly delayMs: number; +}): Promise< + | { readonly ok: true; readonly stdout: string; readonly stderr: string } + | { readonly ok: false; readonly error: string } +> { + const commandId = await sendAwsSsmCommand(input); + if (!commandId) { + return { ok: false, error: "SSM command did not return a command id." }; + } + + for (let attempt = 1; attempt <= input.retries; attempt += 1) { + const invocation = await readAwsSsmCommandInvocation({ + ssm: input.ssm, + instanceId: input.instanceId, + commandId, + }); + if (!invocation.ok) { + return invocation; + } + if (invocation.status === "Success") { + return { + ok: true, + stdout: invocation.stdout, + stderr: invocation.stderr, + }; + } + if (!isAwsSsmPendingStatus(invocation.status)) { + return { + ok: false, + error: formatAwsSsmCommandFailure({ + status: invocation.status, + stdout: invocation.stdout, + stderr: invocation.stderr, + }), + }; + } + if (attempt < input.retries) { + await Bun.sleep(input.delayMs); + } + } + return { + ok: false, + error: `Timed out waiting for SSM command on instance ${input.instanceId}.`, + }; +} + +async function sendAwsSsmCommand(input: { + readonly ssm: SSMClient; + readonly instanceId: string; + readonly command: string; +}): Promise<string> { + try { + const sent = await input.ssm.send( + new SendCommandCommand({ + DocumentName: "AWS-RunShellScript", + InstanceIds: [input.instanceId], + Parameters: { + commands: [input.command], + }, + }) + ); + return sent.Command?.CommandId?.trim() ?? ""; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "SSM SendCommand failed"; + throw new Error(`Failed to send SSM command: ${message}`); + } +} + +async function readAwsSsmCommandInvocation(input: { + readonly ssm: SSMClient; + readonly instanceId: string; + readonly commandId: string; +}): Promise< + | { + readonly ok: true; + readonly status: string; + readonly stdout: string; + readonly stderr: string; + } + | { readonly ok: false; readonly error: string } +> { + try { + const invocation = await input.ssm.send( + new GetCommandInvocationCommand({ + CommandId: input.commandId, + InstanceId: input.instanceId, + }) + ); + return { + ok: true, + status: (invocation.Status ?? "").trim(), + stdout: invocation.StandardOutputContent ?? "", + stderr: invocation.StandardErrorContent ?? "", + }; + } catch (error: unknown) { + const message = + error instanceof Error + ? error.message + : "SSM GetCommandInvocation failed"; + return { + ok: false, + error: `Failed to read SSM command invocation: ${message}`, + }; + } +} + +function isAwsSsmPendingStatus(status: string): boolean { + return ["Pending", "InProgress", "Delayed"].includes(status); +} + +function formatAwsSsmCommandFailure(input: { + readonly status: string; + readonly stdout: string; + readonly stderr: string; +}): string { + const stderr = input.stderr.trim(); + const stdout = input.stdout.trim(); + return [ + `SSM command failed with status ${input.status}.`, + stderr.length > 0 ? `stderr: ${stderr}` : null, + stdout.length > 0 ? `stdout: ${stdout}` : null, + ] + .filter((value): value is string => value !== null) + .join(" "); +} + +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Railway bootstrap remains a controller-facing orchestration flow spanning profile resolution, provisioning, pairing, and status reporting. +async function handleNodeProviderRailwayBootstrap({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: ProviderRailwayBootstrapArgs; +}): Promise<number> { + const defaults = await resolveRailwayBootstrapConfigDefaults({ + cwd: ctx.cwd, + }); + const railwayProject = ( + args.options.railwayProject ?? + defaults.project ?? + "" + ).trim(); + if (!railwayProject) { + logger.error({ + message: + 'Missing Railway project. Pass --railway-project <id|name> or set controlPlane.providers profile config / controlPlane.extensions["dance.hack.railway"].config.project.', + }); + return 1; + } + const railwayBin = (args.options.railwayBin ?? "railway").trim(); + if (!railwayBin) { logger.error({ message: "Invalid --railway-bin value." }); return 1; } @@ -4618,6 +6073,7 @@ function parseRailwayDomainEndpoint(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Railway domain discovery recursively inspects several nested payload shapes returned by different API surfaces. function findRailwayDomainValue(input: { readonly value: unknown; }): string | null { @@ -5040,6 +6496,128 @@ export const __testOnlyNodeStatus = { clearNodeAuthLookupCache, }; +type NodeAuthVerificationResult = + | { + readonly ok: true; + readonly state: "ok"; + readonly authRef: string; + readonly node: NodeRecord; + readonly payload: Record<string, unknown>; + } + | { + readonly ok: false; + readonly state: "missing_token" | "invalid_token" | "unreachable"; + readonly authRef: string; + readonly node: NodeRecord; + readonly error: string; + }; + +async function verifyNodeAuth(input: { + readonly node: NodeRecord; + readonly authRef?: string; +}): Promise<NodeAuthVerificationResult> { + const authRef = (input.authRef ?? input.node.authRef).trim(); + if (!authRef) { + return { + ok: false, + state: "missing_token", + authRef, + node: input.node, + error: "Missing auth token reference.", + }; + } + + const tokenLookup = await resolveNodeAuthLookup({ authRef }); + if (!tokenLookup.ok) { + return { + ok: false, + state: "missing_token", + authRef, + node: input.node, + error: tokenLookup.error, + }; + } + if (!tokenLookup.token) { + return { + ok: false, + state: "missing_token", + authRef, + node: input.node, + error: `Missing auth token for ${authRef}`, + }; + } + + try { + const res = await fetchWithTimeout({ + url: `${input.node.endpoint}/v1/node/status`, + timeoutMs: NODE_PREFLIGHT_HTTP_TIMEOUT_MS, + init: { + method: "GET", + headers: { + Authorization: `Bearer ${tokenLookup.token}`, + }, + }, + }); + if (!res.ok) { + if (res.status === 401 || res.status === 403) { + clearNodeAuthLookupCache({ authRef }); + return { + ok: false, + state: "invalid_token", + authRef, + node: input.node, + error: `HTTP ${res.status}`, + }; + } + return { + ok: false, + state: "unreachable", + authRef, + node: input.node, + error: `HTTP ${res.status}`, + }; + } + + const payload = (await res.json()) as Record<string, unknown>; + const nodeObj = isRecord(payload.node) ? payload.node : {}; + const version = getOptionalString(nodeObj.version); + const platform = getOptionalString(nodeObj.platform); + const arch = getOptionalString(nodeObj.arch); + const upserted = await upsertNodeRecord({ + id: input.node.id, + name: input.node.name, + source: input.node.source, + labels: input.node.labels, + capabilities: input.node.capabilities, + endpoint: input.node.endpoint, + authRef: input.node.authRef, + lastSeenAt: new Date().toISOString(), + status: "healthy", + ...(version ? { version } : {}), + ...(platform ? { platform } : {}), + ...(arch ? { arch } : {}), + }); + return { + ok: true, + state: "ok", + authRef, + node: upserted.node, + payload, + }; + } catch (error: unknown) { + return { + ok: false, + state: "unreachable", + authRef, + node: input.node, + error: describeRequestError({ + error, + timeoutMs: NODE_PREFLIGHT_HTTP_TIMEOUT_MS, + }), + }; + } +} + /** * Resolve node auth token once and reuse a short-lived cache entry to avoid * repeated keychain prompts during polling. @@ -5096,6 +6674,33 @@ async function resolveNodeAuthLookup(input: { } } +async function issueNodeAuthToken(input: { + readonly authRef: string; + readonly name: string; +}): Promise< + | { readonly ok: true; readonly token: string } + | { readonly ok: false; readonly error: string } +> { + try { + const daemonPaths = resolveDaemonPaths({}); + const issued = await createGatewayToken({ + rootDir: daemonPaths.root, + label: `node-ensure:${input.name}`, + scope: "write", + }); + await saveNodeAuthToken({ + authRef: input.authRef, + token: issued.token, + }); + clearNodeAuthLookupCache({ authRef: input.authRef }); + return { ok: true, token: issued.token }; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "failed to issue auth token"; + return { ok: false, error: `Failed to issue auth token: ${message}` }; + } +} + function clearNodeAuthLookupCache(input: { readonly authRef: string; readonly cache?: Map<string, NodeAuthLookupCacheEntry>; diff --git a/src/commands/project.ts b/src/commands/project.ts index c18106309..2f251c249 100644 --- a/src/commands/project.ts +++ b/src/commands/project.ts @@ -1979,6 +1979,7 @@ async function readComposeServicesForInternalOverride( return out; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Compose network parsing accepts several Docker/Compose object shapes and degrades safely on malformed definitions. function parseComposeServiceNetworkNames(value: unknown): readonly string[] { if (!isRecord(value)) { return []; diff --git a/src/commands/session.ts b/src/commands/session.ts index 5d1a7a717..c4611f312 100644 --- a/src/commands/session.ts +++ b/src/commands/session.ts @@ -220,71 +220,268 @@ type TailArgs = CommandArgs< typeof tailSpec.positionals >; -/** - * Interactive session picker (default when no subcommand). - * - * Uses clack prompts with grouped options for sessions and projects. - */ -async function handleSessionPicker(): Promise<number> { - const sessions = await listTmuxSessions(); - const registry = await readProjectsRegistry(); - const projects = registry.projects; - - p.intro("Sessions"); +type SessionOption = { + readonly value: string; + readonly label: string; + readonly hint?: string; +}; - const sessionNames = new Set(sessions.map((s) => s.name)); - const home = process.env.HOME ?? ""; +type SessionStreamContext = { + readonly session: string; + readonly target: string; + readonly lines: number; + readonly follow: boolean; + readonly intervalMs?: number; + readonly maxMs?: number; +}; - // Helper to shorten paths with ~/ - const shortenPath = (path: string): string => { - if (home && path.startsWith(home)) { - return `~${path.slice(home.length)}`; - } - return path; - }; +function shortenHomePath(input: { + readonly path: string; + readonly home: string; +}): string { + if (input.home && input.path.startsWith(input.home)) { + return `~${input.path.slice(input.home.length)}`; + } + return input.path; +} - // Build options for clack select - type SessionOption = { - value: string; - label: string; - hint?: string; - }; +function findProjectByName(input: { + readonly projects: readonly RegisteredProject[]; + readonly name: string; +}): RegisteredProject | undefined { + return input.projects.find((project) => project.name === input.name); +} +function buildSessionPickerOptions(input: { + readonly sessions: readonly TmuxSession[]; + readonly projects: readonly RegisteredProject[]; + readonly home: string; +}): SessionOption[] { + const sessionNames = new Set(input.sessions.map((session) => session.name)); + const availableProjects = input.projects.filter( + (project) => !sessionNames.has(project.name) + ); const options: SessionOption[] = []; - // Active sessions - const attachedSessions = sessions.filter((s) => s.attached); - const detachedSessions = sessions.filter((s) => !s.attached); - - for (const session of attachedSessions) { + for (const session of input.sessions.filter((session) => session.attached)) { options.push({ value: `session:${session.name}`, label: session.name, - hint: `attached${session.path ? ` • ${shortenPath(session.path)}` : ""}`, + hint: `attached${session.path ? ` • ${shortenHomePath({ path: session.path, home: input.home })}` : ""}`, }); } - for (const session of detachedSessions) { + for (const session of input.sessions.filter((session) => !session.attached)) { options.push({ value: `session:${session.name}`, label: session.name, - hint: session.path ? shortenPath(session.path) : "detached", + hint: session.path + ? shortenHomePath({ path: session.path, home: input.home }) + : "detached", }); } - // Projects without active sessions - const availableProjects = projects.filter( - (proj: RegisteredProject) => !sessionNames.has(proj.name) - ); - for (const project of availableProjects) { options.push({ value: `project:${project.name}`, label: project.name, - hint: `new • ${shortenPath(project.repoRoot)}`, + hint: `new • ${shortenHomePath({ path: project.repoRoot, home: input.home })}`, }); } + return options; +} + +async function resolveAttachedSessionAction(input: { + readonly sessionName: string; + readonly sessions: readonly TmuxSession[]; + readonly projects: readonly RegisteredProject[]; +}): Promise< + | { readonly ok: true; readonly action: "attach" } + | { + readonly ok: true; + readonly action: "new"; + readonly cwd: string; + readonly nextNum: number; + } + | { readonly ok: true; readonly action: "cancelled" } +> { + const session = input.sessions.find( + (candidate) => candidate.name === input.sessionName + ); + if (!session?.attached) { + return { ok: true, action: "attach" }; + } + + const nextNum = getNextSessionNumber([...input.sessions], input.sessionName); + const action = await p.select({ + message: `Session '${input.sessionName}' is attached elsewhere`, + options: [ + { value: "attach", label: "Attach", hint: "detaches other clients" }, + { + value: "new", + label: "Create new", + hint: `${input.sessionName}:${nextNum}`, + }, + ], + }); + + if (p.isCancel(action)) { + p.outro("Cancelled"); + return { ok: true, action: "cancelled" }; + } + + if (action === "attach") { + return { ok: true, action: "attach" }; + } + + const project = findProjectByName({ + projects: input.projects, + name: input.sessionName, + }); + return { + ok: true, + action: "new", + cwd: project?.repoRoot ?? session.path ?? process.cwd(), + nextNum, + }; +} + +function isJsonSessionOutput(input: { + readonly json: boolean; + readonly pretty: boolean; +}): boolean | null { + if (input.json && input.pretty) { + process.stderr.write("Cannot combine --json with --pretty.\n"); + return null; + } + return input.json || !input.pretty; +} + +function writeSessionError(input: { + readonly json: boolean; + readonly context: SessionStreamContext; + readonly message: string; +}): void { + if (input.json) { + writeSessionStreamEvent({ + event: buildSessionStreamErrorEvent({ + context: input.context, + message: input.message, + }), + }); + writeSessionStreamEvent({ + event: buildSessionStreamEndEvent({ + context: input.context, + reason: "error", + }), + }); + return; + } + console.error(input.message); +} + +function writeSessionOutputLines(input: { + readonly json: boolean; + readonly context: SessionStreamContext; + readonly text: string; +}): void { + if (input.json) { + for (const line of splitLines(input.text)) { + writeSessionStreamEvent({ + event: buildSessionStreamLogEvent({ context: input.context, line }), + }); + } + return; + } + process.stdout.write(input.text); +} + +function resolveProjectForSessionStart(input: { + readonly projects: readonly RegisteredProject[]; + readonly projectNameOrPath?: string; +}): RegisteredProject | null { + if (!input.projectNameOrPath) { + return null; + } + const resolvedPath = resolve(input.projectNameOrPath); + return ( + input.projects.find( + (project) => + project.name === input.projectNameOrPath || + project.projectDir === resolvedPath + ) ?? null + ); +} + +async function resolveSessionStartName(input: { + readonly baseName: string; + readonly forceNew: boolean; + readonly customName?: string; + readonly detach: boolean; + readonly runUp: boolean; + readonly project: RegisteredProject; +}): Promise< + | { readonly ok: true; readonly sessionName: string } + | { readonly ok: true; readonly existingSession: string } +> { + if (input.customName) { + return { + ok: true, + sessionName: `${input.baseName}:${input.customName}`, + }; + } + + const sessions = await listTmuxSessions(); + if (!input.forceNew) { + const existing = sessions.find( + (session) => session.name === input.baseName + ); + if (existing) { + if (input.detach) { + logger.info({ message: `Session ready: ${input.baseName}` }); + } else { + logger.info({ + message: `Attaching to existing session: ${input.baseName}`, + }); + } + if (input.runUp) { + await runHackUp(input.project.projectDir); + } + return { + ok: true, + existingSession: input.baseName, + }; + } + } + + if (input.forceNew) { + return { + ok: true, + sessionName: `${input.baseName}:${getNextSessionNumber(sessions, input.baseName)}`, + }; + } + + return { + ok: true, + sessionName: input.baseName, + }; +} + +/** + * Interactive session picker (default when no subcommand). + * + * Uses clack prompts with grouped options for sessions and projects. + */ +async function handleSessionPicker(): Promise<number> { + const sessions = await listTmuxSessions(); + const registry = await readProjectsRegistry(); + const projects = registry.projects; + + p.intro("Sessions"); + const home = process.env.HOME ?? ""; + const options = buildSessionPickerOptions({ sessions, projects, home }); + if (options.length === 0) { p.log.warn( "No sessions or projects found. Run 'hack init' in a project first." @@ -313,44 +510,26 @@ async function handleSessionPicker(): Promise<number> { } if (type === "session") { - const session = sessions.find((s) => s.name === name); - - // If session is attached elsewhere, offer choice - if (session?.attached) { - const nextNum = getNextSessionNumber(sessions, 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}` }, - ], + const action = await resolveAttachedSessionAction({ + sessionName: name, + sessions, + projects, + }); + if (action.action === "cancelled") { + return 0; + } + if (action.action === "new") { + return await createAndAttachSession({ + name: `${name}:${action.nextNum}`, + cwd: action.cwd, }); - - 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, - }); - } } return await attachToSession(name); } // Create new session for project - const project = projects.find( - (proj: RegisteredProject) => proj.name === name - ); + const project = findProjectByName({ projects, name }); if (!project) { p.log.error(`Project not found: ${name}`); return 1; @@ -366,7 +545,7 @@ async function handleSessionPicker(): Promise<number> { * Get the next available session number for a base name. */ function getNextSessionNumber( - sessions: TmuxSession[], + sessions: readonly TmuxSession[], baseName: string ): number { const existing = sessions.filter( @@ -428,21 +607,10 @@ const handleStart = async ({ // Find project const registry = await readProjectsRegistry(); const projects = registry.projects; - let project = projectNameOrPath - ? projects.find( - (p: RegisteredProject) => - p.name === projectNameOrPath || - p.projectDir === resolve(projectNameOrPath) - ) - : null; - - if (!project && projectNameOrPath) { - // Try as path - const resolvedPath = resolve(projectNameOrPath); - project = projects.find( - (p: RegisteredProject) => p.projectDir === resolvedPath - ); - } + const project = resolveProjectForSessionStart({ + projects, + projectNameOrPath, + }); if (!project) { if (projectNameOrPath) { @@ -456,44 +624,21 @@ const handleStart = async ({ } 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) { - if (detach) { - logger.info({ message: `Session ready: ${baseName}` }); - } else { - logger.info({ message: `Attaching to existing session: ${baseName}` }); - } - if (runUp) { - await runHackUp(project.projectDir); - } - if (detach) { - return 0; - } - return await attachToSession(baseName); + const sessionPlan = await resolveSessionStartName({ + baseName, + forceNew, + customName, + detach, + runUp, + project, + }); + if ("existingSession" in sessionPlan) { + if (detach) { + return 0; } + return await attachToSession(sessionPlan.existingSession); } + const sessionName = sessionPlan.sessionName; // Run hack up if requested if (runUp) { @@ -705,14 +850,15 @@ const handleTail = async ({ 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"); + const json = isJsonSessionOutput({ + json: args.options.json === true, + pretty, + }); + if (json === null) { return 1; } - const context = { + const context: SessionStreamContext = { session: sessionName, target, lines, @@ -729,17 +875,11 @@ const handleTail = async ({ 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); - } + writeSessionError({ + json, + context, + message: initial.stderr || `Failed to capture ${sessionName}`, + }); return 1; } @@ -751,32 +891,18 @@ const handleTail = async ({ 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); - } + writeSessionError({ + json, + context, + message: result.stderr || `Failed to capture ${sessionName}`, + }); return 1; } const nextOutput = result.stdout; const suffix = diffNewLines({ previous: lastOutput, next: nextOutput }); if (suffix) { - if (json) { - for (const line of splitLines(suffix)) { - writeSessionStreamEvent({ - event: buildSessionStreamLogEvent({ context, line }), - }); - } - } else { - process.stdout.write(suffix); - } + writeSessionOutputLines({ json, context, text: suffix }); } lastOutput = nextOutput; diff --git a/src/commands/workspace.ts b/src/commands/workspace.ts new file mode 100644 index 000000000..0aa64acdd --- /dev/null +++ b/src/commands/workspace.ts @@ -0,0 +1,457 @@ +import { rm } from "node:fs/promises"; +import { resolve } from "node:path"; +import type { CliContext, CommandArgs } from "../cli/command.ts"; +import { + CliUsageError, + defineCommand, + defineOption, + withHandler, +} from "../cli/command.ts"; +import { optJson, optPath, optProject } from "../cli/options.ts"; +import { pathExists } from "../lib/fs.ts"; +import { findProjectContext, sanitizeProjectSlug } from "../lib/project.ts"; +import { resolveRegisteredProjectByName } from "../lib/projects-registry.ts"; +import { exec } from "../lib/shell.ts"; +import { display } from "../ui/display.ts"; +import { logger } from "../ui/logger.ts"; + +const optBase = defineOption({ + name: "base", + type: "string", + long: "--base", + valueHint: "<ref>", + description: "Base ref to reset to (for example: origin/main or HEAD)", +} as const); + +const optExclude = defineOption({ + name: "exclude", + type: "string", + long: "--exclude", + valueHint: "<a,b,c>", + description: "Comma-separated git clean exclude patterns", +} as const); + +const workspaceSpec = defineCommand({ + name: "workspace", + summary: "Inspect and repair project workspaces", + group: "Project", + options: [], + positionals: [], + subcommands: [], +} as const); + +const workspaceResetSpec = defineCommand({ + name: "reset", + summary: "Reset a workspace to a clean base ref while preserving excludes", + group: "Project", + options: [optPath, optProject, optBase, optExclude, optJson], + positionals: [], + subcommands: [], +} as const); + +type WorkspaceResetArgs = CommandArgs< + typeof workspaceResetSpec.options, + typeof workspaceResetSpec.positionals +>; + +type WorkspaceResetSummary = { + readonly ok: boolean; + readonly projectRoot: string; + readonly baseRef: string; + readonly checkoutBranch: string | null; + readonly fetchedRemote: string | null; + readonly removedGitIndexLock: boolean; + readonly cleanArgs: readonly string[]; + readonly cleanedPaths: readonly string[]; + readonly preservedExcludes: readonly string[]; + readonly statusBefore: readonly string[]; + readonly statusAfter: readonly string[]; +}; + +type ParsedBaseRef = { + readonly baseRef: string; + readonly remote: string | null; + readonly branch: string | null; +}; + +export const workspaceCommand = withHandler( + defineCommand({ + ...workspaceSpec, + subcommands: [withHandler(workspaceResetSpec, handleWorkspaceReset)], + } as const), + async () => { + await display.panel({ + title: "Workspace commands", + tone: "info", + lines: [ + "hack workspace reset --base <ref> [--path <dir>|--project <name>] [--exclude .env,.hack/]", + ], + }); + return 0; + } +); + +async function handleWorkspaceReset({ + ctx, + args, +}: { + readonly ctx: CliContext; + readonly args: WorkspaceResetArgs; +}): Promise<number> { + try { + const project = await resolveProjectForArgs({ + ctx, + pathOpt: + typeof args.options.path === "string" ? args.options.path : undefined, + projectOpt: + typeof args.options.project === "string" + ? args.options.project + : undefined, + }); + const baseRef = + typeof args.options.base === "string" ? args.options.base.trim() : ""; + if (!baseRef) { + throw new CliUsageError("Missing --base <ref>."); + } + + const excludes = parseCsv( + typeof args.options.exclude === "string" + ? args.options.exclude + : undefined + ); + const summary = await resetWorkspace({ + projectRoot: project.projectRoot, + baseRef, + excludes, + }); + + if (args.options.json) { + process.stdout.write(`${JSON.stringify(summary, null, 2)}\n`); + return summary.ok ? 0 : 1; + } + + await display.kv({ + title: summary.ok ? "Workspace reset complete" : "Workspace reset failed", + entries: [ + ["project", summary.projectRoot], + ["base", summary.baseRef], + ["branch", summary.checkoutBranch ?? ""], + ["fetched", summary.fetchedRemote ?? ""], + ["removed index.lock", summary.removedGitIndexLock ? "yes" : "no"], + ["cleaned paths", String(summary.cleanedPaths.length)], + ], + }); + if (!summary.ok) { + logger.error({ + message: + summary.statusAfter.length > 0 + ? `Workspace still dirty after reset:\n${summary.statusAfter.join("\n")}` + : "Workspace reset failed.", + }); + } + return summary.ok ? 0 : 1; + } catch (error: unknown) { + const message = + error instanceof Error ? error.message : "workspace reset failed"; + logger.error({ message }); + return 1; + } +} + +async function resetWorkspace(input: { + readonly projectRoot: string; + readonly baseRef: string; + readonly excludes: readonly string[]; +}): Promise<WorkspaceResetSummary> { + const parsedBase = parseBaseRef({ value: input.baseRef }); + const lockPath = resolve(input.projectRoot, ".git", "index.lock"); + const removedGitIndexLock = await removeGitIndexLock({ + lockPath, + }); + const statusBefore = await readStatusLines({ + projectRoot: input.projectRoot, + }); + + let fetchedRemote: string | null = null; + if ( + parsedBase.remote && + (await hasGitRemote({ + projectRoot: input.projectRoot, + remote: parsedBase.remote, + })) + ) { + await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: ["fetch", parsedBase.remote, "--prune"], + lockPath, + context: `git fetch ${parsedBase.remote} --prune`, + }); + fetchedRemote = parsedBase.remote; + } + + if (parsedBase.remote && parsedBase.branch) { + await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: ["checkout", "-B", parsedBase.branch, parsedBase.baseRef], + lockPath, + context: `git checkout -B ${parsedBase.branch} ${parsedBase.baseRef}`, + }); + } + + await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: ["reset", "--hard", parsedBase.baseRef], + lockPath, + context: `git reset --hard ${parsedBase.baseRef}`, + }); + + const cleanArgs = ["clean", "-fd", ...buildCleanExcludeArgs(input.excludes)]; + const dryRun = await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: [...cleanArgs, "-n"], + lockPath, + context: `git ${[...cleanArgs, "-n"].join(" ")}`, + }); + const cleanedPaths = parseGitCleanOutput(dryRun.stdout); + + await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: cleanArgs, + lockPath, + context: `git ${cleanArgs.join(" ")}`, + }); + + const trackedClean = await isTrackedTreeClean({ + projectRoot: input.projectRoot, + }); + const remainingUntracked = await listRemainingUntracked({ + projectRoot: input.projectRoot, + excludes: input.excludes, + lockPath, + }); + const statusAfter = await readStatusLines({ + projectRoot: input.projectRoot, + }); + + return { + ok: trackedClean && remainingUntracked.length === 0, + projectRoot: input.projectRoot, + baseRef: parsedBase.baseRef, + checkoutBranch: parsedBase.branch, + fetchedRemote, + removedGitIndexLock, + cleanArgs, + cleanedPaths, + preservedExcludes: [...input.excludes], + statusBefore, + statusAfter, + }; +} + +async function resolveProjectForArgs(input: { + readonly ctx: CliContext; + readonly pathOpt?: string; + readonly projectOpt?: string; +}) { + if (input.pathOpt && input.projectOpt) { + throw new CliUsageError("Use either --path or --project (not both)."); + } + + if (input.projectOpt) { + const name = sanitizeProjectSlug(input.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 = input.pathOpt + ? resolve(input.ctx.cwd, input.pathOpt) + : input.ctx.cwd; + const project = await findProjectContext(startDir); + if (!project) { + throw new CliUsageError("No .hack/ found. Run: hack init"); + } + return project; +} + +function parseCsv(value: string | undefined): string[] { + return (value ?? "") + .split(",") + .map((entry) => entry.trim()) + .filter(Boolean); +} + +function parseBaseRef(input: { readonly value: string }): ParsedBaseRef { + const baseRef = input.value.trim(); + const slashIndex = baseRef.indexOf("/"); + if (slashIndex <= 0 || slashIndex === baseRef.length - 1) { + return { baseRef, remote: null, branch: null }; + } + return { + baseRef, + remote: baseRef.slice(0, slashIndex), + branch: baseRef.slice(slashIndex + 1), + }; +} + +function buildCleanExcludeArgs(excludes: readonly string[]): string[] { + const args: string[] = []; + for (const exclude of excludes) { + args.push("-e", exclude); + } + return args; +} + +function hasGitIndexLockError(message: string): boolean { + const normalized = message.toLowerCase(); + return ( + normalized.includes("index.lock") && + (normalized.includes("file exists") || + normalized.includes("another git process seems to be running") || + normalized.includes("unable to create")) + ); +} + +async function removeGitIndexLock(input: { + readonly lockPath: string; +}): Promise<boolean> { + const existed = await pathExists(input.lockPath); + if (!existed) { + return false; + } + await rm(input.lockPath, { force: true }); + return true; +} + +async function runGitWithLockRecovery(input: { + readonly projectRoot: string; + readonly args: readonly string[]; + readonly lockPath: string; + readonly context: string; +}) { + let result = await exec(["git", "-C", input.projectRoot, ...input.args], { + stdin: "ignore", + }); + if (result.exitCode === 0) { + return result; + } + + const combined = `${result.stdout}\n${result.stderr}`; + if (hasGitIndexLockError(combined)) { + await removeGitIndexLock({ lockPath: input.lockPath }); + result = await exec(["git", "-C", input.projectRoot, ...input.args], { + stdin: "ignore", + }); + } + + if (result.exitCode !== 0) { + const stderr = result.stderr.trim(); + const stdout = result.stdout.trim(); + const detail = stderr || stdout || "command failed"; + throw new Error(`${input.context} failed: ${detail}`); + } + return result; +} + +async function hasGitRemote(input: { + readonly projectRoot: string; + readonly remote: string; +}): Promise<boolean> { + const result = await exec( + ["git", "-C", input.projectRoot, "remote", "get-url", input.remote], + { + stdin: "ignore", + } + ); + return result.exitCode === 0; +} + +async function readStatusLines(input: { + readonly projectRoot: string; +}): Promise<string[]> { + const result = await exec( + [ + "git", + "-C", + input.projectRoot, + "status", + "--porcelain", + "--untracked-files=all", + ], + { stdin: "ignore" } + ); + if (result.exitCode !== 0) { + return []; + } + return result.stdout + .split("\n") + .map((line) => line.trimEnd()) + .filter(Boolean); +} + +async function isTrackedTreeClean(input: { + readonly projectRoot: string; +}): Promise<boolean> { + const worktree = await exec( + ["git", "-C", input.projectRoot, "diff", "--quiet", "--exit-code"], + { stdin: "ignore" } + ); + if (worktree.exitCode !== 0) { + return false; + } + const index = await exec( + [ + "git", + "-C", + input.projectRoot, + "diff", + "--cached", + "--quiet", + "--exit-code", + ], + { stdin: "ignore" } + ); + return index.exitCode === 0; +} + +async function listRemainingUntracked(input: { + readonly projectRoot: string; + readonly excludes: readonly string[]; + readonly lockPath: string; +}): Promise<string[]> { + const cleanArgs = [ + "clean", + "-fdn", + ...buildCleanExcludeArgs(input.excludes), + ] as const; + const result = await runGitWithLockRecovery({ + projectRoot: input.projectRoot, + args: cleanArgs, + lockPath: input.lockPath, + context: `git ${cleanArgs.join(" ")}`, + }); + return parseGitCleanOutput(result.stdout); +} + +function parseGitCleanOutput(stdout: string): string[] { + return stdout + .split("\n") + .map((line) => line.trim()) + .filter(Boolean) + .flatMap((line) => { + if (line.startsWith("Would remove ")) { + return [line.slice("Would remove ".length).trim()]; + } + if (line.startsWith("Removing ")) { + return [line.slice("Removing ".length).trim()]; + } + return []; + }); +} diff --git a/src/constants.ts b/src/constants.ts index 1bffc7df3..6fe337dd5 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -42,6 +42,7 @@ export const GLOBAL_CERTS_DIR_NAME = "certs" as const; export const GLOBAL_COREDNS_FILENAME = "Corefile" as const; export const GLOBAL_ONLY_EXTENSION_IDS = [ + "dance.hack.aws", "dance.hack.cloudflare", "dance.hack.github", "dance.hack.railway", diff --git a/src/control-plane/extensions/github/auth.ts b/src/control-plane/extensions/github/auth.ts index ac0a22b05..275c7c5c5 100644 --- a/src/control-plane/extensions/github/auth.ts +++ b/src/control-plane/extensions/github/auth.ts @@ -333,25 +333,14 @@ export async function resolveGitHubAppToken(input: { allowProjectOverride: input.allowProjectOverride, }); if (!resolved.ok) { - const fallback = resolveGitHubAuthSettings({ - controlPlaneConfig: input.controlPlaneConfig, - ...(input.profileId ? { profileId: input.profileId } : {}), - allowProjectOverride: input.allowProjectOverride, - }); - return { - ok: false, + return buildGitHubTokenFallbackFailure({ + input, error: resolved.error, - tokenEnv: fallback.tokenEnv, - authRef: fallback.authRef, - service: fallback.service, - profileId: fallback.profileId, - profileSource: fallback.profileSource, - }; + }); } const settings = resolved.settings; const allowProjectOverride = input.allowProjectOverride ?? true; - const profileId = settings.profileId; const env = input.env ?? process.env; const store = input.store ?? DEFAULT_SECRET_STORE; const nowMs = input.nowMs ?? Date.now(); @@ -359,26 +348,13 @@ export async function resolveGitHubAppToken(input: { if (input.preferEnvTokenOnly) { if (envToken.length > 0) { - return { - ok: true, + return buildGitHubTokenSuccess({ + settings, token: envToken, source: "env", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + }); } - return { - ok: false, - error: `Missing GitHub token for profile "${profileId}". Store one with \`hack x github connect --profile ${profileId}\`, or set ${settings.tokenEnv}.`, - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + return buildGitHubMissingTokenFailure({ settings }); } const stored = await store.get({ @@ -391,23 +367,16 @@ export async function resolveGitHubAppToken(input: { !!storedEnvelope.expiresAt && isTokenExpiredSoon({ expiresAt: storedEnvelope.expiresAt, nowMs }); if (!stale) { - return { - ok: true, + return buildGitHubTokenSuccess({ + settings, token: storedEnvelope.token, source: "keychain", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - ...(storedEnvelope.expiresAt - ? { expiresAt: storedEnvelope.expiresAt } - : {}), - }; + expiresAt: storedEnvelope.expiresAt, + }); } const refreshed = await refreshGitHubInstallationToken({ controlPlaneConfig: input.controlPlaneConfig, - profileId, + profileId: settings.profileId, allowProjectOverride, env, store, @@ -415,63 +384,35 @@ export async function resolveGitHubAppToken(input: { nowMs, }); if (refreshed.ok) { - return { - ok: true, + return buildGitHubTokenSuccess({ + settings, token: refreshed.token, source: "refreshed", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}), - }; + expiresAt: refreshed.expiresAt, + }); } if (envToken.length > 0) { - return { - ok: true, + return buildGitHubTokenSuccess({ + settings, token: envToken, source: "env", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + }); } - return { - ok: false, - error: `Stored GitHub token for profile "${profileId}" is expired and refresh failed: ${refreshed.error}`, - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + return buildGitHubTokenFailure({ + settings, + error: `Stored GitHub token for profile "${settings.profileId}" is expired and refresh failed: ${refreshed.error}`, + }); } if (envToken.length > 0) { - return { - ok: true, + return buildGitHubTokenSuccess({ + settings, token: envToken, source: "env", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + }); } - return { - ok: false, - error: `Missing GitHub token for profile "${profileId}". Store one with \`hack x github connect --profile ${profileId}\`, or set ${settings.tokenEnv}.`, - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId, - profileSource: settings.profileSource, - }; + return buildGitHubMissingTokenFailure({ settings }); } /** @@ -758,6 +699,68 @@ function getGitHubExtensionConfig(input: { return extension.config; } +function buildGitHubTokenSuccess(input: { + readonly settings: GitHubAuthSettings; + readonly token: string; + readonly source: "keychain" | "env" | "refreshed"; + readonly expiresAt?: string; +}): GitHubTokenResolution { + return { + ok: true, + token: input.token, + source: input.source, + tokenEnv: input.settings.tokenEnv, + authRef: input.settings.authRef, + service: input.settings.service, + profileId: input.settings.profileId, + profileSource: input.settings.profileSource, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + }; +} + +function buildGitHubTokenFailure(input: { + readonly settings: GitHubAuthSettings; + readonly error: string; +}): GitHubTokenResolution { + return { + ok: false, + error: input.error, + tokenEnv: input.settings.tokenEnv, + authRef: input.settings.authRef, + service: input.settings.service, + profileId: input.settings.profileId, + profileSource: input.settings.profileSource, + }; +} + +function buildGitHubMissingTokenFailure(input: { + readonly settings: GitHubAuthSettings; +}): GitHubTokenResolution { + return buildGitHubTokenFailure({ + settings: input.settings, + error: `Missing GitHub token for profile "${input.settings.profileId}". Store one with \`hack x github connect --profile ${input.settings.profileId}\`, or set ${input.settings.tokenEnv}.`, + }); +} + +function buildGitHubTokenFallbackFailure(input: { + readonly input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + }; + readonly error: string; +}): GitHubTokenResolution { + const fallback = resolveGitHubAuthSettings({ + controlPlaneConfig: input.input.controlPlaneConfig, + ...(input.input.profileId ? { profileId: input.input.profileId } : {}), + allowProjectOverride: input.input.allowProjectOverride, + }); + return buildGitHubTokenFailure({ + settings: fallback, + error: input.error, + }); +} + function buildGitHubProfileSettings(input: { readonly profileId: string; readonly profileConfig: Record<string, unknown> | null; @@ -766,50 +769,83 @@ function buildGitHubProfileSettings(input: { }): GitHubProfileSettings { const fallback = input.includeLegacyFallback ? input.legacyConfig : null; const tokenEnv = - normalizeConfigString(input.profileConfig?.tokenEnv) ?? - normalizeConfigString(fallback?.tokenEnv) ?? - DEFAULT_GITHUB_TOKEN_ENV; + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "tokenEnv", + defaultValue: DEFAULT_GITHUB_TOKEN_ENV, + }) ?? DEFAULT_GITHUB_TOKEN_ENV; const authRef = - normalizeConfigString(input.profileConfig?.authRef) ?? - normalizeConfigString(fallback?.authRef) ?? - resolveDefaultAuthRefForProfile({ profileId: input.profileId }); + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "authRef", + defaultValue: resolveDefaultAuthRefForProfile({ + profileId: input.profileId, + }), + }) ?? resolveDefaultAuthRefForProfile({ profileId: input.profileId }); const service = - normalizeConfigString(input.profileConfig?.service) ?? - normalizeConfigString(fallback?.service) ?? - GITHUB_SECRET_SERVICE; - const appId = - normalizeConfigString(input.profileConfig?.appId) ?? - normalizeConfigString(fallback?.appId); - const installationId = - normalizeConfigString(input.profileConfig?.installationId) ?? - normalizeConfigString(fallback?.installationId); + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "service", + defaultValue: GITHUB_SECRET_SERVICE, + }) ?? GITHUB_SECRET_SERVICE; + const appId = resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "appId", + }); + const installationId = resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "installationId", + }); const privateKeyEnv = - normalizeConfigString(input.profileConfig?.privateKeyEnv) ?? - normalizeConfigString(fallback?.privateKeyEnv) ?? - DEFAULT_GITHUB_PRIVATE_KEY_ENV; - const privateKeyAuthRef = - normalizeConfigString(input.profileConfig?.privateKeyAuthRef) ?? - normalizeConfigString(fallback?.privateKeyAuthRef); + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "privateKeyEnv", + defaultValue: DEFAULT_GITHUB_PRIVATE_KEY_ENV, + }) ?? DEFAULT_GITHUB_PRIVATE_KEY_ENV; + const privateKeyAuthRef = resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "privateKeyAuthRef", + }); const apiBaseUrl = normalizeApiBaseUrl({ value: - normalizeConfigString(input.profileConfig?.apiBaseUrl) ?? - normalizeConfigString(fallback?.apiBaseUrl) ?? - DEFAULT_GITHUB_API_BASE, + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "apiBaseUrl", + }) ?? DEFAULT_GITHUB_API_BASE, + }); + const accountLogin = resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "accountLogin", + }); + const accountName = resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "accountName", }); - const accountLogin = - normalizeConfigString(input.profileConfig?.accountLogin) ?? - normalizeConfigString(fallback?.accountLogin); - const accountName = - normalizeConfigString(input.profileConfig?.accountName) ?? - normalizeConfigString(fallback?.accountName); const accountId = - normalizeConfigNumberishString(input.profileConfig?.accountId) ?? - normalizeConfigNumberishString(fallback?.accountId); + resolveGitHubProfileNumberishSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "accountId", + }) ?? null; const mode = - normalizeGitHubAuthMode(input.profileConfig?.mode) ?? - normalizeGitHubAuthMode(fallback?.mode) ?? - (appId && installationId ? "app" : "token"); + normalizeGitHubAuthMode( + resolveGitHubProfileStringSetting({ + profileConfig: input.profileConfig, + fallbackConfig: fallback, + key: "mode", + }) + ) ?? (appId && installationId ? "app" : "token"); return { profileId: input.profileId, @@ -828,6 +864,32 @@ function buildGitHubProfileSettings(input: { }; } +function resolveGitHubProfileStringSetting(input: { + readonly profileConfig: Record<string, unknown> | null; + readonly fallbackConfig: Record<string, unknown> | null; + readonly key: string; + readonly defaultValue?: string; +}): string | null { + return ( + normalizeConfigString(input.profileConfig?.[input.key]) ?? + normalizeConfigString(input.fallbackConfig?.[input.key]) ?? + input.defaultValue ?? + null + ); +} + +function resolveGitHubProfileNumberishSetting(input: { + readonly profileConfig: Record<string, unknown> | null; + readonly fallbackConfig: Record<string, unknown> | null; + readonly key: string; +}): string | null { + return ( + normalizeConfigNumberishString(input.profileConfig?.[input.key]) ?? + normalizeConfigNumberishString(input.fallbackConfig?.[input.key]) ?? + null + ); +} + function resolveProjectGitHubProfileOverride(input: { readonly controlPlaneConfig: ControlPlaneConfig; }): string | null { @@ -853,86 +915,124 @@ function resolveGitHubProfileSelection(input: { const extensionConfig = getGitHubExtensionConfig({ controlPlaneConfig: input.controlPlaneConfig, }); + const profilesById = collectGitHubProfilesById({ + extensionConfig, + }); + const sortedProfileIds = Object.keys(profilesById).sort((left, right) => + left.localeCompare(right) + ); + const defaultProfileFromConfig = resolveGitHubProfileStringSetting({ + profileConfig: extensionConfig, + fallbackConfig: null, + key: "defaultProfile", + }); + const defaultProfileId = resolveGitHubDefaultProfileId({ + profilesById, + configuredDefaultProfileId: defaultProfileFromConfig ?? undefined, + sortedProfileIds, + }); + + const explicitProfileId = normalizeConfigString(input.explicitProfileId); + const projectProfileOverride = + input.allowProjectOverride === false + ? null + : resolveProjectGitHubProfileOverride({ + controlPlaneConfig: input.controlPlaneConfig, + }); + + const selection = resolveSelectedGitHubProfile({ + explicitProfileId, + projectProfileOverride, + defaultProfileFromConfig, + defaultProfileId, + }); + + const selectedProfileExists = Object.hasOwn( + profilesById, + selection.selectedProfileId + ); + return { + selectedProfileId: selection.selectedProfileId, + selectedProfileSource: selection.selectedProfileSource, + selectedProfileExists, + defaultProfileId, + sortedProfileIds, + profilesById, + ...(projectProfileOverride ? { projectProfileOverride } : {}), + }; +} +function collectGitHubProfilesById(input: { + readonly extensionConfig: Record<string, unknown> | null; +}): Record<string, GitHubProfileSettings> { const profilesById: Record<string, GitHubProfileSettings> = {}; - const profilesValue = extensionConfig?.profiles; + const profilesValue = input.extensionConfig?.profiles; if (isRecord(profilesValue)) { for (const [profileIdRaw, profileRaw] of Object.entries(profilesValue)) { const profileId = profileIdRaw.trim(); - if (!profileId) { - continue; - } - if (!isRecord(profileRaw)) { + if (!(profileId && isRecord(profileRaw))) { continue; } profilesById[profileId] = buildGitHubProfileSettings({ profileId, profileConfig: profileRaw, - legacyConfig: extensionConfig, + legacyConfig: input.extensionConfig, includeLegacyFallback: profileId === DEFAULT_GITHUB_PROFILE_ID, }); } } - if (Object.keys(profilesById).length === 0) { profilesById[DEFAULT_GITHUB_PROFILE_ID] = buildGitHubProfileSettings({ profileId: DEFAULT_GITHUB_PROFILE_ID, profileConfig: null, - legacyConfig: extensionConfig, + legacyConfig: input.extensionConfig, includeLegacyFallback: true, }); } + return profilesById; +} - const sortedProfileIds = Object.keys(profilesById).sort((left, right) => - left.localeCompare(right) - ); - const defaultProfileFromConfig = normalizeConfigString( - extensionConfig?.defaultProfile - ); - const defaultProfileId = - (defaultProfileFromConfig && - Object.hasOwn(profilesById, defaultProfileFromConfig) - ? defaultProfileFromConfig - : null) ?? - (Object.hasOwn(profilesById, DEFAULT_GITHUB_PROFILE_ID) - ? DEFAULT_GITHUB_PROFILE_ID - : null) ?? - sortedProfileIds[0] ?? - DEFAULT_GITHUB_PROFILE_ID; - - const explicitProfileId = normalizeConfigString(input.explicitProfileId); - const projectProfileOverride = - input.allowProjectOverride === false - ? null - : resolveProjectGitHubProfileOverride({ - controlPlaneConfig: input.controlPlaneConfig, - }); +function resolveGitHubDefaultProfileId(input: { + readonly profilesById: Record<string, GitHubProfileSettings>; + readonly configuredDefaultProfileId?: string; + readonly sortedProfileIds: readonly string[]; +}): string { + if ( + input.configuredDefaultProfileId && + Object.hasOwn(input.profilesById, input.configuredDefaultProfileId) + ) { + return input.configuredDefaultProfileId; + } + if (Object.hasOwn(input.profilesById, DEFAULT_GITHUB_PROFILE_ID)) { + return DEFAULT_GITHUB_PROFILE_ID; + } + return input.sortedProfileIds[0] ?? DEFAULT_GITHUB_PROFILE_ID; +} - const selectedProfile = explicitProfileId ?? projectProfileOverride; - let selectedProfileId: string; - let selectedProfileSource: GitHubProfileSelectionSource; +function resolveSelectedGitHubProfile(input: { + readonly explicitProfileId: string | null; + readonly projectProfileOverride: string | null; + readonly defaultProfileFromConfig: string | null; + readonly defaultProfileId: string; +}): { + readonly selectedProfileId: string; + readonly selectedProfileSource: GitHubProfileSelectionSource; +} { + const selectedProfile = + input.explicitProfileId ?? input.projectProfileOverride; if (selectedProfile) { - selectedProfileId = selectedProfile; - selectedProfileSource = explicitProfileId - ? "command_flags" - : "project_routing"; - } else if (defaultProfileFromConfig) { - selectedProfileId = defaultProfileId; - selectedProfileSource = "global_default"; - } else { - selectedProfileId = defaultProfileId; - selectedProfileSource = "implicit_default"; + return { + selectedProfileId: selectedProfile, + selectedProfileSource: input.explicitProfileId + ? "command_flags" + : "project_routing", + }; } - - const selectedProfileExists = Object.hasOwn(profilesById, selectedProfileId); return { - selectedProfileId, - selectedProfileSource, - selectedProfileExists, - defaultProfileId, - sortedProfileIds, - profilesById, - ...(projectProfileOverride ? { projectProfileOverride } : {}), + selectedProfileId: input.defaultProfileId, + selectedProfileSource: input.defaultProfileFromConfig + ? "global_default" + : "implicit_default", }; } diff --git a/src/control-plane/extensions/github/commands.ts b/src/control-plane/extensions/github/commands.ts index e554ded30..8a251c15f 100644 --- a/src/control-plane/extensions/github/commands.ts +++ b/src/control-plane/extensions/github/commands.ts @@ -552,6 +552,7 @@ const OAUTH_VALUE_KEYS = { "api-base-url": "apiBaseUrl", } as const; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: GitHub connect parsing models the full flag grammar, stdin fallback, and validation in one place. function parseConnectArgs(input: { readonly args: readonly string[]; }): ConnectParseResult { @@ -1323,6 +1324,7 @@ function resolveGhExecutable(input: { return findExecutableInPath("gh"); } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: GitHub identity inspection intentionally normalizes multiple API payload shapes and installation/account cases for operator output. async function inspectGitHubIdentity(input: { readonly token: string; readonly apiBaseUrl: string; diff --git a/src/control-plane/extensions/linear/auth.ts b/src/control-plane/extensions/linear/auth.ts index b3c34cc1e..6ba75d189 100644 --- a/src/control-plane/extensions/linear/auth.ts +++ b/src/control-plane/extensions/linear/auth.ts @@ -150,6 +150,209 @@ const DEFAULT_SECRET_STORE: SecretStore = { const TOKEN_REFRESH_WINDOW_MS = 60_000; +function buildLinearTokenSuccess(input: { + readonly settings: LinearAuthSettings; + readonly token: string; + readonly source: "keychain" | "env" | "refreshed"; + readonly expiresAt?: string; +}): LinearTokenResolution { + return { + ok: true, + token: input.token, + source: input.source, + tokenEnv: input.settings.tokenEnv, + authRef: input.settings.authRef, + service: input.settings.service, + profileId: input.settings.profileId, + profileSource: input.settings.profileSource, + ...(input.expiresAt ? { expiresAt: input.expiresAt } : {}), + }; +} + +function buildLinearTokenFailure(input: { + readonly settings: LinearAuthSettings; + readonly error: string; +}): LinearTokenResolution { + return { + ok: false, + error: input.error, + tokenEnv: input.settings.tokenEnv, + authRef: input.settings.authRef, + service: input.settings.service, + profileId: input.settings.profileId, + profileSource: input.settings.profileSource, + }; +} + +function buildLinearTokenFallbackFailure(input: { + readonly controlPlaneConfig: ControlPlaneConfig; + readonly profileId?: string; + readonly allowProjectOverride?: boolean; + readonly error: string; +}): LinearTokenResolution { + const fallback = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.profileId ? { profileId: input.profileId } : {}), + allowProjectOverride: input.allowProjectOverride, + }); + return buildLinearTokenFailure({ + settings: fallback, + error: input.error, + }); +} + +function buildRefreshedLinearTokenEnvelope(input: { + readonly refreshed: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + }; + readonly storedEnvelope: LinearTokenEnvelope; +}): LinearTokenEnvelope { + const nextRefreshToken = + input.refreshed.refreshToken ?? input.storedEnvelope.refreshToken; + const nextRefreshTokenExpiresAt = + input.refreshed.refreshTokenExpiresAt ?? + input.storedEnvelope.refreshTokenExpiresAt; + return { + token: input.refreshed.token, + ...(input.refreshed.expiresAt + ? { expiresAt: input.refreshed.expiresAt } + : {}), + ...(nextRefreshToken ? { refreshToken: nextRefreshToken } : {}), + ...(nextRefreshTokenExpiresAt + ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } + : {}), + }; +} + +async function refreshStoredLinearTokenIfNeeded(input: { + readonly settings: LinearAuthSettings; + readonly store: SecretStore; + readonly storedEnvelope: LinearTokenEnvelope | null; + readonly refreshConfig?: LinearRefreshConfig; + readonly nowMs: number; +}): Promise<{ + readonly storedEnvelope: LinearTokenEnvelope | null; + readonly refreshedToken: boolean; + readonly refreshError: string | null; + readonly fallbackResolution?: LinearTokenResolution; +}> { + if ( + !( + input.storedEnvelope && + shouldRefreshStoredToken({ + envelope: input.storedEnvelope, + nowMs: input.nowMs, + }) && + input.refreshConfig + ) + ) { + return { + storedEnvelope: input.storedEnvelope, + refreshedToken: false, + refreshError: null, + }; + } + + const refreshed = await refreshLinearAccessToken({ + refreshConfig: input.refreshConfig, + refreshToken: input.storedEnvelope.refreshToken ?? "", + currentRefreshTokenExpiresAt: input.storedEnvelope.refreshTokenExpiresAt, + }); + if (refreshed.ok) { + const storedEnvelope = buildRefreshedLinearTokenEnvelope({ + refreshed, + storedEnvelope: input.storedEnvelope, + }); + try { + await input.store.set({ + service: input.settings.service, + name: input.settings.authRef, + value: serializeStoredToken(storedEnvelope), + }); + } catch { + // Best effort: use the fresh token for this command even if re-persist fails. + } + return { + storedEnvelope, + refreshedToken: true, + refreshError: null, + }; + } + + if ( + isUsableStoredAccessToken({ + envelope: input.storedEnvelope, + nowMs: input.nowMs, + }) + ) { + return { + storedEnvelope: input.storedEnvelope, + refreshedToken: false, + refreshError: null, + fallbackResolution: buildLinearTokenSuccess({ + settings: input.settings, + token: input.storedEnvelope.token ?? "", + source: "keychain", + expiresAt: input.storedEnvelope.expiresAt, + }), + }; + } + + return { + storedEnvelope: input.storedEnvelope, + refreshedToken: false, + refreshError: refreshed.error, + }; +} + +async function resolveLinearFallbackManagementToken(input: { + readonly settings: LinearAuthSettings; + readonly authRef: string; + readonly service: string; + readonly error: string; +}): Promise< + | { + readonly ok: true; + readonly managementToken: string; + readonly managementTokenExpiresAt?: string; + readonly profileId: string; + readonly authRef: string; + readonly service: string; + } + | { + readonly ok: false; + readonly error: string; + readonly profileId: string; + readonly authRef: string; + readonly service: string; + } +> { + const genericHackSession = await loadHackAuthSession().catch(() => null); + const genericManagementToken = genericHackSession?.token?.trim() ?? ""; + if (genericManagementToken) { + return { + ok: true, + managementToken: genericManagementToken, + ...(genericHackSession?.expiresAt + ? { managementTokenExpiresAt: genericHackSession.expiresAt } + : {}), + profileId: input.settings.profileId, + authRef: input.authRef, + service: input.service, + }; + } + return { + ok: false, + error: input.error, + profileId: input.settings.profileId, + authRef: input.authRef, + service: input.service, + }; +} + /** * Resolve Linear profile catalog using command flags, project overrides, and global defaults. */ @@ -300,20 +503,12 @@ export async function resolveLinearToken(input: { }); if (!resolved.ok) { - const fallback = resolveLinearAuthSettings({ + return buildLinearTokenFallbackFailure({ controlPlaneConfig: input.controlPlaneConfig, ...(input.profileId ? { profileId: input.profileId } : {}), allowProjectOverride: input.allowProjectOverride, - }); - return { - ok: false, error: resolved.error, - tokenEnv: fallback.tokenEnv, - authRef: fallback.authRef, - service: fallback.service, - profileId: fallback.profileId, - profileSource: fallback.profileSource, - }; + }); } const settings = resolved.settings; @@ -325,113 +520,45 @@ export async function resolveLinearToken(input: { service: settings.service, name: settings.authRef, }); - let storedEnvelope = parseStoredTokenEnvelope(stored); - let refreshError: string | null = null; - let refreshedToken = false; - if ( - storedEnvelope && - shouldRefreshStoredToken({ - envelope: storedEnvelope, - nowMs, - }) && - input.refreshConfig - ) { - const refreshed = await refreshLinearAccessToken({ - refreshConfig: input.refreshConfig, - refreshToken: storedEnvelope.refreshToken ?? "", - currentRefreshTokenExpiresAt: storedEnvelope.refreshTokenExpiresAt, - }); - if (refreshed.ok) { - refreshedToken = true; - const nextRefreshToken = - refreshed.refreshToken ?? storedEnvelope.refreshToken; - const nextRefreshTokenExpiresAt = - refreshed.refreshTokenExpiresAt ?? storedEnvelope.refreshTokenExpiresAt; - storedEnvelope = { - token: refreshed.token, - ...(refreshed.expiresAt ? { expiresAt: refreshed.expiresAt } : {}), - ...(nextRefreshToken ? { refreshToken: nextRefreshToken } : {}), - ...(nextRefreshTokenExpiresAt - ? { refreshTokenExpiresAt: nextRefreshTokenExpiresAt } - : {}), - }; - try { - await store.set({ - service: settings.service, - name: settings.authRef, - value: serializeStoredToken(storedEnvelope), - }); - } catch { - // Best effort: use the fresh token for this command even if re-persist fails. - } - } else if ( - isUsableStoredAccessToken({ - envelope: storedEnvelope, - nowMs, - }) - ) { - return { - ok: true, - token: storedEnvelope.token ?? "", - source: "keychain", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId: settings.profileId, - profileSource: settings.profileSource, - ...(storedEnvelope.expiresAt - ? { expiresAt: storedEnvelope.expiresAt } - : {}), - }; - } else { - refreshError = refreshed.error; - } + const refreshResult = await refreshStoredLinearTokenIfNeeded({ + settings, + store, + storedEnvelope: parseStoredTokenEnvelope(stored), + refreshConfig: input.refreshConfig, + nowMs, + }); + if (refreshResult.fallbackResolution) { + return refreshResult.fallbackResolution; } + const storedEnvelope = refreshResult.storedEnvelope; if ( storedEnvelope?.token && isUsableStoredAccessToken({ envelope: storedEnvelope, nowMs }) ) { - return { - ok: true, + return buildLinearTokenSuccess({ + settings, token: storedEnvelope.token, - source: refreshedToken ? "refreshed" : "keychain", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId: settings.profileId, - profileSource: settings.profileSource, - ...(storedEnvelope.expiresAt - ? { expiresAt: storedEnvelope.expiresAt } - : {}), - }; + source: refreshResult.refreshedToken ? "refreshed" : "keychain", + expiresAt: storedEnvelope.expiresAt, + }); } const envToken = (env[settings.tokenEnv] ?? "").trim(); if (envToken) { - return { - ok: true, + return buildLinearTokenSuccess({ + settings, token: envToken, source: "env", - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId: settings.profileId, - profileSource: settings.profileSource, - }; + }); } - return { - ok: false, + return buildLinearTokenFailure({ + settings, error: - refreshError ?? + refreshResult.refreshError ?? `Missing Linear token for profile "${settings.profileId}". Store one with \`hack x linear connect --profile ${settings.profileId}\`, or set ${settings.tokenEnv}.`, - tokenEnv: settings.tokenEnv, - authRef: settings.authRef, - service: settings.service, - profileId: settings.profileId, - profileSource: settings.profileSource, - }; + }); } /** @@ -544,27 +671,12 @@ export async function resolveLinearBrokerManagementToken(input: { const envelope = parseStoredTokenEnvelope(stored); const managementToken = envelope?.managementToken?.trim() ?? ""; if (!managementToken) { - const genericHackSession = await loadHackAuthSession().catch(() => null); - const genericManagementToken = genericHackSession?.token?.trim() ?? ""; - if (genericManagementToken) { - return { - ok: true, - managementToken: genericManagementToken, - ...(genericHackSession?.expiresAt - ? { managementTokenExpiresAt: genericHackSession.expiresAt } - : {}), - profileId: settings.profileId, - authRef, - service, - }; - } - return { - ok: false, - error: `Linear broker management token is missing for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile if its saved broker token is stale.`, - profileId: settings.profileId, + return await resolveLinearFallbackManagementToken({ + settings, authRef, service, - }; + error: `Linear broker management token is missing for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile if its saved broker token is stale.`, + }); } const managementTokenExpiresAtMs = envelope?.managementTokenExpiresAt ? parseTimestampMs(envelope.managementTokenExpiresAt) @@ -574,27 +686,12 @@ export async function resolveLinearBrokerManagementToken(input: { managementTokenExpiresAtMs !== null && managementTokenExpiresAtMs <= Date.now() ) { - const genericHackSession = await loadHackAuthSession().catch(() => null); - const genericManagementToken = genericHackSession?.token?.trim() ?? ""; - if (genericManagementToken) { - return { - ok: true, - managementToken: genericManagementToken, - ...(genericHackSession?.expiresAt - ? { managementTokenExpiresAt: genericHackSession.expiresAt } - : {}), - profileId: settings.profileId, - authRef, - service, - }; - } - return { - ok: false, - error: `Linear broker management token expired for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile to refresh its saved broker token.`, - profileId: settings.profileId, + return await resolveLinearFallbackManagementToken({ + settings, authRef, service, - }; + error: `Linear broker management token expired for profile "${settings.profileId}". Run \`hack auth login\` for broker-owned access, or reconnect this Linear profile to refresh its saved broker token.`, + }); } return { ok: true, diff --git a/src/control-plane/extensions/linear/commands.ts b/src/control-plane/extensions/linear/commands.ts index ebaf9b342..645b71540 100644 --- a/src/control-plane/extensions/linear/commands.ts +++ b/src/control-plane/extensions/linear/commands.ts @@ -753,194 +753,8 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ summary: "Bind current hack project to a default Linear profile/project", scope: "project", allowWhenDisabled: true, - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - const parsed = parseProjectBindArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - await enableLinearProjectExtension({ - projectDir: ctx.project.projectDir, - }); - - const existingBinding = resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, - }); - - if ( - !( - parsed.value.clear || - parsed.value.projectId || - parsed.value.projectName || - parsed.value.profileId || - parsed.value.teamId - ) - ) { - const payload = buildProjectBindingPayload({ - binding: existingBinding, - }); - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - await display.kv({ - title: "Linear project binding", - entries: [ - ["profile", payload.profileId ?? ""], - ["project_id", payload.projectId ?? ""], - ["project_name", payload.projectName ?? ""], - ["team_id", payload.teamId ?? ""], - [ - "additional_projects", - payload.additionalProjects - .map((project) => project.projectId) - .join(", "), - ], - ], - }); - } - return 0; - } - - if (parsed.value.clear) { - await updateProjectConfigBatch({ - projectDir: ctx.project.projectDir, - values: [ - { - path: "controlPlane.routing.overrides.linear.profile", - value: "", - }, - { - path: "controlPlane.routing.overrides.linear.projectId", - value: "", - }, - { - path: "controlPlane.routing.overrides.linear.projectName", - value: "", - }, - { - path: "controlPlane.routing.overrides.linear.teamId", - value: "", - }, - { - path: "controlPlane.routing.overrides.linear.additionalProjects", - value: [], - }, - ], - }); - - const payload = buildProjectBindingPayload({ - binding: { additionalProjects: [] }, - cleared: true, - }); - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - await display.panel({ - title: "Linear project binding", - tone: "success", - lines: [ - "Cleared project-level Linear routing overrides, including additional synced projects.", - ], - }); - } - return 0; - } - - const boundProfile = parsed.value.profileId ?? existingBinding.profileId; - - if (boundProfile) { - await updateProjectConfig({ - projectDir: ctx.project.projectDir, - path: "controlPlane.routing.overrides.linear.profile", - value: boundProfile, - }); - } - - const projectId = parsed.value.projectId; - if (!projectId) { - ctx.logger.error({ - message: - "Missing --project-id. Use --clear to remove mapping or pass a Linear project id to bind.", - }); - return 1; - } - - const resolvedTeamAndName = await resolveProjectBindingDetails({ - controlPlaneConfig: ctx.controlPlaneConfig, - profileId: parsed.value.profileId, - projectId, - projectName: parsed.value.projectName, - teamId: parsed.value.teamId, - }); - if (!resolvedTeamAndName.ok) { - ctx.logger.error({ message: resolvedTeamAndName.error }); - return 1; - } - - await updateProjectConfigBatch({ - projectDir: ctx.project.projectDir, - values: [ - { - path: "controlPlane.routing.overrides.linear.projectId", - value: projectId, - }, - { - path: "controlPlane.routing.overrides.linear.projectName", - value: resolvedTeamAndName.projectName, - }, - { - path: "controlPlane.routing.overrides.linear.teamId", - value: resolvedTeamAndName.teamId, - }, - { - path: "controlPlane.routing.overrides.linear.additionalProjects", - value: removeAdditionalProjectBinding({ - existing: existingBinding.additionalProjects, - projectId, - }), - }, - ], - }); - - const payload = buildProjectBindingPayload({ - binding: { - ...(boundProfile ? { profileId: boundProfile } : {}), - projectId, - projectName: resolvedTeamAndName.projectName, - teamId: resolvedTeamAndName.teamId, - additionalProjects: removeAdditionalProjectBinding({ - existing: existingBinding.additionalProjects, - projectId, - }), - }, - }); - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); - } else { - await display.kv({ - title: "Linear project binding", - entries: [ - ["profile", payload.profileId ?? ""], - ["project_id", payload.projectId], - ["project_name", payload.projectName], - ["team_id", payload.teamId], - [ - "additional_projects", - payload.additionalProjects - .map((project) => project.projectId) - .join(", "), - ], - ], - }); - } - return 0; - }, + handler: async ({ ctx, args }) => + await handleLinearProjectBindCommand({ ctx, args }), }, { name: "project-link", @@ -1141,132 +955,8 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ "One-off manual sync for a single issue/ticket in either direction", scope: "project", allowWhenDisabled: true, - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - const parsed = parseSyncIssueArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const toggles = resolveSyncToggles({ - controlPlaneConfig: ctx.controlPlaneConfig, - labelsOverride: parsed.value.syncLabels, - }); - - if (parsed.value.from === "linear") { - const runtime = await createSyncRuntime({ - ctx, - profileId: parsed.value.profileId, - }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); - return 1; - } - - const issueIdentifier = parsed.value.issueIdentifier; - if (!issueIdentifier) { - ctx.logger.error({ - message: "Missing --issue <IDENTIFIER> for --from linear.", - }); - return 1; - } - const synced = await syncIssueFromLinearToTicket({ - runtime: runtime.value, - issueIdentifier, - syncToggles: toggles, - }); - if (!synced.ok) { - ctx.logger.error({ message: synced.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(synced, null, 2)}\n`); - } else { - await display.kv({ - title: "Linear -> ticket sync", - entries: [ - ["ticket_id", synced.ticketId], - ["linear_issue", synced.issueIdentifier], - ["operation", synced.operation], - ], - }); - } - return 0; - } - - const ticketInput = parsed.value.ticketId; - if (!ticketInput) { - ctx.logger.error({ - message: "Missing --ticket <T-00001> for --from hack.", - }); - return 1; - } - const normalizedTicketId = normalizeTicketRef(ticketInput); - if (!normalizedTicketId) { - ctx.logger.error({ message: `Invalid ticket id: ${ticketInput}` }); - return 1; - } - - const binding = resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, - }); - const selectedTarget = - (parsed.value.projectId - ? findProjectBindingTarget({ - binding, - projectId: parsed.value.projectId, - }) - : null) ?? - (binding.projectId - ? { - projectId: binding.projectId, - ...(binding.projectName - ? { projectName: binding.projectName } - : {}), - ...(binding.teamId ? { teamId: binding.teamId } : {}), - ...(binding.profileId ? { profileId: binding.profileId } : {}), - } - : null); - const runtime = await createSyncRuntime({ - ctx, - profileId: parsed.value.profileId ?? selectedTarget?.profileId, - }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); - return 1; - } - - const synced = await syncTicketToLinearIssue({ - runtime: runtime.value, - ticketId: normalizedTicketId, - explicitProjectId: parsed.value.projectId ?? selectedTarget?.projectId, - explicitTeamId: parsed.value.teamId ?? selectedTarget?.teamId, - syncToggles: toggles, - }); - if (!synced.ok) { - ctx.logger.error({ message: synced.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(synced, null, 2)}\n`); - } else { - await display.kv({ - title: "Ticket -> Linear sync", - entries: [ - ["ticket_id", synced.ticketId], - ["linear_issue", synced.issueIdentifier], - ["operation", synced.operation], - ], - }); - } - return 0; - }, + handler: async ({ ctx, args }) => + await handleLinearSyncIssueCommand({ ctx, args }), }, { name: "sync-project", @@ -1274,173 +964,8 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ "Manual bulk sync for a bound/selected project in either direction", scope: "project", allowWhenDisabled: true, - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - const parsed = parseSyncProjectArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const toggles = resolveSyncToggles({ - controlPlaneConfig: ctx.controlPlaneConfig, - labelsOverride: parsed.value.syncLabels, - }); - const binding = resolveProjectLinearBinding({ - controlPlaneConfig: ctx.controlPlaneConfig, - }); - - if (parsed.value.from === "linear") { - const projectTargets = resolveProjectPullTargets({ - binding, - explicitProjectId: parsed.value.projectId, - }); - if (projectTargets.length === 0) { - ctx.logger.error({ - message: - "Missing project id. Pass --project-id, bind a default project, or add additional linked projects first.", - }); - return 1; - } - - const targetsByProfile = new Map< - string, - LinearProjectBindingTarget[] - >(); - for (const target of projectTargets) { - const key = ( - parsed.value.profileId ?? - target.profileId ?? - binding.profileId ?? - "" - ).trim(); - const existingTargets = targetsByProfile.get(key) ?? []; - existingTargets.push(target); - targetsByProfile.set(key, existingTargets); - } - - let processed = 0; - let created = 0; - let updated = 0; - let commentsPulled = 0; - let conflictsRecorded = 0; - let checkpointsRecorded = 0; - const syncedProjectIds: string[] = []; - - for (const [profileId, targets] of targetsByProfile.entries()) { - const runtime = await createSyncRuntime({ - ctx, - profileId: profileId || undefined, - }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); - return 1; - } - - const syncResult = await syncProjectFromLinearProjectsToTickets({ - runtime: runtime.value, - projectIds: targets.map((target) => target.projectId), - limit: parsed.value.limit, - syncToggles: toggles, - }); - if (!syncResult.ok) { - ctx.logger.error({ message: syncResult.error }); - return 1; - } - - processed += syncResult.processed; - created += syncResult.created; - updated += syncResult.updated; - commentsPulled += syncResult.commentsPulled; - conflictsRecorded += syncResult.conflictsRecorded; - checkpointsRecorded += syncResult.checkpointsRecorded; - syncedProjectIds.push(...syncResult.projectIds); - } - - const syncResult = { - ok: true as const, - projectIds: [...new Set(syncedProjectIds)], - processed, - created, - updated, - commentsPulled, - conflictsRecorded, - checkpointsRecorded, - }; - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); - } else { - await display.kv({ - title: "Linear project -> tickets sync", - entries: [ - ["linear_project_ids", syncResult.projectIds.join(", ")], - ["processed", String(syncResult.processed)], - ["created", String(syncResult.created)], - ["updated", String(syncResult.updated)], - ], - }); - } - return 0; - } - - const selectedTarget = - (parsed.value.projectId - ? findProjectBindingTarget({ - binding, - projectId: parsed.value.projectId, - }) - : null) ?? - (binding.projectId - ? { - projectId: binding.projectId, - ...(binding.projectName - ? { projectName: binding.projectName } - : {}), - ...(binding.teamId ? { teamId: binding.teamId } : {}), - ...(binding.profileId ? { profileId: binding.profileId } : {}), - } - : null); - const projectId = parsed.value.projectId ?? selectedTarget?.projectId; - const runtime = await createSyncRuntime({ - ctx, - profileId: parsed.value.profileId ?? selectedTarget?.profileId, - }); - if (!runtime.ok) { - ctx.logger.error({ message: runtime.error }); - return 1; - } - const syncResult = await syncProjectFromTicketsToLinear({ - runtime: runtime.value, - projectId, - explicitTeamId: parsed.value.teamId ?? selectedTarget?.teamId, - limit: parsed.value.limit, - ownerMode: parsed.value.ownerMode, - syncToggles: toggles, - }); - if (!syncResult.ok) { - ctx.logger.error({ message: syncResult.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); - } else { - await display.kv({ - title: "Tickets -> Linear project sync", - entries: [ - ["linear_project_id", projectId ?? ""], - ["processed", String(syncResult.processed)], - ["created", String(syncResult.created)], - ["updated", String(syncResult.updated)], - ], - }); - } - return 0; - }, + handler: async ({ ctx, args }) => + await handleLinearSyncProjectCommand({ ctx, args }), }, { name: "run-autosync", @@ -1786,114 +1311,766 @@ export const LINEAR_COMMANDS: readonly ExtensionCommand[] = [ type LinearCommandContext = ExtensionCommandContext; -async function handleLinearOAuthConnectCommand(input: { +function requireLinearProjectContext(input: { readonly ctx: LinearCommandContext; - readonly args: readonly string[]; -}): Promise<number> { - const parsed = parseOAuthConnectArgs({ args: input.args }); - if (!parsed.ok) { - input.ctx.logger.error({ message: parsed.error }); - return 1; +}): NonNullable<LinearCommandContext["project"]> | null { + if (input.ctx.project) { + return input.ctx.project; } + input.ctx.logger.error({ message: "No project found. Run inside a repo." }); + return null; +} - const defaults = resolveLinearAuthSettings({ - controlPlaneConfig: input.ctx.controlPlaneConfig, - ...(parsed.value.profileId ? { profileId: parsed.value.profileId } : {}), - allowProjectOverride: false, - }); - const resolved = { - profileId: parsed.value.profileId ?? defaults.profileId, - apiUrl: parsed.value.apiUrl ?? defaults.apiUrl, - tokenEnv: parsed.value.tokenEnv ?? defaults.tokenEnv, - authRef: parsed.value.authRef ?? defaults.authRef, - service: parsed.value.service ?? defaults.service, - }; +function hasProjectBindUpdates(input: { + readonly parsed: ProjectBindArgs; +}): boolean { + return Boolean( + input.parsed.clear || + input.parsed.projectId || + input.parsed.projectName || + input.parsed.profileId || + input.parsed.teamId + ); +} - let oauthFlow: - | { - readonly ok: true; - readonly tokenExchange: { - readonly token: string; - readonly expiresAt?: string; - readonly refreshToken?: string; - readonly refreshTokenExpiresAt?: string; - readonly managementToken?: string; - readonly managementTokenExpiresAt?: string; - }; - } - | { readonly ok: false; readonly error: string }; +async function writeProjectBindingPayload(input: { + readonly payload: ReturnType<typeof buildProjectBindingPayload>; + readonly json: boolean; +}): Promise<void> { + if (input.json) { + process.stdout.write(`${JSON.stringify(input.payload, null, 2)}\n`); + return; + } - if (shouldUseBrokerOAuthFlow({ parsed: parsed.value })) { - const brokerFlow = await startLinearBrokerOAuthFlow({ - controlPlaneConfig: input.ctx.controlPlaneConfig, - parsed: parsed.value, - brokerConfig: resolveOAuthBrokerRuntimeConfig({ - controlPlaneConfig: input.ctx.controlPlaneConfig, - }), - profileId: resolved.profileId, + await display.kv({ + title: "Linear project binding", + entries: [ + ["profile", input.payload.profileId ?? ""], + ["project_id", input.payload.projectId ?? ""], + ["project_name", input.payload.projectName ?? ""], + ["team_id", input.payload.teamId ?? ""], + [ + "additional_projects", + input.payload.additionalProjects + .map((project) => project.projectId) + .join(", "), + ], + ], + }); +} + +async function clearLinearProjectBinding(input: { + readonly projectDir: string; + readonly json: boolean; +}): Promise<number> { + await updateProjectConfigBatch({ + projectDir: input.projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.profile", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.projectId", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.projectName", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.teamId", + value: "", + }, + { + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: [], + }, + ], + }); + + const payload = buildProjectBindingPayload({ + binding: { additionalProjects: [] }, + cleared: true, + }); + if (input.json) { + process.stdout.write(`${JSON.stringify(payload, null, 2)}\n`); + } else { + await display.panel({ + title: "Linear project binding", + tone: "success", + lines: [ + "Cleared project-level Linear routing overrides, including additional synced projects.", + ], }); - if (parsed.value.startOnly) { - if (!brokerFlow.ok) { - input.ctx.logger.error({ message: brokerFlow.error }); - return 1; - } - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify(buildLinearOAuthStartPayload({ flow: brokerFlow.flow }), null, 2)}\n` - ); - } else { - await display.panel({ - title: "Linear OAuth", - tone: "info", - lines: [ - "Open this URL in your browser to continue:", - brokerFlow.flow.authorizeUrl, - "", - `Status URL: ${buildLinearOAuthStatusUrl({ pollUrl: brokerFlow.flow.pollUrl, deviceCode: brokerFlow.flow.deviceCode })}`, - ], - }); - } - return 0; - } + } + return 0; +} - oauthFlow = await runLinearBrokerOAuthFlow({ - controlPlaneConfig: input.ctx.controlPlaneConfig, - parsed: parsed.value, - brokerConfig: resolveOAuthBrokerRuntimeConfig({ - controlPlaneConfig: input.ctx.controlPlaneConfig, +async function bindLinearProject(input: { + readonly ctx: LinearCommandContext; + readonly projectDir: string; + readonly parsed: ProjectBindArgs; + readonly existingBinding: ReturnType<typeof resolveProjectLinearBinding>; +}): Promise<number> { + const boundProfile = + input.parsed.profileId ?? input.existingBinding.profileId; + if (boundProfile) { + await updateProjectConfig({ + projectDir: input.projectDir, + path: "controlPlane.routing.overrides.linear.profile", + value: boundProfile, + }); + } + + const projectId = input.parsed.projectId; + if (!projectId) { + input.ctx.logger.error({ + message: + "Missing --project-id. Use --clear to remove mapping or pass a Linear project id to bind.", + }); + return 1; + } + + const resolvedTeamAndName = await resolveProjectBindingDetails({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + profileId: input.parsed.profileId, + projectId, + projectName: input.parsed.projectName, + teamId: input.parsed.teamId, + }); + if (!resolvedTeamAndName.ok) { + input.ctx.logger.error({ message: resolvedTeamAndName.error }); + return 1; + } + + const additionalProjects = removeAdditionalProjectBinding({ + existing: input.existingBinding.additionalProjects, + projectId, + }); + await updateProjectConfigBatch({ + projectDir: input.projectDir, + values: [ + { + path: "controlPlane.routing.overrides.linear.projectId", + value: projectId, + }, + { + path: "controlPlane.routing.overrides.linear.projectName", + value: resolvedTeamAndName.projectName, + }, + { + path: "controlPlane.routing.overrides.linear.teamId", + value: resolvedTeamAndName.teamId, + }, + { + path: "controlPlane.routing.overrides.linear.additionalProjects", + value: additionalProjects, + }, + ], + }); + + await writeProjectBindingPayload({ + payload: buildProjectBindingPayload({ + binding: { + ...(boundProfile ? { profileId: boundProfile } : {}), + projectId, + projectName: resolvedTeamAndName.projectName, + teamId: resolvedTeamAndName.teamId, + additionalProjects, + }, + }), + json: input.parsed.json, + }); + return 0; +} + +async function handleLinearProjectBindCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const project = requireLinearProjectContext({ ctx: input.ctx }); + if (!project) { + return 1; + } + const parsed = parseProjectBindArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + await enableLinearProjectExtension({ + projectDir: project.projectDir, + }); + + const existingBinding = resolveProjectLinearBinding({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }); + if (!hasProjectBindUpdates({ parsed: parsed.value })) { + await writeProjectBindingPayload({ + payload: buildProjectBindingPayload({ + binding: existingBinding, }), - profileId: resolved.profileId, + json: parsed.value.json, }); - if (!oauthFlow.ok) { - const oauthConfig = await resolveOAuthRuntimeConfig({ - controlPlaneConfig: input.ctx.controlPlaneConfig, + return 0; + } + + if (parsed.value.clear) { + return await clearLinearProjectBinding({ + projectDir: project.projectDir, + json: parsed.value.json, + }); + } + + return await bindLinearProject({ + ctx: input.ctx, + projectDir: project.projectDir, + parsed: parsed.value, + existingBinding, + }); +} + +function resolveSelectedProjectBindingTarget(input: { + readonly binding: ReturnType<typeof resolveProjectLinearBinding>; + readonly projectId?: string; +}): LinearProjectBindingTarget | null { + const explicitTarget = input.projectId + ? findProjectBindingTarget({ + binding: input.binding, + projectId: input.projectId, + }) + : null; + if (explicitTarget) { + return explicitTarget; + } + if (!input.binding.projectId) { + return null; + } + return { + projectId: input.binding.projectId, + ...(input.binding.projectName + ? { projectName: input.binding.projectName } + : {}), + ...(input.binding.teamId ? { teamId: input.binding.teamId } : {}), + ...(input.binding.profileId ? { profileId: input.binding.profileId } : {}), + }; +} + +async function writeIssueSyncResult(input: { + readonly json: boolean; + readonly result: SyncTicketFromLinearSuccess | SyncTicketToLinearSuccess; + readonly title: string; +}): Promise<void> { + if (input.json) { + process.stdout.write(`${JSON.stringify(input.result, null, 2)}\n`); + return; + } + await display.kv({ + title: input.title, + entries: [ + ["ticket_id", input.result.ticketId], + ["linear_issue", input.result.issueIdentifier], + ["operation", input.result.operation], + ], + }); +} + +async function syncIssueFromLinearCommand(input: { + readonly ctx: LinearCommandContext; + readonly parsed: SyncIssueArgs; + readonly toggles: SyncToggles; +}): Promise<number> { + const runtime = await createSyncRuntime({ + ctx: input.ctx, + profileId: input.parsed.profileId, + }); + if (!runtime.ok) { + input.ctx.logger.error({ message: runtime.error }); + return 1; + } + + const issueIdentifier = input.parsed.issueIdentifier; + if (!issueIdentifier) { + input.ctx.logger.error({ + message: "Missing --issue <IDENTIFIER> for --from linear.", + }); + return 1; + } + + const synced = await syncIssueFromLinearToTicket({ + runtime: runtime.value, + issueIdentifier, + syncToggles: input.toggles, + }); + if (!synced.ok) { + input.ctx.logger.error({ message: synced.error }); + return 1; + } + + await writeIssueSyncResult({ + json: input.parsed.json, + result: synced, + title: "Linear -> ticket sync", + }); + return 0; +} + +async function syncIssueToLinearCommand(input: { + readonly ctx: LinearCommandContext; + readonly parsed: SyncIssueArgs; + readonly toggles: SyncToggles; +}): Promise<number> { + const ticketInput = input.parsed.ticketId; + if (!ticketInput) { + input.ctx.logger.error({ + message: "Missing --ticket <T-00001> for --from hack.", + }); + return 1; + } + const normalizedTicketId = normalizeTicketRef(ticketInput); + if (!normalizedTicketId) { + input.ctx.logger.error({ message: `Invalid ticket id: ${ticketInput}` }); + return 1; + } + + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }); + const selectedTarget = resolveSelectedProjectBindingTarget({ + binding, + projectId: input.parsed.projectId, + }); + const runtime = await createSyncRuntime({ + ctx: input.ctx, + profileId: input.parsed.profileId ?? selectedTarget?.profileId, + }); + if (!runtime.ok) { + input.ctx.logger.error({ message: runtime.error }); + return 1; + } + + const synced = await syncTicketToLinearIssue({ + runtime: runtime.value, + ticketId: normalizedTicketId, + explicitProjectId: input.parsed.projectId ?? selectedTarget?.projectId, + explicitTeamId: input.parsed.teamId ?? selectedTarget?.teamId, + syncToggles: input.toggles, + }); + if (!synced.ok) { + input.ctx.logger.error({ message: synced.error }); + return 1; + } + + await writeIssueSyncResult({ + json: input.parsed.json, + result: synced, + title: "Ticket -> Linear sync", + }); + return 0; +} + +async function handleLinearSyncIssueCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + if (!requireLinearProjectContext({ ctx: input.ctx })) { + return 1; + } + const parsed = parseSyncIssueArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const toggles = resolveSyncToggles({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + labelsOverride: parsed.value.syncLabels, + }); + return parsed.value.from === "linear" + ? await syncIssueFromLinearCommand({ + ctx: input.ctx, parsed: parsed.value, - }); - if (!oauthConfig.ok) { - input.ctx.logger.error({ - message: `${oauthFlow.error} Local fallback also is not configured: ${oauthConfig.error}`, - }); - return 1; - } - oauthFlow = await runLinearOAuthFlow({ + toggles, + }) + : await syncIssueToLinearCommand({ + ctx: input.ctx, parsed: parsed.value, - oauthConfig: oauthConfig.value, + toggles, }); +} + +type ProjectSyncAggregate = { + processed: number; + created: number; + updated: number; + commentsPulled: number; + conflictsRecorded: number; + checkpointsRecorded: number; + syncedProjectIds: string[]; +}; + +function createProjectSyncAggregate(): ProjectSyncAggregate { + return { + processed: 0, + created: 0, + updated: 0, + commentsPulled: 0, + conflictsRecorded: 0, + checkpointsRecorded: 0, + syncedProjectIds: [], + }; +} + +function accumulateProjectSyncAggregate(input: { + readonly aggregate: ProjectSyncAggregate; + readonly result: { + readonly projectIds: readonly string[]; + readonly processed: number; + readonly created: number; + readonly updated: number; + readonly commentsPulled: number; + readonly conflictsRecorded: number; + readonly checkpointsRecorded: number; + }; +}): void { + input.aggregate.processed += input.result.processed; + input.aggregate.created += input.result.created; + input.aggregate.updated += input.result.updated; + input.aggregate.commentsPulled += input.result.commentsPulled; + input.aggregate.conflictsRecorded += input.result.conflictsRecorded; + input.aggregate.checkpointsRecorded += input.result.checkpointsRecorded; + input.aggregate.syncedProjectIds.push(...input.result.projectIds); +} + +function groupProjectTargetsByProfile(input: { + readonly binding: ReturnType<typeof resolveProjectLinearBinding>; + readonly explicitProfileId?: string; + readonly targets: readonly LinearProjectBindingTarget[]; +}): Map<string, LinearProjectBindingTarget[]> { + const targetsByProfile = new Map<string, LinearProjectBindingTarget[]>(); + for (const target of input.targets) { + const key = ( + input.explicitProfileId ?? + target.profileId ?? + input.binding.profileId ?? + "" + ).trim(); + const existingTargets = targetsByProfile.get(key) ?? []; + existingTargets.push(target); + targetsByProfile.set(key, existingTargets); + } + return targetsByProfile; +} + +async function syncProjectsFromLinearCommand(input: { + readonly ctx: LinearCommandContext; + readonly parsed: SyncProjectArgs; + readonly toggles: SyncToggles; + readonly binding: ReturnType<typeof resolveProjectLinearBinding>; +}): Promise<number> { + const projectTargets = resolveProjectPullTargets({ + binding: input.binding, + explicitProjectId: input.parsed.projectId, + }); + if (projectTargets.length === 0) { + input.ctx.logger.error({ + message: + "Missing project id. Pass --project-id, bind a default project, or add additional linked projects first.", + }); + return 1; + } + + const aggregate = createProjectSyncAggregate(); + const targetsByProfile = groupProjectTargetsByProfile({ + binding: input.binding, + explicitProfileId: input.parsed.profileId, + targets: projectTargets, + }); + for (const [profileId, targets] of targetsByProfile.entries()) { + const runtime = await createSyncRuntime({ + ctx: input.ctx, + profileId: profileId || undefined, + }); + if (!runtime.ok) { + input.ctx.logger.error({ message: runtime.error }); + return 1; } - } else { - const oauthConfig = await resolveOAuthRuntimeConfig({ - controlPlaneConfig: input.ctx.controlPlaneConfig, - parsed: parsed.value, + + const syncResult = await syncProjectFromLinearProjectsToTickets({ + runtime: runtime.value, + projectIds: targets.map((target) => target.projectId), + limit: input.parsed.limit, + syncToggles: input.toggles, }); - if (!oauthConfig.ok) { - input.ctx.logger.error({ message: oauthConfig.error }); + if (!syncResult.ok) { + input.ctx.logger.error({ message: syncResult.error }); return 1; } + accumulateProjectSyncAggregate({ + aggregate, + result: syncResult, + }); + } + + const syncResult = { + ok: true as const, + projectIds: [...new Set(aggregate.syncedProjectIds)], + processed: aggregate.processed, + created: aggregate.created, + updated: aggregate.updated, + commentsPulled: aggregate.commentsPulled, + conflictsRecorded: aggregate.conflictsRecorded, + checkpointsRecorded: aggregate.checkpointsRecorded, + }; + if (input.parsed.json) { + process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); + } else { + await display.kv({ + title: "Linear project -> tickets sync", + entries: [ + ["linear_project_ids", syncResult.projectIds.join(", ")], + ["processed", String(syncResult.processed)], + ["created", String(syncResult.created)], + ["updated", String(syncResult.updated)], + ], + }); + } + return 0; +} + +async function syncProjectsToLinearCommand(input: { + readonly ctx: LinearCommandContext; + readonly parsed: SyncProjectArgs; + readonly toggles: SyncToggles; + readonly binding: ReturnType<typeof resolveProjectLinearBinding>; +}): Promise<number> { + const selectedTarget = resolveSelectedProjectBindingTarget({ + binding: input.binding, + projectId: input.parsed.projectId, + }); + const projectId = input.parsed.projectId ?? selectedTarget?.projectId; + const runtime = await createSyncRuntime({ + ctx: input.ctx, + profileId: input.parsed.profileId ?? selectedTarget?.profileId, + }); + if (!runtime.ok) { + input.ctx.logger.error({ message: runtime.error }); + return 1; + } + const syncResult = await syncProjectFromTicketsToLinear({ + runtime: runtime.value, + projectId, + explicitTeamId: input.parsed.teamId ?? selectedTarget?.teamId, + limit: input.parsed.limit, + ownerMode: input.parsed.ownerMode, + syncToggles: input.toggles, + }); + if (!syncResult.ok) { + input.ctx.logger.error({ message: syncResult.error }); + return 1; + } + + if (input.parsed.json) { + process.stdout.write(`${JSON.stringify(syncResult, null, 2)}\n`); + } else { + await display.kv({ + title: "Tickets -> Linear project sync", + entries: [ + ["linear_project_id", projectId ?? ""], + ["processed", String(syncResult.processed)], + ["created", String(syncResult.created)], + ["updated", String(syncResult.updated)], + ], + }); + } + return 0; +} + +async function handleLinearSyncProjectCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + if (!requireLinearProjectContext({ ctx: input.ctx })) { + return 1; + } + const parsed = parseSyncProjectArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const toggles = resolveSyncToggles({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + labelsOverride: parsed.value.syncLabels, + }); + const binding = resolveProjectLinearBinding({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + }); + return parsed.value.from === "linear" + ? await syncProjectsFromLinearCommand({ + ctx: input.ctx, + parsed: parsed.value, + toggles, + binding, + }) + : await syncProjectsToLinearCommand({ + ctx: input.ctx, + parsed: parsed.value, + toggles, + binding, + }); +} - oauthFlow = await runLinearOAuthFlow({ - parsed: parsed.value, +function resolveOAuthConnectSettings(input: { + readonly controlPlaneConfig: LinearCommandContext["controlPlaneConfig"]; + readonly parsed: OAuthConnectArgs; +}): ResolvedOAuthConnectSettings { + const defaults = resolveLinearAuthSettings({ + controlPlaneConfig: input.controlPlaneConfig, + ...(input.parsed.profileId ? { profileId: input.parsed.profileId } : {}), + allowProjectOverride: false, + }); + return { + profileId: input.parsed.profileId ?? defaults.profileId, + apiUrl: input.parsed.apiUrl ?? defaults.apiUrl, + tokenEnv: input.parsed.tokenEnv ?? defaults.tokenEnv, + authRef: input.parsed.authRef ?? defaults.authRef, + service: input.parsed.service ?? defaults.service, + }; +} + +async function displayLinearBrokerOAuthStart(input: { + readonly flow: BrokerStartFlowPayload; + readonly json: boolean; +}): Promise<void> { + if (input.json) { + process.stdout.write( + `${JSON.stringify(buildLinearOAuthStartPayload({ flow: input.flow }), null, 2)}\n` + ); + return; + } + + await display.panel({ + title: "Linear OAuth", + tone: "info", + lines: [ + "Open this URL in your browser to continue:", + input.flow.authorizeUrl, + "", + `Status URL: ${buildLinearOAuthStatusUrl({ pollUrl: input.flow.pollUrl, deviceCode: input.flow.deviceCode })}`, + ], + }); +} + +async function resolveLocalLinearOAuthFlow(input: { + readonly controlPlaneConfig: LinearCommandContext["controlPlaneConfig"]; + readonly parsed: OAuthConnectArgs; +}): Promise< + | { readonly ok: true; readonly value: LinearOAuthFlowResult } + | { readonly ok: false; readonly error: string } +> { + const oauthConfig = await resolveOAuthRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + parsed: input.parsed, + }); + if (!oauthConfig.ok) { + return oauthConfig; + } + + return { + ok: true, + value: await runLinearOAuthFlow({ + parsed: input.parsed, oauthConfig: oauthConfig.value, + }), + }; +} + +async function resolveLinearOAuthFlowResult(input: { + readonly controlPlaneConfig: LinearCommandContext["controlPlaneConfig"]; + readonly parsed: OAuthConnectArgs; + readonly profileId: string; +}): Promise< + | { readonly ok: true; readonly value: LinearOAuthFlowResult } + | { readonly ok: false; readonly error: string; readonly flowError?: string } +> { + if (!shouldUseBrokerOAuthFlow({ parsed: input.parsed })) { + return await resolveLocalLinearOAuthFlow(input); + } + + const brokerInput = { + controlPlaneConfig: input.controlPlaneConfig, + parsed: input.parsed, + brokerConfig: resolveOAuthBrokerRuntimeConfig({ + controlPlaneConfig: input.controlPlaneConfig, + }), + profileId: input.profileId, + } satisfies OAuthBrokerConnectRuntime; + + if (input.parsed.startOnly) { + const brokerFlow = await startLinearBrokerOAuthFlow(brokerInput); + if (!brokerFlow.ok) { + return { ok: false, error: brokerFlow.error }; + } + await displayLinearBrokerOAuthStart({ + flow: brokerFlow.flow, + json: input.parsed.json, + }); + return { + ok: true, + value: { ok: false, error: "broker_start_only" }, + }; + } + + const brokerFlow = await runLinearBrokerOAuthFlow(brokerInput); + if (brokerFlow.ok) { + return { ok: true, value: brokerFlow }; + } + + const localFallback = await resolveLocalLinearOAuthFlow(input); + if (localFallback.ok) { + return localFallback; + } + + return { + ok: false, + error: localFallback.error, + flowError: brokerFlow.error, + }; +} + +async function handleLinearOAuthConnectCommand(input: { + readonly ctx: LinearCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const parsed = parseOAuthConnectArgs({ args: input.args }); + if (!parsed.ok) { + input.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const resolved = resolveOAuthConnectSettings({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + parsed: parsed.value, + }); + const oauthResolution = await resolveLinearOAuthFlowResult({ + controlPlaneConfig: input.ctx.controlPlaneConfig, + parsed: parsed.value, + profileId: resolved.profileId, + }); + if (!oauthResolution.ok) { + input.ctx.logger.error({ + message: oauthResolution.flowError + ? `${oauthResolution.flowError} Local fallback also is not configured: ${oauthResolution.error}` + : oauthResolution.error, }); + return 1; + } + + const oauthFlow = oauthResolution.value; + if (parsed.value.startOnly) { + return 0; } if (!oauthFlow.ok) { input.ctx.logger.error({ message: oauthFlow.error }); @@ -2115,20 +2292,33 @@ type OAuthBrokerConnectRuntime = { readonly profileId: string; }; -async function runLinearOAuthFlow(input: OAuthConnectRuntime): Promise< +type LinearOAuthTokenExchange = { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + readonly managementToken?: string; + readonly managementTokenExpiresAt?: string; +}; + +type LinearOAuthFlowResult = | { readonly ok: true; - readonly tokenExchange: { - readonly token: string; - readonly expiresAt?: string; - readonly refreshToken?: string; - readonly refreshTokenExpiresAt?: string; - readonly managementToken?: string; - readonly managementTokenExpiresAt?: string; - }; + readonly tokenExchange: LinearOAuthTokenExchange; } - | { readonly ok: false; readonly error: string } -> { + | { readonly ok: false; readonly error: string }; + +type ResolvedOAuthConnectSettings = { + readonly profileId: string; + readonly apiUrl: string; + readonly tokenEnv: string; + readonly authRef: string; + readonly service: string; +}; + +async function runLinearOAuthFlow( + input: OAuthConnectRuntime +): Promise<LinearOAuthFlowResult> { const pkce = createLinearPkcePair(); const callbackServer = startLinearOAuthCallbackServer({ timeoutMs: OAUTH_TIMEOUT_MS, @@ -2272,40 +2462,51 @@ function buildLinearOAuthStartPayload(input: { }; } -async function runLinearBrokerOAuthFlow( - input: OAuthBrokerConnectRuntime -): Promise< - | { - readonly ok: true; - readonly tokenExchange: { - readonly token: string; - readonly expiresAt?: string; - readonly refreshToken?: string; - readonly refreshTokenExpiresAt?: string; - readonly managementToken?: string; - readonly managementTokenExpiresAt?: string; - }; - } - | { readonly ok: false; readonly error: string } -> { - const start = await startLinearBrokerOAuthFlow(input); - if (!start.ok) { - return start; +function readClaimedLinearBrokerTokenExchange(input: { + readonly status: BrokerFlowStatusPayload; +}): LinearOAuthFlowResult { + const token = readOptionalString(input.status.token); + if (!token) { + return { + ok: false, + error: + "Linear OAuth completed remotely, but the broker did not return a token.", + }; } - const launch = await launchLinearOAuthBrowser({ - parsed: input.parsed, - authorizeUrl: start.flow.authorizeUrl, - pollUrl: start.flow.pollUrl, - }); - if (!launch.ok) { - return launch; - } + return { + ok: true, + tokenExchange: { + token, + ...(input.status.tokenExpiresAt + ? { expiresAt: input.status.tokenExpiresAt } + : {}), + ...(input.status.refreshToken + ? { refreshToken: input.status.refreshToken } + : {}), + ...(input.status.refreshTokenExpiresAt + ? { refreshTokenExpiresAt: input.status.refreshTokenExpiresAt } + : {}), + ...(input.status.managementToken + ? { managementToken: input.status.managementToken } + : {}), + ...(input.status.managementTokenExpiresAt + ? { managementTokenExpiresAt: input.status.managementTokenExpiresAt } + : {}), + }, + }; +} - const expiresAtMs = Date.parse(start.flow.expiresAt); - const statusUrl = new URL(start.flow.pollUrl); - statusUrl.searchParams.set("deviceCode", start.flow.deviceCode); - statusUrl.searchParams.set("claim", "1"); +async function pollLinearBrokerOAuthStatus(input: { + readonly pollUrl: string; + readonly deviceCode: string; + readonly expiresAt: string; +}): Promise<LinearOAuthFlowResult> { + const expiresAtMs = Date.parse(input.expiresAt); + const statusUrl = buildLinearOAuthStatusUrl({ + pollUrl: input.pollUrl, + deviceCode: input.deviceCode, + }); while (true) { if (Number.isFinite(expiresAtMs) && Date.now() >= expiresAtMs) { @@ -2316,7 +2517,7 @@ async function runLinearBrokerOAuthFlow( } const status = await fetchJson<BrokerFlowStatusEnvelope>({ - url: statusUrl.toString(), + url: statusUrl, }); if (!status.ok) { return { @@ -2337,48 +2538,14 @@ async function runLinearBrokerOAuthFlow( continue; } if (flowStatus.status === "claimed") { - const token = readOptionalString(flowStatus.token); - if (!token) { - return { - ok: false, - error: - "Linear OAuth completed remotely, but the broker did not return a token.", - }; - } - return { - ok: true, - tokenExchange: { - token, - ...(flowStatus.tokenExpiresAt - ? { expiresAt: flowStatus.tokenExpiresAt } - : {}), - ...(flowStatus.refreshToken - ? { refreshToken: flowStatus.refreshToken } - : {}), - ...(flowStatus.refreshTokenExpiresAt - ? { refreshTokenExpiresAt: flowStatus.refreshTokenExpiresAt } - : {}), - ...(flowStatus.managementToken - ? { managementToken: flowStatus.managementToken } - : {}), - ...(flowStatus.managementTokenExpiresAt - ? { - managementTokenExpiresAt: flowStatus.managementTokenExpiresAt, - } - : {}), - }, - }; - } - if (flowStatus.status === "error") { - return { - ok: false, - error: flowStatus.error ?? "Linear OAuth failed.", - }; + return readClaimedLinearBrokerTokenExchange({ + status: flowStatus, + }); } - if (flowStatus.status === "expired") { + if (flowStatus.status === "error" || flowStatus.status === "expired") { return { ok: false, - error: "OAuth callback timed out. Retry the flow.", + error: flowStatus.error ?? "OAuth callback timed out. Retry the flow.", }; } @@ -2386,6 +2553,29 @@ async function runLinearBrokerOAuthFlow( } } +async function runLinearBrokerOAuthFlow( + input: OAuthBrokerConnectRuntime +): Promise<LinearOAuthFlowResult> { + const start = await startLinearBrokerOAuthFlow(input); + if (!start.ok) { + return start; + } + + const launch = await launchLinearOAuthBrowser({ + parsed: input.parsed, + authorizeUrl: start.flow.authorizeUrl, + pollUrl: start.flow.pollUrl, + }); + if (!launch.ok) { + return launch; + } + return await pollLinearBrokerOAuthStatus({ + pollUrl: start.flow.pollUrl, + deviceCode: start.flow.deviceCode, + expiresAt: start.flow.expiresAt, + }); +} + async function launchLinearOAuthBrowser(input: { readonly parsed: OAuthConnectArgs; readonly authorizeUrl: string; @@ -3661,14 +3851,30 @@ async function syncIssueFromLinearToTicket(input: { }); } -async function syncTicketToLinearIssue(input: { +type TicketToLinearSyncContext = { + readonly ticket: TicketSummary; + readonly existingIssue: LinearIssue | null; + readonly target: { + readonly teamId: string; + readonly projectId?: string; + }; + readonly fields: LinearTicketMutationFields; + readonly assignee: Awaited<ReturnType<typeof resolveTicketAssigneeForLinear>>; + readonly recordedConflicts: + | { readonly ok: true; readonly recorded: number } + | { readonly ok: false; readonly error: string }; + readonly authority: ReturnType<typeof resolveTicketAuthority>; +}; + +async function resolveTicketToLinearSyncContext(input: { readonly runtime: HackToLinearRuntime; readonly ticketId: string; readonly explicitProjectId?: string; readonly explicitTeamId?: string; readonly syncToggles: SyncToggles; }): Promise< - SyncTicketToLinearSuccess | { readonly ok: false; readonly error: string } + | { readonly ok: true; readonly value: TicketToLinearSyncContext } + | { readonly ok: false; readonly error: string } > { const ticket = await input.runtime.tickets.getTicket({ ticketId: input.ticketId, @@ -3709,13 +3915,11 @@ async function syncTicketToLinearIssue(input: { const authority = existingIssue.issue ? resolveTicketAuthority({ ticket }) : "hack"; - const assignee = await resolveTicketAssigneeForLinear({ runtime: input.runtime, ticket, teamId: target.value.teamId, }); - const conflicts = existingIssue.issue ? detectAuthoritativeFieldConflicts({ authority, @@ -3732,7 +3936,6 @@ async function syncTicketToLinearIssue(input: { }), }) : []; - const recordedConflicts = existingIssue.issue ? await recordAuthoritativeConflicts({ tickets: input.runtime.tickets, @@ -3740,46 +3943,93 @@ async function syncTicketToLinearIssue(input: { conflicts, }) : { ok: true as const, recorded: 0 }; - if (!recordedConflicts.ok) { - return { ok: false, error: recordedConflicts.error }; + + return { + ok: true, + value: { + ticket, + existingIssue: existingIssue.issue, + target: target.value, + fields: fields.value, + assignee, + recordedConflicts, + authority, + }, + }; +} + +function resolveEffectiveTicketMutationFields(input: { + readonly context: TicketToLinearSyncContext; + readonly syncToggles: SyncToggles; +}): LinearTicketMutationFields { + if (!(input.context.authority === "linear" && input.context.existingIssue)) { + return input.context.fields; } - const effectiveFields = - authority === "linear" && existingIssue.issue - ? { - ...fields.value, - title: existingIssue.issue.title, - description: existingIssue.issue.description ?? "", - ...(existingIssue.issue.assigneeId - ? { assigneeId: existingIssue.issue.assigneeId } - : {}), - ...(input.syncToggles.statuses - ? { stateId: existingIssue.issue.state.id } - : {}), - } - : fields.value; + return { + ...input.context.fields, + title: input.context.existingIssue.title, + description: input.context.existingIssue.description ?? "", + ...(input.context.existingIssue.assigneeId + ? { assigneeId: input.context.existingIssue.assigneeId } + : {}), + ...(input.syncToggles.statuses + ? { stateId: input.context.existingIssue.state.id } + : {}), + }; +} - const effectiveProjectId = - authority === "linear" && existingIssue.issue - ? existingIssue.issue.projectId - : target.value.projectId; +function resolveEffectiveTicketProjectId(input: { + readonly context: TicketToLinearSyncContext; +}): string | undefined { + return input.context.authority === "linear" && input.context.existingIssue + ? input.context.existingIssue.projectId + : input.context.target.projectId; +} - const syncedIssue = await upsertLinearIssueForTicketSync({ +async function syncTicketToLinearIssue(input: { + readonly runtime: HackToLinearRuntime; + readonly ticketId: string; + readonly explicitProjectId?: string; + readonly explicitTeamId?: string; + readonly syncToggles: SyncToggles; +}): Promise< + SyncTicketToLinearSuccess | { readonly ok: false; readonly error: string } +> { + const context = await resolveTicketToLinearSyncContext({ runtime: input.runtime, - ticket, - existingIssue: existingIssue.issue, - fields: effectiveFields, - teamId: target.value.teamId, - projectId: effectiveProjectId, + ticketId: input.ticketId, + explicitProjectId: input.explicitProjectId, + explicitTeamId: input.explicitTeamId, syncToggles: input.syncToggles, }); + if (!context.ok) { + return context; + } + if (!context.value.recordedConflicts.ok) { + return { ok: false, error: context.value.recordedConflicts.error }; + } + const syncedIssue = await upsertLinearIssueForTicketSync({ + runtime: input.runtime, + ticket: context.value.ticket, + existingIssue: context.value.existingIssue, + fields: resolveEffectiveTicketMutationFields({ + context: context.value, + syncToggles: input.syncToggles, + }), + teamId: context.value.target.teamId, + projectId: resolveEffectiveTicketProjectId({ + context: context.value, + }), + syncToggles: input.syncToggles, + }); if (!syncedIssue.ok) { return { ok: false, error: syncedIssue.error }; } const updated = await input.runtime.tickets.updateTicket({ - ticketId: ticket.ticketId, + ticketId: context.value.ticket.ticketId, externalSystem: "linear", externalId: syncedIssue.data.id, externalKey: syncedIssue.data.identifier, @@ -3794,7 +4044,7 @@ async function syncTicketToLinearIssue(input: { const pushedComments = await pushTicketCommentsToLinear({ runtime: input.runtime, - ticketId: ticket.ticketId, + ticketId: context.value.ticket.ticketId, issueId: syncedIssue.data.id, }); if (!pushedComments.ok) { @@ -3803,7 +4053,7 @@ async function syncTicketToLinearIssue(input: { const checkpoint = await recordLinearSyncCheckpoint({ tickets: input.runtime.tickets, - ticketId: ticket.ticketId, + ticketId: context.value.ticket.ticketId, profileId: input.runtime.profileId, direction: "hack_to_linear", issue: syncedIssue.data, @@ -3814,14 +4064,14 @@ async function syncTicketToLinearIssue(input: { return { ok: true, - operation: existingIssue.issue ? "updated" : "created", - ticketId: ticket.ticketId, + operation: context.value.existingIssue ? "updated" : "created", + ticketId: context.value.ticket.ticketId, issueIdentifier: syncedIssue.data.identifier, issueId: syncedIssue.data.id, commentsPushed: pushedComments.commentsPushed, - conflictsRecorded: recordedConflicts.recorded, + conflictsRecorded: context.value.recordedConflicts.recorded, checkpointRecorded: checkpoint.checkpointRecorded, - assignee, + assignee: context.value.assignee, }; } @@ -4095,6 +4345,7 @@ function hasActiveAutosyncSubscription(input: { ); } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Autosync intentionally coordinates subscription discovery, issue/project delivery grouping, sync application, and reporting in one orchestration loop. async function runProjectLinearAutosync<TRuntime>(input: { readonly binding: ResolvedLinearProjectBinding; readonly profileId?: string; @@ -4380,6 +4631,70 @@ async function runProjectLinearAutosync<TRuntime>(input: { }; } +function normalizeLinearProjectIdsForSync(input: { + readonly projectIds: readonly string[]; +}): string[] { + return [...new Set(input.projectIds.map((value) => value.trim()))].filter( + (value) => value.length > 0 + ); +} + +function shouldContinueLinearProjectIssueCollection(input: { + readonly hasNextPage: boolean; + readonly endCursor?: string | null; + readonly collected: number; + readonly limit: number; +}): input is { + readonly hasNextPage: true; + readonly endCursor: string; + readonly collected: number; + readonly limit: number; +} { + return Boolean( + input.hasNextPage && input.endCursor && input.collected < input.limit + ); +} + +async function collectLinearProjectIssuesForId(input: { + readonly runtime: SyncRuntime; + readonly projectId: string; + readonly limit: number; + readonly issuesById: Map<string, LinearIssue>; +}): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } +> { + let cursor: string | undefined; + while (input.issuesById.size < input.limit) { + const page = await input.runtime.linear.listProjectIssuesPage({ + projectId: input.projectId, + first: Math.min(50, input.limit - input.issuesById.size), + ...(cursor ? { after: cursor } : {}), + }); + if (!page.ok) { + return { ok: false, error: page.error }; + } + + for (const issue of page.data.issues) { + input.issuesById.set(issue.id, issue); + if (input.issuesById.size >= input.limit) { + break; + } + } + if ( + !shouldContinueLinearProjectIssueCollection({ + hasNextPage: page.data.hasNextPage, + endCursor: page.data.endCursor, + collected: input.issuesById.size, + limit: input.limit, + }) + ) { + break; + } + cursor = page.data.endCursor; + } + return { ok: true }; +} + async function collectLinearProjectIssuesForSync(input: { readonly runtime: SyncRuntime; readonly projectIds: readonly string[]; @@ -4393,39 +4708,20 @@ async function collectLinearProjectIssuesForSync(input: { } | { readonly ok: false; readonly error: string } > { - const normalizedProjectIds = [ - ...new Set(input.projectIds.map((value) => value.trim())), - ].filter((value) => value.length > 0); + const normalizedProjectIds = normalizeLinearProjectIdsForSync({ + projectIds: input.projectIds, + }); const issuesById = new Map<string, LinearIssue>(); for (const projectId of normalizedProjectIds) { - let cursor: string | undefined; - while (issuesById.size < input.limit) { - const page = await input.runtime.linear.listProjectIssuesPage({ - projectId, - first: Math.min(50, input.limit - issuesById.size), - ...(cursor ? { after: cursor } : {}), - }); - if (!page.ok) { - return { ok: false, error: page.error }; - } - - for (const issue of page.data.issues) { - issuesById.set(issue.id, issue); - if (issuesById.size >= input.limit) { - break; - } - } - if ( - !( - page.data.hasNextPage && - page.data.endCursor && - issuesById.size < input.limit - ) - ) { - break; - } - cursor = page.data.endCursor; + const collected = await collectLinearProjectIssuesForId({ + runtime: input.runtime, + projectId, + limit: input.limit, + issuesById, + }); + if (!collected.ok) { + return collected; } if (issuesById.size >= input.limit) { break; @@ -7043,6 +7339,63 @@ function escapeHtml(input: { readonly text: string }): string { .replaceAll("'", "'"); } +function buildResolvedConnectToken(input: { + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; +}): { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; +} { + return { + ok: true, + token: input.token, + ...(readOptionalString(input.expiresAt) + ? { expiresAt: readOptionalString(input.expiresAt) } + : {}), + ...(readOptionalString(input.refreshToken) + ? { refreshToken: readOptionalString(input.refreshToken) } + : {}), + ...(readOptionalString(input.refreshTokenExpiresAt) + ? { + refreshTokenExpiresAt: readOptionalString( + input.refreshTokenExpiresAt + ), + } + : {}), + }; +} + +async function resolveConnectTokenFromStdin(): Promise< + | { + readonly ok: true; + readonly token: string; + readonly expiresAt?: string; + readonly refreshToken?: string; + readonly refreshTokenExpiresAt?: string; + } + | { readonly ok: false; readonly error: string } +> { + const text = (await Bun.stdin.text()).trim(); + if (!text) { + return { ok: false, error: "Missing token from stdin." }; + } + const envelope = parseLinearTokenEnvelope(text); + if (!envelope?.token) { + return { ok: false, error: "Missing token from stdin." }; + } + return buildResolvedConnectToken({ + token: envelope.token, + expiresAt: envelope.expiresAt, + refreshToken: envelope.refreshToken, + refreshTokenExpiresAt: envelope.refreshTokenExpiresAt, + }); +} + async function resolveConnectToken(input: { readonly token?: string; readonly stdin: boolean; @@ -7062,42 +7415,15 @@ async function resolveConnectToken(input: { > { const direct = (input.token ?? "").trim(); if (direct) { - return { - ok: true, + return buildResolvedConnectToken({ token: direct, - ...(readOptionalString(input.expiresAt) - ? { expiresAt: readOptionalString(input.expiresAt) } - : {}), - ...(readOptionalString(input.refreshToken) - ? { refreshToken: readOptionalString(input.refreshToken) } - : {}), - ...(readOptionalString(input.refreshTokenExpiresAt) - ? { - refreshTokenExpiresAt: readOptionalString( - input.refreshTokenExpiresAt - ), - } - : {}), - }; + expiresAt: input.expiresAt, + refreshToken: input.refreshToken, + refreshTokenExpiresAt: input.refreshTokenExpiresAt, + }); } if (input.stdin) { - const text = (await Bun.stdin.text()).trim(); - if (!text) { - return { ok: false, error: "Missing token from stdin." }; - } - const envelope = parseLinearTokenEnvelope(text); - if (!envelope?.token) { - return { ok: false, error: "Missing token from stdin." }; - } - return { - ok: true, - token: envelope.token, - ...(envelope.expiresAt ? { expiresAt: envelope.expiresAt } : {}), - ...(envelope.refreshToken ? { refreshToken: envelope.refreshToken } : {}), - ...(envelope.refreshTokenExpiresAt - ? { refreshTokenExpiresAt: envelope.refreshTokenExpiresAt } - : {}), - }; + return await resolveConnectTokenFromStdin(); } const envToken = (process.env[input.tokenEnv] ?? "").trim(); if (!envToken) { diff --git a/src/control-plane/extensions/supervisor/commands.ts b/src/control-plane/extensions/supervisor/commands.ts index 106cf1623..507fb6ad9 100644 --- a/src/control-plane/extensions/supervisor/commands.ts +++ b/src/control-plane/extensions/supervisor/commands.ts @@ -407,109 +407,60 @@ export const SUPERVISOR_COMMANDS: readonly ExtensionCommand[] = [ return 1; } - const envGateway = (process.env.HACK_GATEWAY_URL ?? "").trim(); - const gatewayUrl = - parsed.value.gateway ?? - (envGateway.length > 0 ? envGateway : "http://127.0.0.1:7788"); - const token = - parsed.value.token ?? (process.env.HACK_GATEWAY_TOKEN ?? "").trim(); - if (!token) { + const connection = resolveShellConnection({ parsed: parsed.value }); + if (!connection.ok) { ctx.logger.error({ - message: - "Missing gateway token. Set HACK_GATEWAY_TOKEN or pass --token (write scope required).", + message: connection.error, }); return 1; } - const client = createGatewayClient({ baseUrl: gatewayUrl, token }); - let projectId: string | undefined = - parsed.value.projectId ?? ctx.projectId; - let projectName: string | undefined = - parsed.value.project ?? ctx.projectName; - - let localProject: ProjectContext | undefined = ctx.project; - - if (parsed.value.project || parsed.value.path) { - const localProjectResult = await resolveSupervisorProject({ - ctx, - projectOpt: parsed.value.project, - pathOpt: parsed.value.path, - }); - if (!localProjectResult.ok) { - ctx.logger.error({ message: localProjectResult.error }); - return 1; - } - localProject = localProjectResult.project; - projectId = projectId ?? localProjectResult.projectId; - projectName = localProjectResult.projectName ?? projectName; - } - - const projectIdResult = await resolveGatewayProjectId({ + const client = createGatewayClient({ + baseUrl: connection.gatewayUrl, + token: connection.token, + }); + const shellProject = await resolveShellProject({ + ctx, + parsed: parsed.value, client, - projectId, - projectName, }); - if (!projectIdResult.ok) { - ctx.logger.error({ message: projectIdResult.error }); + if (!shellProject.ok) { + ctx.logger.error({ message: shellProject.error }); return 1; } - const fallbackCols = 120; - const fallbackRows = 30; - const cols = - parsed.value.cols ?? - (typeof process.stdout.columns === "number" - ? process.stdout.columns - : fallbackCols); - const rows = - parsed.value.rows ?? - (typeof process.stdout.rows === "number" - ? process.stdout.rows - : fallbackRows); - - const shellInput = { - projectId: projectIdResult.projectId, - ...(parsed.value.shell ? { shell: parsed.value.shell } : {}), - ...(parsed.value.cwd ? { cwd: parsed.value.cwd } : {}), - ...(parsed.value.env ? { env: parsed.value.env } : {}), - cols, - rows, - }; - - let created = await client.createShell(shellInput); - if (!created.ok && created.error.code === "writes_disabled") { - const didEnable = await maybeEnableGatewayWrites({ - ctx, - project: localProject, - }); - if (didEnable) { - created = await client.createShell(shellInput); - } - } + const shellDimensions = resolveShellDimensions({ parsed: parsed.value }); + const shellInput = buildShellCreateInput({ + parsed: parsed.value, + projectId: shellProject.projectId, + cols: shellDimensions.cols, + rows: shellDimensions.rows, + }); + const created = await createShellWithRetry({ + ctx, + client, + localProject: shellProject.localProject, + shellInput, + }); if (!created.ok) { - if ( - created.error.code === "writes_disabled" || - created.error.code === "write_scope_required" - ) { - await reportGatewayConfigSource({ logger: ctx.logger }); - } - const detailed = buildShellCreateErrorHint({ error: created.error }); - ctx.logger.error({ - message: `Shell create failed (${created.status}): ${created.error.message}`, + await reportShellCreateFailure({ + logger: ctx.logger, + created, }); - if (detailed) { - ctx.logger.info({ message: detailed }); - } return 1; } const shellId = created.data.shell.shellId; const ws = client.openShellStream({ - projectId: projectIdResult.projectId, + projectId: shellProject.projectId, shellId, }); - const outcome = await attachGatewayShellStream({ ws, cols, rows }); + const outcome = await attachGatewayShellStream({ + ws, + cols: shellDimensions.cols, + rows: shellDimensions.rows, + }); if (outcome.signal) { ctx.logger.info({ message: `Shell exited via ${outcome.signal}` }); @@ -533,175 +484,55 @@ type ParseResult = | { readonly ok: true; readonly value: SupervisorArgs } | { readonly ok: false; readonly error: string }; +type ArgStepResult = + | { readonly kind: "handled"; readonly nextIndex: number } + | { readonly kind: "unhandled" } + | { readonly kind: "error"; readonly error: string }; + +type SupervisorArgsState = { + project?: string; + path?: string; + json: boolean; + follow: boolean; + logsFrom?: number; + eventsFrom?: number; + rest: string[]; +}; + export function parseSupervisorArgs(opts: { readonly args: readonly string[]; readonly allowLogsFrom?: boolean; readonly allowEventsFrom?: boolean; readonly allowFollow?: boolean; }): ParseResult { - const rest: string[] = []; - let project: string | undefined; - let path: string | undefined; - let json = false; - let follow = true; - let logsFrom: number | undefined; - let eventsFrom: number | undefined; - - const takeValue = ( - _flag: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; + const state: SupervisorArgsState = { + json: false, + follow: true, + rest: [], }; for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; + if (token === "--") { - rest.push(...opts.args.slice(i + 1)); + state.rest.push(...opts.args.slice(i + 1)); break; } - if (token === "--json") { - json = true; - continue; - } - - if (token === "--follow") { - if (!opts.allowFollow) { - return { ok: false, error: "--follow is not supported here." }; - } - follow = true; - continue; - } - - if (token === "--no-follow") { - if (!opts.allowFollow) { - return { ok: false, error: "--no-follow is not supported here." }; - } - follow = false; - continue; - } - - if (token.startsWith("--project=")) { - project = token.slice("--project=".length).trim(); - continue; - } - - if (token === "--project") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--project requires a value." }; - } - project = value; - i += 1; - continue; - } - - if (token.startsWith("--path=")) { - path = token.slice("--path=".length).trim(); - continue; - } - - if (token === "--path") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--path requires a value." }; - } - path = value; - i += 1; - continue; - } - - if (token.startsWith("--logs-from=")) { - if (!opts.allowLogsFrom) { - return { ok: false, error: "--logs-from is not supported here." }; - } - const value = token.slice("--logs-from=".length).trim(); - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--logs-from must be a number." }; - } - logsFrom = parsed; - continue; - } - - if (token === "--logs-from") { - if (!opts.allowLogsFrom) { - return { ok: false, error: "--logs-from is not supported here." }; - } - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--logs-from requires a value." }; - } - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--logs-from must be a number." }; - } - logsFrom = parsed; - i += 1; - continue; - } - - if (token.startsWith("--from=")) { - if (!opts.allowLogsFrom) { - return { ok: false, error: "--from is not supported here." }; - } - const value = token.slice("--from=".length).trim(); - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--from must be a number." }; - } - logsFrom = parsed; - continue; - } - - if (token === "--from") { - if (!opts.allowLogsFrom) { - return { ok: false, error: "--from is not supported here." }; - } - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--from requires a value." }; - } - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--from must be a number." }; - } - logsFrom = parsed; - i += 1; - continue; - } - - if (token.startsWith("--events-from=")) { - if (!opts.allowEventsFrom) { - return { ok: false, error: "--events-from is not supported here." }; - } - const value = token.slice("--events-from=".length).trim(); - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--events-from must be a number." }; - } - eventsFrom = parsed; - continue; + const step = handleSupervisorArg({ + args: opts.args, + index: i, + token, + state, + allowLogsFrom: opts.allowLogsFrom === true, + allowEventsFrom: opts.allowEventsFrom === true, + allowFollow: opts.allowFollow === true, + }); + if (step.kind === "error") { + return { ok: false, error: step.error }; } - - if (token === "--events-from") { - if (!opts.allowEventsFrom) { - return { ok: false, error: "--events-from is not supported here." }; - } - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--events-from requires a value." }; - } - const parsed = parseOffset(value); - if (parsed === null) { - return { ok: false, error: "--events-from must be a number." }; - } - eventsFrom = parsed; - i += 1; + if (step.kind === "handled") { + i = step.nextIndex; continue; } @@ -709,19 +540,21 @@ export function parseSupervisorArgs(opts: { return { ok: false, error: `Unknown option: ${token}` }; } - rest.push(token); + state.rest.push(token); } return { ok: true, value: { - ...(project ? { project } : {}), - ...(path ? { path } : {}), - ...(logsFrom !== undefined ? { logsFrom } : {}), - ...(eventsFrom !== undefined ? { eventsFrom } : {}), - json, - follow, - rest, + ...(state.project ? { project: state.project } : {}), + ...(state.path ? { path: state.path } : {}), + ...(state.logsFrom !== undefined ? { logsFrom: state.logsFrom } : {}), + ...(state.eventsFrom !== undefined + ? { eventsFrom: state.eventsFrom } + : {}), + json: state.json, + follow: state.follow, + rest: state.rest, }, }; } @@ -740,114 +573,44 @@ type JobCreateParseResult = | { readonly ok: true; readonly value: JobCreateArgs } | { readonly ok: false; readonly error: string }; +type JobCreateArgsState = { + project?: string; + path?: string; + json: boolean; + runner: string; + cwd?: string; + command: string[]; + envEntries: string[]; +}; + export function parseJobCreateArgs(opts: { readonly args: readonly string[]; }): JobCreateParseResult { - const command: string[] = []; - const envEntries: string[] = []; - let project: string | undefined; - let path: string | undefined; - let json = false; - let runner = "generic"; - let cwd: string | undefined; - - const takeValue = ( - _flag: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; + const state: JobCreateArgsState = { + json: false, + runner: "generic", + command: [], + envEntries: [], }; for (let i = 0; i < opts.args.length; i += 1) { const token = opts.args[i] ?? ""; if (token === "--") { - command.push(...opts.args.slice(i + 1)); + state.command.push(...opts.args.slice(i + 1)); break; } - if (token === "--json") { - json = true; - continue; - } - - if (token.startsWith("--project=")) { - project = token.slice("--project=".length).trim(); - continue; - } - - if (token === "--project") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--project requires a value." }; - } - project = value; - i += 1; - continue; - } - - if (token.startsWith("--path=")) { - path = token.slice("--path=".length).trim(); - continue; - } - - if (token === "--path") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--path requires a value." }; - } - path = value; - i += 1; - continue; - } - - if (token.startsWith("--runner=")) { - const value = token.slice("--runner=".length).trim(); - if (value.length > 0) { - runner = value; - } - continue; - } - - if (token === "--runner") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--runner requires a value." }; - } - runner = value; - i += 1; - continue; - } - - if (token.startsWith("--cwd=")) { - cwd = token.slice("--cwd=".length).trim(); - continue; - } - - if (token === "--cwd") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--cwd requires a value." }; - } - cwd = value; - i += 1; - continue; - } - - if (token.startsWith("--env=")) { - envEntries.push(token.slice("--env=".length)); - continue; + const step = handleJobCreateArg({ + args: opts.args, + index: i, + token, + state, + }); + if (step.kind === "error") { + return { ok: false, error: step.error }; } - - if (token === "--env") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--env requires KEY=VALUE." }; - } - envEntries.push(value); - i += 1; + if (step.kind === "handled") { + i = step.nextIndex; continue; } @@ -855,18 +618,18 @@ export function parseJobCreateArgs(opts: { return { ok: false, error: `Unknown option: ${token}` }; } - command.push(...opts.args.slice(i)); + state.command.push(...opts.args.slice(i)); break; } - if (command.length === 0) { + if (state.command.length === 0) { return { ok: false, error: "Usage: hack x supervisor job-create [options] -- <command...>", }; } - const envParsed = parseEnvAssignments({ entries: envEntries }); + const envParsed = parseEnvAssignments({ entries: state.envEntries }); if (!envParsed.ok) { return envParsed; } @@ -874,13 +637,13 @@ export function parseJobCreateArgs(opts: { return { ok: true, value: { - ...(project ? { project } : {}), - ...(path ? { path } : {}), - json, - runner, - ...(cwd ? { cwd } : {}), + ...(state.project ? { project: state.project } : {}), + ...(state.path ? { path: state.path } : {}), + json: state.json, + runner: state.runner, + ...(state.cwd ? { cwd: state.cwd } : {}), ...(envParsed.value ? { env: envParsed.value } : {}), - command, + command: state.command, }, }; } @@ -902,241 +665,605 @@ type ShellParseResult = | { readonly ok: true; readonly value: ShellArgs } | { readonly ok: false; readonly error: string }; +type ShellArgsState = { + project?: string; + projectId?: string; + path?: string; + gateway?: string; + token?: string; + shell?: string; + cwd?: string; + cols?: number; + rows?: number; + envEntries: string[]; +}; + export function parseShellArgs(opts: { readonly args: readonly string[]; }): ShellParseResult { - let project: string | undefined; - let projectId: string | undefined; - let path: string | undefined; - let gateway: string | undefined; - let token: string | undefined; - let shell: string | undefined; - let cwd: string | undefined; - let cols: number | undefined; - let rows: number | undefined; - const envEntries: string[] = []; - - const takeValue = ( - _flag: string, - value: string | undefined - ): string | null => { - if (!value || value.startsWith("-")) { - return null; - } - return value; + const parsedState = parseShellArgsState({ args: opts.args }); + if (!parsedState.ok) { + return parsedState; + } + + if (parsedState.state.project && parsedState.state.projectId) { + return { + ok: false, + error: "Use either --project or --project-id (not both).", + }; + } + + const envParsed = parseEnvAssignments({ + entries: parsedState.state.envEntries, + }); + if (!envParsed.ok) { + return envParsed; + } + + return { + ok: true, + value: { + ...(parsedState.state.project + ? { project: parsedState.state.project } + : {}), + ...(parsedState.state.projectId + ? { projectId: parsedState.state.projectId } + : {}), + ...(parsedState.state.path ? { path: parsedState.state.path } : {}), + ...(parsedState.state.gateway + ? { gateway: parsedState.state.gateway } + : {}), + ...(parsedState.state.token ? { token: parsedState.state.token } : {}), + ...(parsedState.state.shell ? { shell: parsedState.state.shell } : {}), + ...(parsedState.state.cwd ? { cwd: parsedState.state.cwd } : {}), + ...(parsedState.state.cols !== undefined + ? { cols: parsedState.state.cols } + : {}), + ...(parsedState.state.rows !== undefined + ? { rows: parsedState.state.rows } + : {}), + ...(envParsed.value ? { env: envParsed.value } : {}), + }, }; +} - for (let i = 0; i < opts.args.length; i += 1) { - const tokenArg = opts.args[i] ?? ""; - if (tokenArg === "--") { - if (opts.args.length > i + 1) { - return { ok: false, error: "Unexpected extra arguments for shell." }; - } +function parseShellArgsState(input: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly state: ShellArgsState } + | { readonly ok: false; readonly error: string } { + const state: ShellArgsState = { + envEntries: [], + }; + + for (let i = 0; i < input.args.length; i += 1) { + const token = input.args[i] ?? ""; + const structuralError = validateShellStructuralToken({ + args: input.args, + index: i, + token, + }); + if (structuralError) { + return { ok: false, error: structuralError }; + } + if (token === "--") { break; } - if (tokenArg.startsWith("--project=")) { - project = tokenArg.slice("--project=".length).trim(); - continue; + const step = handleShellArg({ + args: input.args, + index: i, + token, + state, + }); + if (step.kind === "error") { + return { ok: false, error: step.error }; } - - if (tokenArg === "--project") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--project requires a value." }; - } - project = value; - i += 1; + if (step.kind === "handled") { + i = step.nextIndex; continue; } + return { + ok: false, + error: token.startsWith("-") + ? `Unknown option: ${token}` + : "Unexpected extra arguments for shell.", + }; + } - if (tokenArg.startsWith("--project-id=")) { - projectId = tokenArg.slice("--project-id=".length).trim(); - continue; - } + return { ok: true, state }; +} - if (tokenArg === "--project-id") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--project-id requires a value." }; - } - projectId = value; - i += 1; - continue; - } +function validateShellStructuralToken(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; +}): string | null { + if (input.token !== "--") { + return null; + } + return input.args.length > input.index + 1 + ? "Unexpected extra arguments for shell." + : null; +} - if (tokenArg.startsWith("--path=")) { - path = tokenArg.slice("--path=".length).trim(); - continue; - } +function handleSupervisorArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: SupervisorArgsState; + readonly allowLogsFrom: boolean; + readonly allowEventsFrom: boolean; + readonly allowFollow: boolean; +}): ArgStepResult { + return ( + handleSupervisorBooleanArg(input) ?? + handleSupervisorStringArg(input) ?? + handleSupervisorOffsetArg(input) ?? { kind: "unhandled" } + ); +} - if (tokenArg === "--path") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--path requires a value." }; - } - path = value; - i += 1; - continue; +function handleSupervisorBooleanArg(input: { + readonly token: string; + readonly state: SupervisorArgsState; + readonly allowFollow: boolean; + readonly index: number; +}): ArgStepResult | null { + if (input.token === "--json") { + input.state.json = true; + return { kind: "handled", nextIndex: input.index }; + } + if (input.token === "--follow" || input.token === "--no-follow") { + if (!input.allowFollow) { + return { + kind: "error", + error: `${input.token} is not supported here.`, + }; } + input.state.follow = input.token === "--follow"; + return { kind: "handled", nextIndex: input.index }; + } + return null; +} - if (tokenArg.startsWith("--gateway=")) { - gateway = tokenArg.slice("--gateway=".length).trim(); - continue; - } +function handleSupervisorStringArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: SupervisorArgsState; +}): ArgStepResult | null { + const project = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--project", + }); + if (project.kind !== "unhandled") { + return assignStringOption({ + result: project, + assign: (value) => { + input.state.project = value; + }, + }); + } - if (tokenArg === "--gateway") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--gateway requires a value." }; - } - gateway = value; - i += 1; - continue; - } + const path = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--path", + }); + if (path.kind !== "unhandled") { + return assignStringOption({ + result: path, + assign: (value) => { + input.state.path = value; + }, + }); + } - if (tokenArg.startsWith("--token=")) { - token = tokenArg.slice("--token=".length).trim(); - continue; - } + return null; +} - if (tokenArg === "--token") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--token requires a value." }; - } - token = value; - i += 1; - continue; - } +function handleSupervisorOffsetArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: SupervisorArgsState; + readonly allowLogsFrom: boolean; + readonly allowEventsFrom: boolean; +}): ArgStepResult | null { + const logsFrom = readNumericOption({ + args: input.args, + index: input.index, + token: input.token, + aliases: ["--logs-from", "--from"], + allow: input.allowLogsFrom, + unsupportedError: `${input.token.split("=")[0]} is not supported here.`, + requiredError: + input.token === "--from" + ? "--from requires a value." + : "--logs-from requires a value.", + invalidError: input.token.startsWith("--from") + ? "--from must be a number." + : "--logs-from must be a number.", + parser: parseOffset, + }); + if (logsFrom.kind !== "unhandled") { + return assignNumericOption({ + result: logsFrom, + assign: (value) => { + input.state.logsFrom = value; + }, + }); + } - if (tokenArg.startsWith("--shell=")) { - shell = tokenArg.slice("--shell=".length).trim(); - continue; - } + const eventsFrom = readNumericOption({ + args: input.args, + index: input.index, + token: input.token, + aliases: ["--events-from"], + allow: input.allowEventsFrom, + unsupportedError: "--events-from is not supported here.", + requiredError: "--events-from requires a value.", + invalidError: "--events-from must be a number.", + parser: parseOffset, + }); + if (eventsFrom.kind !== "unhandled") { + return assignNumericOption({ + result: eventsFrom, + assign: (value) => { + input.state.eventsFrom = value; + }, + }); + } - if (tokenArg === "--shell") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--shell requires a value." }; - } - shell = value; - i += 1; - continue; - } + return null; +} - if (tokenArg.startsWith("--cwd=")) { - cwd = tokenArg.slice("--cwd=".length).trim(); - continue; - } +function handleJobCreateArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: JobCreateArgsState; +}): ArgStepResult { + return ( + handleJobCreateBooleanArg(input) ?? + handleJobCreateStringArg(input) ?? + handleJobCreateEnvArg(input) ?? { kind: "unhandled" } + ); +} - if (tokenArg === "--cwd") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--cwd requires a value." }; - } - cwd = value; - i += 1; - continue; - } +function handleJobCreateBooleanArg(input: { + readonly token: string; + readonly state: JobCreateArgsState; + readonly index: number; +}): ArgStepResult | null { + if (input.token !== "--json") { + return null; + } + input.state.json = true; + return { kind: "handled", nextIndex: input.index }; +} - if (tokenArg.startsWith("--cols=")) { - const value = tokenArg.slice("--cols=".length).trim(); - const parsed = parsePositiveInt(value); - if (parsed === null) { - return { ok: false, error: "--cols must be a positive number." }; - } - cols = parsed; - continue; - } +function handleJobCreateStringArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: JobCreateArgsState; +}): ArgStepResult | null { + const project = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--project", + }); + if (project.kind !== "unhandled") { + return assignStringOption({ + result: project, + assign: (value) => { + input.state.project = value; + }, + }); + } - if (tokenArg === "--cols") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--cols requires a value." }; - } - const parsed = parsePositiveInt(value); - if (parsed === null) { - return { ok: false, error: "--cols must be a positive number." }; - } - cols = parsed; - i += 1; - continue; - } + const path = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--path", + }); + if (path.kind !== "unhandled") { + return assignStringOption({ + result: path, + assign: (value) => { + input.state.path = value; + }, + }); + } - if (tokenArg.startsWith("--rows=")) { - const value = tokenArg.slice("--rows=".length).trim(); - const parsed = parsePositiveInt(value); - if (parsed === null) { - return { ok: false, error: "--rows must be a positive number." }; - } - rows = parsed; - continue; - } + const runner = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--runner", + }); + if (runner.kind !== "unhandled") { + return assignStringOption({ + result: runner, + assign: (value) => { + if (value.length > 0) { + input.state.runner = value; + } + }, + }); + } - if (tokenArg === "--rows") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--rows requires a value." }; - } - const parsed = parsePositiveInt(value); - if (parsed === null) { - return { ok: false, error: "--rows must be a positive number." }; - } - rows = parsed; - i += 1; - continue; - } + const cwd = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--cwd", + }); + if (cwd.kind !== "unhandled") { + return assignStringOption({ + result: cwd, + assign: (value) => { + input.state.cwd = value; + }, + }); + } + + return null; +} + +function handleJobCreateEnvArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: JobCreateArgsState; +}): ArgStepResult | null { + const env = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--env", + requiredError: "--env requires KEY=VALUE.", + }); + if (env.kind === "unhandled") { + return null; + } + return assignStringOption({ + result: env, + assign: (value) => { + input.state.envEntries.push(value); + }, + }); +} + +function handleShellArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: ShellArgsState; +}): ArgStepResult { + return ( + handleShellStringArg(input) ?? + handleShellDimensionArg(input) ?? + handleShellEnvArg(input) ?? { kind: "unhandled" } + ); +} - if (tokenArg.startsWith("--env=")) { - envEntries.push(tokenArg.slice("--env=".length)); +function handleShellStringArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: ShellArgsState; +}): ArgStepResult | null { + const options: ReadonlyArray<{ + readonly flag: string; + readonly assign: (value: string) => void; + }> = [ + { flag: "--project", assign: (value) => (input.state.project = value) }, + { + flag: "--project-id", + assign: (value) => (input.state.projectId = value), + }, + { flag: "--path", assign: (value) => (input.state.path = value) }, + { flag: "--gateway", assign: (value) => (input.state.gateway = value) }, + { flag: "--token", assign: (value) => (input.state.token = value) }, + { flag: "--shell", assign: (value) => (input.state.shell = value) }, + { flag: "--cwd", assign: (value) => (input.state.cwd = value) }, + ]; + for (const option of options) { + const result = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: option.flag, + }); + if (result.kind === "unhandled") { continue; } + return assignStringOption({ result, assign: option.assign }); + } + return null; +} - if (tokenArg === "--env") { - const value = takeValue(tokenArg, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--env requires KEY=VALUE." }; - } - envEntries.push(value); - i += 1; +function handleShellDimensionArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: ShellArgsState; +}): ArgStepResult | null { + const dimensions: ReadonlyArray<{ + readonly flag: string; + readonly assign: (value: number) => void; + }> = [ + { flag: "--cols", assign: (value) => (input.state.cols = value) }, + { flag: "--rows", assign: (value) => (input.state.rows = value) }, + ]; + for (const dimension of dimensions) { + const result = readNumericOption({ + args: input.args, + index: input.index, + token: input.token, + aliases: [dimension.flag], + allow: true, + unsupportedError: "", + requiredError: `${dimension.flag} requires a value.`, + invalidError: `${dimension.flag} must be a positive number.`, + parser: parsePositiveInt, + }); + if (result.kind === "unhandled") { continue; } + return assignNumericOption({ result, assign: dimension.assign }); + } + return null; +} + +function handleShellEnvArg(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly state: ShellArgsState; +}): ArgStepResult | null { + const env = readStringOption({ + args: input.args, + index: input.index, + token: input.token, + flag: "--env", + requiredError: "--env requires KEY=VALUE.", + }); + if (env.kind === "unhandled") { + return null; + } + return assignStringOption({ + result: env, + assign: (value) => { + input.state.envEntries.push(value); + }, + }); +} - if (tokenArg.startsWith("-")) { - return { ok: false, error: `Unknown option: ${tokenArg}` }; +type ParsedStringOption = + | { + readonly kind: "handled"; + readonly nextIndex: number; + readonly value: string; } + | { readonly kind: "unhandled" } + | { readonly kind: "error"; readonly error: string }; - return { ok: false, error: "Unexpected extra arguments for shell." }; +function readStringOption(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly flag: string; + readonly requiredError?: string; +}): ParsedStringOption { + const inlinePrefix = `${input.flag}=`; + if (input.token.startsWith(inlinePrefix)) { + return { + kind: "handled", + nextIndex: input.index, + value: input.token.slice(inlinePrefix.length).trim(), + }; } - - if (project && projectId) { + if (input.token !== input.flag) { + return { kind: "unhandled" }; + } + const value = takeOptionValue(input.args[input.index + 1]); + if (value === null) { return { - ok: false, - error: "Use either --project or --project-id (not both).", + kind: "error", + error: input.requiredError ?? `${input.flag} requires a value.`, }; } + return { kind: "handled", nextIndex: input.index + 1, value }; +} - const envParsed = parseEnvAssignments({ entries: envEntries }); - if (!envParsed.ok) { - return envParsed; +type ParsedNumericOption = + | { + readonly kind: "handled"; + readonly nextIndex: number; + readonly value: number; + } + | { readonly kind: "unhandled" } + | { readonly kind: "error"; readonly error: string }; + +function readNumericOption(input: { + readonly args: readonly string[]; + readonly index: number; + readonly token: string; + readonly aliases: readonly string[]; + readonly allow: boolean; + readonly unsupportedError: string; + readonly requiredError: string; + readonly invalidError: string; + readonly parser: (value: string) => number | null; +}): ParsedNumericOption { + for (const alias of input.aliases) { + const inlinePrefix = `${alias}=`; + if (input.token === alias || input.token.startsWith(inlinePrefix)) { + if (!input.allow) { + return { kind: "error", error: input.unsupportedError }; + } + const value = + input.token === alias + ? takeOptionValue(input.args[input.index + 1]) + : input.token.slice(inlinePrefix.length).trim(); + if (value === null) { + return { kind: "error", error: input.requiredError }; + } + const parsed = input.parser(value); + if (parsed === null) { + return { kind: "error", error: input.invalidError }; + } + return { + kind: "handled", + nextIndex: input.token === alias ? input.index + 1 : input.index, + value: parsed, + }; + } } + return { kind: "unhandled" }; +} - return { - ok: true, - value: { - ...(project ? { project } : {}), - ...(projectId ? { projectId } : {}), - ...(path ? { path } : {}), - ...(gateway ? { gateway } : {}), - ...(token ? { token } : {}), - ...(shell ? { shell } : {}), - ...(cwd ? { cwd } : {}), - ...(cols !== undefined ? { cols } : {}), - ...(rows !== undefined ? { rows } : {}), - ...(envParsed.value ? { env: envParsed.value } : {}), - }, - }; +function assignStringOption(input: { + readonly result: ParsedStringOption; + readonly assign: (value: string) => void; +}): ArgStepResult { + if (input.result.kind === "error") { + return input.result; + } + if (input.result.kind === "unhandled") { + return input.result; + } + input.assign(input.result.value); + return { kind: "handled", nextIndex: input.result.nextIndex }; +} + +function assignNumericOption(input: { + readonly result: ParsedNumericOption; + readonly assign: (value: number) => void; +}): ArgStepResult { + if (input.result.kind === "error") { + return input.result; + } + if (input.result.kind === "unhandled") { + return input.result; + } + input.assign(input.result.value); + return { kind: "handled", nextIndex: input.result.nextIndex }; +} + +function takeOptionValue(value: string | undefined): string | null { + if (!value || value.startsWith("-")) { + return null; + } + return value; } function parseOffset(value: string): number | null { @@ -1767,95 +1894,360 @@ async function streamJobLogs(opts: { let lastHeartbeatAt = Date.now(); const heartbeatIntervalMs = 5000; - if (opts.json) { - writeJsonLine({ - type: "start", - jobId: opts.jobId, - logsOffset, - ...(opts.includeEvents ? { eventsSeq } : {}), - }); - } + emitStreamBoundary({ + json: opts.json, + type: "start", + jobId: opts.jobId, + logsOffset, + includeEvents: opts.includeEvents, + eventsSeq, + }); while (true) { - let didWork = false; - - const logChunk = await readFileChunk({ - path: opts.store.getJobPaths({ jobId: opts.jobId }).combinedPath, + const logResult = await streamJobLogChunk({ + store: opts.store, + jobId: opts.jobId, offset: logsOffset, + json: opts.json, + }); + logsOffset = logResult.nextOffset; + + const eventResult = opts.includeEvents + ? await streamJobEventBatch({ + store: opts.store, + jobId: opts.jobId, + eventsSeq, + json: opts.json, + logger: opts.logger, + }) + : { didWork: false, nextEventsSeq: eventsSeq }; + eventsSeq = eventResult.nextEventsSeq; + + const didWork = logResult.didWork || eventResult.didWork; + const now = Date.now(); + lastHeartbeatAt = maybeEmitStreamHeartbeat({ + json: opts.json, + didWork, + now, + lastHeartbeatAt, + heartbeatIntervalMs, + logsOffset, + includeEvents: opts.includeEvents, + eventsSeq, }); - if (logChunk) { - didWork = true; - logsOffset = logChunk.nextOffset; - if (opts.json) { - writeJsonLine({ - type: "log", - stream: "combined", - offset: logsOffset, - data: logChunk.data, - }); - } else { - process.stdout.write(logChunk.data); - } - } - if (opts.includeEvents) { - const events = await opts.store.readEvents({ jobId: opts.jobId }); - const next = events.filter((event) => event.seq > eventsSeq); - if (next.length > 0) { - didWork = true; - for (const event of next) { - eventsSeq = event.seq; - if (opts.json) { - writeJsonLine({ type: "event", seq: event.seq, event }); - } else { - opts.logger.info({ message: `event ${event.type}` }); - } - } - } + if (!opts.follow) { + break; } - if (!opts.follow) { + const shouldStop = await shouldStopStreaming({ + store: opts.store, + jobId: opts.jobId, + didWork, + }); + if (shouldStop) { break; } - if ( - opts.json && - !didWork && - Date.now() - lastHeartbeatAt >= heartbeatIntervalMs - ) { - writeJsonLine({ - type: "heartbeat", - ts: new Date().toISOString(), - logsOffset, - ...(opts.includeEvents ? { eventsSeq } : {}), - }); - lastHeartbeatAt = Date.now(); + await sleep(500); + } + + emitStreamBoundary({ + json: opts.json, + type: "end", + jobId: opts.jobId, + logsOffset, + includeEvents: opts.includeEvents, + eventsSeq, + }); + + return { logsOffset, eventsSeq }; +} + +type ShellConnectionResult = + | { readonly ok: true; readonly gatewayUrl: string; readonly token: string } + | { readonly ok: false; readonly error: string }; + +function resolveShellConnection(input: { + readonly parsed: ShellArgs; +}): ShellConnectionResult { + const envGateway = (process.env.HACK_GATEWAY_URL ?? "").trim(); + const gatewayUrl = + input.parsed.gateway ?? + (envGateway.length > 0 ? envGateway : "http://127.0.0.1:7788"); + const token = + input.parsed.token ?? (process.env.HACK_GATEWAY_TOKEN ?? "").trim(); + if (!token) { + return { + ok: false, + error: + "Missing gateway token. Set HACK_GATEWAY_TOKEN or pass --token (write scope required).", + }; + } + return { ok: true, gatewayUrl, token }; +} + +type ResolvedShellProject = + | { + readonly ok: true; + readonly projectId: string; + readonly localProject?: ProjectContext; } + | { readonly ok: false; readonly error: string }; - if (!didWork) { - const meta = await opts.store.readJobMeta({ jobId: opts.jobId }); - if (meta && TERMINAL_STATUSES.has(meta.status)) { - break; - } +async function resolveShellProject(input: { + readonly ctx: { + readonly project?: ProjectContext; + readonly projectId?: string; + readonly projectName?: string; + readonly cwd: string; + readonly logger: { + warn: (input: { message: string }) => void; + }; + }; + readonly parsed: ShellArgs; + readonly client: GatewayClient; +}): Promise<ResolvedShellProject> { + let projectId: string | undefined = + input.parsed.projectId ?? input.ctx.projectId; + let projectName: string | undefined = + input.parsed.project ?? input.ctx.projectName; + let localProject: ProjectContext | undefined = input.ctx.project; + + if (input.parsed.project || input.parsed.path) { + const localProjectResult = await resolveSupervisorProject({ + ctx: input.ctx, + projectOpt: input.parsed.project, + pathOpt: input.parsed.path, + }); + if (!localProjectResult.ok) { + return localProjectResult; } + localProject = localProjectResult.project; + projectId = projectId ?? localProjectResult.projectId; + projectName = localProjectResult.projectName ?? projectName; + } - if (didWork) { - lastHeartbeatAt = Date.now(); + const projectIdResult = await resolveGatewayProjectId({ + client: input.client, + projectId, + projectName, + }); + if (!projectIdResult.ok) { + return projectIdResult; + } + + return { + ok: true, + projectId: projectIdResult.projectId, + ...(localProject ? { localProject } : {}), + }; +} + +function resolveShellDimensions(input: { readonly parsed: ShellArgs }): { + readonly cols: number; + readonly rows: number; +} { + const fallbackCols = 120; + const fallbackRows = 30; + return { + cols: + input.parsed.cols ?? + (typeof process.stdout.columns === "number" + ? process.stdout.columns + : fallbackCols), + rows: + input.parsed.rows ?? + (typeof process.stdout.rows === "number" + ? process.stdout.rows + : fallbackRows), + }; +} + +function buildShellCreateInput(input: { + readonly parsed: ShellArgs; + readonly projectId: string; + readonly cols: number; + readonly rows: number; +}): { + readonly projectId: string; + readonly shell?: string; + readonly cwd?: string; + readonly env?: Record<string, string>; + readonly cols: number; + readonly rows: number; +} { + return { + projectId: input.projectId, + ...(input.parsed.shell ? { shell: input.parsed.shell } : {}), + ...(input.parsed.cwd ? { cwd: input.parsed.cwd } : {}), + ...(input.parsed.env ? { env: input.parsed.env } : {}), + cols: input.cols, + rows: input.rows, + }; +} + +type ShellCreateResult = Awaited<ReturnType<GatewayClient["createShell"]>>; + +async function createShellWithRetry(input: { + readonly ctx: { + readonly logger: { + info: (input: { message: string }) => void; + warn: (input: { message: string }) => void; + }; + }; + readonly client: GatewayClient; + readonly localProject?: ProjectContext; + readonly shellInput: Parameters<GatewayClient["createShell"]>[0]; +}): Promise<ShellCreateResult> { + let created = await input.client.createShell(input.shellInput); + if (!(created.ok || created.error.code !== "writes_disabled")) { + const didEnable = await maybeEnableGatewayWrites({ + ctx: input.ctx, + project: input.localProject, + }); + if (didEnable) { + created = await input.client.createShell(input.shellInput); } + } + return created; +} - await sleep(500); +async function reportShellCreateFailure(input: { + readonly logger: { + error: (input: { message: string }) => void; + info: (input: { message: string }) => void; + warn: (input: { message: string }) => void; + }; + readonly created: ShellCreateResult; +}): Promise<void> { + if (input.created.ok) { + return; } + if ( + input.created.error.code === "writes_disabled" || + input.created.error.code === "write_scope_required" + ) { + await reportGatewayConfigSource({ logger: input.logger }); + } + const detailed = buildShellCreateErrorHint({ error: input.created.error }); + input.logger.error({ + message: `Shell create failed (${input.created.status}): ${input.created.error.message}`, + }); + if (detailed) { + input.logger.info({ message: detailed }); + } +} - if (opts.json) { +function emitStreamBoundary(input: { + readonly json: boolean; + readonly type: "start" | "end"; + readonly jobId: string; + readonly logsOffset: number; + readonly includeEvents: boolean; + readonly eventsSeq: number; +}): void { + if (!input.json) { + return; + } + writeJsonLine({ + type: input.type, + jobId: input.jobId, + logsOffset: input.logsOffset, + ...(input.includeEvents ? { eventsSeq: input.eventsSeq } : {}), + }); +} + +async function streamJobLogChunk(input: { + readonly store: JobStore; + readonly jobId: string; + readonly offset: number; + readonly json: boolean; +}): Promise<{ readonly didWork: boolean; readonly nextOffset: number }> { + const logChunk = await readFileChunk({ + path: input.store.getJobPaths({ jobId: input.jobId }).combinedPath, + offset: input.offset, + }); + if (!logChunk) { + return { didWork: false, nextOffset: input.offset }; + } + if (input.json) { writeJsonLine({ - type: "end", - jobId: opts.jobId, - logsOffset, - ...(opts.includeEvents ? { eventsSeq } : {}), + type: "log", + stream: "combined", + offset: logChunk.nextOffset, + data: logChunk.data, }); + } else { + process.stdout.write(logChunk.data); } + return { didWork: true, nextOffset: logChunk.nextOffset }; +} - return { logsOffset, eventsSeq }; +async function streamJobEventBatch(input: { + readonly store: JobStore; + readonly jobId: string; + readonly eventsSeq: number; + readonly json: boolean; + readonly logger: { info: (input: { message: string }) => void }; +}): Promise<{ readonly didWork: boolean; readonly nextEventsSeq: number }> { + const events = await input.store.readEvents({ jobId: input.jobId }); + const nextEvents = events.filter((event) => event.seq > input.eventsSeq); + if (nextEvents.length === 0) { + return { didWork: false, nextEventsSeq: input.eventsSeq }; + } + let nextEventsSeq = input.eventsSeq; + for (const event of nextEvents) { + nextEventsSeq = event.seq; + if (input.json) { + writeJsonLine({ type: "event", seq: event.seq, event }); + continue; + } + input.logger.info({ message: `event ${event.type}` }); + } + return { didWork: true, nextEventsSeq }; +} + +function maybeEmitStreamHeartbeat(input: { + readonly json: boolean; + readonly didWork: boolean; + readonly now: number; + readonly lastHeartbeatAt: number; + readonly heartbeatIntervalMs: number; + readonly logsOffset: number; + readonly includeEvents: boolean; + readonly eventsSeq: number; +}): number { + if (input.didWork) { + return input.now; + } + if ( + !( + input.json && + input.now - input.lastHeartbeatAt >= input.heartbeatIntervalMs + ) + ) { + return input.lastHeartbeatAt; + } + writeJsonLine({ + type: "heartbeat", + ts: new Date().toISOString(), + logsOffset: input.logsOffset, + ...(input.includeEvents ? { eventsSeq: input.eventsSeq } : {}), + }); + return input.now; +} + +async function shouldStopStreaming(input: { + readonly store: JobStore; + readonly jobId: string; + readonly didWork: boolean; +}): Promise<boolean> { + if (input.didWork) { + return false; + } + const meta = await input.store.readJobMeta({ jobId: input.jobId }); + return meta ? TERMINAL_STATUSES.has(meta.status) : false; } function writeJsonLine(value: Record<string, unknown>): void { diff --git a/src/control-plane/extensions/tailscale/commands.ts b/src/control-plane/extensions/tailscale/commands.ts index ddb05d3bb..2f2e1bd85 100644 --- a/src/control-plane/extensions/tailscale/commands.ts +++ b/src/control-plane/extensions/tailscale/commands.ts @@ -493,6 +493,7 @@ export function parseTailscaleOauthStatusArgs(opts: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Tailscale OAuth connect parsing models several optional flags, defaults, and validation branches in one parser. export function parseTailscaleOauthConnectArgs(opts: { readonly args: readonly string[]; }): TailscaleOauthConnectArgsParseResult { diff --git a/src/control-plane/extensions/tickets/commands.ts b/src/control-plane/extensions/tickets/commands.ts index 673e0154c..2db0a216e 100644 --- a/src/control-plane/extensions/tickets/commands.ts +++ b/src/control-plane/extensions/tickets/commands.ts @@ -24,7 +24,11 @@ import { createTicketsStore, type TicketSyncConflictResolution, } from "./store.ts"; -import { createGitTicketsChannel } from "./tickets-git-channel.ts"; +import { + createGitTicketsChannel, + type TicketsGitHealth, + type TicketsGitRepairResult, +} from "./tickets-git-channel.ts"; import { checkTicketsSkill, installTicketsSkill, @@ -41,503 +45,19 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ name: "setup", summary: "Install tickets integrations (skill + agent docs)", scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - // Enable the extension in project config if not already enabled - await ensureTicketsExtensionEnabled({ - projectDir: ctx.project.projectDir, - logger: ctx.logger, - }); - - const parsed = parseTicketsSetupArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const targets = parsed.value.all - ? (["agents", "claude"] as const) - : ([ - ...(parsed.value.agents ? (["agents"] as const) : []), - ...(parsed.value.claude ? (["claude"] as const) : []), - ] as const); - - const resolvedTargets = - targets.length > 0 ? targets : (["agents", "claude"] as const); - - const scope = parsed.value.global ? "user" : "project"; - const projectRoot = ctx.project.projectRoot; - - let action: "install" | "check" | "remove"; - if (parsed.value.remove) { - action = "remove"; - } else if (parsed.value.check) { - action = "check"; - } else { - action = "install"; - } - const repoState = await checkTicketsRepoState({ projectRoot }); - - let repoGitignore: { - status: TicketsRepoGitignoreStatus | TicketsRepoGitignoreFixStatus; - path: string; - message?: string; - } = { - status: repoState.gitignore.status, - path: repoState.gitignore.path, - message: repoState.gitignore.message, - }; - let repoTracking: { - status: TicketsRepoTrackedStatus | TicketsRepoUntrackStatus; - message?: string; - } = { - status: repoState.tracked.status, - message: repoState.tracked.message, - }; - - if (action === "install") { - if (repoState.gitignore.status === "missing") { - repoGitignore = await ensureTicketsGitignore({ projectRoot }); - } else if (repoState.gitignore.status === "present") { - repoGitignore = { status: "noop", path: repoState.gitignore.path }; - } - - if (repoState.tracked.status === "tracked") { - const canPrompt = isTty() && isGumAvailable() && !parsed.value.json; - if (canPrompt) { - const confirmed = await gumConfirm({ - prompt: - "Untrack .hack/tickets from the main branch? (keeps files on disk)", - default: true, - }); - if (confirmed.ok && confirmed.value) { - repoTracking = await untrackTicketsRepo({ projectRoot }); - } else { - repoTracking = { - status: "skipped", - message: "Skipped untracking .hack/tickets.", - }; - } - } else { - repoTracking = { - status: "skipped", - message: "Run: git rm -r --cached .hack/tickets", - }; - } - } - } - - let skill: Awaited<ReturnType<typeof checkTicketsSkill>>; - const skillProjectRoot = scope === "project" ? projectRoot : undefined; - if (action === "check") { - skill = await checkTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } else if (action === "remove") { - skill = await removeTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } else { - skill = await installTicketsSkill({ - scope, - projectRoot: skillProjectRoot, - }); - } - - let docs: - | TicketsAgentDocCheckResult[] - | TicketsAgentDocRemoveResult[] - | TicketsAgentDocUpdateResult[]; - if (action === "check") { - docs = await checkTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } else if (action === "remove") { - docs = await removeTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } else { - docs = await upsertTicketsAgentDocs({ - projectRoot, - targets: resolvedTargets, - }); - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ skill, docs, repo: { gitignore: repoGitignore, tracking: repoTracking } }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Tickets setup", - tone: "success", - lines: [ - `skill: ${skill.status} (${skill.path})`, - ...docs.map((r) => `${r.target}: ${r.status} (${r.path})`), - `repo.gitignore: ${repoGitignore.status} (${repoGitignore.path})`, - `repo.tracking: ${repoTracking.status}${ - repoTracking.message ? ` (${repoTracking.message})` : "" - }`, - ], - }); - - if (action === "install") { - await maybeEnsureTicketsGitHealth({ ctx, json: parsed.value.json }); - } - - return docs.some((r) => r.status === "error") || skill.status === "error" - ? 1 - : 0; - }, + handler: ({ ctx, args }) => handleTicketsSetupCommand({ ctx, args }), }, { name: "create", summary: "Create a new ticket", scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const title = (parsed.value.title ?? "").trim(); - if (!title) { - ctx.logger.error({ - message: 'Usage: hack x tickets create --title "..."', - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const body = await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - }); - - const dependsOnResult = resolveTicketRefs({ - values: parsed.value.dependsOn, - label: "--depends-on", - }); - if (!dependsOnResult.ok) { - ctx.logger.error({ message: dependsOnResult.error }); - return 1; - } - - const blocksResult = resolveTicketRefs({ - values: parsed.value.blocks, - label: "--blocks", - }); - if (!blocksResult.ok) { - ctx.logger.error({ message: blocksResult.error }); - return 1; - } - - const created = await store.createTicket({ - title, - body, - ...(dependsOnResult.refs.length > 0 - ? { dependsOn: dependsOnResult.refs } - : {}), - ...(blocksResult.refs.length > 0 ? { blocks: blocksResult.refs } : {}), - ...(parsed.value.owner ? { owner: parsed.value.owner } : {}), - ...(parsed.value.source ? { source: parsed.value.source } : {}), - ...(parsed.value.assignee ? { assignee: parsed.value.assignee } : {}), - ...(parsed.value.tags.length > 0 ? { tags: parsed.value.tags } : {}), - ...(parsed.value.externalSystem - ? { externalSystem: parsed.value.externalSystem } - : {}), - ...(parsed.value.externalId - ? { externalId: parsed.value.externalId } - : {}), - ...(parsed.value.externalKey - ? { externalKey: parsed.value.externalKey } - : {}), - ...(parsed.value.externalUrl - ? { externalUrl: parsed.value.externalUrl } - : {}), - ...(parsed.value.externalProjectId - ? { externalProjectId: parsed.value.externalProjectId } - : {}), - ...(parsed.value.externalProjectName - ? { externalProjectName: parsed.value.externalProjectName } - : {}), - ...(parsed.value.externalTeamId - ? { externalTeamId: parsed.value.externalTeamId } - : {}), - actor: parsed.value.actor, - }); - - if (!created.ok) { - ctx.logger.error({ message: created.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ticket: created.ticket }, null, 2)}\n` - ); - return 0; - } - - await display.kv({ - title: "Ticket created", - entries: [ - ["ticket_id", created.ticket.ticketId], - ["title", created.ticket.title], - ["status", created.ticket.status], - ["owner", created.ticket.owner], - ["source", created.ticket.source], - ["created_at", created.ticket.createdAt], - ["updated_at", created.ticket.updatedAt], - ], - }); - return 0; - }, + handler: ({ ctx, args }) => handleTicketsCreateCommand({ ctx, args }), }, { name: "update", summary: "Update a ticket", scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseTicketsArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets update <ticket-id> [--title "..."] [--body "..."] [--body-file <path>] [--body-stdin] [--depends-on "..."] [--blocks "..."] [--clear-depends-on] [--clear-blocks] [--json]', - }); - return 1; - } - - const title = parsed.value.title?.trim(); - if (parsed.value.title !== undefined && !title) { - ctx.logger.error({ message: "Title cannot be empty." }); - return 1; - } - - if (parsed.value.clearDependsOn && parsed.value.dependsOn.length > 0) { - ctx.logger.error({ - message: "--clear-depends-on cannot be combined with --depends-on.", - }); - return 1; - } - - if (parsed.value.clearBlocks && parsed.value.blocks.length > 0) { - ctx.logger.error({ - message: "--clear-blocks cannot be combined with --blocks.", - }); - return 1; - } - if (parsed.value.clearTags && parsed.value.tags.length > 0) { - ctx.logger.error({ - message: "--clear-tags cannot be combined with --tags/--tag.", - }); - return 1; - } - if (parsed.value.clearAssignee && parsed.value.assignee !== undefined) { - ctx.logger.error({ - message: "--clear-assignee cannot be combined with --assignee.", - }); - return 1; - } - - const bodyRequested = - parsed.value.body !== undefined || - parsed.value.bodyFile !== undefined || - parsed.value.bodyStdin; - - const body = bodyRequested - ? await resolveTicketBody({ - body: parsed.value.body, - bodyFile: parsed.value.bodyFile, - bodyStdin: parsed.value.bodyStdin, - allowEmpty: true, - }) - : undefined; - - const dependsOnResult = - parsed.value.dependsOn.length > 0 - ? resolveTicketRefs({ - values: parsed.value.dependsOn, - label: "--depends-on", - }) - : { ok: true as const, refs: [] }; - - if (!dependsOnResult.ok) { - ctx.logger.error({ message: dependsOnResult.error }); - return 1; - } - - const blocksResult = - parsed.value.blocks.length > 0 - ? resolveTicketRefs({ - values: parsed.value.blocks, - label: "--blocks", - }) - : { ok: true as const, refs: [] }; - - if (!blocksResult.ok) { - ctx.logger.error({ message: blocksResult.error }); - return 1; - } - - let dependsOn: string[] | undefined; - if (parsed.value.clearDependsOn) { - dependsOn = []; - } else if (parsed.value.dependsOn.length > 0) { - dependsOn = dependsOnResult.refs; - } else { - dependsOn = undefined; - } - - let blocks: string[] | undefined; - if (parsed.value.clearBlocks) { - blocks = []; - } else if (parsed.value.blocks.length > 0) { - blocks = blocksResult.refs; - } else { - blocks = undefined; - } - - let tags: string[] | undefined; - if (parsed.value.clearTags) { - tags = []; - } else if (parsed.value.tags.length > 0) { - tags = [...parsed.value.tags]; - } else { - tags = undefined; - } - - const hasUpdates = - title !== undefined || - bodyRequested || - dependsOn !== undefined || - blocks !== undefined || - parsed.value.assignee !== undefined || - parsed.value.clearAssignee || - tags !== undefined || - parsed.value.owner !== undefined || - parsed.value.source !== undefined || - parsed.value.externalSystem !== undefined || - parsed.value.externalId !== undefined || - parsed.value.externalKey !== undefined || - parsed.value.externalUrl !== undefined || - parsed.value.externalProjectId !== undefined || - parsed.value.externalProjectName !== undefined || - parsed.value.externalTeamId !== undefined; - - if (!hasUpdates) { - ctx.logger.error({ message: "No updates provided." }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const updated = await store.updateTicket({ - ticketId, - ...(title !== undefined ? { title } : {}), - ...(bodyRequested ? { body } : {}), - ...(dependsOn !== undefined ? { dependsOn } : {}), - ...(blocks !== undefined ? { blocks } : {}), - ...(tags !== undefined ? { tags } : {}), - ...(parsed.value.owner !== undefined - ? { owner: parsed.value.owner } - : {}), - ...(parsed.value.source !== undefined - ? { source: parsed.value.source } - : {}), - ...(parsed.value.clearAssignee ? { assignee: "" } : {}), - ...(!parsed.value.clearAssignee && parsed.value.assignee !== undefined - ? { assignee: parsed.value.assignee } - : {}), - ...(parsed.value.externalSystem !== undefined - ? { externalSystem: parsed.value.externalSystem } - : {}), - ...(parsed.value.externalId !== undefined - ? { externalId: parsed.value.externalId } - : {}), - ...(parsed.value.externalKey !== undefined - ? { externalKey: parsed.value.externalKey } - : {}), - ...(parsed.value.externalUrl !== undefined - ? { externalUrl: parsed.value.externalUrl } - : {}), - ...(parsed.value.externalProjectId !== undefined - ? { externalProjectId: parsed.value.externalProjectId } - : {}), - ...(parsed.value.externalProjectName !== undefined - ? { externalProjectName: parsed.value.externalProjectName } - : {}), - ...(parsed.value.externalTeamId !== undefined - ? { externalTeamId: parsed.value.externalTeamId } - : {}), - actor: parsed.value.actor, - }); - - if (!updated.ok) { - ctx.logger.error({ message: updated.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify({ ok: true, ticketId }, null, 2)}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket updated", - tone: "success", - lines: [`${ticketId} updated`], - }); - - return 0; - }, + handler: ({ ctx, args }) => handleTicketsUpdateCommand({ ctx, args }), }, { name: "comment", @@ -793,13 +313,19 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ name: "show", summary: "Show a ticket", scope: "project", + handler: ({ ctx, args }) => handleTicketsShowCommand({ ctx, args }), + }, + { + name: "resolve-conflict", + summary: "Resolve a recorded ticket sync conflict", + scope: "project", handler: async ({ ctx, args }) => { if (!ctx.project) { ctx.logger.error({ message: "No project found. Run inside a repo." }); return 1; } - const parsed = parseTicketsArgs({ args }); + const parsed = parseResolveConflictArgs({ args }); if (!parsed.ok) { ctx.logger.error({ message: parsed.error }); return 1; @@ -807,7 +333,10 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ const ticketId = (parsed.value.rest[0] ?? "").trim(); if (!ticketId) { - ctx.logger.error({ message: "Usage: hack x tickets show <ticket-id>" }); + ctx.logger.error({ + message: + 'Usage: hack x tickets resolve-conflict <ticket-id> --conflict-id <id> --resolution <accept_local|accept_remote|merged|ignore> [--summary "..."] [--json]', + }); return 1; } @@ -821,10 +350,17 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ logger: ctx.logger, }); - const detail = await store.getTicketDetail({ ticketId }); - const ticket = detail.ticket; - if (!ticket) { - ctx.logger.error({ message: `Ticket not found: ${ticketId}` }); + const resolved = await store.resolveSyncConflict({ + ticketId, + conflictId: parsed.value.conflictId, + resolution: parsed.value.resolution, + ...(parsed.value.summary !== undefined + ? { summary: parsed.value.summary } + : {}), + actor: parsed.value.actor, + }); + if (!resolved.ok) { + ctx.logger.error({ message: resolved.error }); return 1; } @@ -832,12 +368,10 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ process.stdout.write( `${JSON.stringify( { - ticket, - comments: detail.comments, - reviewNotes: detail.reviewNotes, - syncCheckpoints: detail.syncCheckpoints, - conflicts: detail.conflicts, - events: detail.events, + ok: true, + ticketId, + conflictId: parsed.value.conflictId, + resolution: parsed.value.resolution, }, null, 2 @@ -846,178 +380,11 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ return 0; } - await display.kv({ - title: `Ticket ${ticket.ticketId}`, - entries: [ - ["title", ticket.title], - ["status", ticket.status], - ["owner", ticket.owner], - ["source", ticket.source], - ["assignee", ticket.assignee ?? ""], - ["tags", ticket.tags.join(", ")], - ["external_system", ticket.externalSystem ?? ""], - ["external_id", ticket.externalId ?? ""], - ["external_key", ticket.externalKey ?? ""], - ["external_url", ticket.externalUrl ?? ""], - ["external_project_id", ticket.externalProjectId ?? ""], - ["external_project_name", ticket.externalProjectName ?? ""], - ["external_team_id", ticket.externalTeamId ?? ""], - ["depends_on", ticket.dependsOn.join(", ")], - ["blocks", ticket.blocks.join(", ")], - ["created_at", ticket.createdAt], - ["updated_at", ticket.updatedAt], - ["project_id", ticket.projectId ?? ""], - ["project_name", ticket.projectName ?? ""], - ], - }); - - if (ticket.body) { - await display.panel({ - title: "Body", - tone: "info", - lines: ticket.body.split("\n"), - }); - } - - if (detail.comments.length > 0) { - await display.table({ - columns: ["comment_id", "source", "actor", "created_at", "body"], - rows: detail.comments.map((comment) => [ - comment.commentId, - comment.source, - comment.actor, - comment.createdAt, - comment.body, - ]), - }); - } - - if (detail.reviewNotes.length > 0) { - await display.table({ - columns: ["note_id", "actor", "created_at", "context", "body"], - rows: detail.reviewNotes.map((reviewNote) => [ - reviewNote.noteId, - reviewNote.actor, - reviewNote.createdAt, - reviewNote.context ?? "", - reviewNote.body, - ]), - }); - } - - if (detail.syncCheckpoints.length > 0) { - await display.table({ - columns: [ - "checkpoint_id", - "provider", - "profile", - "direction", - "cursor", - ], - rows: detail.syncCheckpoints.map((checkpoint) => [ - checkpoint.checkpointId, - checkpoint.provider, - checkpoint.profileId ?? "", - checkpoint.direction ?? "", - checkpoint.remoteCursor ?? "", - ]), - }); - } - - if (detail.conflicts.length > 0) { - await display.table({ - columns: ["conflict_id", "field", "status", "provider", "resolution"], - rows: detail.conflicts.map((conflict) => [ - conflict.conflictId, - conflict.field, - conflict.status, - conflict.provider, - conflict.resolution ?? "", - ]), - }); - } - - await display.table({ - columns: ["ts", "type", "event_id"], - rows: detail.events.map((event) => [ - event.tsIso, - event.type, - event.eventId, - ]), - }); - return 0; - }, - }, - { - name: "resolve-conflict", - summary: "Resolve a recorded ticket sync conflict", - scope: "project", - handler: async ({ ctx, args }) => { - if (!ctx.project) { - ctx.logger.error({ message: "No project found. Run inside a repo." }); - return 1; - } - - const parsed = parseResolveConflictArgs({ args }); - if (!parsed.ok) { - ctx.logger.error({ message: parsed.error }); - return 1; - } - - const ticketId = (parsed.value.rest[0] ?? "").trim(); - if (!ticketId) { - ctx.logger.error({ - message: - 'Usage: hack x tickets resolve-conflict <ticket-id> --conflict-id <id> --resolution <accept_local|accept_remote|merged|ignore> [--summary "..."] [--json]', - }); - return 1; - } - - await maybeEnsureTicketsSetup({ ctx, json: parsed.value.json }); - - const store = createTicketsStore({ - projectRoot: ctx.project.projectRoot, - projectId: ctx.projectId, - projectName: ctx.projectName, - controlPlaneConfig: ctx.controlPlaneConfig, - logger: ctx.logger, - }); - - const resolved = await store.resolveSyncConflict({ - ticketId, - conflictId: parsed.value.conflictId, - resolution: parsed.value.resolution, - ...(parsed.value.summary !== undefined - ? { summary: parsed.value.summary } - : {}), - actor: parsed.value.actor, - }); - if (!resolved.ok) { - ctx.logger.error({ message: resolved.error }); - return 1; - } - - if (parsed.value.json) { - process.stdout.write( - `${JSON.stringify( - { - ok: true, - ticketId, - conflictId: parsed.value.conflictId, - resolution: parsed.value.resolution, - }, - null, - 2 - )}\n` - ); - return 0; - } - - await display.panel({ - title: "Ticket conflict resolved", - tone: "success", - lines: [ - `${ticketId} ${parsed.value.conflictId} → ${parsed.value.resolution}`, + await display.panel({ + title: "Ticket conflict resolved", + tone: "success", + lines: [ + `${ticketId} ${parsed.value.conflictId} → ${parsed.value.resolution}`, ], }); return 0; @@ -1148,6 +515,903 @@ export const TICKETS_COMMANDS: readonly ExtensionCommand[] = [ }, ]; +const DEFAULT_TICKETS_SETUP_TARGETS = ["agents", "claude"] as const; +const TICKETS_SETUP_USAGE = + "Usage: hack x tickets setup [--agents|--claude|--all] [--global] [--check|--remove] [--json]"; +const TICKETS_UPDATE_USAGE = + 'Usage: hack x tickets update <ticket-id> [--title "..."] [--body "..."] [--body-file <path>] [--body-stdin] [--depends-on "..."] [--blocks "..."] [--clear-depends-on] [--clear-blocks] [--json]'; +const TICKETS_RESOLUTION_ERROR = + "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore."; + +type TicketsSetupTarget = (typeof DEFAULT_TICKETS_SETUP_TARGETS)[number]; +type TicketsSetupAction = "install" | "check" | "remove"; +type TicketsRepoGitignoreResult = { + status: TicketsRepoGitignoreStatus | TicketsRepoGitignoreFixStatus; + path: string; + message?: string; +}; +type TicketsRepoTrackingResult = { + status: TicketsRepoTrackedStatus | TicketsRepoUntrackStatus; + message?: string; +}; +type TicketsSetupDocResults = + | TicketsAgentDocCheckResult[] + | TicketsAgentDocRemoveResult[] + | TicketsAgentDocUpdateResult[]; +type ProjectContext = NonNullable<ExtensionCommandContext["project"]>; + +function requireTicketsProject(opts: { + readonly ctx: ExtensionCommandContext; +}): ProjectContext | null { + if (!opts.ctx.project) { + opts.ctx.logger.error({ message: "No project found. Run inside a repo." }); + return null; + } + return opts.ctx.project; +} + +function createTicketsStoreForProject(opts: { + readonly ctx: ExtensionCommandContext; + readonly projectRoot: string; +}) { + return createTicketsStore({ + projectRoot: opts.projectRoot, + projectId: opts.ctx.projectId, + projectName: opts.ctx.projectName, + controlPlaneConfig: opts.ctx.controlPlaneConfig, + logger: opts.ctx.logger, + }); +} + +function resolveTicketsSetupTargets(opts: { + readonly input: TicketsSetupArgs; +}): readonly TicketsSetupTarget[] { + if (opts.input.all) { + return DEFAULT_TICKETS_SETUP_TARGETS; + } + const targets = [ + ...(opts.input.agents ? (["agents"] as const) : []), + ...(opts.input.claude ? (["claude"] as const) : []), + ]; + return targets.length > 0 ? targets : DEFAULT_TICKETS_SETUP_TARGETS; +} + +function resolveTicketsSetupAction(opts: { + readonly input: TicketsSetupArgs; +}): TicketsSetupAction { + if (opts.input.remove) { + return "remove"; + } + if (opts.input.check) { + return "check"; + } + return "install"; +} + +function createRepoGitignoreResult(opts: { + readonly status: TicketsRepoGitignoreStatus | TicketsRepoGitignoreFixStatus; + readonly path: string; + readonly message?: string; +}): TicketsRepoGitignoreResult { + return { + status: opts.status, + path: opts.path, + ...(opts.message ? { message: opts.message } : {}), + }; +} + +function createRepoTrackingResult(opts: { + readonly status: TicketsRepoTrackedStatus | TicketsRepoUntrackStatus; + readonly message?: string; +}): TicketsRepoTrackingResult { + return { + status: opts.status, + ...(opts.message ? { message: opts.message } : {}), + }; +} + +async function resolveTicketsSetupRepoState(opts: { + readonly action: TicketsSetupAction; + readonly projectRoot: string; + readonly json: boolean; +}) { + const repoState = await checkTicketsRepoState({ + projectRoot: opts.projectRoot, + }); + let repoGitignore = createRepoGitignoreResult({ + status: repoState.gitignore.status, + path: repoState.gitignore.path, + message: repoState.gitignore.message, + }); + let repoTracking = createRepoTrackingResult({ + status: repoState.tracked.status, + message: repoState.tracked.message, + }); + if (opts.action !== "install") { + return { repoGitignore, repoTracking }; + } + + repoGitignore = await ensureTicketsSetupGitignore({ + projectRoot: opts.projectRoot, + status: repoState.gitignore.status, + path: repoState.gitignore.path, + }); + repoTracking = await ensureTicketsSetupTracking({ + projectRoot: opts.projectRoot, + status: repoState.tracked.status, + json: opts.json, + }); + + return { repoGitignore, repoTracking }; +} + +function ensureTicketsSetupGitignore(opts: { + readonly projectRoot: string; + readonly status: TicketsRepoGitignoreStatus; + readonly path: string; +}): Promise<TicketsRepoGitignoreResult> { + if (opts.status === "missing") { + return ensureTicketsGitignore({ projectRoot: opts.projectRoot }); + } + if (opts.status === "present") { + return Promise.resolve( + createRepoGitignoreResult({ status: "noop", path: opts.path }) + ); + } + return Promise.resolve( + createRepoGitignoreResult({ status: opts.status, path: opts.path }) + ); +} + +async function ensureTicketsSetupTracking(opts: { + readonly projectRoot: string; + readonly status: TicketsRepoTrackedStatus; + readonly json: boolean; +}): Promise<TicketsRepoTrackingResult> { + if (opts.status !== "tracked") { + return createRepoTrackingResult({ status: opts.status }); + } + const canPrompt = isTty() && isGumAvailable() && !opts.json; + if (!canPrompt) { + return createRepoTrackingResult({ + status: "skipped", + message: "Run: git rm -r --cached .hack/tickets", + }); + } + const confirmed = await gumConfirm({ + prompt: "Untrack .hack/tickets from the main branch? (keeps files on disk)", + default: true, + }); + if (!(confirmed.ok && confirmed.value)) { + return createRepoTrackingResult({ + status: "skipped", + message: "Skipped untracking .hack/tickets.", + }); + } + return untrackTicketsRepo({ projectRoot: opts.projectRoot }); +} + +function runTicketsSkillAction(opts: { + readonly action: TicketsSetupAction; + readonly scope: "project" | "user"; + readonly projectRoot: string; +}) { + const projectRoot = opts.scope === "project" ? opts.projectRoot : undefined; + if (opts.action === "check") { + return checkTicketsSkill({ scope: opts.scope, projectRoot }); + } + if (opts.action === "remove") { + return removeTicketsSkill({ scope: opts.scope, projectRoot }); + } + return installTicketsSkill({ scope: opts.scope, projectRoot }); +} + +function runTicketsDocsAction(opts: { + readonly action: TicketsSetupAction; + readonly projectRoot: string; + readonly targets: readonly TicketsSetupTarget[]; +}): Promise<TicketsSetupDocResults> { + if (opts.action === "check") { + return checkTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: opts.targets, + }); + } + if (opts.action === "remove") { + return removeTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: opts.targets, + }); + } + return upsertTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: opts.targets, + }); +} + +async function writeTicketsSetupOutput(opts: { + readonly json: boolean; + readonly skill: Awaited<ReturnType<typeof checkTicketsSkill>>; + readonly docs: TicketsSetupDocResults; + readonly repoGitignore: TicketsRepoGitignoreResult; + readonly repoTracking: TicketsRepoTrackingResult; +}) { + if (opts.json) { + process.stdout.write( + `${JSON.stringify( + { + skill: opts.skill, + docs: opts.docs, + repo: { + gitignore: opts.repoGitignore, + tracking: opts.repoTracking, + }, + }, + null, + 2 + )}\n` + ); + return; + } + + await display.panel({ + title: "Tickets setup", + tone: "success", + lines: [ + `skill: ${opts.skill.status} (${opts.skill.path})`, + ...opts.docs.map( + (result) => `${result.target}: ${result.status} (${result.path})` + ), + `repo.gitignore: ${opts.repoGitignore.status} (${opts.repoGitignore.path})`, + `repo.tracking: ${opts.repoTracking.status}${ + opts.repoTracking.message ? ` (${opts.repoTracking.message})` : "" + }`, + ], + }); +} + +function getTicketsSetupExitCode(opts: { + readonly skill: Awaited<ReturnType<typeof checkTicketsSkill>>; + readonly docs: TicketsSetupDocResults; +}): number { + return opts.docs.some((result) => result.status === "error") || + opts.skill.status === "error" + ? 1 + : 0; +} + +async function handleTicketsSetupCommand(opts: { + readonly ctx: ExtensionCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const project = requireTicketsProject({ ctx: opts.ctx }); + if (!project) { + return 1; + } + + await ensureTicketsExtensionEnabled({ + projectDir: project.projectDir, + logger: opts.ctx.logger, + }); + + const parsed = parseTicketsSetupArgs({ args: opts.args }); + if (!parsed.ok) { + opts.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const action = resolveTicketsSetupAction({ input: parsed.value }); + const projectRoot = project.projectRoot; + const scope = parsed.value.global ? "user" : "project"; + const targets = resolveTicketsSetupTargets({ input: parsed.value }); + const { repoGitignore, repoTracking } = await resolveTicketsSetupRepoState({ + action, + projectRoot, + json: parsed.value.json, + }); + const skill = await runTicketsSkillAction({ action, scope, projectRoot }); + const docs = await runTicketsDocsAction({ action, projectRoot, targets }); + + await writeTicketsSetupOutput({ + json: parsed.value.json, + skill, + docs, + repoGitignore, + repoTracking, + }); + + if (action === "install") { + await maybeEnsureTicketsGitHealth({ + ctx: opts.ctx, + json: parsed.value.json, + }); + } + + return getTicketsSetupExitCode({ skill, docs }); +} + +function buildCreateTicketInput(opts: { + readonly parsed: TicketsArgs; + readonly title: string; + readonly body?: string; + readonly dependsOn: readonly string[]; + readonly blocks: readonly string[]; +}) { + return { + title: opts.title, + ...(opts.body !== undefined ? { body: opts.body } : {}), + ...(opts.dependsOn.length > 0 ? { dependsOn: opts.dependsOn } : {}), + ...(opts.blocks.length > 0 ? { blocks: opts.blocks } : {}), + ...(opts.parsed.owner ? { owner: opts.parsed.owner } : {}), + ...(opts.parsed.source ? { source: opts.parsed.source } : {}), + ...(opts.parsed.assignee ? { assignee: opts.parsed.assignee } : {}), + ...(opts.parsed.tags.length > 0 ? { tags: opts.parsed.tags } : {}), + ...(opts.parsed.externalSystem + ? { externalSystem: opts.parsed.externalSystem } + : {}), + ...(opts.parsed.externalId ? { externalId: opts.parsed.externalId } : {}), + ...(opts.parsed.externalKey + ? { externalKey: opts.parsed.externalKey } + : {}), + ...(opts.parsed.externalUrl + ? { externalUrl: opts.parsed.externalUrl } + : {}), + ...(opts.parsed.externalProjectId + ? { externalProjectId: opts.parsed.externalProjectId } + : {}), + ...(opts.parsed.externalProjectName + ? { externalProjectName: opts.parsed.externalProjectName } + : {}), + ...(opts.parsed.externalTeamId + ? { externalTeamId: opts.parsed.externalTeamId } + : {}), + actor: opts.parsed.actor, + }; +} + +async function handleTicketsCreateCommand(opts: { + readonly ctx: ExtensionCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const project = requireTicketsProject({ ctx: opts.ctx }); + if (!project) { + return 1; + } + + const parsed = parseTicketsArgs({ args: opts.args }); + if (!parsed.ok) { + opts.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const title = (parsed.value.title ?? "").trim(); + if (!title) { + opts.ctx.logger.error({ + message: 'Usage: hack x tickets create --title "..."', + }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx: opts.ctx, json: parsed.value.json }); + + const dependsOnResult = resolveTicketRefs({ + values: parsed.value.dependsOn, + label: "--depends-on", + }); + if (!dependsOnResult.ok) { + opts.ctx.logger.error({ message: dependsOnResult.error }); + return 1; + } + const blocksResult = resolveTicketRefs({ + values: parsed.value.blocks, + label: "--blocks", + }); + if (!blocksResult.ok) { + opts.ctx.logger.error({ message: blocksResult.error }); + return 1; + } + + const body = await resolveTicketBody({ + body: parsed.value.body, + bodyFile: parsed.value.bodyFile, + bodyStdin: parsed.value.bodyStdin, + }); + const store = createTicketsStoreForProject({ + ctx: opts.ctx, + projectRoot: project.projectRoot, + }); + const created = await store.createTicket( + buildCreateTicketInput({ + parsed: parsed.value, + title, + body, + dependsOn: dependsOnResult.refs, + blocks: blocksResult.refs, + }) + ); + if (!created.ok) { + opts.ctx.logger.error({ message: created.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ ticket: created.ticket }, null, 2)}\n` + ); + return 0; + } + + await display.kv({ + title: "Ticket created", + entries: [ + ["ticket_id", created.ticket.ticketId], + ["title", created.ticket.title], + ["status", created.ticket.status], + ["owner", created.ticket.owner], + ["source", created.ticket.source], + ["created_at", created.ticket.createdAt], + ["updated_at", created.ticket.updatedAt], + ], + }); + return 0; +} + +function resolveUpdateRefValue(opts: { + readonly clear: boolean; + readonly values: readonly string[]; + readonly refs: readonly string[]; +}): string[] | undefined { + if (opts.clear) { + return []; + } + if (opts.values.length > 0) { + return [...opts.refs]; + } + return undefined; +} + +function resolveUpdateTags(opts: { + readonly clear: boolean; + readonly tags: readonly string[]; +}): string[] | undefined { + if (opts.clear) { + return []; + } + if (opts.tags.length > 0) { + return [...opts.tags]; + } + return undefined; +} + +function validateTicketUpdateArgs(opts: { + readonly parsed: TicketsArgs; + readonly ticketId: string; + readonly title?: string; +}): string | null { + if (!opts.ticketId) { + return TICKETS_UPDATE_USAGE; + } + if (opts.parsed.title !== undefined && !opts.title) { + return "Title cannot be empty."; + } + if (opts.parsed.clearDependsOn && opts.parsed.dependsOn.length > 0) { + return "--clear-depends-on cannot be combined with --depends-on."; + } + if (opts.parsed.clearBlocks && opts.parsed.blocks.length > 0) { + return "--clear-blocks cannot be combined with --blocks."; + } + if (opts.parsed.clearTags && opts.parsed.tags.length > 0) { + return "--clear-tags cannot be combined with --tags/--tag."; + } + if (opts.parsed.clearAssignee && opts.parsed.assignee !== undefined) { + return "--clear-assignee cannot be combined with --assignee."; + } + return null; +} + +type TicketUpdatePayload = { + readonly ticketId: string; + readonly title?: string; + readonly bodyRequested: boolean; + readonly body?: string; + readonly dependsOn?: readonly string[]; + readonly blocks?: readonly string[]; + readonly tags?: readonly string[]; +}; + +async function resolveTicketUpdatePayload(opts: { + readonly parsed: TicketsArgs; + readonly ticketId: string; + readonly title?: string; +}): Promise< + | { readonly ok: true; readonly value: TicketUpdatePayload } + | { readonly ok: false; readonly error: string } +> { + const dependsOnResult = + opts.parsed.dependsOn.length > 0 + ? resolveTicketRefs({ + values: opts.parsed.dependsOn, + label: "--depends-on", + }) + : { ok: true as const, refs: [] }; + if (!dependsOnResult.ok) { + return dependsOnResult; + } + const blocksResult = + opts.parsed.blocks.length > 0 + ? resolveTicketRefs({ + values: opts.parsed.blocks, + label: "--blocks", + }) + : { ok: true as const, refs: [] }; + if (!blocksResult.ok) { + return blocksResult; + } + + const bodyRequested = + opts.parsed.body !== undefined || + opts.parsed.bodyFile !== undefined || + opts.parsed.bodyStdin; + const body = bodyRequested + ? await resolveTicketBody({ + body: opts.parsed.body, + bodyFile: opts.parsed.bodyFile, + bodyStdin: opts.parsed.bodyStdin, + allowEmpty: true, + }) + : undefined; + + return { + ok: true, + value: { + ticketId: opts.ticketId, + ...(opts.title !== undefined ? { title: opts.title } : {}), + bodyRequested, + ...(bodyRequested ? { body } : {}), + ...(resolveUpdateRefValue({ + clear: opts.parsed.clearDependsOn, + values: opts.parsed.dependsOn, + refs: dependsOnResult.refs, + }) !== undefined + ? { + dependsOn: resolveUpdateRefValue({ + clear: opts.parsed.clearDependsOn, + values: opts.parsed.dependsOn, + refs: dependsOnResult.refs, + }), + } + : {}), + ...(resolveUpdateRefValue({ + clear: opts.parsed.clearBlocks, + values: opts.parsed.blocks, + refs: blocksResult.refs, + }) !== undefined + ? { + blocks: resolveUpdateRefValue({ + clear: opts.parsed.clearBlocks, + values: opts.parsed.blocks, + refs: blocksResult.refs, + }), + } + : {}), + ...(resolveUpdateTags({ + clear: opts.parsed.clearTags, + tags: opts.parsed.tags, + }) !== undefined + ? { + tags: resolveUpdateTags({ + clear: opts.parsed.clearTags, + tags: opts.parsed.tags, + }), + } + : {}), + }, + }; +} + +function hasTicketUpdateChanges(opts: { + readonly parsed: TicketsArgs; + readonly payload: TicketUpdatePayload; +}): boolean { + return ( + opts.payload.title !== undefined || + opts.payload.bodyRequested || + opts.payload.dependsOn !== undefined || + opts.payload.blocks !== undefined || + opts.payload.tags !== undefined || + opts.parsed.assignee !== undefined || + opts.parsed.clearAssignee || + opts.parsed.owner !== undefined || + opts.parsed.source !== undefined || + opts.parsed.externalSystem !== undefined || + opts.parsed.externalId !== undefined || + opts.parsed.externalKey !== undefined || + opts.parsed.externalUrl !== undefined || + opts.parsed.externalProjectId !== undefined || + opts.parsed.externalProjectName !== undefined || + opts.parsed.externalTeamId !== undefined + ); +} + +function buildTicketUpdateInput(opts: { + readonly parsed: TicketsArgs; + readonly payload: TicketUpdatePayload; +}) { + return { + ticketId: opts.payload.ticketId, + ...(opts.payload.title !== undefined ? { title: opts.payload.title } : {}), + ...(opts.payload.bodyRequested ? { body: opts.payload.body } : {}), + ...(opts.payload.dependsOn !== undefined + ? { dependsOn: opts.payload.dependsOn } + : {}), + ...(opts.payload.blocks !== undefined + ? { blocks: opts.payload.blocks } + : {}), + ...(opts.payload.tags !== undefined ? { tags: opts.payload.tags } : {}), + ...(opts.parsed.owner !== undefined ? { owner: opts.parsed.owner } : {}), + ...(opts.parsed.source !== undefined ? { source: opts.parsed.source } : {}), + ...(opts.parsed.clearAssignee ? { assignee: "" } : {}), + ...(!opts.parsed.clearAssignee && opts.parsed.assignee !== undefined + ? { assignee: opts.parsed.assignee } + : {}), + ...(opts.parsed.externalSystem !== undefined + ? { externalSystem: opts.parsed.externalSystem } + : {}), + ...(opts.parsed.externalId !== undefined + ? { externalId: opts.parsed.externalId } + : {}), + ...(opts.parsed.externalKey !== undefined + ? { externalKey: opts.parsed.externalKey } + : {}), + ...(opts.parsed.externalUrl !== undefined + ? { externalUrl: opts.parsed.externalUrl } + : {}), + ...(opts.parsed.externalProjectId !== undefined + ? { externalProjectId: opts.parsed.externalProjectId } + : {}), + ...(opts.parsed.externalProjectName !== undefined + ? { externalProjectName: opts.parsed.externalProjectName } + : {}), + ...(opts.parsed.externalTeamId !== undefined + ? { externalTeamId: opts.parsed.externalTeamId } + : {}), + actor: opts.parsed.actor, + }; +} + +async function handleTicketsUpdateCommand(opts: { + readonly ctx: ExtensionCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const project = requireTicketsProject({ ctx: opts.ctx }); + if (!project) { + return 1; + } + + const parsed = parseTicketsArgs({ args: opts.args }); + if (!parsed.ok) { + opts.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const ticketId = (parsed.value.rest[0] ?? "").trim(); + const title = parsed.value.title?.trim(); + const validationError = validateTicketUpdateArgs({ + parsed: parsed.value, + ticketId, + title, + }); + if (validationError) { + opts.ctx.logger.error({ message: validationError }); + return 1; + } + + const payload = await resolveTicketUpdatePayload({ + parsed: parsed.value, + ticketId, + title, + }); + if (!payload.ok) { + opts.ctx.logger.error({ message: payload.error }); + return 1; + } + if ( + !hasTicketUpdateChanges({ parsed: parsed.value, payload: payload.value }) + ) { + opts.ctx.logger.error({ message: "No updates provided." }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx: opts.ctx, json: parsed.value.json }); + + const store = createTicketsStoreForProject({ + ctx: opts.ctx, + projectRoot: project.projectRoot, + }); + const updated = await store.updateTicket( + buildTicketUpdateInput({ parsed: parsed.value, payload: payload.value }) + ); + if (!updated.ok) { + opts.ctx.logger.error({ message: updated.error }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify({ ok: true, ticketId }, null, 2)}\n` + ); + return 0; + } + + await display.panel({ + title: "Ticket updated", + tone: "success", + lines: [`${ticketId} updated`], + }); + return 0; +} + +async function writeTicketDetailSections(opts: { + readonly detail: Awaited< + ReturnType<ReturnType<typeof createTicketsStore>["getTicketDetail"]> + >; +}) { + const { detail } = opts; + const ticket = detail.ticket; + if (!ticket) { + return; + } + + await display.kv({ + title: `Ticket ${ticket.ticketId}`, + entries: [ + ["title", ticket.title], + ["status", ticket.status], + ["owner", ticket.owner], + ["source", ticket.source], + ["assignee", ticket.assignee ?? ""], + ["tags", ticket.tags.join(", ")], + ["external_system", ticket.externalSystem ?? ""], + ["external_id", ticket.externalId ?? ""], + ["external_key", ticket.externalKey ?? ""], + ["external_url", ticket.externalUrl ?? ""], + ["external_project_id", ticket.externalProjectId ?? ""], + ["external_project_name", ticket.externalProjectName ?? ""], + ["external_team_id", ticket.externalTeamId ?? ""], + ["depends_on", ticket.dependsOn.join(", ")], + ["blocks", ticket.blocks.join(", ")], + ["created_at", ticket.createdAt], + ["updated_at", ticket.updatedAt], + ["project_id", ticket.projectId ?? ""], + ["project_name", ticket.projectName ?? ""], + ], + }); + if (ticket.body) { + await display.panel({ + title: "Body", + tone: "info", + lines: ticket.body.split("\n"), + }); + } + await maybeDisplayTable({ + rows: detail.comments.map((comment) => [ + comment.commentId, + comment.source, + comment.actor, + comment.createdAt, + comment.body, + ]), + columns: ["comment_id", "source", "actor", "created_at", "body"], + }); + await maybeDisplayTable({ + rows: detail.reviewNotes.map((reviewNote) => [ + reviewNote.noteId, + reviewNote.actor, + reviewNote.createdAt, + reviewNote.context ?? "", + reviewNote.body, + ]), + columns: ["note_id", "actor", "created_at", "context", "body"], + }); + await maybeDisplayTable({ + rows: detail.syncCheckpoints.map((checkpoint) => [ + checkpoint.checkpointId, + checkpoint.provider, + checkpoint.profileId ?? "", + checkpoint.direction ?? "", + checkpoint.remoteCursor ?? "", + ]), + columns: ["checkpoint_id", "provider", "profile", "direction", "cursor"], + }); + await maybeDisplayTable({ + rows: detail.conflicts.map((conflict) => [ + conflict.conflictId, + conflict.field, + conflict.status, + conflict.provider, + conflict.resolution ?? "", + ]), + columns: ["conflict_id", "field", "status", "provider", "resolution"], + }); + await display.table({ + columns: ["ts", "type", "event_id"], + rows: detail.events.map((event) => [ + event.tsIso, + event.type, + event.eventId, + ]), + }); +} + +async function maybeDisplayTable(opts: { + readonly columns: readonly string[]; + readonly rows: readonly (readonly string[])[]; +}) { + if (opts.rows.length === 0) { + return; + } + await display.table({ + columns: [...opts.columns], + rows: opts.rows.map((row) => [...row]), + }); +} + +async function handleTicketsShowCommand(opts: { + readonly ctx: ExtensionCommandContext; + readonly args: readonly string[]; +}): Promise<number> { + const project = requireTicketsProject({ ctx: opts.ctx }); + if (!project) { + return 1; + } + + const parsed = parseTicketsArgs({ args: opts.args }); + if (!parsed.ok) { + opts.ctx.logger.error({ message: parsed.error }); + return 1; + } + + const ticketId = (parsed.value.rest[0] ?? "").trim(); + if (!ticketId) { + opts.ctx.logger.error({ + message: "Usage: hack x tickets show <ticket-id>", + }); + return 1; + } + + await maybeEnsureTicketsSetup({ ctx: opts.ctx, json: parsed.value.json }); + const store = createTicketsStoreForProject({ + ctx: opts.ctx, + projectRoot: project.projectRoot, + }); + const detail = await store.getTicketDetail({ ticketId }); + if (!detail.ticket) { + opts.ctx.logger.error({ message: `Ticket not found: ${ticketId}` }); + return 1; + } + + if (parsed.value.json) { + process.stdout.write( + `${JSON.stringify( + { + ticket: detail.ticket, + comments: detail.comments, + reviewNotes: detail.reviewNotes, + syncCheckpoints: detail.syncCheckpoints, + conflicts: detail.conflicts, + events: detail.events, + }, + null, + 2 + )}\n` + ); + return 0; + } + + await writeTicketDetailSections({ detail }); + return 0; +} + type TicketsArgs = { readonly title?: string; readonly body?: string; @@ -1196,483 +1460,336 @@ type ResolveConflictArgs = { readonly actor?: string; readonly json: boolean; readonly rest: readonly string[]; -}; - -type TicketsSetupParseResult = - | { readonly ok: true; readonly value: TicketsSetupArgs } - | { readonly ok: false; readonly error: string }; - -function parseTicketsArgs(opts: { - readonly args: readonly string[]; -}): TicketsParseResult { - const rest: string[] = []; - let title: string | undefined; - let body: string | undefined; - let bodyFile: string | undefined; - let bodyStdin = false; - const dependsOn: string[] = []; - const blocks: string[] = []; - let clearDependsOn = false; - let clearBlocks = false; - let owner: string | undefined; - let source: string | undefined; - let assignee: string | undefined; - let clearAssignee = false; - const tags: string[] = []; - let clearTags = false; - let externalSystem: string | undefined; - let externalId: string | undefined; - let externalKey: string | undefined; - let externalUrl: string | undefined; - let externalProjectId: string | undefined; - let externalProjectName: string | undefined; - let externalTeamId: string | undefined; - let actor: string | undefined; - let json = false; - - const takeValue = ( - _flag: 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 === "--") { - rest.push(...opts.args.slice(i + 1)); - break; - } - - if (token === "--json") { - json = true; - continue; - } - - if (token.startsWith("--title=")) { - title = token.slice("--title=".length); - continue; - } - - if (token === "--title") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--title requires a value." }; - } - title = value; - i += 1; - continue; - } - - if (token.startsWith("--body=")) { - body = token.slice("--body=".length); - continue; - } - - if (token === "--body") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--body requires a value." }; - } - body = value; - i += 1; - continue; - } - - if (token.startsWith("--body-file=")) { - bodyFile = token.slice("--body-file=".length); - continue; - } - - if (token === "--body-file") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--body-file requires a value." }; - } - bodyFile = value; - i += 1; - continue; - } - - if (token === "--body-stdin") { - bodyStdin = true; - continue; - } - - if (token === "--clear-depends-on") { - clearDependsOn = true; - continue; - } - - if (token === "--clear-blocks") { - clearBlocks = true; - continue; - } - if (token === "--clear-tags") { - clearTags = true; - continue; - } - if (token === "--clear-assignee") { - clearAssignee = true; - continue; - } - - if (token.startsWith("--depends-on=")) { - dependsOn.push(...splitTicketRefs(token.slice("--depends-on=".length))); - continue; - } - - if (token === "--depends-on") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--depends-on requires a value." }; - } - dependsOn.push(...splitTicketRefs(value)); - i += 1; - continue; - } - - if (token.startsWith("--blocks=")) { - blocks.push(...splitTicketRefs(token.slice("--blocks=".length))); - continue; - } - - if (token === "--blocks") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--blocks requires a value." }; - } - blocks.push(...splitTicketRefs(value)); - i += 1; - continue; - } - - if (token.startsWith("--actor=")) { - actor = token.slice("--actor=".length); - continue; - } - - if (token.startsWith("--owner=")) { - owner = token.slice("--owner=".length); - continue; - } - if (token === "--owner") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--owner requires a value." }; - } - owner = value; - i += 1; - continue; - } - if (token.startsWith("--source=")) { - source = token.slice("--source=".length); - continue; - } - if (token === "--source") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--source requires a value." }; - } - source = value; - i += 1; - continue; - } - if (token.startsWith("--assignee=")) { - assignee = token.slice("--assignee=".length); - continue; - } - if (token === "--assignee") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--assignee requires a value." }; - } - assignee = value; - i += 1; - continue; - } - if (token.startsWith("--tags=")) { - tags.push(...splitTags(token.slice("--tags=".length))); - continue; - } - if (token === "--tags") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--tags requires a value." }; - } - tags.push(...splitTags(value)); - i += 1; - continue; - } - if (token.startsWith("--tag=")) { - tags.push(token.slice("--tag=".length)); - continue; - } - if (token === "--tag") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--tag requires a value." }; - } - tags.push(value); - i += 1; - continue; - } - if (token.startsWith("--external-system=")) { - externalSystem = token.slice("--external-system=".length); - continue; - } - if (token === "--external-system") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-system requires a value." }; - } - externalSystem = value; - i += 1; - continue; - } - if (token.startsWith("--external-id=")) { - externalId = token.slice("--external-id=".length); - continue; - } - if (token === "--external-id") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-id requires a value." }; - } - externalId = value; - i += 1; - continue; - } - if (token.startsWith("--external-key=")) { - externalKey = token.slice("--external-key=".length); - continue; - } - if (token === "--external-key") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-key requires a value." }; - } - externalKey = value; - i += 1; - continue; - } - if (token.startsWith("--external-url=")) { - externalUrl = token.slice("--external-url=".length); - continue; - } - if (token === "--external-url") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-url requires a value." }; - } - externalUrl = value; - i += 1; - continue; - } - if (token.startsWith("--external-project-id=")) { - externalProjectId = token.slice("--external-project-id=".length); - continue; - } - if (token === "--external-project-id") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-project-id requires a value." }; - } - externalProjectId = value; - i += 1; - continue; - } - if (token.startsWith("--external-project-name=")) { - externalProjectName = token.slice("--external-project-name=".length); - continue; - } - if (token === "--external-project-name") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { - ok: false, - error: "--external-project-name requires a value.", - }; - } - externalProjectName = value; - i += 1; - continue; - } - if (token.startsWith("--external-team-id=")) { - externalTeamId = token.slice("--external-team-id=".length); - continue; - } - if (token === "--external-team-id") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--external-team-id requires a value." }; - } - externalTeamId = value; - i += 1; - continue; - } +}; - if (token === "--actor") { - const value = takeValue(token, opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--actor requires a value." }; - } - actor = value; - i += 1; - continue; - } +type TicketsSetupParseResult = + | { readonly ok: true; readonly value: TicketsSetupArgs } + | { readonly ok: false; readonly error: string }; - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; +type MutableTicketsArgs = { + rest: string[]; + title?: string; + body?: string; + bodyFile?: string; + bodyStdin: boolean; + dependsOn: string[]; + blocks: string[]; + clearDependsOn: boolean; + clearBlocks: boolean; + owner?: string; + source?: string; + assignee?: string; + clearAssignee: boolean; + tags: string[]; + clearTags: boolean; + externalSystem?: string; + externalId?: string; + externalKey?: string; + externalUrl?: string; + externalProjectId?: string; + externalProjectName?: string; + externalTeamId?: string; + actor?: string; + json: boolean; +}; + +type MutableResolveConflictArgs = { + rest: string[]; + conflictId?: string; + resolution?: TicketSyncConflictResolution; + summary?: string; + actor?: string; + json: boolean; +}; + +type ParsedCliToken = + | { readonly kind: "terminator" } + | { + readonly kind: "option"; + readonly name: string; + readonly inlineValue?: string; } + | { readonly kind: "positional"; readonly value: string }; - rest.push(token); - } +type OptionApplyResult = + | { readonly ok: true; readonly consumedNext: boolean } + | { readonly ok: false; readonly error: string }; +const TICKETS_STRING_OPTION_FIELDS = { + title: "title", + body: "body", + "body-file": "bodyFile", + actor: "actor", + owner: "owner", + source: "source", + assignee: "assignee", + "external-system": "externalSystem", + "external-id": "externalId", + "external-key": "externalKey", + "external-url": "externalUrl", + "external-project-id": "externalProjectId", + "external-project-name": "externalProjectName", + "external-team-id": "externalTeamId", +} as const; + +const TICKETS_BOOLEAN_OPTION_FIELDS = { + json: "json", + "body-stdin": "bodyStdin", + "clear-depends-on": "clearDependsOn", + "clear-blocks": "clearBlocks", + "clear-tags": "clearTags", + "clear-assignee": "clearAssignee", +} as const; + +const TICKETS_LIST_OPTION_FIELDS = { + "depends-on": { field: "dependsOn", split: splitTicketRefs }, + blocks: { field: "blocks", split: splitTicketRefs }, + tags: { field: "tags", split: splitTags }, + tag: { field: "tags", split: (value: string) => [value] }, +} as const; + +const TICKETS_SETUP_FLAGS = { + "--agents": "agents", + "--agents-md": "agents", + "--claude": "claude", + "--claude-md": "claude", + "--all": "all", + "--global": "global", + "--check": "check", + "--remove": "remove", + "--json": "json", +} as const; + +function createMutableTicketsArgs(): MutableTicketsArgs { return { - ok: true, - value: { - ...(title ? { title } : {}), - ...(body ? { body } : {}), - ...(bodyFile ? { bodyFile } : {}), - bodyStdin, - dependsOn, - blocks, - clearDependsOn, - clearBlocks, - ...(owner ? { owner } : {}), - ...(source ? { source } : {}), - ...(assignee !== undefined ? { assignee } : {}), - clearAssignee, - tags: normalizeTags(tags), - clearTags, - ...(externalSystem !== undefined ? { externalSystem } : {}), - ...(externalId !== undefined ? { externalId } : {}), - ...(externalKey !== undefined ? { externalKey } : {}), - ...(externalUrl !== undefined ? { externalUrl } : {}), - ...(externalProjectId !== undefined ? { externalProjectId } : {}), - ...(externalProjectName !== undefined ? { externalProjectName } : {}), - ...(externalTeamId !== undefined ? { externalTeamId } : {}), - ...(actor ? { actor } : {}), - json, - rest, - }, + rest: [], + bodyStdin: false, + dependsOn: [], + blocks: [], + clearDependsOn: false, + clearBlocks: false, + clearAssignee: false, + tags: [], + clearTags: false, + json: false, }; } -function parseResolveConflictArgs(opts: { - readonly args: readonly string[]; +function createMutableResolveConflictArgs(): MutableResolveConflictArgs { + return { + rest: [], + json: false, + }; +} + +function parseCliToken(opts: { readonly token: string }): ParsedCliToken { + if (opts.token === "--") { + return { kind: "terminator" }; + } + if (!opts.token.startsWith("-")) { + return { kind: "positional", value: opts.token }; + } + if (!opts.token.startsWith("--")) { + return { kind: "option", name: opts.token.slice(1) }; + } + const equalsIndex = opts.token.indexOf("="); + if (equalsIndex === -1) { + return { kind: "option", name: opts.token.slice(2) }; + } + return { + kind: "option", + name: opts.token.slice(2, equalsIndex), + inlineValue: opts.token.slice(equalsIndex + 1), + }; +} + +function takeRequiredOptionValue(opts: { + readonly option: string; + readonly inlineValue?: string; + readonly nextToken?: string; }): - | { readonly ok: true; readonly value: ResolveConflictArgs } + | { + readonly ok: true; + readonly value: string; + readonly consumedNext: boolean; + } | { readonly ok: false; readonly error: string } { - const rest: string[] = []; - let conflictId: string | undefined; - let resolution: TicketSyncConflictResolution | undefined; - let summary: string | undefined; - let actor: string | undefined; - let json = false; - - const takeValue = (value: string | undefined): string | null => { - if (!value || value.startsWith("-")) { - return null; + if (opts.inlineValue !== undefined) { + return { ok: true, value: opts.inlineValue, consumedNext: false }; + } + if (!opts.nextToken || opts.nextToken.startsWith("-")) { + return { ok: false, error: `--${opts.option} requires a value.` }; + } + return { ok: true, value: opts.nextToken, consumedNext: true }; +} + +function applyTicketsOption(opts: { + readonly state: MutableTicketsArgs; + readonly option: string; + readonly inlineValue?: string; + readonly nextToken?: string; +}): OptionApplyResult { + const booleanField = + TICKETS_BOOLEAN_OPTION_FIELDS[ + opts.option as keyof typeof TICKETS_BOOLEAN_OPTION_FIELDS + ]; + if (booleanField) { + if (opts.inlineValue !== undefined) { + return { + ok: false, + error: `Unknown option: --${opts.option}=${opts.inlineValue}`, + }; } - return value; + opts.state[booleanField] = true; + return { ok: true, consumedNext: false }; + } + + const stringField = + TICKETS_STRING_OPTION_FIELDS[ + opts.option as keyof typeof TICKETS_STRING_OPTION_FIELDS + ]; + if (stringField) { + const value = takeRequiredOptionValue({ + option: opts.option, + inlineValue: opts.inlineValue, + nextToken: opts.nextToken, + }); + if (!value.ok) { + return value; + } + opts.state[stringField] = value.value; + return { ok: true, consumedNext: value.consumedNext }; + } + + const listField = + TICKETS_LIST_OPTION_FIELDS[ + opts.option as keyof typeof TICKETS_LIST_OPTION_FIELDS + ]; + if (listField) { + const value = takeRequiredOptionValue({ + option: opts.option, + inlineValue: opts.inlineValue, + nextToken: opts.nextToken, + }); + if (!value.ok) { + return value; + } + opts.state[listField.field].push(...listField.split(value.value)); + return { ok: true, consumedNext: value.consumedNext }; + } + + return { ok: false, error: `Unknown option: --${opts.option}` }; +} + +function finalizeTicketsArgs(opts: { + readonly state: MutableTicketsArgs; +}): TicketsArgs { + const { state } = opts; + return { + ...(state.title ? { title: state.title } : {}), + ...(state.body ? { body: state.body } : {}), + ...(state.bodyFile ? { bodyFile: state.bodyFile } : {}), + bodyStdin: state.bodyStdin, + dependsOn: state.dependsOn, + blocks: state.blocks, + clearDependsOn: state.clearDependsOn, + clearBlocks: state.clearBlocks, + ...(state.owner ? { owner: state.owner } : {}), + ...(state.source ? { source: state.source } : {}), + ...(state.assignee !== undefined ? { assignee: state.assignee } : {}), + clearAssignee: state.clearAssignee, + tags: normalizeTags(state.tags), + clearTags: state.clearTags, + ...(state.externalSystem !== undefined + ? { externalSystem: state.externalSystem } + : {}), + ...(state.externalId !== undefined ? { externalId: state.externalId } : {}), + ...(state.externalKey !== undefined + ? { externalKey: state.externalKey } + : {}), + ...(state.externalUrl !== undefined + ? { externalUrl: state.externalUrl } + : {}), + ...(state.externalProjectId !== undefined + ? { externalProjectId: state.externalProjectId } + : {}), + ...(state.externalProjectName !== undefined + ? { externalProjectName: state.externalProjectName } + : {}), + ...(state.externalTeamId !== undefined + ? { externalTeamId: state.externalTeamId } + : {}), + ...(state.actor ? { actor: state.actor } : {}), + json: state.json, + rest: state.rest, }; +} +function parseTicketsArgs(opts: { + readonly args: readonly string[]; +}): TicketsParseResult { + const state = createMutableTicketsArgs(); for (let i = 0; i < opts.args.length; i += 1) { - const token = opts.args[i] ?? ""; - if (token === "--") { - rest.push(...opts.args.slice(i + 1)); + const parsedToken = parseCliToken({ token: opts.args[i] ?? "" }); + if (parsedToken.kind === "terminator") { + state.rest.push(...opts.args.slice(i + 1)); break; } - if (token === "--json") { - json = true; - continue; - } - if (token.startsWith("--conflict-id=")) { - conflictId = token.slice("--conflict-id=".length); - continue; - } - if (token === "--conflict-id") { - const value = takeValue(opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--conflict-id requires a value." }; - } - conflictId = value; - i += 1; + if (parsedToken.kind === "positional") { + state.rest.push(parsedToken.value); continue; } - if (token.startsWith("--resolution=")) { - const parsedResolution = parseConflictResolutionValue({ - value: token.slice("--resolution=".length), - }); - if (!parsedResolution) { - return { - ok: false, - error: - "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", - }; - } - resolution = parsedResolution; - continue; + const applied = applyTicketsOption({ + state, + option: parsedToken.name, + inlineValue: parsedToken.inlineValue, + nextToken: opts.args[i + 1], + }); + if (!applied.ok) { + return applied; } - if (token === "--resolution") { - const value = takeValue(opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--resolution requires a value." }; - } - const parsedResolution = parseConflictResolutionValue({ value }); - if (!parsedResolution) { - return { - ok: false, - error: - "Invalid --resolution value. Expected accept_local|accept_remote|merged|ignore.", - }; - } - resolution = parsedResolution; + if (applied.consumedNext) { i += 1; - continue; } - if (token.startsWith("--summary=")) { - summary = token.slice("--summary=".length); - continue; + } + + return { ok: true, value: finalizeTicketsArgs({ state }) }; +} + +function parseResolveConflictArgs(opts: { + readonly args: readonly string[]; +}): + | { readonly ok: true; readonly value: ResolveConflictArgs } + | { readonly ok: false; readonly error: string } { + const state = createMutableResolveConflictArgs(); + for (let i = 0; i < opts.args.length; i += 1) { + const parsedToken = parseCliToken({ token: opts.args[i] ?? "" }); + if (parsedToken.kind === "terminator") { + state.rest.push(...opts.args.slice(i + 1)); + break; } - if (token === "--summary") { - const value = takeValue(opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--summary requires a value." }; - } - summary = value; - i += 1; + if (parsedToken.kind === "positional") { + state.rest.push(parsedToken.value); continue; } - if (token.startsWith("--actor=")) { - actor = token.slice("--actor=".length); - continue; + const applied = applyResolveConflictOption({ + state, + option: parsedToken.name, + inlineValue: parsedToken.inlineValue, + nextToken: opts.args[i + 1], + }); + if (!applied.ok) { + return applied; } - if (token === "--actor") { - const value = takeValue(opts.args[i + 1]); - if (!value) { - return { ok: false, error: "--actor requires a value." }; - } - actor = value; + if (applied.consumedNext) { i += 1; - continue; - } - if (token.startsWith("-")) { - return { ok: false, error: `Unknown option: ${token}` }; } - rest.push(token); } - if (!conflictId) { + if (!state.conflictId) { return { ok: false, error: "Missing --conflict-id <ID>." }; } - if (!resolution) { + if (!state.resolution) { return { ok: false, error: "Missing --resolution <accept_local|accept_remote|merged|ignore>.", @@ -1682,16 +1799,69 @@ function parseResolveConflictArgs(opts: { return { ok: true, value: { - conflictId, - resolution, - ...(summary !== undefined ? { summary } : {}), - ...(actor ? { actor } : {}), - json, - rest, + conflictId: state.conflictId, + resolution: state.resolution, + ...(state.summary !== undefined ? { summary: state.summary } : {}), + ...(state.actor ? { actor: state.actor } : {}), + json: state.json, + rest: state.rest, }, }; } +function applyResolveConflictOption(opts: { + readonly state: MutableResolveConflictArgs; + readonly option: string; + readonly inlineValue?: string; + readonly nextToken?: string; +}): OptionApplyResult { + if (opts.option === "json") { + if (opts.inlineValue !== undefined) { + return { + ok: false, + error: `Unknown option: --${opts.option}=${opts.inlineValue}`, + }; + } + opts.state.json = true; + return { ok: true, consumedNext: false }; + } + if (opts.option === "resolution") { + const value = takeRequiredOptionValue({ + option: opts.option, + inlineValue: opts.inlineValue, + nextToken: opts.nextToken, + }); + if (!value.ok) { + return value; + } + const resolution = parseConflictResolutionValue({ value: value.value }); + if (!resolution) { + return { ok: false, error: TICKETS_RESOLUTION_ERROR }; + } + opts.state.resolution = resolution; + return { ok: true, consumedNext: value.consumedNext }; + } + const fieldMap = { + "conflict-id": "conflictId", + summary: "summary", + actor: "actor", + } as const; + const field = fieldMap[opts.option as keyof typeof fieldMap]; + if (!field) { + return { ok: false, error: `Unknown option: --${opts.option}` }; + } + const value = takeRequiredOptionValue({ + option: opts.option, + inlineValue: opts.inlineValue, + nextToken: opts.nextToken, + }); + if (!value.ok) { + return value; + } + opts.state[field] = value.value; + return { ok: true, consumedNext: value.consumedNext }; +} + async function resolveTicketBody(opts: { readonly body?: string; readonly bodyFile?: string; @@ -1800,6 +1970,64 @@ function resolveTicketRefs(opts: { return { ok: true, refs: normalizeTicketRefs(normalized) }; } +function buildTicketsSetupNotices(opts: { + readonly needsGitignore: boolean; + readonly needsUntrack: boolean; + readonly needsSkill: boolean; + readonly needsDocs: boolean; +}): string[] { + const notices: string[] = []; + if (opts.needsGitignore) { + notices.push("add .hack/tickets/ to .gitignore"); + } + if (opts.needsUntrack) { + notices.push("untrack .hack/tickets from main branch"); + } + if (opts.needsSkill || opts.needsDocs) { + notices.push("run tickets setup"); + } + return notices; +} + +async function applyTicketsSetupRepairs(opts: { + readonly projectRoot: string; + readonly needsGitignore: boolean; + readonly needsUntrack: boolean; + readonly needsSkill: boolean; + readonly needsDocs: boolean; +}): Promise<string[]> { + const lines: string[] = []; + if (opts.needsGitignore) { + const gitignore = await ensureTicketsGitignore({ + projectRoot: opts.projectRoot, + }); + lines.push(`repo.gitignore: ${gitignore.status} (${gitignore.path})`); + } + if (opts.needsUntrack) { + const untrack = await untrackTicketsRepo({ projectRoot: opts.projectRoot }); + lines.push( + `repo.tracking: ${untrack.status}${untrack.message ? ` (${untrack.message})` : ""}` + ); + } + if (opts.needsSkill) { + const installed = await installTicketsSkill({ + scope: "project", + projectRoot: opts.projectRoot, + }); + lines.push(`skill: ${installed.status} (${installed.path})`); + } + if (opts.needsDocs) { + const updatedDocs = await upsertTicketsAgentDocs({ + projectRoot: opts.projectRoot, + targets: ["agents", "claude"], + }); + lines.push( + ...updatedDocs.map((doc) => `${doc.target}: ${doc.status} (${doc.path})`) + ); + } + return lines; +} + async function maybeEnsureTicketsSetup(opts: { readonly ctx: ExtensionCommandContext; readonly json: boolean; @@ -1827,21 +2055,18 @@ async function maybeEnsureTicketsSetup(opts: { (doc) => doc.status === "missing" || doc.status === "error" ); - if (!(needsGitignore || needsUntrack || needsSkill || needsDocs)) { + const needsRepair = needsGitignore || needsUntrack || needsSkill || needsDocs; + if (!needsRepair) { return; } + const notices = buildTicketsSetupNotices({ + needsGitignore, + needsUntrack, + needsSkill, + needsDocs, + }); if (!(isTty() && isGumAvailable())) { - const notices: string[] = []; - if (needsGitignore) { - notices.push("add .hack/tickets/ to .gitignore"); - } - if (needsUntrack) { - notices.push("untrack .hack/tickets from main branch"); - } - if (needsSkill || needsDocs) { - notices.push("run tickets setup"); - } if (notices.length > 0) { opts.ctx.logger.warn({ message: `Tickets setup incomplete: ${notices.join("; ")}.`, @@ -1857,48 +2082,99 @@ async function maybeEnsureTicketsSetup(opts: { if (!(confirmed.ok && confirmed.value)) { return; } + const lines = await applyTicketsSetupRepairs({ + projectRoot, + needsGitignore, + needsUntrack, + needsSkill, + needsDocs, + }); + if (lines.length > 0) { + await display.panel({ + title: "Tickets setup", + tone: "success", + lines, + }); + } - const lines: string[] = []; + await maybeEnsureTicketsGitHealth({ ctx: opts.ctx, json: opts.json }); +} - if (needsGitignore) { - const gitignore = await ensureTicketsGitignore({ projectRoot }); - lines.push(`repo.gitignore: ${gitignore.status} (${gitignore.path})`); - } +function needsTicketsGitHealthRepair(opts: { + readonly health: TicketsGitHealth; +}): boolean { + return ( + opts.health.hasLegacyRef || + opts.health.hasRefDivergence || + opts.health.hasNonTicketFiles + ); +} - if (needsUntrack) { - const untrack = await untrackTicketsRepo({ projectRoot }); - lines.push( - `repo.tracking: ${untrack.status}${untrack.message ? ` (${untrack.message})` : ""}` - ); +function buildTicketsGitHealthReasons(opts: { + readonly health: TicketsGitHealth; +}): string[] { + const reasons: string[] = []; + if (opts.health.hasRefDivergence) { + reasons.push("hidden ref diverges from legacy branch"); + } + if (opts.health.hasLegacyRef && opts.health.legacyRef) { + reasons.push(`legacy ref ${opts.health.legacyRef}`); } + if (opts.health.hasNonTicketFiles) { + reasons.push("non-ticket files in tickets ref"); + } + return reasons; +} - if (needsSkill) { - const installed = await installTicketsSkill({ - scope: "project", - projectRoot, - }); - lines.push(`skill: ${installed.status} (${installed.path})`); +async function confirmLegacyRefPrune(opts: { + readonly health: TicketsGitHealth; +}): Promise<boolean> { + if (!(opts.health.hasLegacyRef && opts.health.legacyRef)) { + return false; } + const prune = await gumConfirm({ + prompt: `Remove legacy ref ${opts.health.legacyRef} from the remote?`, + default: true, + }); + return prune.ok && prune.value; +} - if (needsDocs) { - const updatedDocs = await upsertTicketsAgentDocs({ - projectRoot, - targets: ["agents", "claude"], - }); +function buildTicketsRepairLines(opts: { + readonly health: TicketsGitHealth; + readonly pruneLegacyRef: boolean; + readonly repaired: Extract<TicketsGitRepairResult, { readonly ok: true }>; +}): string[] { + const lines: string[] = []; + if (opts.health.hasNonTicketFiles) { + const sample = opts.health.nonTicketPaths.slice(0, 5); + const extra = opts.health.nonTicketPaths.length - sample.length; + lines.push(`non-ticket files: ${opts.health.nonTicketPaths.length}`); + if (sample.length > 0) { + lines.push( + `sample: ${sample.join(", ")}${extra > 0 ? ` (+${extra} more)` : ""}` + ); + } + } + if (opts.health.hasLegacyRef && opts.health.legacyRef) { lines.push( - ...updatedDocs.map((doc) => `${doc.target}: ${doc.status} (${doc.path})`) + `legacy ref: ${opts.health.legacyRef} ${ + opts.pruneLegacyRef ? "pruned" : "left intact" + }` ); } - - if (lines.length > 0) { - await display.panel({ - title: "Tickets setup", - tone: "success", - lines, - }); + if (opts.health.hasRefDivergence) { + lines.push( + `ref divergence: ${ + opts.health.remoteRefOid?.slice(0, 8) ?? "missing" + } vs ${opts.health.legacyRefOid?.slice(0, 8) ?? "missing"}` + ); } - - await maybeEnsureTicketsGitHealth({ ctx: opts.ctx, json: opts.json }); + lines.push(`commit: ${opts.repaired.didCommit ? "created" : "noop"}`); + lines.push(`push: ${opts.repaired.didPush ? "pushed" : "skipped"}`); + if (opts.repaired.pruneError) { + lines.push(`legacy prune error: ${opts.repaired.pruneError}`); + } + return lines; } async function maybeEnsureTicketsGitHealth(opts: { @@ -1937,26 +2213,11 @@ async function maybeEnsureTicketsGitHealth(opts: { } const health = inspected.health; - if ( - !( - health.hasLegacyRef || - health.hasRefDivergence || - health.hasNonTicketFiles - ) - ) { + if (!needsTicketsGitHealthRepair({ health })) { return; } - const reasons: string[] = []; - if (health.hasRefDivergence) { - reasons.push("hidden ref diverges from legacy branch"); - } - if (health.hasLegacyRef && health.legacyRef) { - reasons.push(`legacy ref ${health.legacyRef}`); - } - if (health.hasNonTicketFiles) { - reasons.push("non-ticket files in tickets ref"); - } + const reasons = buildTicketsGitHealthReasons({ health }); if (!(isTty() && isGumAvailable())) { opts.ctx.logger.warn({ @@ -1972,16 +2233,7 @@ async function maybeEnsureTicketsGitHealth(opts: { if (!(confirmed.ok && confirmed.value)) { return; } - - let pruneLegacyRef = false; - if (health.hasLegacyRef && health.legacyRef) { - const prune = await gumConfirm({ - prompt: `Remove legacy ref ${health.legacyRef} from the remote?`, - default: true, - }); - pruneLegacyRef = prune.ok && prune.value; - } - + const pruneLegacyRef = await confirmLegacyRefPrune({ health }); const repaired = await channel.repair({ pruneLegacyRef }); if (!repaired.ok) { await display.panel({ @@ -1991,109 +2243,45 @@ async function maybeEnsureTicketsGitHealth(opts: { }); return; } - - const lines: string[] = []; - if (health.hasNonTicketFiles) { - const sample = health.nonTicketPaths.slice(0, 5); - const extra = health.nonTicketPaths.length - sample.length; - lines.push(`non-ticket files: ${health.nonTicketPaths.length}`); - if (sample.length > 0) { - lines.push( - `sample: ${sample.join(", ")}${extra > 0 ? ` (+${extra} more)` : ""}` - ); - } - } - if (health.hasLegacyRef && health.legacyRef) { - lines.push( - `legacy ref: ${health.legacyRef} ${pruneLegacyRef ? "pruned" : "left intact"}` - ); - } - if (health.hasRefDivergence) { - lines.push( - `ref divergence: ${ - health.remoteRefOid?.slice(0, 8) ?? "missing" - } vs ${health.legacyRefOid?.slice(0, 8) ?? "missing"}` - ); - } - lines.push(`commit: ${repaired.didCommit ? "created" : "noop"}`); - lines.push(`push: ${repaired.didPush ? "pushed" : "skipped"}`); - if (repaired.pruneError) { - lines.push(`legacy prune error: ${repaired.pruneError}`); - } - await display.panel({ title: "Tickets repair", tone: repaired.pruneError ? "warn" : "success", - lines, + lines: buildTicketsRepairLines({ + health, + pruneLegacyRef, + repaired, + }), }); } function parseTicketsSetupArgs(opts: { readonly args: readonly string[]; }): TicketsSetupParseResult { - let agents = false; - let claude = false; - let all = false; - let global = false; - let check = false; - let remove = false; - let json = false; - + const value = { + agents: false, + claude: false, + all: false, + global: false, + check: false, + remove: false, + json: false, + }; for (const token of opts.args) { - if (token === "--agents" || token === "--agents-md") { - agents = true; - continue; - } - - if (token === "--claude" || token === "--claude-md") { - claude = true; - continue; - } - - if (token === "--all") { - all = true; - continue; - } - - if (token === "--global") { - global = true; - continue; - } - - if (token === "--check") { - check = true; - continue; - } - - if (token === "--remove") { - remove = true; - continue; - } - - if (token === "--json") { - json = true; - continue; - } - if (token === "--help" || token === "help") { - return { - ok: false, - error: - "Usage: hack x tickets setup [--agents|--claude|--all] [--global] [--check|--remove] [--json]", - }; + return { ok: false, error: TICKETS_SETUP_USAGE }; } - - return { ok: false, error: `Unknown option: ${token}` }; + const field = + TICKETS_SETUP_FLAGS[token as keyof typeof TICKETS_SETUP_FLAGS]; + if (!field) { + return { ok: false, error: `Unknown option: ${token}` }; + } + value[field] = true; } - if (check && remove) { + if (value.check && value.remove) { return { ok: false, error: "--check and --remove are mutually exclusive." }; } - - return { - ok: true, - value: { agents, claude, all, global, check, remove, json }, - }; + return { ok: true, value }; } /** diff --git a/src/control-plane/extensions/tickets/store.ts b/src/control-plane/extensions/tickets/store.ts index cdf69ba2c..7832b6f3f 100644 --- a/src/control-plane/extensions/tickets/store.ts +++ b/src/control-plane/extensions/tickets/store.ts @@ -157,6 +157,96 @@ type MaterializedTicketState = { readonly conflictsByTicket: Map<string, TicketSyncConflict[]>; }; +type Mutable<T> = { + -readonly [K in keyof T]: T[K]; +}; + +type TicketOptionalMetadataInput = { + readonly owner?: string; + readonly source?: string; + readonly assignee?: string; + readonly tags?: readonly string[]; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; +}; + +type TicketNormalizedMetadata = { + readonly owner: string; + readonly source: string; + readonly assignee?: string; + readonly tags: readonly string[]; + readonly externalSystem?: string; + readonly externalId?: string; + readonly externalKey?: string; + readonly externalUrl?: string; + readonly externalProjectId?: string; + readonly externalProjectName?: string; + readonly externalTeamId?: string; +}; + +type TicketCreateInput = { + readonly title: string; + readonly body?: string; + readonly dependsOn?: readonly string[]; + readonly blocks?: readonly string[]; + readonly actor?: string; +} & TicketOptionalMetadataInput; + +type TicketUpdateInput = { + readonly ticketId: string; + readonly title?: string; + readonly body?: string; + readonly dependsOn?: readonly string[]; + readonly blocks?: readonly string[]; + readonly actor?: string; +} & TicketOptionalMetadataInput; + +type TicketCheckpointInput = { + readonly ticketId: string; + readonly provider: string; + readonly profileId?: string; + readonly direction?: string; + readonly remoteCursor?: string; + readonly remoteUpdatedAt?: string; + readonly localUpdatedAt?: string; + readonly actor?: string; +}; + +type TicketConflictInput = { + readonly ticketId: string; + readonly provider: string; + readonly field: string; + readonly authority?: string; + readonly summary?: string; + readonly localValue?: TicketMetadataValue; + readonly remoteValue?: TicketMetadataValue; + readonly actor?: string; +}; + +const TICKET_OPTIONAL_METADATA_KEYS = [ + "externalSystem", + "externalId", + "externalKey", + "externalUrl", + "externalProjectId", + "externalProjectName", + "externalTeamId", +] as const; + +const TICKET_OPTIONAL_UPDATE_KEYS = [ + "assignee", + "owner", + "source", + ...TICKET_OPTIONAL_METADATA_KEYS, +] as const; + +type TicketOptionalUpdateKey = (typeof TICKET_OPTIONAL_UPDATE_KEYS)[number]; + export function createTicketsStore(opts: { readonly projectRoot: string; readonly projectId?: string; @@ -471,64 +561,24 @@ export function createTicketsStore(opts: { }; return { - createTicket: async (input) => { + createTicket: async (input: TicketCreateInput) => { const ticketId = await computeNextTicketId(); const dependsOn = normalizeTicketRefs(input.dependsOn ?? []); const blocks = normalizeTicketRefs(input.blocks ?? []); - const owner = normalizeOwnerOrSource({ - value: input.owner, - fallback: "hack", - }); - const source = normalizeOwnerOrSource({ - value: input.source, - fallback: "hack", - }); - const assignee = normalizeOptionalMetadataString({ - value: input.assignee, - }); - const tags = normalizeTags(input.tags ?? []); - const externalSystem = normalizeOptionalMetadataString({ - value: input.externalSystem, - }); - const externalId = normalizeOptionalMetadataString({ - value: input.externalId, - }); - const externalKey = normalizeOptionalMetadataString({ - value: input.externalKey, - }); - const externalUrl = normalizeOptionalMetadataString({ - value: input.externalUrl, - }); - const externalProjectId = normalizeOptionalMetadataString({ - value: input.externalProjectId, - }); - const externalProjectName = normalizeOptionalMetadataString({ - value: input.externalProjectName, - }); - const externalTeamId = normalizeOptionalMetadataString({ - value: input.externalTeamId, + const metadata = normalizeTicketMetadata({ + input, + ownerFallback: "hack", + sourceFallback: "hack", }); const event = buildEvent({ ticketId, type: "ticket.created", - payload: { - title: input.title, - ...(input.body ? { body: input.body } : {}), - ...(dependsOn.length > 0 ? { dependsOn } : {}), - ...(blocks.length > 0 ? { blocks } : {}), - owner, - source, - ...(assignee ? { assignee } : {}), - ...(tags.length > 0 ? { tags } : {}), - ...(externalSystem ? { externalSystem } : {}), - ...(externalId ? { externalId } : {}), - ...(externalKey ? { externalKey } : {}), - ...(externalUrl ? { externalUrl } : {}), - ...(externalProjectId ? { externalProjectId } : {}), - ...(externalProjectName ? { externalProjectName } : {}), - ...(externalTeamId ? { externalTeamId } : {}), - status: "open", - }, + payload: buildTicketCreatedPayload({ + input, + dependsOn, + blocks, + metadata, + }), actor: input.actor, }); @@ -539,121 +589,35 @@ export function createTicketsStore(opts: { return { ok: true, - ticket: { + ticket: buildCreatedTicketSummary({ ticketId, - title: input.title, - ...(input.body ? { body: input.body } : {}), - status: "open", - createdAt: event.tsIso, - updatedAt: event.tsIso, + input, dependsOn, blocks, - owner, - source, - ...(assignee ? { assignee } : {}), - tags, - ...(externalSystem ? { externalSystem } : {}), - ...(externalId ? { externalId } : {}), - ...(externalKey ? { externalKey } : {}), - ...(externalUrl ? { externalUrl } : {}), - ...(externalProjectId ? { externalProjectId } : {}), - ...(externalProjectName ? { externalProjectName } : {}), - ...(externalTeamId ? { externalTeamId } : {}), - ...(opts.projectId ? { projectId: opts.projectId } : {}), - ...(opts.projectName ? { projectName: opts.projectName } : {}), - }, + metadata, + event, + projectId: opts.projectId, + projectName: opts.projectName, + }), }; }, - updateTicket: async (input) => { + updateTicket: async (input: TicketUpdateInput) => { const tickets = await materializeTickets(); const current = tickets.get(input.ticketId); if (!current) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } - const payload: Record<string, unknown> = {}; - if (input.title !== undefined) { - const title = input.title.trim(); - if (!title) { - return { ok: false, error: "Title cannot be empty." }; - } - payload.title = title; - } - if (input.body !== undefined) { - payload.body = input.body; - } - if (input.dependsOn !== undefined) { - payload.dependsOn = normalizeTicketRefs(input.dependsOn); - } - if (input.blocks !== undefined) { - payload.blocks = normalizeTicketRefs(input.blocks); - } - if (input.owner !== undefined) { - payload.owner = normalizeOwnerOrSource({ - value: input.owner, - fallback: "hack", - }); - } - if (input.source !== undefined) { - payload.source = normalizeOwnerOrSource({ - value: input.source, - fallback: "hack", - }); - } - if (input.assignee !== undefined) { - payload.assignee = - normalizeOptionalMetadataString({ - value: input.assignee, - }) ?? null; - } - if (input.tags !== undefined) { - payload.tags = normalizeTags(input.tags); - } - if (input.externalSystem !== undefined) { - payload.externalSystem = normalizeOptionalMetadataString({ - value: input.externalSystem, - }); - } - if (input.externalId !== undefined) { - payload.externalId = normalizeOptionalMetadataString({ - value: input.externalId, - }); - } - if (input.externalKey !== undefined) { - payload.externalKey = normalizeOptionalMetadataString({ - value: input.externalKey, - }); - } - if (input.externalUrl !== undefined) { - payload.externalUrl = normalizeOptionalMetadataString({ - value: input.externalUrl, - }); - } - if (input.externalProjectId !== undefined) { - payload.externalProjectId = normalizeOptionalMetadataString({ - value: input.externalProjectId, - }); - } - if (input.externalProjectName !== undefined) { - payload.externalProjectName = normalizeOptionalMetadataString({ - value: input.externalProjectName, - }); - } - if (input.externalTeamId !== undefined) { - payload.externalTeamId = normalizeOptionalMetadataString({ - value: input.externalTeamId, - }); - } - - if (Object.keys(payload).length === 0) { - return { ok: false, error: "No updates provided." }; + const payloadResult = buildTicketUpdatedPayload({ input }); + if (!payloadResult.ok) { + return payloadResult; } const event = buildEvent({ ticketId: input.ticketId, type: "ticket.updated", - payload, + payload: payloadResult.payload, actor: input.actor, }); @@ -824,47 +788,22 @@ export function createTicketsStore(opts: { return await git.appendEvents({ events: [event] }); }, - recordSyncCheckpoint: async (input) => { + recordSyncCheckpoint: async (input: TicketCheckpointInput) => { const tickets = await materializeTickets(); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } - const provider = normalizeOptionalMetadataString({ - value: input.provider, - }); - if (!provider) { - return { ok: false, error: "Provider is required." }; + const checkpointResult = buildTicketSyncCheckpointData({ input }); + if (!checkpointResult.ok) { + return checkpointResult; } - const checkpointId = randomUUID(); - const profileId = normalizeOptionalMetadataString({ - value: input.profileId, - }); - const direction = normalizeOptionalMetadataString({ - value: input.direction, - }); - const remoteCursor = normalizeOptionalMetadataString({ - value: input.remoteCursor, - }); - const remoteUpdatedAt = normalizeOptionalMetadataString({ - value: input.remoteUpdatedAt, - }); - const localUpdatedAt = normalizeOptionalMetadataString({ - value: input.localUpdatedAt, - }); + const { payload, checkpoint } = checkpointResult; const event = buildEvent({ ticketId: input.ticketId, type: "ticket.sync_checkpoint_recorded", - payload: { - checkpointId, - provider, - ...(profileId ? { profileId } : {}), - ...(direction ? { direction } : {}), - ...(remoteCursor ? { remoteCursor } : {}), - ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), - ...(localUpdatedAt ? { localUpdatedAt } : {}), - }, + payload, actor: input.actor, }); @@ -876,62 +815,29 @@ export function createTicketsStore(opts: { return { ok: true, checkpoint: { - checkpointId, - ticketId: input.ticketId, - provider, - ...(profileId ? { profileId } : {}), - ...(direction ? { direction } : {}), - ...(remoteCursor ? { remoteCursor } : {}), - ...(remoteUpdatedAt ? { remoteUpdatedAt } : {}), - ...(localUpdatedAt ? { localUpdatedAt } : {}), + ...checkpoint, actor: event.actor, createdAt: event.tsIso, }, }; }, - recordSyncConflict: async (input) => { + recordSyncConflict: async (input: TicketConflictInput) => { const tickets = await materializeTickets(); if (!tickets.has(input.ticketId)) { return { ok: false, error: `Ticket not found: ${input.ticketId}` }; } - const provider = normalizeOptionalMetadataString({ - value: input.provider, - }); - if (!provider) { - return { ok: false, error: "Provider is required." }; - } - const field = normalizeOptionalMetadataString({ - value: input.field, - }); - if (!field) { - return { ok: false, error: "Field is required." }; + const conflictResult = buildTicketSyncConflictData({ input }); + if (!conflictResult.ok) { + return conflictResult; } - const conflictId = randomUUID(); - const authority = normalizeOptionalMetadataString({ - value: input.authority, - }); - const summary = normalizeOptionalMetadataString({ - value: input.summary, - }); + const { payload, conflict } = conflictResult; const event = buildEvent({ ticketId: input.ticketId, type: "ticket.sync_conflict_recorded", - payload: { - conflictId, - provider, - field, - ...(authority ? { authority } : {}), - ...(summary ? { summary } : {}), - ...(input.localValue !== undefined - ? { localValue: input.localValue } - : {}), - ...(input.remoteValue !== undefined - ? { remoteValue: input.remoteValue } - : {}), - }, + payload, actor: input.actor, }); @@ -943,19 +849,7 @@ export function createTicketsStore(opts: { return { ok: true, conflict: { - conflictId, - ticketId: input.ticketId, - provider, - field, - status: "open", - ...(authority ? { authority } : {}), - ...(summary ? { summary } : {}), - ...(input.localValue !== undefined - ? { localValue: input.localValue } - : {}), - ...(input.remoteValue !== undefined - ? { remoteValue: input.remoteValue } - : {}), + ...conflict, createdAt: event.tsIso, updatedAt: event.tsIso, }, @@ -1111,65 +1005,53 @@ function applyTicketCreatedEvent(input: { const blocks = parseDependencyList({ value: input.event.payload.blocks, }); - const owner = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: input.event.payload.owner }), - fallback: "hack", - }); - const source = normalizeOwnerOrSource({ - value: readOptionalStringPayload({ value: input.event.payload.source }), - fallback: "hack", - }); - const assignee = readOptionalStringPayload({ - value: input.event.payload.assignee, - }); - const tags = parseTags({ value: input.event.payload.tags }); - const externalSystem = readOptionalStringPayload({ - value: input.event.payload.externalSystem, - }); - const externalId = readOptionalStringPayload({ - value: input.event.payload.externalId, - }); - const externalKey = readOptionalStringPayload({ - value: input.event.payload.externalKey, - }); - const externalUrl = readOptionalStringPayload({ - value: input.event.payload.externalUrl, - }); - const externalProjectId = readOptionalStringPayload({ - value: input.event.payload.externalProjectId, - }); - const externalProjectName = readOptionalStringPayload({ - value: input.event.payload.externalProjectName, - }); - const externalTeamId = readOptionalStringPayload({ - value: input.event.payload.externalTeamId, + const metadata = normalizeTicketMetadata({ + input: { + owner: readOptionalStringPayload({ value: input.event.payload.owner }), + source: readOptionalStringPayload({ value: input.event.payload.source }), + assignee: readOptionalStringPayload({ + value: input.event.payload.assignee, + }), + tags: parseTags({ value: input.event.payload.tags }), + externalSystem: readOptionalStringPayload({ + value: input.event.payload.externalSystem, + }), + externalId: readOptionalStringPayload({ + value: input.event.payload.externalId, + }), + externalKey: readOptionalStringPayload({ + value: input.event.payload.externalKey, + }), + externalUrl: readOptionalStringPayload({ + value: input.event.payload.externalUrl, + }), + externalProjectId: readOptionalStringPayload({ + value: input.event.payload.externalProjectId, + }), + externalProjectName: readOptionalStringPayload({ + value: input.event.payload.externalProjectName, + }), + externalTeamId: readOptionalStringPayload({ + value: input.event.payload.externalTeamId, + }), + }, + ownerFallback: "hack", + sourceFallback: "hack", }); - input.tickets.set(input.event.ticketId, { - ticketId: input.event.ticketId, - title, - body, - status: "open", - createdAt: input.event.tsIso, - updatedAt: input.event.tsIso, - dependsOn, - blocks, - owner, - source, - ...(assignee ? { assignee } : {}), - tags, - ...(externalSystem ? { externalSystem } : {}), - ...(externalId ? { externalId } : {}), - ...(externalKey ? { externalKey } : {}), - ...(externalUrl ? { externalUrl } : {}), - ...(externalProjectId ? { externalProjectId } : {}), - ...(externalProjectName ? { externalProjectName } : {}), - ...(externalTeamId ? { externalTeamId } : {}), - ...(input.event.projectId ? { projectId: input.event.projectId } : {}), - ...(input.event.projectName - ? { projectName: input.event.projectName } - : {}), - }); + input.tickets.set( + input.event.ticketId, + buildCreatedTicketSummary({ + ticketId: input.event.ticketId, + input: { title, body }, + dependsOn, + blocks, + metadata, + event: input.event, + projectId: input.event.projectId, + projectName: input.event.projectName, + }) + ); } function applyTicketStatusChangedEvent(input: { @@ -1202,108 +1084,355 @@ function applyTicketUpdatedEvent(input: { return; } - const title = - typeof input.event.payload.title === "string" - ? input.event.payload.title - : undefined; - const body = - typeof input.event.payload.body === "string" - ? input.event.payload.body - : undefined; - const dependsOn = readDependencyUpdate({ - payload: input.event.payload, - key: "dependsOn", - }); - const blocks = readDependencyUpdate({ - payload: input.event.payload, - key: "blocks", - }); - const owner = readOptionalStringUpdate({ + const next = buildTicketSummaryFromUpdate({ + current, payload: input.event.payload, - key: "owner", + tsIso: input.event.tsIso, }); - const source = readOptionalStringUpdate({ - payload: input.event.payload, - key: "source", - }); - const assignee = readOptionalStringUpdate({ - payload: input.event.payload, - key: "assignee", + input.tickets.set(input.event.ticketId, next); +} + +function normalizeTicketMetadata(input: { + readonly input: TicketOptionalMetadataInput; + readonly ownerFallback: string; + readonly sourceFallback: string; +}): TicketNormalizedMetadata { + const metadata: Mutable<TicketNormalizedMetadata> = { + owner: normalizeOwnerOrSource({ + value: input.input.owner, + fallback: input.ownerFallback, + }), + source: normalizeOwnerOrSource({ + value: input.input.source, + fallback: input.sourceFallback, + }), + tags: normalizeTags(input.input.tags ?? []), + }; + + const assignee = normalizeOptionalMetadataString({ + value: input.input.assignee, }); - const tags = readTagsUpdate({ - payload: input.event.payload, - key: "tags", + if (assignee) { + metadata.assignee = assignee; + } + + for (const key of TICKET_OPTIONAL_METADATA_KEYS) { + const value = normalizeOptionalMetadataString({ + value: input.input[key], + }); + if (value) { + metadata[key] = value; + } + } + + return metadata; +} + +function buildTicketCreatedPayload(input: { + readonly input: Pick<TicketCreateInput, "title" | "body">; + readonly dependsOn: readonly string[]; + readonly blocks: readonly string[]; + readonly metadata: TicketNormalizedMetadata; +}): Record<string, unknown> { + return { + title: input.input.title, + ...(input.input.body ? { body: input.input.body } : {}), + ...(input.dependsOn.length > 0 ? { dependsOn: input.dependsOn } : {}), + ...(input.blocks.length > 0 ? { blocks: input.blocks } : {}), + ...serializeTicketMetadata({ metadata: input.metadata }), + status: "open", + }; +} + +function buildCreatedTicketSummary(input: { + readonly ticketId: string; + readonly input: Pick<TicketCreateInput, "title" | "body">; + readonly dependsOn: readonly string[]; + readonly blocks: readonly string[]; + readonly metadata: TicketNormalizedMetadata; + readonly event: TicketEvent; + readonly projectId?: string; + readonly projectName?: string; +}): TicketSummary { + return { + ticketId: input.ticketId, + title: input.input.title, + ...(input.input.body ? { body: input.input.body } : {}), + status: "open", + createdAt: input.event.tsIso, + updatedAt: input.event.tsIso, + dependsOn: [...input.dependsOn], + blocks: [...input.blocks], + ...input.metadata, + ...(input.projectId ? { projectId: input.projectId } : {}), + ...(input.projectName ? { projectName: input.projectName } : {}), + }; +} + +function serializeTicketMetadata(input: { + readonly metadata: TicketNormalizedMetadata; +}): Record<string, unknown> { + const payload: Record<string, unknown> = { + owner: input.metadata.owner, + source: input.metadata.source, + ...(input.metadata.tags.length > 0 ? { tags: input.metadata.tags } : {}), + }; + + if (input.metadata.assignee) { + payload.assignee = input.metadata.assignee; + } + + for (const key of TICKET_OPTIONAL_METADATA_KEYS) { + const value = input.metadata[key]; + if (value) { + payload[key] = value; + } + } + + return payload; +} + +function buildTicketUpdatedPayload(input: { + readonly input: TicketUpdateInput; +}): + | { readonly ok: true; readonly payload: Record<string, unknown> } + | { readonly ok: false; readonly error: string } { + const payload: Record<string, unknown> = {}; + + if (input.input.title !== undefined) { + const title = input.input.title.trim(); + if (!title) { + return { ok: false, error: "Title cannot be empty." }; + } + payload.title = title; + } + + if (input.input.body !== undefined) { + payload.body = input.input.body; + } + if (input.input.dependsOn !== undefined) { + payload.dependsOn = normalizeTicketRefs(input.input.dependsOn); + } + if (input.input.blocks !== undefined) { + payload.blocks = normalizeTicketRefs(input.input.blocks); + } + if (input.input.tags !== undefined) { + payload.tags = normalizeTags(input.input.tags); + } + + if (input.input.owner !== undefined) { + payload.owner = normalizeOwnerOrSource({ + value: input.input.owner, + fallback: "hack", + }); + } + if (input.input.source !== undefined) { + payload.source = normalizeOwnerOrSource({ + value: input.input.source, + fallback: "hack", + }); + } + if (input.input.assignee !== undefined) { + payload.assignee = + normalizeOptionalMetadataString({ + value: input.input.assignee, + }) ?? null; + } + + for (const key of TICKET_OPTIONAL_METADATA_KEYS) { + const value = input.input[key]; + if (value !== undefined) { + payload[key] = normalizeOptionalMetadataString({ value }); + } + } + + if (Object.keys(payload).length === 0) { + return { ok: false, error: "No updates provided." }; + } + + return { ok: true, payload }; +} + +function buildTicketSyncCheckpointData(input: { + readonly input: TicketCheckpointInput; +}): + | { + readonly ok: true; + readonly payload: Record<string, unknown>; + readonly checkpoint: Omit<TicketSyncCheckpoint, "actor" | "createdAt">; + } + | { readonly ok: false; readonly error: string } { + const provider = normalizeOptionalMetadataString({ + value: input.input.provider, }); - const externalSystem = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalSystem", + if (!provider) { + return { ok: false, error: "Provider is required." }; + } + + const checkpointId = randomUUID(); + const payload: Record<string, unknown> = { + checkpointId, + provider, + }; + const checkpoint: Mutable<Omit<TicketSyncCheckpoint, "actor" | "createdAt">> = + { + checkpointId, + ticketId: input.input.ticketId, + provider, + }; + + for (const key of [ + "profileId", + "direction", + "remoteCursor", + "remoteUpdatedAt", + "localUpdatedAt", + ] as const) { + const value = normalizeOptionalMetadataString({ + value: input.input[key], + }); + if (!value) { + continue; + } + payload[key] = value; + checkpoint[key] = value; + } + + return { ok: true, payload, checkpoint }; +} + +function buildTicketSyncConflictData(input: { + readonly input: TicketConflictInput; +}): + | { + readonly ok: true; + readonly payload: Record<string, unknown>; + readonly conflict: Omit<TicketSyncConflict, "createdAt" | "updatedAt">; + } + | { readonly ok: false; readonly error: string } { + const provider = normalizeOptionalMetadataString({ + value: input.input.provider, }); - const externalId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalId", + if (!provider) { + return { ok: false, error: "Provider is required." }; + } + + const field = normalizeOptionalMetadataString({ + value: input.input.field, }); - const externalKey = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalKey", + if (!field) { + return { ok: false, error: "Field is required." }; + } + + const conflictId = randomUUID(); + const authority = normalizeOptionalMetadataString({ + value: input.input.authority, }); - const externalUrl = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalUrl", + const summary = normalizeOptionalMetadataString({ + value: input.input.summary, }); - const externalProjectId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalProjectId", + const payload: Record<string, unknown> = { + conflictId, + provider, + field, + }; + const conflict: Mutable<Omit<TicketSyncConflict, "createdAt" | "updatedAt">> = + { + conflictId, + ticketId: input.input.ticketId, + provider, + field, + status: "open", + }; + + if (authority) { + payload.authority = authority; + conflict.authority = authority; + } + if (summary) { + payload.summary = summary; + conflict.summary = summary; + } + + if (input.input.localValue !== undefined) { + payload.localValue = input.input.localValue; + conflict.localValue = input.input.localValue; + } + if (input.input.remoteValue !== undefined) { + payload.remoteValue = input.input.remoteValue; + conflict.remoteValue = input.input.remoteValue; + } + + return { ok: true, payload, conflict }; +} + +function buildTicketSummaryFromUpdate(input: { + readonly current: TicketSummary; + readonly payload: Record<string, unknown>; + readonly tsIso: string; +}): TicketSummary { + const updates: Mutable<Partial<TicketSummary>> = { + updatedAt: input.tsIso, + }; + const title = readTicketTitleUpdate({ payload: input.payload }); + if (title) { + updates.title = title; + } + const body = readTicketBodyUpdate({ payload: input.payload }); + if (body !== null) { + updates.body = body || undefined; + } + const dependsOn = readDependencyUpdate({ + payload: input.payload, + key: "dependsOn", }); - const externalProjectName = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalProjectName", + if (dependsOn !== null) { + updates.dependsOn = dependsOn; + } + const blocks = readDependencyUpdate({ + payload: input.payload, + key: "blocks", }); - const externalTeamId = readOptionalStringUpdate({ - payload: input.event.payload, - key: "externalTeamId", + if (blocks !== null) { + updates.blocks = blocks; + } + const tags = readTagsUpdate({ + payload: input.payload, + key: "tags", }); + if (tags !== null) { + updates.tags = tags; + } - input.tickets.set(input.event.ticketId, { - ...current, - ...(title ? { title } : {}), - ...(body !== undefined ? { body } : {}), - ...(dependsOn !== null ? { dependsOn } : {}), - ...(blocks !== null ? { blocks } : {}), - ...(owner !== null - ? { - owner: normalizeOwnerOrSource({ - value: owner, - fallback: current.owner, - }), - } - : {}), - ...(source !== null - ? { - source: normalizeOwnerOrSource({ - value: source, - fallback: current.source, - }), - } - : {}), - ...(assignee !== null ? { assignee: assignee || undefined } : {}), - ...(tags !== null ? { tags } : {}), - ...(externalSystem !== null - ? { externalSystem: externalSystem || undefined } - : {}), - ...(externalId !== null ? { externalId: externalId || undefined } : {}), - ...(externalKey !== null ? { externalKey: externalKey || undefined } : {}), - ...(externalUrl !== null ? { externalUrl: externalUrl || undefined } : {}), - ...(externalProjectId !== null - ? { externalProjectId: externalProjectId || undefined } - : {}), - ...(externalProjectName !== null - ? { externalProjectName: externalProjectName || undefined } - : {}), - ...(externalTeamId !== null - ? { externalTeamId: externalTeamId || undefined } - : {}), - updatedAt: input.event.tsIso, - }); + for (const key of TICKET_OPTIONAL_UPDATE_KEYS) { + const value = readOptionalStringUpdate({ + payload: input.payload, + key, + }); + if (value === null) { + continue; + } + if (key === "owner") { + updates.owner = normalizeOwnerOrSource({ + value, + fallback: input.current.owner, + }); + continue; + } + if (key === "source") { + updates.source = normalizeOwnerOrSource({ + value, + fallback: input.current.source, + }); + continue; + } + updates[key] = value || undefined; + } + + return { + ...input.current, + ...updates, + }; } function applyTicketCommentAppendedEvent(input: { @@ -1619,19 +1748,30 @@ function readTagsUpdate(input: { return parseTags({ value: input.payload[input.key] }); } +function readTicketTitleUpdate(input: { + readonly payload: Record<string, unknown>; +}): string | undefined { + if (typeof input.payload.title !== "string") { + return undefined; + } + return input.payload.title; +} + +function readTicketBodyUpdate(input: { + readonly payload: Record<string, unknown>; +}): string | null { + if (!Object.hasOwn(input.payload, "body")) { + return null; + } + if (typeof input.payload.body !== "string") { + return ""; + } + return input.payload.body; +} + function readOptionalStringUpdate(input: { readonly payload: Record<string, unknown>; - readonly key: - | "assignee" - | "owner" - | "source" - | "externalSystem" - | "externalId" - | "externalKey" - | "externalUrl" - | "externalProjectId" - | "externalProjectName" - | "externalTeamId"; + readonly key: TicketOptionalUpdateKey; }): string | null { if (!Object.hasOwn(input.payload, input.key)) { return null; diff --git a/src/control-plane/extensions/tickets/tickets-git-channel.ts b/src/control-plane/extensions/tickets/tickets-git-channel.ts index 7517170e2..9726b946b 100644 --- a/src/control-plane/extensions/tickets/tickets-git-channel.ts +++ b/src/control-plane/extensions/tickets/tickets-git-channel.ts @@ -341,140 +341,262 @@ export function createGitTicketsChannel(opts: { return await fetchRemoteRefToTracking(ref, trackingRef); }; - const checkoutHead = async (input: { - readonly remoteUrl: string | null; + async function checkoutRefAndReset(input: { + readonly checkoutRef: string; }): Promise< | { readonly ok: true; readonly pushRef: string } | { readonly ok: false; readonly error: string } - > => { - await rm(worktreeDir, { recursive: true, force: true }); - await mkdir(worktreeDir, { recursive: true }); - - if (input.remoteUrl) { - let canCheckoutRemote = false; - let checkoutRef = `origin/${branch}`; - - const fetched = await fetchRemoteRef(remoteRef); - if (fetched.ok) { - canCheckoutRemote = true; - if (legacyRemoteRef && legacyTrackingRef) { - const legacyFetch = await fetchRemoteRefToTracking( - legacyRemoteRef, - legacyTrackingRef - ); - if (!(legacyFetch.ok || legacyFetch.missing)) { - return { - ok: false, - error: `git fetch failed: ${legacyFetch.error}`, - }; - } - } - } else if (fetched.missing && legacyRemoteRef) { - const legacyFetch = legacyTrackingRef - ? await fetchRemoteRefToTracking(legacyRemoteRef, legacyTrackingRef) - : await fetchRemoteRef(legacyRemoteRef); - if (legacyFetch.ok) { - canCheckoutRemote = true; - if (legacyTrackingRef) { - checkoutRef = legacyTrackingRef; - } - } else if (!legacyFetch.missing) { - return { ok: false, error: `git fetch failed: ${legacyFetch.error}` }; - } - } else if (!fetched.missing) { - return { ok: false, error: `git fetch failed: ${fetched.error}` }; - } + > { + const rev = await runGitDir({ + args: ["rev-parse", "--verify", input.checkoutRef], + }); + if (!rev.ok) { + return { ok: false, error: `git rev-parse failed: ${rev.stderr.trim()}` }; + } + const checkout = await runGitDir({ + args: ["checkout", "-B", branch, rev.stdout.trim()], + }); + if (!checkout.ok) { + return { + ok: false, + error: `git checkout failed: ${checkout.stderr.trim()}`, + }; + } + const reset = await runGitDir({ args: ["reset", "--hard"] }); + if (!reset.ok) { + return { ok: false, error: `git reset failed: ${reset.stderr.trim()}` }; + } + return { + ok: true, + pushRef: resolvePushRefForCheckout({ checkoutRef: input.checkoutRef }), + }; + } - if (canCheckoutRemote) { - const rev = await runGitDir({ - args: ["rev-parse", "--verify", checkoutRef], - }); - if (rev.ok) { - const checkout = await runGitDir({ - args: ["checkout", "-B", branch, rev.stdout.trim()], - }); - if (!checkout.ok) { - return { - ok: false, - error: `git checkout failed: ${checkout.stderr.trim()}`, - }; - } - - const reset = await runGitDir({ args: ["reset", "--hard"] }); - if (!reset.ok) { - return { - ok: false, - error: `git reset failed: ${reset.stderr.trim()}`, - }; - } - - return { - ok: true, - pushRef: resolvePushRefForCheckout({ checkoutRef }), - }; - } - } + async function initializeOrphanBranch(): Promise< + | { readonly ok: true; readonly pushRef: string } + | { readonly ok: false; readonly error: string } + > { + const orphan = await runGitDir({ + args: ["checkout", "--orphan", branch], + }); + if (!orphan.ok) { + return { + ok: false, + error: `git checkout --orphan failed: ${orphan.stderr.trim()}`, + }; } - const localRef = await runGitDir({ - args: ["rev-parse", "--verify", branch], + await mkdir(resolve(worktreeDir, ".hack/tickets"), { recursive: true }); + await Bun.write( + resolve(worktreeDir, ".hack/tickets/README.md"), + "Tickets ref for hack-cli\n" + ); + + const added = await runGitDir({ args: ["add", "-A"] }); + if (!added.ok) { + return { ok: false, error: `git add failed: ${added.stderr.trim()}` }; + } + const committed = await runGitDir({ + args: ["commit", "-m", "init tickets"], }); - if (!localRef.ok) { - const orphan = await runGitDir({ - args: ["checkout", "--orphan", branch], - }); - if (!orphan.ok) { + if (!committed.ok) { + return { + ok: false, + error: `git commit failed: ${committed.stderr.trim()}`, + }; + } + return { ok: true, pushRef: remoteRef }; + } + + async function fetchPrimaryCheckoutRef(): Promise< + | { readonly ok: true; readonly checkoutRef: string } + | { readonly ok: false; readonly error?: string; readonly missing: boolean } + > { + const fetched = await fetchRemoteRef(remoteRef); + if (!fetched.ok) { + return fetched.missing + ? { ok: false, missing: true } + : { + ok: false, + missing: false, + error: `git fetch failed: ${fetched.error}`, + }; + } + if (legacyRemoteRef && legacyTrackingRef) { + const legacyFetch = await fetchRemoteRefToTracking( + legacyRemoteRef, + legacyTrackingRef + ); + if (!(legacyFetch.ok || legacyFetch.missing)) { return { ok: false, - error: `git checkout --orphan failed: ${orphan.stderr.trim()}`, + missing: false, + error: `git fetch failed: ${legacyFetch.error}`, }; } + } + return { ok: true, checkoutRef: `origin/${branch}` }; + } - await mkdir(resolve(worktreeDir, ".hack/tickets"), { recursive: true }); - await Bun.write( - resolve(worktreeDir, ".hack/tickets/README.md"), - "Tickets ref for hack-cli\n" - ); + async function fetchLegacyCheckoutRef(): Promise< + | { readonly ok: true; readonly checkoutRef: string } + | { readonly ok: false; readonly error?: string; readonly missing: boolean } + > { + if (!legacyRemoteRef) { + return { ok: false, missing: true }; + } + const legacyFetch = legacyTrackingRef + ? await fetchRemoteRefToTracking(legacyRemoteRef, legacyTrackingRef) + : await fetchRemoteRef(legacyRemoteRef); + if (legacyFetch.ok) { + return { + ok: true, + checkoutRef: legacyTrackingRef ?? legacyRemoteRef, + }; + } + return legacyFetch.missing + ? { ok: false, missing: true } + : { + ok: false, + missing: false, + error: `git fetch failed: ${legacyFetch.error}`, + }; + } - const added = await runGitDir({ args: ["add", "-A"] }); - if (!added.ok) { - return { ok: false, error: `git add failed: ${added.stderr.trim()}` }; - } - const committed = await runGitDir({ - args: ["commit", "-m", "init tickets"], + async function resolveRemoteCheckoutRef(input: { + readonly remoteUrl: string | null; + }): Promise< + | { readonly ok: true; readonly checkoutRef: string } + | { readonly ok: false; readonly error?: string } + > { + if (!input.remoteUrl) { + return { ok: false }; + } + + const primary = await fetchPrimaryCheckoutRef(); + if (primary.ok) { + return primary; + } + if (primary.error) { + return { ok: false, error: primary.error }; + } + + const legacy = await fetchLegacyCheckoutRef(); + if (legacy.ok) { + return legacy; + } + return legacy.error ? { ok: false, error: legacy.error } : { ok: false }; + } + + async function checkoutHead(input: { + readonly remoteUrl: string | null; + }): Promise< + | { readonly ok: true; readonly pushRef: string } + | { readonly ok: false; readonly error: string } + > { + await rm(worktreeDir, { recursive: true, force: true }); + await mkdir(worktreeDir, { recursive: true }); + + if (input.remoteUrl) { + const remoteCheckout = await resolveRemoteCheckoutRef({ + remoteUrl: input.remoteUrl, }); - if (!committed.ok) { + if (remoteCheckout.ok) { + return await checkoutRefAndReset({ + checkoutRef: remoteCheckout.checkoutRef, + }); + } + if (remoteCheckout.error) { return { ok: false, - error: `git commit failed: ${committed.stderr.trim()}`, + error: remoteCheckout.error, }; } + } + + const localRef = await runGitDir({ + args: ["rev-parse", "--verify", branch], + }); + if (!localRef.ok) { + return await initializeOrphanBranch(); + } - return { ok: true, pushRef: remoteRef }; + return await checkoutRefAndReset({ checkoutRef: branch }); + } + + async function listLegacyEventPaths(): Promise< + | { readonly ok: true; readonly paths: readonly string[] } + | { readonly ok: false; readonly error: string } + > { + if (!legacyTrackingRef) { + return { ok: true, paths: [] }; + } + const listed = await runGitDir({ + args: [ + "ls-tree", + "-r", + "--name-only", + legacyTrackingRef, + ".hack/tickets/events", + ], + }); + if (!listed.ok) { + return { + ok: false, + error: `git ls-tree failed: ${listed.stderr.trim() || listed.stdout.trim()}`, + }; } + return { + ok: true, + paths: listed.stdout + .split("\n") + .map((path) => path.trim()) + .filter((path) => path.startsWith(".hack/tickets/events/")), + }; + } - const checkout = await runGitDir({ args: ["checkout", branch] }); - if (!checkout.ok) { + async function mergeLegacyEventPath(input: { + readonly relativePath: string; + }): Promise< + | { readonly ok: true; readonly imported: boolean } + | { readonly ok: false; readonly error: string } + > { + if (!legacyTrackingRef) { + return { ok: true, imported: false }; + } + const shown = await runGitDir({ + args: ["show", `${legacyTrackingRef}:${input.relativePath}`], + }); + if (!shown.ok) { return { ok: false, - error: `git checkout failed: ${checkout.stderr.trim()}`, + error: `git show failed: ${shown.stderr.trim() || shown.stdout.trim()}`, }; } - const reset = await runGitDir({ args: ["reset", "--hard"] }); - if (!reset.ok) { - return { ok: false, error: `git reset failed: ${reset.stderr.trim()}` }; + const targetPath = resolve(worktreeDir, input.relativePath); + const existing = await Bun.file(targetPath) + .text() + .catch(() => ""); + const merged = mergeTicketEventLogs({ + existing, + incoming: shown.stdout, + }); + if (merged === existing) { + return { ok: true, imported: false }; } - return { ok: true, pushRef: remoteRef }; - }; + await mkdir(dirname(targetPath), { recursive: true }); + await Bun.write(targetPath, merged); + return { ok: true, imported: true }; + } - const mergeLegacyRefIntoCurrentBranch = async (input: { + async function mergeLegacyRefIntoCurrentBranch(input: { readonly remoteUrl: string | null; }): Promise< | { readonly ok: true; readonly imported: boolean } | { readonly ok: false; readonly error: string } - > => { + > { if (!(input.remoteUrl && legacyRemoteRef && legacyTrackingRef)) { return { ok: true, imported: false }; } @@ -490,54 +612,17 @@ export function createGitTicketsChannel(opts: { return { ok: false, error: `git fetch failed: ${fetched.error}` }; } - const listed = await runGitDir({ - args: [ - "ls-tree", - "-r", - "--name-only", - legacyTrackingRef, - ".hack/tickets/events", - ], - }); + const listed = await listLegacyEventPaths(); if (!listed.ok) { - return { - ok: false, - error: `git ls-tree failed: ${listed.stderr.trim() || listed.stdout.trim()}`, - }; + return listed; } - let imported = false; - const legacyPaths = listed.stdout - .split("\n") - .map((path) => path.trim()) - .filter((path) => path.startsWith(".hack/tickets/events/")); - - for (const relativePath of legacyPaths) { - const shown = await runGitDir({ - args: ["show", `${legacyTrackingRef}:${relativePath}`], - }); - if (!shown.ok) { - return { - ok: false, - error: `git show failed: ${shown.stderr.trim() || shown.stdout.trim()}`, - }; - } - - const targetPath = resolve(worktreeDir, relativePath); - const existing = await Bun.file(targetPath) - .text() - .catch(() => ""); - const merged = mergeTicketEventLogs({ - existing, - incoming: shown.stdout, - }); - if (merged === existing) { - continue; + for (const relativePath of listed.paths) { + const merged = await mergeLegacyEventPath({ relativePath }); + if (!merged.ok) { + return merged; } - - await mkdir(dirname(targetPath), { recursive: true }); - await Bun.write(targetPath, merged); - imported = true; + imported = imported || merged.imported; } if (!imported) { @@ -550,7 +635,7 @@ export function createGitTicketsChannel(opts: { } return { ok: true, imported: true }; - }; + } const ensureCheckedOut = async (): Promise< | { @@ -605,57 +690,71 @@ export function createGitTicketsChannel(opts: { ); }; - const writeEvents = async (input: { - readonly events: readonly Record<string, unknown>[]; - }): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { - await mkdir(resolve(worktreeDir, ".hack/tickets/events"), { - recursive: true, - }); - + function groupEventsByPath( + events: readonly Record<string, unknown>[] + ): ReadonlyMap<string, Record<string, unknown>[]> { const grouped = new Map<string, Record<string, unknown>[]>(); - for (const ev of input.events) { + for (const event of events) { const ts = - typeof ev.ts === "number" - ? (ev.ts as number) - : Math.floor(Date.now() / 1000); + typeof event.ts === "number" ? event.ts : Math.floor(Date.now() / 1000); const path = resolveEventsPath(ts); const list = grouped.get(path) ?? []; - list.push(ev); + list.push(event); grouped.set(path, list); } + return grouped; + } - for (const [path, events] of grouped) { - const existing = await Bun.file(path) - .text() - .catch(() => ""); - const existingIds = new Set<string>(); - for (const line of existing.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const parsed = safeJsonParse(trimmed); - if (isRecord(parsed) && typeof parsed.eventId === "string") { - existingIds.add(parsed.eventId); - } - } + async function appendEventLogFile(input: { + readonly path: string; + readonly events: readonly Record<string, unknown>[]; + }): Promise<void> { + const existing = await Bun.file(input.path) + .text() + .catch(() => ""); + const lines = buildNewTicketEventLines({ + existing, + events: input.events, + }); + if (lines.length === 0) { + return; + } + const prefix = existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; + await Bun.write(input.path, `${existing}${prefix}${lines.join("\n")}\n`); + } - const lines: string[] = []; - for (const ev of events) { - const id = typeof ev.eventId === "string" ? ev.eventId : ""; - if (!id || existingIds.has(id)) { - continue; - } - lines.push(stableStringify(ev)); - } + async function listEventLogFiles(): Promise<string[]> { + const eventsDir = resolve(worktreeDir, ".hack/tickets/events"); + try { + return (await readdir(eventsDir)) + .filter((file) => file.endsWith(".jsonl")) + .sort(); + } catch { + return []; + } + } - if (lines.length > 0) { - const prefix = - existing.length > 0 && !existing.endsWith("\n") ? "\n" : ""; - await Bun.write(path, `${existing}${prefix}${lines.join("\n")}\n`); - } + async function normalizeEventLogFile(path: string): Promise<void> { + const text = await Bun.file(path) + .text() + .catch(() => ""); + const normalized = normalizeTicketEventLogText(text); + if (normalized !== text) { + await Bun.write(path, normalized); + } + } + + async function writeEvents(input: { + readonly events: readonly Record<string, unknown>[]; + }): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } + > { + await mkdir(resolve(worktreeDir, ".hack/tickets/events"), { + recursive: true, + }); + + for (const [path, events] of groupEventsByPath(input.events)) { + await appendEventLogFile({ path, events }); } const normalized = await normalizeLogs(); @@ -664,68 +763,48 @@ export function createGitTicketsChannel(opts: { } return { ok: true }; - }; + } - const normalizeLogs = async (): Promise< - { readonly ok: true } | { readonly ok: false; readonly error: string } - > => { + async function readAllWorktreeEvents(): Promise< + | { + readonly ok: true; + readonly events: readonly Record<string, unknown>[]; + } + | { readonly ok: false; readonly error: string } + > { const eventsDir = resolve(worktreeDir, ".hack/tickets/events"); - let files: string[] = []; - try { - files = (await readdir(eventsDir)).filter((f) => f.endsWith(".jsonl")); - } catch { - return { ok: true }; + const files = await listEventLogFiles(); + if (files.length === 0) { + return { ok: true, events: [] }; } - for (const file of files.sort()) { + const events: Record<string, unknown>[] = []; + for (const file of files) { const path = resolve(eventsDir, file); const text = await Bun.file(path) .text() .catch(() => ""); - const parsed: Record<string, unknown>[] = []; - const seen = new Set<string>(); + events.push(...readTicketEventRecords(text)); + } - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const value = safeJsonParse(trimmed); - if (!isRecord(value)) { - continue; - } - const eventId = typeof value.eventId === "string" ? value.eventId : ""; - const ts = typeof value.ts === "number" ? value.ts : Number.NaN; - if (!(eventId && Number.isFinite(ts))) { - continue; - } - if (seen.has(eventId)) { - continue; - } - seen.add(eventId); - parsed.push(value); - } + return { ok: true, events }; + } - parsed.sort((a, b) => { - const aTs = typeof a.ts === "number" ? (a.ts as number) : 0; - const bTs = typeof b.ts === "number" ? (b.ts as number) : 0; - if (aTs !== bTs) { - return aTs - bTs; - } - const aId = typeof a.eventId === "string" ? (a.eventId as string) : ""; - const bId = typeof b.eventId === "string" ? (b.eventId as string) : ""; - return aId.localeCompare(bId); - }); + async function normalizeLogs(): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } + > { + const eventsDir = resolve(worktreeDir, ".hack/tickets/events"); + const files = await listEventLogFiles(); + if (files.length === 0) { + return { ok: true }; + } - const next = parsed.map((ev) => stableStringify(ev)).join("\n"); - const normalized = next.length > 0 ? `${next}\n` : ""; - if (normalized !== text) { - await Bun.write(path, normalized); - } + for (const file of files) { + await normalizeEventLogFile(resolve(eventsDir, file)); } return { ok: true }; - }; + } const commitAll = async ( message: string @@ -753,14 +832,58 @@ export function createGitTicketsChannel(opts: { return { ok: true, didCommit: true }; }; - const pushWithRetry = async (input: { + function buildHiddenRefRejectedError(message: string): string { + return `git push failed: ${message}\nRemote rejected hidden refs. Set controlPlane.tickets.git.refMode to "heads" to use a branch ref.`; + } + + async function replayEventsAfterRefresh(input: { + readonly remoteUrl: string | null; + readonly pendingEvents?: readonly Record<string, unknown>[]; + }): Promise< + | { + readonly ok: true; + readonly pushRef: string; + } + | { readonly ok: false; readonly error: string } + > { + const replayEvents = input.pendingEvents + ? { ok: true as const, events: input.pendingEvents } + : await readAllWorktreeEvents(); + if (!replayEvents.ok) { + return replayEvents; + } + + const checkedOut = await checkoutHead({ remoteUrl: input.remoteUrl }); + if (!checkedOut.ok) { + return checkedOut; + } + + if (replayEvents.events.length > 0) { + const wrote = await writeEvents({ events: replayEvents.events }); + if (!wrote.ok) { + return wrote; + } + } + + const committed = await commitAll("tickets: retry"); + if (!committed.ok) { + return committed; + } + + return { + ok: true, + pushRef: checkedOut.pushRef, + }; + } + + async function pushWithRetry(input: { readonly remoteUrl: string | null; readonly pushRef: string; readonly pendingEvents?: readonly Record<string, unknown>[]; }): Promise< | { readonly ok: true; readonly didPush: boolean } | { readonly ok: false; readonly error: string } - > => { + > { if (!input.remoteUrl) { return { ok: true, didPush: false }; } @@ -776,47 +899,37 @@ export function createGitTicketsChannel(opts: { if (refMode === "hidden" && isHiddenRefRejected(pushMessage)) { return { ok: false, - error: `git push failed: ${pushMessage}\nRemote rejected hidden refs. Set controlPlane.tickets.git.refMode to "heads" to use a branch ref.`, + error: buildHiddenRefRejectedError(pushMessage), }; } opts.logger.warn({ message: `git push failed, retrying after fetch: ${pushMessage}`, }); - - const checkedOut = await checkoutHead({ remoteUrl: input.remoteUrl }); - if (!checkedOut.ok) { - return checkedOut; - } - - if (input.pendingEvents && input.pendingEvents.length > 0) { - const wrote = await writeEvents({ events: input.pendingEvents }); - if (!wrote.ok) { - return wrote; - } - } - - const committed = await commitAll("tickets: retry"); - if (!committed.ok) { - return committed; + const replayed = await replayEventsAfterRefresh({ + remoteUrl: input.remoteUrl, + pendingEvents: input.pendingEvents, + }); + if (!replayed.ok) { + return replayed; } const retry = await runGitDir({ - args: ["push", "origin", `${localBranchRef}:${checkedOut.pushRef}`], + args: ["push", "origin", `${localBranchRef}:${replayed.pushRef}`], }); if (!retry.ok) { const retryMessage = `${retry.stderr}\n${retry.stdout}`.trim(); if (refMode === "hidden" && isHiddenRefRejected(retryMessage)) { return { ok: false, - error: `git push failed: ${retryMessage}\nRemote rejected hidden refs. Set controlPlane.tickets.git.refMode to "heads" to use a branch ref.`, + error: buildHiddenRefRejectedError(retryMessage), }; } return { ok: false, error: `git push failed: ${retryMessage}` }; } return { ok: true, didPush: true }; - }; + } const listTrackedPaths = async (): Promise< | { readonly ok: true; readonly paths: readonly string[] } @@ -948,17 +1061,13 @@ export function createGitTicketsChannel(opts: { }; }; - const repair = async (input: { - readonly pruneLegacyRef: boolean; - }): Promise<TicketsGitRepairResult> => { - const checkedOut = await ensureCheckedOut(); - if (!checkedOut.ok) { - return checkedOut; - } - - const repairBranch = `${branch}-repair`; + async function createRepairBranch(input: { + readonly repairBranch: string; + }): Promise< + { readonly ok: true } | { readonly ok: false; readonly error: string } + > { const orphan = await runGitDir({ - args: ["checkout", "--orphan", repairBranch], + args: ["checkout", "--orphan", input.repairBranch], }); if (!orphan.ok) { return { @@ -966,14 +1075,8 @@ export function createGitTicketsChannel(opts: { error: `git checkout --orphan failed: ${orphan.stderr.trim()}`, }; } - - const pruned = await pruneWorktreeToTickets(); - if (!pruned.ok) { - return pruned; - } - const renamed = await runGitDir({ - args: ["branch", "-M", repairBranch, branch], + args: ["branch", "-M", input.repairBranch, branch], }); if (!renamed.ok) { return { @@ -981,6 +1084,54 @@ export function createGitTicketsChannel(opts: { error: `git branch -M failed: ${renamed.stderr.trim()}`, }; } + return { ok: true }; + } + + async function pruneLegacyRemoteRef(input: { + readonly pruneLegacyRef: boolean; + readonly remoteUrl: string | null; + }): Promise<{ + readonly didPruneLegacy: boolean; + readonly pruneError?: string; + }> { + if (!(input.pruneLegacyRef && legacyRemoteRef && input.remoteUrl)) { + return { didPruneLegacy: false }; + } + const prunedLegacy = await runGitDir({ + args: ["push", "origin", `:${legacyRemoteRef}`], + }); + if (!prunedLegacy.ok) { + return { + didPruneLegacy: false, + pruneError: `${prunedLegacy.stderr}\n${prunedLegacy.stdout}`.trim(), + }; + } + if (legacyTrackingRef) { + await runGitDir({ + args: ["update-ref", "-d", legacyTrackingRef], + }); + } + return { didPruneLegacy: true }; + } + + async function repair(input: { + readonly pruneLegacyRef: boolean; + }): Promise<TicketsGitRepairResult> { + const checkedOut = await ensureCheckedOut(); + if (!checkedOut.ok) { + return checkedOut; + } + + const repairBranch = `${branch}-repair`; + const created = await createRepairBranch({ repairBranch }); + if (!created.ok) { + return created; + } + + const pruned = await pruneWorktreeToTickets(); + if (!pruned.ok) { + return pruned; + } const committed = await commitAll("tickets: repair"); if (!committed.ok) { @@ -995,33 +1146,19 @@ export function createGitTicketsChannel(opts: { return pushed; } - let didPruneLegacy = false; - let pruneError: string | undefined; - - if (input.pruneLegacyRef && legacyRemoteRef && checkedOut.remoteUrl) { - const prunedLegacy = await runGitDir({ - args: ["push", "origin", `:${legacyRemoteRef}`], - }); - if (prunedLegacy.ok) { - didPruneLegacy = true; - if (legacyTrackingRef) { - await runGitDir({ - args: ["update-ref", "-d", legacyTrackingRef], - }); - } - } else { - pruneError = `${prunedLegacy.stderr}\n${prunedLegacy.stdout}`.trim(); - } - } + const legacyPrune = await pruneLegacyRemoteRef({ + pruneLegacyRef: input.pruneLegacyRef, + remoteUrl: checkedOut.remoteUrl, + }); return { ok: true, didCommit: committed.didCommit, didPush: pushed.didPush, - didPruneLegacy, - ...(pruneError ? { pruneError } : {}), + didPruneLegacy: legacyPrune.didPruneLegacy, + ...(legacyPrune.pruneError ? { pruneError: legacyPrune.pruneError } : {}), }; - }; + } const appendEvents = async (input: { readonly events: readonly Record<string, unknown>[]; @@ -1172,51 +1309,118 @@ function safeJsonParse(text: string): unknown { } } -function mergeTicketEventLogs(input: { - readonly existing: string; - readonly incoming: string; -}): string { - const parsed: Record<string, unknown>[] = []; - const seen = new Set<string>(); +function parseTicketEventRecord( + value: unknown +): Record<string, unknown> | null { + if (!isRecord(value)) { + return null; + } + const eventId = typeof value.eventId === "string" ? value.eventId.trim() : ""; + const ts = typeof value.ts === "number" ? value.ts : Number.NaN; + if (!(eventId && Number.isFinite(ts))) { + return null; + } + return value; +} - for (const text of [input.existing, input.incoming]) { - for (const line of text.split("\n")) { - const trimmed = line.trim(); - if (!trimmed) { - continue; - } - const value = safeJsonParse(trimmed); - if (!isRecord(value)) { - continue; - } - const eventId = typeof value.eventId === "string" ? value.eventId : ""; - const ts = typeof value.ts === "number" ? value.ts : Number.NaN; - if (!(eventId && Number.isFinite(ts))) { - continue; - } - if (seen.has(eventId)) { - continue; - } - seen.add(eventId); - parsed.push(value); +function readTicketEventRecords(text: string): Record<string, unknown>[] { + const events: Record<string, unknown>[] = []; + for (const line of text.split("\n")) { + const trimmed = line.trim(); + if (!trimmed) { + continue; + } + const parsed = parseTicketEventRecord(safeJsonParse(trimmed)); + if (parsed) { + events.push(parsed); } } + return events; +} - parsed.sort((a, b) => { - const aTs = typeof a.ts === "number" ? (a.ts as number) : 0; - const bTs = typeof b.ts === "number" ? (b.ts as number) : 0; - if (aTs !== bTs) { - return aTs - bTs; - } - const aId = typeof a.eventId === "string" ? (a.eventId as string) : ""; - const bId = typeof b.eventId === "string" ? (b.eventId as string) : ""; - return aId.localeCompare(bId); +function sortTicketEventRecords(events: Record<string, unknown>[]): void { + events.sort((left, right) => { + const leftTs = typeof left.ts === "number" ? (left.ts as number) : 0; + const rightTs = typeof right.ts === "number" ? (right.ts as number) : 0; + if (leftTs !== rightTs) { + return leftTs - rightTs; + } + const leftId = + typeof left.eventId === "string" ? (left.eventId as string) : ""; + const rightId = + typeof right.eventId === "string" ? (right.eventId as string) : ""; + return leftId.localeCompare(rightId); }); +} + +function dedupeTicketEventRecords( + events: readonly Record<string, unknown>[] +): Record<string, unknown>[] { + const seen = new Set<string>(); + const deduped: Record<string, unknown>[] = []; + for (const event of events) { + const eventId = + typeof event.eventId === "string" ? event.eventId.trim() : ""; + if (!(eventId && !seen.has(eventId))) { + continue; + } + seen.add(eventId); + deduped.push(event); + } + return deduped; +} - const next = parsed.map((event) => stableStringify(event)).join("\n"); +function formatTicketEventRecords( + events: readonly Record<string, unknown>[] +): string { + const next = events.map((event) => stableStringify(event)).join("\n"); return next ? `${next}\n` : ""; } +function buildNewTicketEventLines(input: { + readonly existing: string; + readonly events: readonly Record<string, unknown>[]; +}): string[] { + const existingIds = new Set<string>(); + for (const event of readTicketEventRecords(input.existing)) { + const eventId = + typeof event.eventId === "string" ? event.eventId.trim() : ""; + if (eventId) { + existingIds.add(eventId); + } + } + + const lines: string[] = []; + for (const event of input.events) { + const parsed = parseTicketEventRecord(event); + const eventId = + parsed && typeof parsed.eventId === "string" ? parsed.eventId.trim() : ""; + if (!(parsed && eventId && !existingIds.has(eventId))) { + continue; + } + lines.push(stableStringify(parsed)); + } + return lines; +} + +function normalizeTicketEventLogText(text: string): string { + const parsed = dedupeTicketEventRecords(readTicketEventRecords(text)); + sortTicketEventRecords(parsed); + return formatTicketEventRecords(parsed); +} + +function mergeTicketEventLogs(input: { + readonly existing: string; + readonly incoming: string; +}): string { + const parsed = dedupeTicketEventRecords([ + ...readTicketEventRecords(input.existing), + ...readTicketEventRecords(input.incoming), + ]); + sortTicketEventRecords(parsed); + return formatTicketEventRecords(parsed); +} + function isRetryableTrackingRefLockFailure(input: { readonly stderr: string; readonly trackingRef: string; diff --git a/src/control-plane/sdk/gateway-client.ts b/src/control-plane/sdk/gateway-client.ts index e831c4ade..6b0cbda4f 100644 --- a/src/control-plane/sdk/gateway-client.ts +++ b/src/control-plane/sdk/gateway-client.ts @@ -562,6 +562,7 @@ export function createGatewayClient(opts: GatewayClientOptions): GatewayClient { readonly path?: string; readonly branch?: string; readonly bootstrap?: GatewayNodeWorkspaceBootstrap; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Workspace attach requests must normalize optional routing, SSH, sync, and bootstrap parameters into one gateway payload. }): Promise<GatewayResponse<GatewayNodeWorkspaceResponse>> => await requestJson({ method: "POST", diff --git a/src/daemon/routes/node.ts b/src/daemon/routes/node.ts index c8e7e7b74..bf4ab111f 100644 --- a/src/daemon/routes/node.ts +++ b/src/daemon/routes/node.ts @@ -104,6 +104,7 @@ type WorkspaceLookupResult = } | { readonly kind: "skip" }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Node route handling must dispatch multiple controller subroutes while preserving explicit error mapping. export async function handleNodeRoutes(opts: { readonly req: Request; readonly url: URL; diff --git a/src/daemon/server.ts b/src/daemon/server.ts index f9bff1ef0..83eb13f16 100644 --- a/src/daemon/server.ts +++ b/src/daemon/server.ts @@ -514,6 +514,7 @@ async function routeExtensionRequest(input: { return await handleEnvRoutes({ req: input.req, url: input.url }); } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Gateway requests multiplex websocket upgrades, auth checks, and several controller route families in one entrypoint. async function handleGatewayRequest(opts: { readonly req: Request; readonly server: ReturnType<typeof Bun.serve>; @@ -730,6 +731,7 @@ function sanitizeGatewayAuditPath(opts: { url: URL }): string { return `${opts.url.pathname}${search.length > 0 ? `?${search}` : ""}`; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Control-plane routing intentionally centralizes the daemon's HTTP API surface and method-specific validation. async function handleControlPlaneRequest(opts: { readonly req: Request; readonly url: URL; diff --git a/src/lib/dispatch-runs.ts b/src/lib/dispatch-runs.ts index 86cf9b9f8..1d9199c3f 100644 --- a/src/lib/dispatch-runs.ts +++ b/src/lib/dispatch-runs.ts @@ -16,6 +16,13 @@ export type DispatchRunStatus = | "cancelled" | "error"; +export type DispatchRunTerminalState = + | "completed" + | "pr_created" + | "no_diff" + | "no_commit" + | "pr_failed"; + export type DispatchRunPolicyDecision = { readonly level: "low" | "medium" | "high" | "critical"; readonly requiresApproval: boolean; @@ -41,6 +48,7 @@ export type DispatchRunRecord = { readonly createdAt: string; readonly updatedAt: string; readonly status: DispatchRunStatus; + readonly terminalState?: DispatchRunTerminalState; readonly nodeId: string; readonly nodeName: string; readonly nodeEndpoint: string; @@ -382,12 +390,16 @@ function parseDispatchRunRecord(value: unknown): DispatchRunRecord | null { const jobStatus = parseOptionalStringField(value, "jobStatus"); const startedAt = parseOptionalStringField(value, "startedAt"); const finishedAt = parseOptionalStringField(value, "finishedAt"); + const terminalState = normalizeRunTerminalState( + parseOptionalStringField(value, "terminalState") + ); return { runId: required.runId, createdAt: required.createdAt, updatedAt: required.updatedAt, status: normalizeRunStatus(required.status), + ...(terminalState ? { terminalState } : {}), nodeId: required.nodeId, nodeName: required.nodeName, nodeEndpoint: required.nodeEndpoint, @@ -425,6 +437,21 @@ function normalizeRunStatus(value: string): DispatchRunStatus { return "error"; } +function normalizeRunTerminalState( + value: string | undefined +): DispatchRunTerminalState | undefined { + if ( + value === "completed" || + value === "pr_created" || + value === "no_diff" || + value === "no_commit" || + value === "pr_failed" + ) { + return value; + } + return undefined; +} + function parsePolicyDecision(value: unknown): DispatchRunPolicyDecision | null { if (!isRecord(value)) { return null; diff --git a/src/lib/nodes-registry.ts b/src/lib/nodes-registry.ts index 4115474f6..e0b5b27b1 100644 --- a/src/lib/nodes-registry.ts +++ b/src/lib/nodes-registry.ts @@ -267,6 +267,7 @@ export async function upsertNodeRecord(input: { readonly platform?: string; readonly arch?: string; }): Promise<{ readonly node: NodeRecord; readonly created: boolean }> { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Node upsert must merge partial updates, defaults, timestamps, and token state atomically under the registry lock. return await withRegistryLock(async () => { const registry = await readNodesRegistry(); const nowIso = new Date().toISOString(); diff --git a/src/lib/project-views.ts b/src/lib/project-views.ts index d134753c9..6dbbd7199 100644 --- a/src/lib/project-views.ts +++ b/src/lib/project-views.ts @@ -111,6 +111,7 @@ type BuildProjectViewsOptions = { readonly muxSessions?: readonly MuxSession[]; }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Project view building composes runtime, registry, controller, and branch metadata into one dashboard-oriented shape. export async function buildProjectViews( opts: BuildProjectViewsOptions ): Promise<ProjectView[]> { @@ -861,6 +862,8 @@ function mapExtensionFeature(id: string): string | null { switch (id) { case "dance.hack.tickets": return "tickets"; + case "dance.hack.aws": + return "aws"; case "dance.hack.cloudflare": return "cloudflare"; case "dance.hack.railway": diff --git a/src/lib/remote-caddy-routes.ts b/src/lib/remote-caddy-routes.ts index bf593a20d..3e19d5edd 100644 --- a/src/lib/remote-caddy-routes.ts +++ b/src/lib/remote-caddy-routes.ts @@ -403,6 +403,7 @@ function resolveRemoteRouteBridgePaths(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Remote route registry parsing must repair historical file shapes and tolerate partial writes from multiple producers. async function readRemoteRouteRegistry(input: { readonly registryPath: string; }): Promise<RemoteRouteRegistry> { diff --git a/src/lib/runtime-projects.ts b/src/lib/runtime-projects.ts index 9fda0045e..e213c6ad5 100644 --- a/src/lib/runtime-projects.ts +++ b/src/lib/runtime-projects.ts @@ -92,6 +92,7 @@ export function filterRuntimeProjects(opts: { return opts.runtime.filter((project) => !project.isGlobal); } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Runtime project discovery intentionally folds together docker, registry, lifecycle, and global-project state. export async function readRuntimeProjects(opts: { readonly includeGlobal: boolean; }): Promise<RuntimeProjectsResult> { @@ -262,6 +263,7 @@ type LifecycleActivity = { readonly runningWindows: ReadonlySet<string> | null; }; +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Lifecycle service synthesis maps host-managed processes onto runtime views and container-derived service state. async function addLifecycleProcessServices(opts: { readonly runtime: readonly RuntimeProject[]; }): Promise<RuntimeProject[]> { diff --git a/src/lib/secret-store.ts b/src/lib/secret-store.ts index f9d8fd821..85084c095 100644 --- a/src/lib/secret-store.ts +++ b/src/lib/secret-store.ts @@ -383,6 +383,7 @@ export async function provisionEncryptedFileKey(input: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Encrypted-file key resolution intentionally checks env, file, keychain, generation, and migration paths in one routine. async function resolveEncryptedFileKeyMaterial(input?: { readonly keyPath?: string; readonly storePath?: string; diff --git a/src/mcp/agent-docs.ts b/src/mcp/agent-docs.ts index 11c2e4174..c455b624a 100644 --- a/src/mcp/agent-docs.ts +++ b/src/mcp/agent-docs.ts @@ -257,6 +257,8 @@ export function renderAgentDocsSnippet(): string { "- On node host, inspect workspace map via `hack node workspace list|resolve|attach|remove`.", "- Inspect/repair controller-side route bridge with `hack node routes status` and `hack node routes repair`.", '- Dispatch remote commands: `hack dispatch run --project <name|id> --node default --branch <branch> --runner generic -- "<command>"`.', + '- Dispatch local fallback runs with the same artifact pipeline: `hack dispatch run --project <name|id> --local -- "<command>"`.', + '- Reset unattended runner workspaces safely: `hack workspace reset --base origin/main --exclude ".env,.env.*"`.', "", "When to use a branch instance:", "- You need two versions running at once (PR review, experiments, migrations).", diff --git a/src/tui/hack-tui.ts b/src/tui/hack-tui.ts index d2a4bef2c..1ccfa7670 100644 --- a/src/tui/hack-tui.ts +++ b/src/tui/hack-tui.ts @@ -1069,7 +1069,8 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { const sinceMs = sinceTime ? sinceTime.getTime() : null; const untilMs = untilTime ? untilTime.getTime() : null; - return logState.entries.filter((entry) => { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Local log search intentionally combines service, level, time-window, and query matching in one predicate. + function matchesLocalSearchEntry(entry: LogEntry): boolean { if (opts.service && entry.service !== opts.service) { return false; } @@ -1098,9 +1099,12 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { .map(normalizeSearchText) .join(" "); return haystack.includes(query); - }); + } + + return logState.entries.filter(matchesLocalSearchEntry); }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Footer copy is a stateful projection of search, selection, follow, and TTY status into a single compact help bar. const renderFooter = () => { let focusLabel: string; if (searchOverlayVisible) { @@ -1295,9 +1299,10 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { : t`${fg("#c0caf5")(projectLine)}`; }; - const renderMetaPanel = (opts: { + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The meta panel intentionally derives a dense runtime summary from several live TUI state sources. + function renderMetaPanel(opts: { readonly runtime: RuntimeProject | null; - }) => { + }): void { if (!isActive) { return; } @@ -1450,7 +1455,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { ]; const lines = metaVariant === "compact" ? baseLines : fullLines; metaText.content = joinStyledText({ parts: lines, separator: "\n" }); - }; + } const refreshCaddyStatus = async () => { if (!isActive) { @@ -1487,6 +1492,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { } }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The resources panel intentionally branches across loading, unavailable, empty, and sampled container states. const renderResourcesPanel = (opts: { readonly targetLabel: string }) => { if (!isActive) { return; @@ -1611,6 +1617,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { resourcesText.content = joinStyledText({ parts: lines, separator: "\n" }); }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Sidebar layout intentionally adapts multiple panels to constrained terminal height in one place. const layoutSidebar = () => { if (!isActive) { return; @@ -1919,6 +1926,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { lastScrollTop = logsScroll.scrollTop; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Log text rendering intentionally combines scope, level, search, follow, and selection state before painting. const updateLogText = (opts?: { readonly force?: boolean }) => { if (!isActive) { return; @@ -2070,6 +2078,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { return null; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Appending logs intentionally merges continuation lines, trims history, and preserves paused-follow behavior. const appendLogEntry = (entry: LogEntry) => { if (!isActive) { return; @@ -2259,6 +2268,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { return entries; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: History loading intentionally branches by backend and capacity while keeping UI state synchronized. const loadMoreHistory = async () => { if (!isActive || historyState.loading || !historyState.canLoadMore) { return; @@ -2440,6 +2450,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { searchProc = null; }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Search submission intentionally coordinates overlay teardown, backend selection, local fallback, and result-mode transition. const runSearch = async () => { const query = searchQueryInput.value.trim(); const service = searchServiceSelect.getSelectedOption()?.value ?? null; @@ -2899,6 +2910,7 @@ export async function runHackTui({ project }: HackTuiOptions): Promise<number> { activeRenderer.on("selection", handleSelectionChange); + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: The main TUI key handler is an intentional event router for modal search, filters, service actions, and lifecycle controls. activeRenderer.keyInput.on("keypress", (key) => { if ((key.ctrl || key.meta) && key.name === "f") { key.preventDefault(); @@ -3290,6 +3302,7 @@ function formatLogEntry(event: LogStreamEvent): LogEntry | null { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Docker stats parsing intentionally tolerates sparse/malformed fields while normalizing CLI output into samples. function parseDockerStatsOutput(opts: { readonly output: string; }): DockerStatsSample[] { @@ -4176,6 +4189,7 @@ function buildAnsiChunk(opts: { }; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: ANSI parsing intentionally keeps the full SGR state machine local so styled log rendering remains deterministic. function applyAnsiCodes(opts: { readonly style: AnsiStyle; readonly codes: number[]; diff --git a/src/tui/tickets-tui.ts b/src/tui/tickets-tui.ts index 2d3d00d30..da2dcd445 100644 --- a/src/tui/tickets-tui.ts +++ b/src/tui/tickets-tui.ts @@ -5,6 +5,7 @@ import { dim, fg, InputRenderable, + type KeyEvent, RenderableEvents, RGBA, ScrollBoxRenderable, @@ -839,61 +840,81 @@ export async function runTicketsTui({ focusPane(activePane); }; - const submitOverlay = async () => { - if (overlayState.mode === "new") { - const title = titleInput.value.trim(); - const body = bodyInput.value.trim(); - if (!title) { - overlayStatus.content = t`${fg("#e0af68")("Title is required.")}`; - return; - } + const setOverlayWarning = (message: string) => { + overlayStatus.content = t`${fg("#e0af68")(message)}`; + }; - overlayStatus.content = t`${fg("#7dcfff")("Creating ticket...")}`; - const created = await store.createTicket({ - title, - body: body.length > 0 ? body : undefined, - }); + const setOverlayProgress = (message: string) => { + overlayStatus.content = t`${fg("#7dcfff")(message)}`; + }; - if (!created.ok) { - overlayStatus.content = t`${fg("#f7768e")(`${created.error}`)}`; - return; - } + const setOverlayError = (message: string) => { + overlayStatus.content = t`${fg("#f7768e")(message)}`; + }; - closeOverlay(); - await refreshTickets(); - const index = ticketsSelect.options.findIndex( - (option) => option.value === created.ticket.ticketId - ); - ticketsSelect.setSelectedIndex(index >= 0 ? index : 0); - setToast({ message: `Created ${created.ticket.ticketId}` }); + const submitNewOverlay = async () => { + const title = titleInput.value.trim(); + const body = bodyInput.value.trim(); + if (!title) { + setOverlayWarning("Title is required."); return; } - if (overlayState.mode === "status") { - const ticketId = selectedTicketId(); - if (!ticketId) { - overlayStatus.content = t`${fg("#e0af68")("Select a ticket first.")}`; - return; - } + setOverlayProgress("Creating ticket..."); + const created = await store.createTicket({ + title, + body: body.length > 0 ? body : undefined, + }); - const next = statusSelect.getSelectedOption()?.value as - | StatusOption - | undefined; - if (!next) { - overlayStatus.content = t`${fg("#e0af68")("Select a status.")}`; - return; - } + if (!created.ok) { + setOverlayError(created.error); + return; + } - overlayStatus.content = t`${fg("#7dcfff")("Updating status...")}`; - const updated = await store.setStatus({ ticketId, status: next }); - if (!updated.ok) { - overlayStatus.content = t`${fg("#f7768e")(`${updated.error}`)}`; - return; - } + closeOverlay(); + await refreshTickets(); + const index = ticketsSelect.options.findIndex( + (option) => option.value === created.ticket.ticketId + ); + ticketsSelect.setSelectedIndex(index >= 0 ? index : 0); + setToast({ message: `Created ${created.ticket.ticketId}` }); + }; - closeOverlay(); - await refreshTickets(); - setToast({ message: `Updated ${ticketId} -> ${next}` }); + const submitStatusOverlay = async () => { + const ticketId = selectedTicketId(); + if (!ticketId) { + setOverlayWarning("Select a ticket first."); + return; + } + + const next = statusSelect.getSelectedOption()?.value as + | StatusOption + | undefined; + if (!next) { + setOverlayWarning("Select a status."); + return; + } + + setOverlayProgress("Updating status..."); + const updated = await store.setStatus({ ticketId, status: next }); + if (!updated.ok) { + setOverlayError(updated.error); + return; + } + + closeOverlay(); + await refreshTickets(); + setToast({ message: `Updated ${ticketId} -> ${next}` }); + }; + + const submitOverlay = async () => { + if (overlayState.mode === "new") { + await submitNewOverlay(); + return; + } + + if (overlayState.mode === "status") { + await submitStatusOverlay(); } }; @@ -916,67 +937,77 @@ export async function runTicketsTui({ renderFooter(); setActivePane("list"); - activeRenderer.keyInput.on("keypress", (key) => { - if (overlayState.mode !== "none") { - if (key.name === "escape") { - key.preventDefault(); - closeOverlay(); - return; - } - if (key.name === "tab") { - key.preventDefault(); - const total = overlayState.focusables.length; - if (total === 0) { - return; - } - const next = - (overlayState.focusIndex + (key.shift ? -1 : 1) + total) % total; - overlayState.focusIndex = next; - overlayState.focusables[next]?.focus(); - return; - } - if ( - key.name === "enter" || - key.name === "return" || - key.name === "linefeed" - ) { - key.preventDefault(); - void submitOverlay(); - return; - } + const focusNextOverlayField = (direction: number) => { + const total = overlayState.focusables.length; + if (total === 0) { return; } + const next = (overlayState.focusIndex + direction + total) % total; + overlayState.focusIndex = next; + overlayState.focusables[next]?.focus(); + }; + const handleOverlayKeypress = (key: KeyEvent) => { + if (key.name === "escape") { + key.preventDefault(); + closeOverlay(); + return true; + } + if (key.name === "tab") { + key.preventDefault(); + focusNextOverlayField(key.shift ? -1 : 1); + return true; + } + if ( + key.name === "enter" || + key.name === "return" || + key.name === "linefeed" + ) { + key.preventDefault(); + void submitOverlay(); + return true; + } + return false; + }; + + const handleGlobalKeypress = (key: KeyEvent) => { if (key.name === "q" || (key.ctrl && key.name === "c")) { key.preventDefault(); void shutdown(); return; } - if (key.name === "tab") { key.preventDefault(); focusNextPane(key.shift ? -1 : 1); return; } - - if (key.name === "n" && !key.ctrl && !key.meta) { + if (key.ctrl || key.meta) { + return; + } + if (key.name === "n") { key.preventDefault(); openOverlay("new"); return; } - - if (key.name === "s" && !key.ctrl && !key.meta) { + if (key.name === "s") { key.preventDefault(); openOverlay("status"); return; } - - if (key.name === "r" && !key.ctrl && !key.meta) { + if (key.name === "r") { key.preventDefault(); void refreshTickets().then(() => setToast({ message: "Refreshed tickets" }) ); } + }; + + activeRenderer.keyInput.on("keypress", (key) => { + if (overlayState.mode !== "none") { + handleOverlayKeypress(key); + return; + } + handleGlobalKeypress(key); }); await refreshTickets(); @@ -1126,44 +1157,33 @@ function parseTicketBodyMeta(opts: { readonly body: string }): TicketBodyMeta { for (const rawLine of lines) { const line = rawLine.trim(); - if (line.startsWith("```")) { - inCodeBlock = !inCodeBlock; + const nextCodeBlockState = getNextCodeBlockState({ + line, + inCodeBlock, + }); + if (nextCodeBlockState !== null) { + inCodeBlock = nextCodeBlockState; continue; } if (inCodeBlock) { continue; } - const headingMatch = line.match(MARKDOWN_HEADING_REGEX); - if (headingMatch) { - const heading = normalizeHeading(headingMatch[1] ?? ""); - section = resolveMetaSection(heading); + const nextSection = resolveMetaHeadingLine({ line }); + if (nextSection !== undefined) { + section = nextSection; continue; } - if (!meta.priority) { - const priorityMatch = line.match(PRIORITY_LINE_REGEX); - if (priorityMatch?.[1]) { - meta.priority = priorityMatch[1].trim(); - continue; - } - } - - if (!section || line.length === 0) { + if (!meta.priority && applyTicketPriorityLine({ line, meta })) { continue; } - const listItem = extractListItem({ line }); - if (!listItem) { + if (!section || line.length === 0) { continue; } - if (section === "links") { - const link = extractLinkLabel({ text: listItem }) ?? listItem; - meta.links.push(link); - } else { - meta.acceptanceCriteria.push(listItem); - } + appendTicketMetaListItem({ line, section, meta }); } return { @@ -1173,6 +1193,58 @@ function parseTicketBodyMeta(opts: { readonly body: string }): TicketBodyMeta { }; } +function getNextCodeBlockState(opts: { + readonly line: string; + readonly inCodeBlock: boolean; +}): boolean | null { + if (!opts.line.startsWith("```")) { + return null; + } + return !opts.inCodeBlock; +} + +function resolveMetaHeadingLine(opts: { + readonly line: string; +}): "links" | "acceptance" | null | undefined { + const headingMatch = opts.line.match(MARKDOWN_HEADING_REGEX); + if (!headingMatch) { + return undefined; + } + const heading = normalizeHeading(headingMatch[1] ?? ""); + return resolveMetaSection(heading); +} + +function applyTicketPriorityLine(opts: { + readonly line: string; + readonly meta: MutableTicketBodyMeta; +}): boolean { + const priorityMatch = opts.line.match(PRIORITY_LINE_REGEX); + if (!priorityMatch?.[1]) { + return false; + } + opts.meta.priority = priorityMatch[1].trim(); + return true; +} + +function appendTicketMetaListItem(opts: { + readonly line: string; + readonly section: "links" | "acceptance"; + readonly meta: MutableTicketBodyMeta; +}): void { + const listItem = extractListItem({ line: opts.line }); + if (!listItem) { + return; + } + + if (opts.section === "links") { + const link = extractLinkLabel({ text: listItem }) ?? listItem; + opts.meta.links.push(link); + return; + } + + opts.meta.acceptanceCriteria.push(listItem); +} + function renderMetaExtras(meta: TicketBodyMeta): StyledText[] { const extras: StyledText[] = []; const priority = meta.priority ? meta.priority.trim() : ""; @@ -1425,122 +1497,142 @@ function applyAnsiCodes(opts: { let i = 0; while (i < opts.codes.length) { const code = opts.codes[i] ?? 0; - switch (code) { - case 0: - style = createAnsiStyle(); - i += 1; - break; - case 1: - style = { ...style, bold: true }; - i += 1; - break; - case 2: - style = { ...style, dim: true }; - i += 1; - break; - case 3: - style = { ...style, italic: true }; - i += 1; - break; - case 4: - style = { ...style, underline: true }; - i += 1; - break; - case 7: - style = { ...style, inverse: true }; - i += 1; - break; - case 9: - style = { ...style, strikethrough: true }; - i += 1; - break; - case 22: - style = { ...style, bold: false, dim: false }; - i += 1; - break; - case 23: - style = { ...style, italic: false }; - i += 1; - break; - case 24: - style = { ...style, underline: false }; - i += 1; - break; - case 27: - style = { ...style, inverse: false }; - i += 1; - break; - case 29: - style = { ...style, strikethrough: false }; - i += 1; - break; - case 39: - style = { ...style, fg: undefined }; - i += 1; - break; - case 49: - style = { ...style, bg: undefined }; - i += 1; - break; - default: { - if (code >= 30 && code <= 37) { - style = { ...style, fg: ansiToRgba(code - 30, false) }; - i += 1; - break; - } - if (code >= 90 && code <= 97) { - style = { ...style, fg: ansiToRgba(code - 90, true) }; - i += 1; - break; - } - if (code >= 40 && code <= 47) { - style = { ...style, bg: ansiToRgba(code - 40, false) }; - i += 1; - break; - } - if (code >= 100 && code <= 107) { - style = { ...style, bg: ansiToRgba(code - 100, true) }; - i += 1; - break; - } - if (code === 38 || code === 48) { - const isFg = code === 38; - const next = opts.codes[i + 1]; - if (next === 5) { - const colorIndex = opts.codes[i + 2]; - if (typeof colorIndex === "number") { - const rgba = xtermToRgba(colorIndex); - style = isFg ? { ...style, fg: rgba } : { ...style, bg: rgba }; - } - i += 3; - break; - } - if (next === 2) { - const r = opts.codes[i + 2]; - const g = opts.codes[i + 3]; - const b = opts.codes[i + 4]; - if ([r, g, b].every((v) => typeof v === "number")) { - const rgba = RGBA.fromInts( - clampColor(r ?? 0), - clampColor(g ?? 0), - clampColor(b ?? 0), - 255 - ); - style = isFg ? { ...style, fg: rgba } : { ...style, bg: rgba }; - } - i += 5; - break; - } - } - i += 1; - break; - } + const basicStyle = applyBasicAnsiCode({ style, code }); + if (basicStyle) { + style = basicStyle; + i += 1; + continue; + } + + const paletteStyle = applyPaletteAnsiCode({ style, code }); + if (paletteStyle) { + style = paletteStyle; + i += 1; + continue; } + + const extended = applyExtendedAnsiCode({ + style, + codes: opts.codes, + index: i, + }); + if (extended) { + style = extended.style; + i += extended.consumed; + continue; + } + + i += 1; } return style; } +function applyBasicAnsiCode(opts: { + readonly style: AnsiStyle; + readonly code: number; +}): AnsiStyle | null { + switch (opts.code) { + case 0: + return createAnsiStyle(); + case 1: + return { ...opts.style, bold: true }; + case 2: + return { ...opts.style, dim: true }; + case 3: + return { ...opts.style, italic: true }; + case 4: + return { ...opts.style, underline: true }; + case 7: + return { ...opts.style, inverse: true }; + case 9: + return { ...opts.style, strikethrough: true }; + case 22: + return { ...opts.style, bold: false, dim: false }; + case 23: + return { ...opts.style, italic: false }; + case 24: + return { ...opts.style, underline: false }; + case 27: + return { ...opts.style, inverse: false }; + case 29: + return { ...opts.style, strikethrough: false }; + case 39: + return { ...opts.style, fg: undefined }; + case 49: + return { ...opts.style, bg: undefined }; + default: + return null; + } +} + +function applyPaletteAnsiCode(opts: { + readonly style: AnsiStyle; + readonly code: number; +}): AnsiStyle | null { + if (opts.code >= 30 && opts.code <= 37) { + return { ...opts.style, fg: ansiToRgba(opts.code - 30, false) }; + } + if (opts.code >= 90 && opts.code <= 97) { + return { ...opts.style, fg: ansiToRgba(opts.code - 90, true) }; + } + if (opts.code >= 40 && opts.code <= 47) { + return { ...opts.style, bg: ansiToRgba(opts.code - 40, false) }; + } + if (opts.code >= 100 && opts.code <= 107) { + return { ...opts.style, bg: ansiToRgba(opts.code - 100, true) }; + } + return null; +} + +function applyExtendedAnsiCode(opts: { + readonly style: AnsiStyle; + readonly codes: readonly number[]; + readonly index: number; +}): { readonly style: AnsiStyle; readonly consumed: number } | null { + const code = opts.codes[opts.index]; + if (code !== 38 && code !== 48) { + return null; + } + + const isFg = code === 38; + const next = opts.codes[opts.index + 1]; + if (next === 5) { + const colorIndex = opts.codes[opts.index + 2]; + if (typeof colorIndex !== "number") { + return { style: opts.style, consumed: 3 }; + } + const rgba = xtermToRgba(colorIndex); + return { + style: isFg ? { ...opts.style, fg: rgba } : { ...opts.style, bg: rgba }, + consumed: 3, + }; + } + if (next !== 2) { + return null; + } + + const [r, g, b] = [ + opts.codes[opts.index + 2], + opts.codes[opts.index + 3], + opts.codes[opts.index + 4], + ]; + if ([r, g, b].every((value) => typeof value === "number")) { + const rgba = RGBA.fromInts( + clampColor(r ?? 0), + clampColor(g ?? 0), + clampColor(b ?? 0), + 255 + ); + return { + style: isFg ? { ...opts.style, fg: rgba } : { ...opts.style, bg: rgba }, + consumed: 5, + }; + } + + return { style: opts.style, consumed: 5 }; +} + function clampColor(value: number): number { if (value < 0) { return 0; diff --git a/src/ui/log-group.ts b/src/ui/log-group.ts index f37ff21c3..8f5769bfc 100644 --- a/src/ui/log-group.ts +++ b/src/ui/log-group.ts @@ -38,6 +38,7 @@ export function createStructuredLogGrouper(opts: { emit(buffer.rawLines); }; + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Grouping log lines must track nested ANSI resets and boundary detection while streaming incrementally. const handleLine = (line: string) => { const current: string | null = line; while (current) { diff --git a/src/ui/loki-logs.ts b/src/ui/loki-logs.ts index d97b446c9..c238f07b2 100644 --- a/src/ui/loki-logs.ts +++ b/src/ui/loki-logs.ts @@ -141,6 +141,7 @@ function linePrefix(opts: { return `${project}/${service}`; } +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Loki range queries intentionally coordinate auth, query shaping, formatting, pagination, and error reporting in one operator flow. async function lokiQueryRange(opts: LokiLogsParams): Promise<number> { const baseUrl = normalizeBaseUrl(opts.baseUrl); @@ -351,6 +352,7 @@ async function lokiTail(opts: LokiLogsParams): Promise<number> { }); } + // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Websocket log streaming must parse mixed message types, update cursors, and format incremental output inline. ws.addEventListener("message", (event) => { const text = typeof event.data === "string" ? event.data : null; if (!text) { diff --git a/src/ui/planet.ts b/src/ui/planet.ts index 159861676..41b474f9a 100644 --- a/src/ui/planet.ts +++ b/src/ui/planet.ts @@ -67,12 +67,19 @@ export async function playPlanetAnimation(opts: { return true; } - const lipgloss = await loadLipgloss(); - const prepared = animations.map((anim) => ({ + return await playTerminalPlanetAnimations({ animations, loop: opts.loop }); +} + +async function playTerminalPlanetAnimations(opts: { + readonly animations: readonly PlanetAnimation[]; + readonly loop: boolean; +}): Promise<boolean> { + const animations = opts.animations.map((anim) => ({ anim, metrics: computeFrameMetrics(anim.frames), })); + const lipgloss = await loadLipgloss(); const banner = lipgloss ? buildPlanetBanner(lipgloss) : null; const renderFrame = (opts: RenderFrameOptions) => renderPlanetFrame({ ...opts, banner }); @@ -85,31 +92,13 @@ export async function playPlanetAnimation(opts: { process.stdout.write("\x1b[0m\x1b[H\x1b[J"); do { - for (const { anim, metrics } of prepared) { - const frameMs = Math.max(1, Math.round(1000 / anim.fps)); - for (const frame of anim.frames) { - if (stop.shouldStop()) { - break; - } - - // Reset styles before clearing to avoid per-frame ANSI state leaking into the border. - process.stdout.write("\x1b[0m\x1b[H\x1b[J"); - const size = readTerminalSize(); - process.stdout.write( - renderFrame({ - frame, - contentWidth: metrics.width, - contentHeight: metrics.height, - cols: size.cols ?? undefined, - rows: size.rows ?? undefined, - }) - ); - - await sleep(frameMs); - } - if (stop.shouldStop()) { - break; - } + const completed = await playLegacyFrameSequence({ + animations, + shouldStop: stop.shouldStop, + renderFrame, + }); + if (!completed) { + break; } } while (opts.loop && !stop.shouldStop()); } finally { @@ -213,51 +202,17 @@ async function playCompositedAnimations(opts: { try { process.stdout.write("\x1b[0m\x1b[H\x1b[J"); do { - for (const anim of opts.animations) { - const frameMs = Math.max(1, Math.round(1000 / anim.fps)); - for (const frame of anim.frames) { - if (stop.shouldStop()) { - break; - } - const contentLines = fitFrameToArea({ - frame, - width: videoBox.innerWidth, - height: videoBox.innerHeight, - }); - const panelLines = opts.layout.panel - ? renderPanelFrame({ - tick, - seed, - panel: opts.layout.panel, - theme, - }) - : null; - - const screen = renderCompositedFrame({ - layout: opts.layout, - theme, - tick, - contentLines, - panelLines, - videoBox, - }); - process.stdout.write(`\x1b[H${screen}`); - if (shouldShowOverlay(tick)) { - const overlay = renderOverlayFrame({ - tick, - layout: opts.layout, - theme, - }); - if (overlay) { - writeOverlayFrame(overlay); - } - } - await sleep(frameMs); - tick += 1; - } - if (stop.shouldStop()) { - break; - } + tick = await playCompositedAnimationLoop({ + animations: opts.animations, + layout: opts.layout, + theme, + videoBox, + seed, + tick, + shouldStop: stop.shouldStop, + }); + if (stop.shouldStop()) { + break; } } while (opts.loop && !stop.shouldStop()); } finally { @@ -268,6 +223,119 @@ async function playCompositedAnimations(opts: { return true; } +async function playLegacyFrameSequence(input: { + readonly animations: ReadonlyArray<{ + readonly anim: PlanetAnimation; + readonly metrics: { readonly width: number; readonly height: number }; + }>; + readonly shouldStop: () => boolean; + readonly renderFrame: (opts: RenderFrameOptions) => string; +}): Promise<boolean> { + for (const { anim, metrics } of input.animations) { + const frameMs = Math.max(1, Math.round(1000 / anim.fps)); + for (const frame of anim.frames) { + if (input.shouldStop()) { + return false; + } + process.stdout.write("\x1b[0m\x1b[H\x1b[J"); + const size = readTerminalSize(); + process.stdout.write( + input.renderFrame({ + frame, + contentWidth: metrics.width, + contentHeight: metrics.height, + cols: size.cols ?? undefined, + rows: size.rows ?? undefined, + }) + ); + await sleep(frameMs); + } + } + return true; +} + +async function playCompositedAnimationLoop(input: { + readonly animations: readonly PlanetAnimation[]; + readonly layout: ChafaLayout; + readonly theme: PlanetTheme; + readonly videoBox: VideoBox; + readonly seed: number; + readonly tick: number; + readonly shouldStop: () => boolean; +}): Promise<number> { + let nextTick = input.tick; + for (const anim of input.animations) { + const frameMs = Math.max(1, Math.round(1000 / anim.fps)); + for (const frame of anim.frames) { + if (input.shouldStop()) { + return nextTick; + } + renderCompositedAnimationFrame({ + frame, + layout: input.layout, + theme: input.theme, + videoBox: input.videoBox, + seed: input.seed, + tick: nextTick, + }); + await sleep(frameMs); + nextTick += 1; + } + } + return nextTick; +} + +function renderCompositedAnimationFrame(input: { + readonly frame: string; + readonly layout: ChafaLayout; + readonly theme: PlanetTheme; + readonly videoBox: VideoBox; + readonly seed: number; + readonly tick: number; +}): void { + const contentLines = fitFrameToArea({ + frame: input.frame, + width: input.videoBox.innerWidth, + height: input.videoBox.innerHeight, + }); + const panelLines = input.layout.panel + ? renderPanelFrame({ + tick: input.tick, + seed: input.seed, + panel: input.layout.panel, + theme: input.theme, + }) + : null; + const screen = renderCompositedFrame({ + layout: input.layout, + theme: input.theme, + tick: input.tick, + contentLines, + panelLines, + videoBox: input.videoBox, + }); + process.stdout.write(`\x1b[H${screen}`); + writeOverlayIfNeeded({ + tick: input.tick, + layout: input.layout, + theme: input.theme, + }); +} + +function writeOverlayIfNeeded(input: { + readonly tick: number; + readonly layout: ChafaLayout; + readonly theme: PlanetTheme; +}): void { + if (!shouldShowOverlay(input.tick)) { + return; + } + const overlay = renderOverlayFrame(input); + if (overlay) { + writeOverlayFrame(overlay); + } +} + type ChafaEntry = { readonly path: string; readonly fps: number; @@ -1132,16 +1200,14 @@ function renderTvFrameLegacy(opts: RenderFrameOptions): string { const desiredPadX = 4; const desiredPadY = 1; - - const maxPadX = cols - ? Math.max(0, Math.floor((cols - contentWidth - 2) / 2)) - : desiredPadX; - const maxPadY = rows - ? Math.max(0, Math.floor((rows - contentHeight - 2) / 2)) - : desiredPadY; - - const padX = Math.min(desiredPadX, maxPadX); - const padY = Math.min(desiredPadY, maxPadY); + const { padX, padY } = resolveTvPadding({ + cols, + rows, + contentWidth, + contentHeight, + desiredPadX, + desiredPadY, + }); const innerWidth = contentWidth + padX * 2; const outerWidth = innerWidth + 2; @@ -1149,27 +1215,15 @@ function renderTvFrameLegacy(opts: RenderFrameOptions): string { // If the terminal is too small to fit the border, just center the raw frame. if ( - (cols !== null && contentWidth > cols) || - (rows !== null && contentHeight > rows) - ) { - return centerRaw({ - lines, - width: contentWidth, - height: contentHeight, + shouldCenterRawFrame({ cols, rows, - }); - } - if (cols !== null && outerWidth > cols) { - return centerRaw({ - lines, - width: contentWidth, - height: contentHeight, - cols, - rows, - }); - } - if (rows !== null && outerHeight > rows) { + contentWidth, + contentHeight, + outerWidth, + outerHeight, + }) + ) { return centerRaw({ lines, width: contentWidth, @@ -1212,6 +1266,48 @@ function renderTvFrameLegacy(opts: RenderFrameOptions): string { return out.join("\n"); } +function resolveTvPadding(input: { + readonly cols: number | null; + readonly rows: number | null; + readonly contentWidth: number; + readonly contentHeight: number; + readonly desiredPadX: number; + readonly desiredPadY: number; +}): { readonly padX: number; readonly padY: number } { + const maxPadX = + input.cols !== null + ? Math.max(0, Math.floor((input.cols - input.contentWidth - 2) / 2)) + : input.desiredPadX; + const maxPadY = + input.rows !== null + ? Math.max(0, Math.floor((input.rows - input.contentHeight - 2) / 2)) + : input.desiredPadY; + return { + padX: Math.min(input.desiredPadX, maxPadX), + padY: Math.min(input.desiredPadY, maxPadY), + }; +} + +function shouldCenterRawFrame(input: { + readonly cols: number | null; + readonly rows: number | null; + readonly contentWidth: number; + readonly contentHeight: number; + readonly outerWidth: number; + readonly outerHeight: number; +}): boolean { + if (input.cols !== null && input.contentWidth > input.cols) { + return true; + } + if (input.rows !== null && input.contentHeight > input.rows) { + return true; + } + if (input.cols !== null && input.outerWidth > input.cols) { + return true; + } + return input.rows !== null && input.outerHeight > input.rows; +} + function centerRaw(opts: { readonly lines: readonly string[]; readonly width: number; diff --git a/tests/dispatch-local-command.test.ts b/tests/dispatch-local-command.test.ts new file mode 100644 index 000000000..f1b44443a --- /dev/null +++ b/tests/dispatch-local-command.test.ts @@ -0,0 +1,482 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { findProjectContext } from "../src/lib/project.ts"; +import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; + +type CliRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +let tempDir: string | null = null; +const originalHome = process.env.HOME; +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const originalLogger = process.env.HACK_LOGGER; +const originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; +const originalGitHubTokenEnv = process.env.HACK_GITHUB_APP_TOKEN; +const originalFetch = globalThis.fetch; + +const { runCli } = await import("../src/cli/run.ts"); + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-dispatch-local-")); + process.env.HOME = tempDir; + process.env.HACK_GLOBAL_CONFIG_PATH = resolve(tempDir, "hack.config.json"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_SETUP_SYNC_MODE = "off"; + await writeFile(process.env.HACK_GLOBAL_CONFIG_PATH, "{}\n"); +}); + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + if (originalHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = originalHome; + } + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } + if (originalLogger === undefined) { + process.env.HACK_LOGGER = undefined; + } else { + process.env.HACK_LOGGER = originalLogger; + } + if (originalSetupSyncMode === undefined) { + process.env.HACK_SETUP_SYNC_MODE = undefined; + } else { + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; + } + if (originalGitHubTokenEnv === undefined) { + process.env.HACK_GITHUB_APP_TOKEN = undefined; + } else { + process.env.HACK_GITHUB_APP_TOKEN = originalGitHubTokenEnv; + } + globalThis.fetch = originalFetch; +}); + +test("dispatch run --local executes in the project workspace and persists terminalState", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo"); + await createMinimalHackRepo({ repoRoot }); + + const result = await runCliWithCapturedIo({ + argv: [ + "dispatch", + "run", + "--project", + "dispatch-local-test", + "--local", + "--json", + "--", + "bash", + "-lc", + "printf 'hello from local dispatch\\n'", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly runId: string; + readonly status: string; + readonly terminalState?: string; + readonly artifacts: { + readonly manifestPath: string; + readonly logPath: string; + }; + readonly exitCode?: number; + }; + expect(payload.status).toBe("completed"); + expect(payload.terminalState).toBe("completed"); + expect(payload.exitCode).toBe(0); + + const manifest = JSON.parse( + await readFile(payload.artifacts.manifestPath, "utf8") + ) as { + readonly status: string; + readonly terminalState?: string; + readonly mode?: string; + }; + expect(manifest.status).toBe("completed"); + expect(manifest.terminalState).toBe("completed"); + expect(manifest.mode).toBe("local"); + + const logText = await readFile(payload.artifacts.logPath, "utf8"); + expect(logText).toContain("hello from local dispatch"); +}); + +test("dispatch run --local --pr returns no_diff when branch has no committed diff", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo-no-diff"); + await createMinimalHackRepo({ repoRoot }); + await initGitRepo({ repoRoot, remoteName: "origin" }); + + process.env.HACK_GITHUB_APP_TOKEN = "test-token"; + + const result = await runCliWithCapturedIo({ + argv: [ + "dispatch", + "run", + "--project", + "dispatch-local-test", + "--local", + "--pr", + "--json", + "--", + "bash", + "-lc", + "printf 'no-op\\n'", + ], + }); + + expect(result.exitCode).toBe(20); + const payload = JSON.parse(result.stdout) as { + readonly status: string; + readonly terminalState?: string; + readonly artifacts: { + readonly manifestPath: string; + }; + }; + expect(payload.status).toBe("completed"); + expect(payload.terminalState).toBe("no_diff"); + + const manifest = JSON.parse( + await readFile(payload.artifacts.manifestPath, "utf8") + ) as { + readonly terminalState?: string; + }; + expect(manifest.terminalState).toBe("no_diff"); +}); + +test("dispatch run --local --pr returns no_commit when command leaves local diff uncommitted", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo-no-commit"); + await createMinimalHackRepo({ repoRoot }); + await initGitRepo({ repoRoot, remoteName: "origin" }); + await git({ cwd: repoRoot, cmd: ["checkout", "-b", "feat/no-commit"] }); + + process.env.HACK_GITHUB_APP_TOKEN = "test-token"; + + const result = await runCliWithCapturedIo({ + argv: [ + "dispatch", + "run", + "--project", + "dispatch-local-test", + "--local", + "--pr", + "--json", + "--", + "bash", + "-lc", + "printf 'dirty\\n' >> local-change.txt", + ], + }); + + expect(result.exitCode).toBe(21); + const payload = JSON.parse(result.stdout) as { + readonly terminalState?: string; + readonly artifacts: { + readonly manifestPath: string; + }; + }; + expect(payload.terminalState).toBe("no_commit"); + + const manifest = JSON.parse( + await readFile(payload.artifacts.manifestPath, "utf8") + ) as { + readonly terminalState?: string; + }; + expect(manifest.terminalState).toBe("no_commit"); +}); + +test("dispatch run --local --pr creates a pull request when branch is committed and ahead", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo-pr-created"); + await createMinimalHackRepo({ repoRoot }); + await initGitRepo({ repoRoot, remoteName: "origin" }); + await git({ cwd: repoRoot, cmd: ["checkout", "-b", "feat/local-pr"] }); + await writeFile(resolve(repoRoot, "feature.txt"), "feature\n"); + await git({ cwd: repoRoot, cmd: ["add", "feature.txt"] }); + await git({ + cwd: repoRoot, + cmd: ["commit", "-m", "Add local feature for PR"], + }); + + process.env.HACK_GITHUB_APP_TOKEN = "test-token"; + globalThis.fetch = (async (input, init) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? "GET"; + if ( + method === "GET" && + requestUrl.includes("/repos/acme/dispatch-local-test/pulls?") + ) { + return Response.json([]); + } + if ( + method === "POST" && + requestUrl.endsWith("/repos/acme/dispatch-local-test/pulls") + ) { + return Response.json({ + number: 42, + url: "https://api.github.com/repos/acme/dispatch-local-test/pulls/42", + html_url: "https://github.com/acme/dispatch-local-test/pull/42", + title: "hack: dispatch-local-test (feat/local-pr)", + state: "open", + head: { ref: "feat/local-pr" }, + base: { ref: "main" }, + }); + } + if ( + method === "POST" && + requestUrl.endsWith("/repos/acme/dispatch-local-test/issues/42/comments") + ) { + return Response.json({ + id: 7, + url: "https://api.github.com/repos/acme/dispatch-local-test/issues/comments/7", + }); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + const result = await runCliWithCapturedIo({ + argv: [ + "dispatch", + "run", + "--project", + "dispatch-local-test", + "--local", + "--pr", + "--json", + "--", + "bash", + "-lc", + "printf 'ready\\n'", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly terminalState?: string; + readonly pr?: { + readonly ok: boolean; + readonly pull?: { + readonly number: number; + readonly htmlUrl: string; + }; + }; + }; + expect(payload.terminalState).toBe("pr_created"); + expect(payload.pr?.ok).toBe(true); + expect(payload.pr?.pull?.number).toBe(42); +}); + +test("dispatch run --local --pr returns pr_failed when GitHub PR automation fails", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo-pr-failed"); + await createMinimalHackRepo({ repoRoot }); + await initGitRepo({ repoRoot, remoteName: "origin" }); + await git({ cwd: repoRoot, cmd: ["checkout", "-b", "feat/pr-fails"] }); + await writeFile(resolve(repoRoot, "feature.txt"), "feature\n"); + await git({ cwd: repoRoot, cmd: ["add", "feature.txt"] }); + await git({ + cwd: repoRoot, + cmd: ["commit", "-m", "Add local feature for failing PR"], + }); + + process.env.HACK_GITHUB_APP_TOKEN = "test-token"; + globalThis.fetch = (async (input, init) => { + const requestUrl = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + const method = init?.method ?? "GET"; + if ( + method === "GET" && + requestUrl.includes("/repos/acme/dispatch-local-test/pulls?") + ) { + return Response.json([]); + } + if ( + method === "POST" && + requestUrl.endsWith("/repos/acme/dispatch-local-test/pulls") + ) { + return Response.json( + { + message: "validation failed", + }, + { status: 422 } + ); + } + return new Response("unexpected request", { status: 500 }); + }) as typeof fetch; + + const result = await runCliWithCapturedIo({ + argv: [ + "dispatch", + "run", + "--project", + "dispatch-local-test", + "--local", + "--pr", + "--json", + "--", + "bash", + "-lc", + "printf 'ready\\n'", + ], + }); + + expect(result.exitCode).toBe(22); + const payload = JSON.parse(result.stdout) as { + readonly terminalState?: string; + readonly pr?: { + readonly ok: boolean; + readonly error?: string; + }; + }; + expect(payload.terminalState).toBe("pr_failed"); + expect(payload.pr?.ok).toBe(false); + expect(payload.pr?.error).toContain("GitHub PR upsert failed"); +}); + +async function createMinimalHackRepo(input: { + readonly repoRoot: string; +}): Promise<void> { + await mkdir(resolve(input.repoRoot, ".hack"), { recursive: true }); + await writeFile( + resolve(input.repoRoot, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + await writeFile( + resolve(input.repoRoot, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "dispatch-local-test" }, null, 2)}\n` + ); + const ctx = await findProjectContext(input.repoRoot); + if (!ctx) { + throw new Error("Missing project context"); + } + await upsertProjectRegistration({ project: ctx }); +} + +async function initGitRepo(input: { + readonly repoRoot: string; + readonly remoteName: string; +}): Promise<void> { + const bareRemoteRoot = resolve( + input.repoRoot, + "..", + "dispatch-local-test.git" + ); + await git({ cwd: input.repoRoot, cmd: ["init", "-b", "main"] }); + await git({ + cwd: input.repoRoot, + cmd: ["config", "user.name", "Hack Test"], + }); + await git({ + cwd: input.repoRoot, + cmd: ["config", "user.email", "hack@example.com"], + }); + await writeFile(resolve(input.repoRoot, "README.md"), "# dispatch local\n"); + await git({ cwd: input.repoRoot, cmd: ["add", "."] }); + await git({ + cwd: input.repoRoot, + cmd: ["commit", "-m", "Initial commit"], + }); + + await git({ + cwd: resolve(input.repoRoot, ".."), + cmd: ["init", "--bare", bareRemoteRoot], + }); + await git({ + cwd: input.repoRoot, + cmd: [ + "remote", + "add", + input.remoteName, + "https://github.com/acme/dispatch-local-test.git", + ], + }); + await git({ + cwd: input.repoRoot, + cmd: ["remote", "set-url", "--push", input.remoteName, bareRemoteRoot], + }); + await git({ + cwd: input.repoRoot, + cmd: ["push", "-u", input.remoteName, "main"], + }); +} + +async function git(input: { + readonly cwd: string; + readonly cmd: readonly string[]; +}): Promise<void> { + const proc = Bun.spawn(["git", ...input.cmd], { + cwd: input.cwd, + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const stdout = await new Response(proc.stdout).text(); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + if (exitCode !== 0) { + throw new Error( + `git ${input.cmd.join(" ")} failed with ${exitCode}\nstdout:\n${stdout}\nstderr:\n${stderr}` + ); + } +} + +async function runCliWithCapturedIo(input: { + readonly argv: readonly string[]; +}): Promise<CliRunResult> { + let stdout = ""; + let stderr = ""; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stderr.write; + + try { + const exitCode = await runCli(input.argv); + return { exitCode, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} diff --git a/tests/node-provider-aws.test.ts b/tests/node-provider-aws.test.ts new file mode 100644 index 000000000..a9da4a8ac --- /dev/null +++ b/tests/node-provider-aws.test.ts @@ -0,0 +1,614 @@ +import { afterAll, afterEach, beforeEach, expect, mock, test } from "bun:test"; +import { mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +import { + readNodeAuthToken, + readNodesRegistry, +} from "../src/lib/nodes-registry.ts"; + +type CliRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +type MockInstanceState = { + readonly instanceId: string; + readonly state: string; +}; + +type MockCommandInvocation = { + readonly status: string; + readonly stdout: string; + readonly stderr: string; +}; + +type AwsMockState = { + ec2DescribeQueue: MockInstanceState[]; + ssmInstanceInfoQueue: Array<readonly string[]>; + commandInvocations: Record<string, MockCommandInvocation>; + commandIds: string[]; + sentSsmCommands: string[]; + startedInstances: string[]; + describeInstanceFilters: unknown[]; + fromIniProfiles: string[]; +}; + +const awsMockState: AwsMockState = { + ec2DescribeQueue: [], + ssmInstanceInfoQueue: [], + commandInvocations: {}, + commandIds: [], + sentSsmCommands: [], + startedInstances: [], + describeInstanceFilters: [], + fromIniProfiles: [], +}; + +mock.module("@aws-sdk/credential-providers", () => ({ + fromIni: (input: { readonly profile?: string }) => { + if (input.profile) { + awsMockState.fromIniProfiles.push(input.profile); + } + return { __mockProfile: input.profile ?? "" }; + }, +})); + +mock.module("@aws-sdk/client-ec2", () => { + class DescribeInstancesCommand { + readonly input: Record<string, unknown>; + + constructor(input: Record<string, unknown>) { + this.input = input; + } + } + + class StartInstancesCommand { + readonly input: Record<string, unknown>; + + constructor(input: Record<string, unknown>) { + this.input = input; + } + } + + class EC2Client { + async send(command: { readonly input: Record<string, unknown> }) { + if (command instanceof DescribeInstancesCommand) { + awsMockState.describeInstanceFilters.push(command.input); + const next = + awsMockState.ec2DescribeQueue.shift() ?? + awsMockState.ec2DescribeQueue.at(-1); + if (!next) { + return { Reservations: [] }; + } + return { + Reservations: [ + { + Instances: [ + { + InstanceId: next.instanceId, + State: { Name: next.state }, + }, + ], + }, + ], + }; + } + if (command instanceof StartInstancesCommand) { + const ids = Array.isArray(command.input.InstanceIds) + ? command.input.InstanceIds.filter( + (value): value is string => typeof value === "string" + ) + : []; + awsMockState.startedInstances.push(...ids); + return {}; + } + throw new Error("Unexpected EC2 command"); + } + } + + return { + EC2Client, + DescribeInstancesCommand, + StartInstancesCommand, + }; +}); + +mock.module("@aws-sdk/client-ssm", () => { + class DescribeInstanceInformationCommand { + readonly input: Record<string, unknown>; + + constructor(input: Record<string, unknown>) { + this.input = input; + } + } + + class SendCommandCommand { + readonly input: Record<string, unknown>; + + constructor(input: Record<string, unknown>) { + this.input = input; + } + } + + class GetCommandInvocationCommand { + readonly input: Record<string, unknown>; + + constructor(input: Record<string, unknown>) { + this.input = input; + } + } + + class SSMClient { + async send(command: { readonly input: Record<string, unknown> }) { + if (command instanceof DescribeInstanceInformationCommand) { + const onlineIds = + awsMockState.ssmInstanceInfoQueue.shift() ?? + awsMockState.ssmInstanceInfoQueue.at(-1) ?? + []; + return { + InstanceInformationList: onlineIds.map((instanceId) => ({ + InstanceId: instanceId, + PingStatus: "Online", + })), + }; + } + if (command instanceof SendCommandCommand) { + const commandId = awsMockState.commandIds.shift(); + if (!commandId) { + throw new Error("Missing mock command id"); + } + const parameters = command.input.Parameters as + | { + readonly commands?: readonly unknown[]; + } + | undefined; + const commands = Array.isArray(parameters?.commands) + ? parameters.commands.filter( + (value): value is string => typeof value === "string" + ) + : []; + awsMockState.sentSsmCommands.push(commands.join("\n")); + return { + Command: { + CommandId: commandId, + }, + }; + } + if (command instanceof GetCommandInvocationCommand) { + const commandId = + typeof command.input.CommandId === "string" + ? command.input.CommandId + : ""; + const invocation = awsMockState.commandInvocations[commandId]; + if (!invocation) { + throw new Error(`Missing mock invocation for ${commandId}`); + } + return { + Status: invocation.status, + StandardOutputContent: invocation.stdout, + StandardErrorContent: invocation.stderr, + }; + } + throw new Error("Unexpected SSM command"); + } + } + + return { + DescribeInstanceInformationCommand, + GetCommandInvocationCommand, + SendCommandCommand, + SSMClient, + }; +}); + +let tempDir: string | null = null; +let server: Bun.Server<unknown> | null = null; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.HOME; +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const originalSecretsKey = process.env.HACK_SECRETS_FILE_KEY; +const originalLogger = process.env.HACK_LOGGER; +const originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; + +const { runCli } = await import("../src/cli/run.ts"); + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-node-provider-aws-")); + process.env.HOME = tempDir; + process.env.HACK_GLOBAL_CONFIG_PATH = resolve(tempDir, "hack.config.json"); + process.env.HACK_SECRETS_FILE_KEY = "test-node-provider-aws-key"; + process.env.HACK_LOGGER = "console"; + process.env.HACK_SETUP_SYNC_MODE = "off"; + + await writeFile( + process.env.HACK_GLOBAL_CONFIG_PATH, + `${JSON.stringify( + { + controlPlane: { + secrets: { + backend: "encrypted_file", + allowEnvAuthRefs: true, + encryptedFile: { + path: resolve(tempDir, "secrets.enc.json"), + }, + }, + }, + }, + null, + 2 + )}\n` + ); + + awsMockState.ec2DescribeQueue = []; + awsMockState.ssmInstanceInfoQueue = []; + awsMockState.commandInvocations = {}; + awsMockState.commandIds = []; + awsMockState.sentSsmCommands = []; + awsMockState.startedInstances = []; + awsMockState.describeInstanceFilters = []; + awsMockState.fromIniProfiles = []; + + server = Bun.serve({ + port: 0, + fetch() { + return Response.json({ + node: { + status: "online", + }, + }); + }, + }); +}); + +afterEach(async () => { + server?.stop(true); + server = null; + globalThis.fetch = originalFetch; + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + if (originalHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = originalHome; + } + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } + if (originalSecretsKey === undefined) { + process.env.HACK_SECRETS_FILE_KEY = undefined; + } else { + process.env.HACK_SECRETS_FILE_KEY = originalSecretsKey; + } + if (originalLogger === undefined) { + process.env.HACK_LOGGER = undefined; + } else { + process.env.HACK_LOGGER = originalLogger; + } + if (originalSetupSyncMode === undefined) { + process.env.HACK_SETUP_SYNC_MODE = undefined; + } else { + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; + } +}); + +afterAll(() => { + mock.restore(); +}); + +test("node provider aws bootstrap registers a running instance via SSM bootstrap and preserves source metadata", async () => { + const endpoint = resolveLocalEndpoint(); + awsMockState.ec2DescribeQueue = [ + { instanceId: "i-running", state: "running" }, + ]; + awsMockState.ssmInstanceInfoQueue = [["i-running"]]; + awsMockState.commandIds = ["cmd-bootstrap", "cmd-node-init"]; + awsMockState.commandInvocations = { + "cmd-bootstrap": { + status: "Success", + stdout: "bootstrap ok", + stderr: "", + }, + "cmd-node-init": { + status: "Success", + stdout: JSON.stringify({ + bundle: { + version: 1, + node: { + id: "node-aws-running", + name: "aws-runner", + endpoint, + authRef: "env:HACK_NODE_AWS_TOKEN", + labels: ["aws", "linux"], + capabilities: ["jobs"], + platform: "linux", + arch: "arm64", + version: "1.0.0", + }, + token: "node-token-running", + }, + }), + stderr: "", + }, + }; + + const result = await runCliWithCapturedIo({ + argv: [ + "node", + "provider", + "aws", + "bootstrap", + "--instance-id", + "i-running", + "--region", + "us-east-1", + "--profile", + "qa", + "--source", + "ec2-user@runner.internal", + "--endpoint", + endpoint, + "--name", + "aws-runner", + "--labels", + "aws,linux", + "--bootstrap-command", + "sudo systemctl start hack-node", + "--default", + "--json", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly provider: string; + readonly created: boolean; + readonly endpoint: string; + readonly node: { + readonly id: string; + readonly source?: string; + readonly endpoint: string; + readonly status: string; + }; + }; + expect(payload.provider).toBe("aws"); + expect(payload.created).toBe(true); + expect(payload.endpoint).toBe(endpoint); + expect(payload.node.id).toBe("node-aws-running"); + expect(payload.node.source).toBe("ec2-user@runner.internal"); + expect(payload.node.status).toBe("healthy"); + expect(awsMockState.startedInstances).toEqual([]); + expect(awsMockState.fromIniProfiles).toEqual(["qa"]); + expect(awsMockState.sentSsmCommands[0]).toContain( + "sudo systemctl start hack-node" + ); + expect(awsMockState.sentSsmCommands[1]).toContain("node"); + expect(awsMockState.sentSsmCommands[1]).toContain("init"); + + const registry = await readNodesRegistry(); + const node = registry.nodes.find((entry) => entry.id === "node-aws-running"); + expect(node?.source).toBe("ec2-user@runner.internal"); + expect(node?.endpoint).toBe(endpoint); + const token = await readNodeAuthToken({ authRef: "env:HACK_NODE_AWS_TOKEN" }); + expect(token).toBe("node-token-running"); +}); + +test("node provider aws bootstrap starts stopped instances and supports tag lookup", async () => { + const endpoint = resolveLocalEndpoint(); + awsMockState.ec2DescribeQueue = [ + { instanceId: "i-stopped", state: "stopped" }, + { instanceId: "i-stopped", state: "pending" }, + { instanceId: "i-stopped", state: "running" }, + ]; + awsMockState.ssmInstanceInfoQueue = [[], ["i-stopped"]]; + awsMockState.commandIds = ["cmd-node-init"]; + awsMockState.commandInvocations = { + "cmd-node-init": { + status: "Success", + stdout: JSON.stringify({ + bundle: { + version: 1, + node: { + id: "node-aws-stopped", + name: "aws-stopped-runner", + endpoint, + authRef: "env:HACK_NODE_AWS_STOPPED_TOKEN", + labels: ["aws"], + capabilities: ["jobs"], + platform: "linux", + arch: "amd64", + version: "1.0.0", + }, + token: "node-token-stopped", + }, + }), + stderr: "", + }, + }; + + const result = await runCliWithCapturedIo({ + argv: [ + "node", + "provider", + "aws", + "bootstrap", + "--instance-tag-value", + "event-agent-qa-runner", + "--region", + "us-east-1", + "--source", + "ec2-user@runner.internal", + "--endpoint", + endpoint, + "--name", + "aws-stopped-runner", + "--json", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly provider: string; + readonly created: boolean; + readonly node: { + readonly id: string; + }; + }; + expect(payload.provider).toBe("aws"); + expect(payload.created).toBe(true); + expect(payload.node.id).toBe("node-aws-stopped"); + expect(awsMockState.startedInstances).toEqual(["i-stopped"]); + expect(awsMockState.describeInstanceFilters[0]).toMatchObject({ + Filters: expect.arrayContaining([ + expect.objectContaining({ + Name: "tag:Name", + Values: ["event-agent-qa-runner"], + }), + ]), + }); +}); + +test("node provider aws inspect reports resolved instance and ssm readiness from saved defaults", async () => { + const configPath = process.env.HACK_GLOBAL_CONFIG_PATH; + if (!configPath) { + throw new Error("Missing global config path"); + } + await writeFile( + configPath, + `${JSON.stringify( + { + controlPlane: { + secrets: { + backend: "encrypted_file", + allowEnvAuthRefs: true, + encryptedFile: { + path: resolve(tempDir ?? "", "secrets.enc.json"), + }, + }, + extensions: { + "dance.hack.aws": { + enabled: true, + config: { + instanceTagValue: "event-agent-qa-runner", + region: "us-east-1", + profile: "qa", + source: "ec2-user@runner.internal", + endpoint: "https://runner.hack", + }, + }, + }, + }, + }, + null, + 2 + )}\n` + ); + + awsMockState.ec2DescribeQueue = [ + { instanceId: "i-inspect", state: "running" }, + ]; + awsMockState.ssmInstanceInfoQueue = [["i-inspect"]]; + + const result = await runCliWithCapturedIo({ + argv: ["node", "provider", "aws", "inspect", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly provider: string; + readonly selectorSummary: string; + readonly canBootstrap: boolean; + readonly config: { + readonly region?: string; + readonly profile?: string; + readonly endpoint?: string; + readonly endpointValid: boolean; + }; + readonly target?: { + readonly resolved: boolean; + readonly instanceId?: string; + readonly state?: string; + readonly ssmOnline?: boolean; + }; + }; + expect(payload.provider).toBe("aws"); + expect(payload.selectorSummary).toBe("tag:Name=event-agent-qa-runner"); + expect(payload.canBootstrap).toBe(true); + expect(payload.config.region).toBe("us-east-1"); + expect(payload.config.profile).toBe("qa"); + expect(payload.config.endpoint).toBe("https://runner.hack"); + expect(payload.config.endpointValid).toBe(true); + expect(payload.target?.resolved).toBe(true); + expect(payload.target?.instanceId).toBe("i-inspect"); + expect(payload.target?.state).toBe("running"); + expect(payload.target?.ssmOnline).toBe(true); +}); + +test("node provider aws inspect reports missing bootstrap requirements without contacting aws", async () => { + const result = await runCliWithCapturedIo({ + argv: ["node", "provider", "aws", "inspect", "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly canBootstrap: boolean; + readonly issues: readonly string[]; + readonly target?: { + readonly resolved: boolean; + }; + }; + expect(payload.canBootstrap).toBe(false); + expect(payload.issues).toEqual( + expect.arrayContaining([ + "Configure exactly one selector: instance id or instance tag value.", + "Missing region.", + "Missing SSH source.", + "Missing gateway endpoint.", + ]) + ); + expect(payload.target).toBeUndefined(); + expect(awsMockState.describeInstanceFilters).toEqual([]); +}); + +function resolveLocalEndpoint(): string { + if (!server) { + throw new Error("Missing local test server"); + } + return `http://127.0.0.1:${server.port}`; +} + +async function runCliWithCapturedIo(input: { + readonly argv: readonly string[]; +}): Promise<CliRunResult> { + let stdout = ""; + let stderr = ""; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stderr.write; + + try { + const exitCode = await runCli(input.argv); + return { exitCode, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} diff --git a/tests/node-runner-commands.test.ts b/tests/node-runner-commands.test.ts new file mode 100644 index 000000000..07deef54b --- /dev/null +++ b/tests/node-runner-commands.test.ts @@ -0,0 +1,439 @@ +import { afterAll, afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; +import { + readNodeAuthToken, + readNodesRegistry, + upsertNodeRecord, +} from "../src/lib/nodes-registry.ts"; +import { findProjectContext } from "../src/lib/project.ts"; +import { upsertProjectRegistration } from "../src/lib/projects-registry.ts"; + +type FetchLike = ( + input: string | URL | Request, + init?: RequestInit +) => Promise<Response>; + +type CliRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +let tempDir: string | null = null; +let fetchImpl: FetchLike; +const fetchCalls: Array<{ readonly url: string; readonly init?: RequestInit }> = + []; + +const originalFetch = globalThis.fetch; +const originalHome = process.env.HOME; +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const originalSecretsKey = process.env.HACK_SECRETS_FILE_KEY; +const originalLogger = process.env.HACK_LOGGER; +const originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; + +const { runCli } = await import("../src/cli/run.ts"); + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-node-runner-")); + process.env.HOME = tempDir; + process.env.HACK_GLOBAL_CONFIG_PATH = resolve(tempDir, "hack.config.json"); + process.env.HACK_SECRETS_FILE_KEY = "test-node-runner-key"; + process.env.HACK_LOGGER = "console"; + process.env.HACK_SETUP_SYNC_MODE = "off"; + + await writeFile( + process.env.HACK_GLOBAL_CONFIG_PATH, + `${JSON.stringify( + { + controlPlane: { + secrets: { + backend: "encrypted_file", + allowEnvAuthRefs: true, + encryptedFile: { + path: resolve(tempDir, "secrets.enc.json"), + }, + }, + }, + }, + null, + 2 + )}\n` + ); + + await createGatewayEnabledProject({ root: resolve(tempDir, "project") }); + + fetchCalls.length = 0; + fetchImpl = async () => + new Response("not mocked", { + status: 500, + headers: { "content-type": "text/plain" }, + }); + globalThis.fetch = (async ( + input: string | URL | Request, + init?: RequestInit + ) => { + const url = + typeof input === "string" + ? input + : input instanceof URL + ? input.toString() + : input.url; + fetchCalls.push({ url, init }); + return await fetchImpl(input, init); + }) as typeof globalThis.fetch; +}); + +afterEach(async () => { + globalThis.fetch = originalFetch; + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + fetchCalls.length = 0; + + if (originalHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = originalHome; + } + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } + if (originalSecretsKey === undefined) { + process.env.HACK_SECRETS_FILE_KEY = undefined; + } else { + process.env.HACK_SECRETS_FILE_KEY = originalSecretsKey; + } + if (originalLogger === undefined) { + process.env.HACK_LOGGER = undefined; + } else { + process.env.HACK_LOGGER = originalLogger; + } + if (originalSetupSyncMode === undefined) { + process.env.HACK_SETUP_SYNC_MODE = undefined; + } else { + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; + } +}); + +afterAll(() => { + globalThis.fetch = originalFetch; +}); + +test("node auth verify reports missing_token when auth ref has no stored token", async () => { + const node = await upsertNodeRecord({ + id: "node-missing-token", + name: "Missing Token Node", + endpoint: "http://127.0.0.1:7788", + authRef: "env:HACK_NODE_MISSING_TOKEN", + status: "unknown", + }); + + const result = await runCliWithCapturedIo({ + argv: ["node", "auth", "verify", "--node", node.node.id, "--json"], + }); + + expect(result.exitCode).toBe(1); + expect(fetchCalls).toHaveLength(0); + + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly state: string; + readonly node?: { readonly id?: string }; + readonly error?: string; + }; + expect(payload.ok).toBe(false); + expect(payload.state).toBe("missing_token"); + expect(payload.node?.id).toBe(node.node.id); + expect(payload.error).toContain("Missing auth token"); +}); + +test("node auth verify reports invalid_token on 401 response", async () => { + process.env.HACK_NODE_INVALID_TOKEN = "node-invalid-token"; + const node = await upsertNodeRecord({ + id: "node-invalid-token", + name: "Invalid Token Node", + endpoint: "http://127.0.0.1:7788", + authRef: "env:HACK_NODE_INVALID_TOKEN", + status: "unknown", + }); + + fetchImpl = async (_input, init) => { + expect(init?.headers).toMatchObject({ + Authorization: "Bearer node-invalid-token", + }); + return Response.json({ error: "unauthorized" }, { status: 401 }); + }; + + const result = await runCliWithCapturedIo({ + argv: ["node", "auth", "verify", "--node", node.node.id, "--json"], + }); + + expect(result.exitCode).toBe(1); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly state: string; + readonly error?: string; + }; + expect(payload.ok).toBe(false); + expect(payload.state).toBe("invalid_token"); + expect(payload.error).toContain("HTTP 401"); +}); + +test("node auth verify reports ok when node status endpoint accepts the token", async () => { + process.env.HACK_NODE_OK_TOKEN = "node-ok-token"; + const node = await upsertNodeRecord({ + id: "node-ok-token", + name: "Healthy Node", + endpoint: "http://127.0.0.1:7788", + authRef: "env:HACK_NODE_OK_TOKEN", + status: "unknown", + }); + + fetchImpl = async (_input, init) => { + expect(init?.headers).toMatchObject({ + Authorization: "Bearer node-ok-token", + }); + return Response.json({ + status: "ok", + node: { + version: "1.2.3", + platform: "linux", + arch: "arm64", + }, + }); + }; + + const result = await runCliWithCapturedIo({ + argv: ["node", "auth", "verify", "--node", node.node.id, "--json"], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly state: string; + readonly node?: { + readonly id?: string; + readonly version?: string; + readonly platform?: string; + readonly arch?: string; + }; + }; + expect(payload.ok).toBe(true); + expect(payload.state).toBe("ok"); + expect(payload.node?.id).toBe(node.node.id); + expect(payload.node?.version).toBe("1.2.3"); + expect(payload.node?.platform).toBe("linux"); + expect(payload.node?.arch).toBe("arm64"); +}); + +test("node ensure creates a local registration and stores a token for the provided auth ref", async () => { + fetchImpl = async (_input, init) => { + expect(init?.headers).toMatchObject({ + Authorization: expect.stringContaining("Bearer "), + }); + return Response.json({ + status: "ok", + node: { + version: "2.0.0", + platform: "darwin", + arch: "arm64", + }, + }); + }; + + const result = await runCliWithCapturedIo({ + argv: [ + "node", + "ensure", + "--auth-ref", + "env:HACK_NODE_ENSURE_TOKEN", + "--name", + "Runner Node", + "--default", + "--json", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly created: boolean; + readonly node?: { readonly name?: string; readonly authRef?: string }; + readonly token?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.created).toBe(true); + expect(payload.node?.name).toBe("Runner Node"); + expect(payload.node?.authRef).toBe("env:HACK_NODE_ENSURE_TOKEN"); + expect(payload.token).toBe(process.env.HACK_NODE_ENSURE_TOKEN); + + const registry = await readNodesRegistry(); + expect(registry.nodes).toHaveLength(1); + expect(registry.defaultNodeId).toBe(registry.nodes[0]?.id ?? null); +}); + +test("node ensure reuses an existing healthy node without minting a new token", async () => { + fetchImpl = async () => + Response.json({ + status: "ok", + node: { + version: "2.0.0", + platform: "darwin", + arch: "arm64", + }, + }); + + const first = await runCliWithCapturedIo({ + argv: [ + "node", + "ensure", + "--auth-ref", + "env:HACK_NODE_REUSE_TOKEN", + "--name", + "Reusable Runner", + "--json", + ], + }); + expect(first.exitCode).toBe(0); + const firstPayload = JSON.parse(first.stdout) as { + readonly node?: { readonly id?: string }; + readonly token?: string; + }; + const firstToken = firstPayload.token; + + const second = await runCliWithCapturedIo({ + argv: [ + "node", + "ensure", + "--auth-ref", + "env:HACK_NODE_REUSE_TOKEN", + "--name", + "Reusable Runner", + "--json", + ], + }); + + expect(second.exitCode).toBe(0); + const secondPayload = JSON.parse(second.stdout) as { + readonly ok: boolean; + readonly created: boolean; + readonly node?: { readonly id?: string }; + readonly token?: string; + }; + expect(secondPayload.ok).toBe(true); + expect(secondPayload.created).toBe(false); + expect(secondPayload.node?.id).toBe(firstPayload.node?.id); + expect(secondPayload.token).toBeUndefined(); + expect(process.env.HACK_NODE_REUSE_TOKEN).toBe(firstToken); +}); + +test("node ensure repairs a missing token for an existing auth ref", async () => { + const existing = await upsertNodeRecord({ + id: "node-repair-token", + name: "Repair Me", + endpoint: "http://127.0.0.1:7788", + authRef: "env:HACK_NODE_REPAIR_TOKEN", + status: "unknown", + }); + + fetchImpl = async () => + Response.json({ + status: "ok", + node: { + version: "2.0.0", + platform: "darwin", + arch: "arm64", + }, + }); + + const result = await runCliWithCapturedIo({ + argv: [ + "node", + "ensure", + "--auth-ref", + "env:HACK_NODE_REPAIR_TOKEN", + "--name", + "Repair Me", + "--json", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly created: boolean; + readonly node?: { readonly id?: string }; + readonly token?: string; + }; + expect(payload.ok).toBe(true); + expect(payload.created).toBe(false); + expect(payload.node?.id).toBe(existing.node.id); + expect(payload.token).toBe(process.env.HACK_NODE_REPAIR_TOKEN); + expect( + await readNodeAuthToken({ authRef: "env:HACK_NODE_REPAIR_TOKEN" }) + ).toBe(process.env.HACK_NODE_REPAIR_TOKEN ?? null); +}); + +async function createGatewayEnabledProject(input: { + readonly root: string; +}): Promise<void> { + await mkdir(resolve(input.root, ".hack"), { recursive: true }); + await writeFile( + resolve(input.root, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + await writeFile( + resolve(input.root, ".hack", "hack.config.json"), + `${JSON.stringify( + { + name: "node-runner-test-project", + controlPlane: { + gateway: { + enabled: true, + }, + }, + }, + null, + 2 + )}\n` + ); + const ctx = await findProjectContext(input.root); + if (!ctx) { + throw new Error("Missing project context"); + } + await upsertProjectRegistration({ project: ctx }); +} + +async function runCliWithCapturedIo(input: { + readonly argv: readonly string[]; +}): Promise<CliRunResult> { + let stdout = ""; + let stderr = ""; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stderr.write; + + try { + const exitCode = await runCli(input.argv); + return { exitCode, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +} diff --git a/tests/workspace-reset-command.test.ts b/tests/workspace-reset-command.test.ts new file mode 100644 index 000000000..be33f2b1d --- /dev/null +++ b/tests/workspace-reset-command.test.ts @@ -0,0 +1,205 @@ +import { afterEach, beforeEach, expect, test } from "bun:test"; +import { mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join, resolve } from "node:path"; + +type CliRunResult = { + readonly exitCode: number; + readonly stdout: string; + readonly stderr: string; +}; + +let tempDir: string | null = null; +const originalHome = process.env.HOME; +const originalGlobalConfigPath = process.env.HACK_GLOBAL_CONFIG_PATH; +const originalLogger = process.env.HACK_LOGGER; +const originalSetupSyncMode = process.env.HACK_SETUP_SYNC_MODE; + +const { runCli } = await import("../src/cli/run.ts"); + +beforeEach(async () => { + tempDir = await mkdtemp(join(tmpdir(), "hack-workspace-reset-")); + process.env.HOME = tempDir; + process.env.HACK_GLOBAL_CONFIG_PATH = resolve(tempDir, "hack.config.json"); + process.env.HACK_LOGGER = "console"; + process.env.HACK_SETUP_SYNC_MODE = "off"; + await writeFile(process.env.HACK_GLOBAL_CONFIG_PATH, "{}\n"); +}); + +afterEach(async () => { + if (tempDir) { + await rm(tempDir, { recursive: true, force: true }); + tempDir = null; + } + if (originalHome === undefined) { + process.env.HOME = undefined; + } else { + process.env.HOME = originalHome; + } + if (originalGlobalConfigPath === undefined) { + process.env.HACK_GLOBAL_CONFIG_PATH = undefined; + } else { + process.env.HACK_GLOBAL_CONFIG_PATH = originalGlobalConfigPath; + } + if (originalLogger === undefined) { + process.env.HACK_LOGGER = undefined; + } else { + process.env.HACK_LOGGER = originalLogger; + } + if (originalSetupSyncMode === undefined) { + process.env.HACK_SETUP_SYNC_MODE = undefined; + } else { + process.env.HACK_SETUP_SYNC_MODE = originalSetupSyncMode; + } +}); + +test("workspace reset restores tracked files, removes untracked files, preserves excludes, and recovers index.lock", async () => { + if (!tempDir) { + throw new Error("Missing temp dir"); + } + const repoRoot = resolve(tempDir, "repo"); + await createMinimalHackRepo({ repoRoot }); + + await writeFile(resolve(repoRoot, "tracked.txt"), "dirty change\n"); + await writeFile(resolve(repoRoot, "scratch.txt"), "delete me\n"); + await writeFile(resolve(repoRoot, ".env"), "KEEP_ME=yes\n"); + await writeFile(resolve(repoRoot, ".git", "index.lock"), "stale lock\n"); + + const result = await runCliWithCapturedIo({ + argv: [ + "workspace", + "reset", + "--path", + repoRoot, + "--base", + "HEAD", + "--exclude", + ".env", + "--json", + ], + }); + + expect(result.exitCode).toBe(0); + const payload = JSON.parse(result.stdout) as { + readonly ok: boolean; + readonly removedGitIndexLock: boolean; + readonly cleanedPaths: readonly string[]; + readonly preservedExcludes: readonly string[]; + }; + expect(payload.ok).toBe(true); + expect(payload.removedGitIndexLock).toBe(true); + expect(payload.cleanedPaths).toContain("scratch.txt"); + expect(payload.preservedExcludes).toEqual([".env"]); + + expect(await readFile(resolve(repoRoot, "tracked.txt"), "utf8")).toBe( + "clean\n" + ); + expect(await Bun.file(resolve(repoRoot, "scratch.txt")).exists()).toBe(false); + expect(await readFile(resolve(repoRoot, ".env"), "utf8")).toBe( + "KEEP_ME=yes\n" + ); + expect(await Bun.file(resolve(repoRoot, ".git", "index.lock")).exists()).toBe( + false + ); + + const status = await runGit({ + repoRoot, + args: ["status", "--porcelain", "--untracked-files=all"], + }); + expect(status.exitCode).toBe(0); + expect(status.stdout.trim()).toBe("?? .env"); +}); + +async function createMinimalHackRepo(input: { + readonly repoRoot: string; +}): Promise<void> { + await mkdir(resolve(input.repoRoot, ".hack"), { recursive: true }); + await writeFile( + resolve(input.repoRoot, ".hack", "docker-compose.yml"), + "services: {}\n" + ); + await writeFile( + resolve(input.repoRoot, ".hack", "hack.config.json"), + `${JSON.stringify({ name: "workspace-reset-test" }, null, 2)}\n` + ); + await writeFile(resolve(input.repoRoot, "tracked.txt"), "clean\n"); + + await expectGitSuccess({ + repoRoot: input.repoRoot, + args: ["init", "-b", "main"], + }); + await expectGitSuccess({ + repoRoot: input.repoRoot, + args: ["config", "user.email", "test@example.com"], + }); + await expectGitSuccess({ + repoRoot: input.repoRoot, + args: ["config", "user.name", "Test User"], + }); + await expectGitSuccess({ + repoRoot: input.repoRoot, + args: ["add", "."], + }); + await expectGitSuccess({ + repoRoot: input.repoRoot, + args: ["commit", "-m", "initial"], + }); +} + +async function expectGitSuccess(input: { + readonly repoRoot: string; + readonly args: readonly string[]; +}): Promise<void> { + const result = await runGit(input); + if (result.exitCode !== 0) { + throw new Error( + `git ${input.args.join(" ")} failed\nstdout:\n${result.stdout}\nstderr:\n${result.stderr}` + ); + } +} + +async function runGit(input: { + readonly repoRoot: string; + readonly args: readonly string[]; +}): Promise<CliRunResult> { + const proc = Bun.spawn(["git", "-C", input.repoRoot, ...input.args], { + stdin: "ignore", + stdout: "pipe", + stderr: "pipe", + }); + const [stdout, stderr, exitCode] = await Promise.all([ + new Response(proc.stdout).text(), + new Response(proc.stderr).text(), + proc.exited, + ]); + return { exitCode, stdout, stderr }; +} + +async function runCliWithCapturedIo(input: { + readonly argv: readonly string[]; +}): Promise<CliRunResult> { + let stdout = ""; + let stderr = ""; + const originalStdoutWrite = process.stdout.write; + const originalStderrWrite = process.stderr.write; + + process.stdout.write = ((chunk: string | Uint8Array) => { + stdout += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stdout.write; + + process.stderr.write = ((chunk: string | Uint8Array) => { + stderr += + typeof chunk === "string" ? chunk : Buffer.from(chunk).toString("utf8"); + return true; + }) as typeof process.stderr.write; + + try { + const exitCode = await runCli(input.argv); + return { exitCode, stdout, stderr }; + } finally { + process.stdout.write = originalStdoutWrite; + process.stderr.write = originalStderrWrite; + } +}