From ced8d07453755e08381ad42587a052e7443069d6 Mon Sep 17 00:00:00 2001 From: Dimitri Kennedy Date: Mon, 20 Apr 2026 15:58:07 -0400 Subject: [PATCH 01/19] feat!: simplify hack to a local-first core Release signal: major. This removes the supported hosted/web/auth/GitHub/Linear surfaces, narrows Hack to the local-first CLI plus slim macOS companion, and hardens env/runtime/tickets behavior for the simplified v3 product boundary. BREAKING CHANGE: Hack now ships as a local-first runtime. Built-in web dashboard, auth-broker, GitHub, Linear, and account/org/team control-plane flows are removed from the supported product surface; workflows should migrate to native git/gh and the local CLI/macOS companion. --- .hack/docker-compose.yml | 42 - .hack/hack.env.remote.yaml | 12 +- README.md | 408 +- WORKFLOW.md | 88 +- apps/macos/App/HackDesktopApp.swift | 9 +- apps/macos/App/Info.plist | 8 +- apps/macos/Package.swift | 5 +- .../DashboardFeature/CommandPalette.swift | 144 +- .../DashboardFeature/DashboardModel.swift | 1098 +- .../DashboardFeature/DashboardView.swift | 208 +- .../DashboardFeature/GatewayDetailView.swift | 597 - .../GatewayExposure+Status.swift | 268 - .../GatewayExposureDetailView.swift | 123 - .../DashboardFeature/GlobalStatusStrip.swift | 43 +- .../DashboardFeature/HomeDashboardView.swift | 15 - .../DashboardFeature/MenuBarView.swift | 28 - .../NodeTopologySettingsView.swift | 2025 --- .../DashboardFeature/ProjectDetailView.swift | 1891 +-- .../ProjectRemoteExecutionView.swift | 46 - .../DashboardFeature/RuntimeDetailView.swift | 2 +- .../SettingsOverlayView.swift | 6186 +-------- .../DashboardFeature/SetupAssistantView.swift | 102 +- .../DashboardFeature/SystemSidebarRows.swift | 121 - .../TerminalNotifications.swift | 20 + .../DashboardFeature/TicketsView.swift | 3043 ----- .../HackCLIService/HackCLIClient.swift | 2036 +-- .../Sources/HackDesktopModels/Models.swift | 2605 ---- .../LinearSyncUXModelTests.swift | 128 - .../RailwayBootstrapRequestTests.swift | 53 - .../TicketDetailReviewModelTests.swift | 416 - apps/macos/README.md | 40 +- apps/web/components.json | 27 - apps/web/next-env.d.ts | 6 - apps/web/next.config.ts | 18 - apps/web/package.json | 42 - apps/web/postcss.config.mjs | 5 - .../web/src/app/account/integrations/page.tsx | 154 - apps/web/src/app/account/layout.tsx | 23 - apps/web/src/app/account/page.tsx | 186 - apps/web/src/app/account/projects/page.tsx | 171 - apps/web/src/app/account/secrets/page.tsx | 104 - apps/web/src/app/account/teams/page.tsx | 139 - apps/web/src/app/account/tickets/page.tsx | 49 - .../src/app/api/auth/flows/[flowId]/route.ts | 51 - apps/web/src/app/api/auth/providers/route.ts | 30 - apps/web/src/app/api/auth/sign-out/route.ts | 61 - apps/web/src/app/api/auth/social/route.ts | 92 - .../invitations/[inviteId]/accept/route.ts | 75 - .../invitations/[inviteId]/decline/route.ts | 75 - .../orgs/[org]/members/invite/route.ts | 91 - .../orgs/[org]/members/remove/route.ts | 91 - .../src/app/api/control-plane/orgs/route.ts | 100 - .../projects/[project]/access/grant/route.ts | 142 - .../projects/[project]/access/revoke/route.ts | 124 - .../app/api/control-plane/projects/route.ts | 139 - .../teams/[team]/members/invite/route.ts | 113 - .../teams/[team]/members/remove/route.ts | 96 - .../src/app/api/control-plane/teams/route.ts | 103 - apps/web/src/app/auth/account/page.tsx | 45 - apps/web/src/app/auth/page.tsx | 34 - apps/web/src/app/auth/sign-up/page.tsx | 35 - apps/web/src/app/layout.tsx | 57 - apps/web/src/app/page.tsx | 13 - apps/web/src/app/providers.tsx | 14 - .../account-control-plane-sections.tsx | 2002 --- .../web/src/components/account-page-frame.tsx | 91 - .../src/components/account-shell-loading.tsx | 100 - .../web/src/components/account-shell-page.tsx | 92 - apps/web/src/components/app-navbar.tsx | 73 - apps/web/src/components/app-shell.tsx | 26 - apps/web/src/components/app-sidebar.tsx | 152 - apps/web/src/components/auth-entrypoint.tsx | 627 - apps/web/src/components/big-logo.tsx | 145 - .../src/components/control-plane-shell.tsx | 396 - .../src/components/custom-sidebar-trigger.tsx | 24 - .../web/src/components/dashboard-skeleton.tsx | 20 - apps/web/src/components/github-icon.tsx | 13 - apps/web/src/components/leatest-change.tsx | 56 - .../components/linear-management-section.tsx | 549 - apps/web/src/components/logo.tsx | 31 - apps/web/src/components/marketing-chrome.tsx | 17 - apps/web/src/components/mode-toggle.tsx | 63 - apps/web/src/components/nav-user.tsx | 108 - .../src/components/organization-switcher.tsx | 125 - apps/web/src/components/theme-provider.tsx | 94 - apps/web/src/components/ui/avatar.tsx | 109 - apps/web/src/components/ui/breadcrumb.tsx | 107 - apps/web/src/components/ui/button.tsx | 65 - apps/web/src/components/ui/card.tsx | 103 - apps/web/src/components/ui/dropdown-menu.tsx | 257 - apps/web/src/components/ui/input.tsx | 21 - apps/web/src/components/ui/kbd.tsx | 28 - apps/web/src/components/ui/label.tsx | 24 - apps/web/src/components/ui/separator.tsx | 28 - apps/web/src/components/ui/sheet.tsx | 143 - apps/web/src/components/ui/sidebar.tsx | 733 - apps/web/src/components/ui/skeleton.tsx | 13 - apps/web/src/components/ui/tooltip.tsx | 57 - apps/web/src/hooks/use-mobile.ts | 21 - apps/web/src/lib/account-control-plane.ts | 1202 -- apps/web/src/lib/account-navigation.ts | 83 - apps/web/src/lib/account-shell.ts | 208 - apps/web/src/lib/auth-config.ts | 288 - apps/web/src/lib/auth-handoff.ts | 109 - apps/web/src/lib/browser-auth-session.ts | 71 - .../src/lib/browser-shared-project-scope.ts | 113 - apps/web/src/lib/control-plane-shell.ts | 108 - apps/web/src/lib/env-management.ts | 453 - apps/web/src/lib/github-management.ts | 468 - apps/web/src/lib/linear-management.ts | 1171 -- apps/web/src/lib/next-dev-origins.ts | 134 - apps/web/src/lib/server-navigation.ts | 7 - apps/web/src/lib/utils.ts | 6 - apps/web/src/styles/globals.css | 177 - apps/web/tests/account-app-shell.test.tsx | 154 - .../account-control-plane-routes.test.ts | 496 - apps/web/tests/account-control-plane.test.ts | 637 - .../tests/account-shell-hydration.test.tsx | 288 - apps/web/tests/account-shell.test.tsx | 731 - apps/web/tests/auth-config.test.ts | 33 - apps/web/tests/auth-entrypoints.test.tsx | 332 - apps/web/tests/auth-handoff.test.ts | 99 - apps/web/tests/control-plane-shell.test.tsx | 317 - apps/web/tests/env-management.test.ts | 489 - apps/web/tests/linear-audit-fixtures.ts | 215 - apps/web/tests/linear-management.test.ts | 698 - apps/web/tests/next-dev-origins.test.ts | 39 - apps/web/tests/package-manifest.test.ts | 40 - .../project-control-plane-render.test.tsx | 270 - .../project-control-plane-routes.test.ts | 229 - apps/web/tests/project-control-plane.test.ts | 257 - apps/web/tsconfig.json | 36 - automation-verification/runtime-qa-digest.md | 64 + docs/README.md | 5 +- docs/beta.md | 5 +- docs/cli.md | 2744 +--- docs/core.md | 2 +- docs/docs-ia.md | 106 +- docs/env.md | 19 +- docs/extensions.md | 735 +- docs/guides/auth-broker-callbacks.md | 43 - docs/guides/auth-broker-neon-bootstrap.md | 63 - docs/guides/codex-managed-environments.md | 2 - docs/guides/github-workflows.md | 113 - .../guides/linear-integration-architecture.md | 137 - docs/guides/prerequisite-detection-matrix.md | 190 +- docs/guides/remote-node-laptop-e2e.md | 13 +- docs/guides/remote-node-quickstart.md | 24 +- docs/guides/tickets-normalization.md | 177 - docs/guides/tickets.md | 4 +- docs/guides/web-control-plane-local-dev.md | 131 - docs/integrations.md | 158 +- docs/lifecycle.md | 2 + docs/reference.md | 39 +- package.json | 6 - packages/auth-contract/package.json | 21 - packages/auth-contract/src/index.ts | 477 - .../tests/shared-auth-contract.test.ts | 126 - packages/auth-contract/tsconfig.json | 20 - scripts/privacy-check.ts | 9 +- scripts/railway-node-e2e.ts | 4 +- scripts/run-tests-deterministic.ts | 4 - scripts/setup-auth-broker-neon.test.ts | 133 - scripts/setup-auth-broker-neon.ts | 1050 -- services/auth-broker/.env.example | 65 - services/auth-broker/Dockerfile | 16 - services/auth-broker/README.md | 419 - services/auth-broker/package.json | 27 - services/auth-broker/railway.json | 13 - services/auth-broker/src/app.ts | 451 - services/auth-broker/src/better-auth-link.ts | 264 - services/auth-broker/src/better-auth.ts | 422 - services/auth-broker/src/config.ts | 489 - services/auth-broker/src/db.ts | 8 - services/auth-broker/src/db/ensure-columns.ts | 109 - services/auth-broker/src/db/schema.ts | 485 - services/auth-broker/src/flow-store.ts | 579 - services/auth-broker/src/github.ts | 342 - services/auth-broker/src/index.ts | 46 - services/auth-broker/src/linear.ts | 350 - .../modules/better-auth/management-token.ts | 230 - .../src/modules/better-auth/plugin.ts | 60 - .../src/modules/better-auth/session.ts | 317 - .../src/modules/better-auth/shell-plugin.ts | 2235 ---- .../auth-broker/src/modules/core/plugin.ts | 28 - .../github-oauth/callback-routes-plugin.ts | 37 - .../github-oauth/flow-status-routes-plugin.ts | 53 - .../src/modules/github-oauth/model.ts | 39 - .../src/modules/github-oauth/plugin.ts | 49 - .../src/modules/github-oauth/service.ts | 501 - .../github-oauth/start-routes-plugin.ts | 53 - .../src/modules/linear-agent/plugin.ts | 458 - .../src/modules/linear-autosync/plugin.ts | 256 - .../src/modules/linear-autosync/service.ts | 640 - .../linear-connections/local-access.ts | 208 - .../src/modules/linear-connections/plugin.ts | 247 - .../src/modules/linear-connections/service.ts | 879 -- .../linear-oauth/callback-routes-plugin.ts | 38 - .../linear-oauth/flow-status-routes-plugin.ts | 42 - .../src/modules/linear-oauth/model.ts | 32 - .../src/modules/linear-oauth/plugin.ts | 53 - .../linear-oauth/refresh-routes-plugin.ts | 46 - .../src/modules/linear-oauth/service.ts | 755 -- .../linear-oauth/start-routes-plugin.ts | 106 - .../src/modules/linear-sync-store/plugin.ts | 214 - .../src/modules/linear-sync-store/service.ts | 625 - .../auth-broker/src/modules/orgs/db-store.ts | 1214 -- .../auth-broker/src/modules/orgs/plugin.ts | 637 - .../auth-broker/src/modules/orgs/service.ts | 725 - .../src/modules/projects/db-store.ts | 1151 -- .../src/modules/projects/plugin.ts | 313 - .../src/modules/projects/service.ts | 1091 -- .../src/modules/providers/plugin.ts | 73 - .../src/plugins/shared-middleware.ts | 67 - services/auth-broker/src/types.ts | 109 - .../tests/better-auth-config.test.ts | 13 - .../tests/better-auth-link.test.ts | 73 - .../tests/better-auth-schema.test.ts | 21 - services/auth-broker/tests/config.test.ts | 294 - services/auth-broker/tests/deeplink.test.ts | 45 - .../auth-broker/tests/ensure-columns.test.ts | 98 - services/auth-broker/tests/flow-store.test.ts | 118 - services/auth-broker/tests/index.test.ts | 3957 ------ .../tests/linear-auth-header.test.ts | 90 - .../tests/linear-ownership-compat.test.ts | 165 - .../tests/linear-store-schema.test.ts | 78 - .../tests/linear-token-custody.test.ts | 336 - .../tests/org-team-membership.test.ts | 909 -- .../tests/org-team-store-schema.test.ts | 79 - .../tests/package-manifest.test.ts | 19 - .../auth-broker/tests/project-admin.test.ts | 1258 -- .../tests/project-store-schema.test.ts | 76 - .../auth-broker/tests/session-auth.test.ts | 930 -- services/auth-broker/tests/test-env.ts | 151 - services/auth-broker/tsconfig.json | 20 - src/cli/help.ts | 8 +- src/cli/prerequisites.ts | 356 +- src/cli/run.ts | 6 +- src/cli/spec.ts | 2 +- src/commands/auth.ts | 687 +- src/commands/crash-capture.ts | 25 + src/commands/dispatch.ts | 468 +- src/commands/doctor.ts | 190 + src/commands/env.ts | 19 +- src/commands/linear.ts | 206 +- src/commands/node.ts | 1 + src/commands/org.ts | 402 +- src/commands/project-admin.ts | 252 +- src/commands/project.ts | 131 +- src/commands/projects.ts | 87 +- src/commands/recovery-guidance.ts | 35 + src/commands/removed-surface.ts | 58 + src/commands/team.ts | 410 +- src/commands/x.ts | 9 + src/constants.ts | 5 +- src/control-plane/extensions/builtins.ts | 4 - src/control-plane/extensions/github/auth.ts | 1312 -- src/control-plane/extensions/github/client.ts | 265 - .../extensions/github/commands.ts | 1930 --- .../extensions/github/extension.ts | 14 - src/control-plane/extensions/linear/audit.ts | 373 - src/control-plane/extensions/linear/auth.ts | 1387 -- src/control-plane/extensions/linear/client.ts | 2113 --- .../extensions/linear/closeout.ts | 328 - .../extensions/linear/commands.ts | 11055 ---------------- .../extensions/linear/extension.ts | 13 - .../extensions/linear/project-artifacts.ts | 948 -- .../extensions/supervisor/commands.ts | 285 +- .../extensions/tailscale/commands.ts | 229 +- .../extensions/tickets/commands.ts | 3 + .../extensions/tickets/domain.ts | 51 +- .../extensions/tickets/normalized-model.ts | 903 -- .../extensions/tickets/provenance.ts | 35 +- src/control-plane/extensions/tickets/store.ts | 36 +- .../extensions/tickets/tickets-git-channel.ts | 410 +- src/control-plane/sdk/gateway-client.ts | 120 +- src/daemon/routes/auth.ts | 1048 -- src/daemon/routes/env.ts | 1 + src/daemon/routes/node.ts | 201 +- src/daemon/server.ts | 66 +- src/lib/auth-broker-client.ts | 123 - src/lib/auth-session.ts | 506 - src/lib/git-worktree.ts | 74 + src/lib/integration-project-scope.ts | 378 - src/lib/nodes-registry.ts | 1 + src/lib/project-env-config.ts | 517 +- src/lib/project-lifecycle-hygiene.ts | 92 + src/lib/project-lifecycle-processes.ts | 146 + src/lib/project-runtime-hygiene.ts | 101 + src/lib/project-views.ts | 1 + src/lib/project.ts | 1 + src/lib/remote-caddy-routes.ts | 1 + src/lib/runtime-projects.ts | 2 + src/lib/secret-store.ts | 1 + src/mcp/server.ts | 819 +- src/templates.ts | 6 - src/tui/hack-tui.ts | 1 + src/tui/tickets-tui.ts | 1 + src/ui/log-group.ts | 1 + src/ui/loki-logs.ts | 1 + src/ui/planet.ts | 1 + tests/apps/web.test.ts | 10 - tests/auth-command.test.ts | 698 - tests/cli-command.test.ts | 115 +- tests/cli-help.test.ts | 58 +- tests/cli-offline-optionality-matrix.test.ts | 359 - tests/config.test.ts | 22 +- tests/coredns-config.test.ts | 11 +- tests/crash-capture.test.ts | 131 + tests/daemon-auth-routes.test.ts | 72 - tests/dispatch-e2e.test.ts | 18 +- tests/doctor-command.test.ts | 116 +- tests/gateway-client.test.ts | 26 +- tests/github-auth.test.ts | 783 -- tests/github-client.test.ts | 30 - tests/github-commands.test.ts | 361 - tests/linear-auth.test.ts | 320 - tests/linear-client.test.ts | 1038 -- tests/linear-command-alias.test.ts | 130 - tests/linear-commands.test.ts | 5021 ------- tests/linear-project-artifacts.test.ts | 401 - tests/mcp.test.ts | 564 +- tests/node-workspace-bootstrap-auth.test.ts | 72 - tests/org-team-command.test.ts | 375 - tests/prerequisites-matrix.test.ts | 180 +- tests/privacy-check.test.ts | 143 - tests/project-env-config.test.ts | 337 + tests/project-lifecycle-hygiene.test.ts | 50 + tests/project-lifecycle-processes.test.ts | 66 +- tests/project-owner-broker.test.ts | 475 - tests/project-owner-command.test.ts | 2 + tests/project-runtime-hygiene.test.ts | 205 + tests/runtime-routing-config.test.ts | 43 +- tests/supervisor-commands.test.ts | 14 + tests/tickets-git-channel.test.ts | 74 + tests/tickets-normalized-model.test.ts | 561 - tests/tickets-store.test.ts | 164 +- 337 files changed, 4295 insertions(+), 111819 deletions(-) delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRemoteExecutionView.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift delete mode 100644 apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift delete mode 100644 apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift delete mode 100644 apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/RailwayBootstrapRequestTests.swift delete mode 100644 apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift delete mode 100644 apps/web/components.json delete mode 100644 apps/web/next-env.d.ts delete mode 100644 apps/web/next.config.ts delete mode 100644 apps/web/package.json delete mode 100644 apps/web/postcss.config.mjs delete mode 100644 apps/web/src/app/account/integrations/page.tsx delete mode 100644 apps/web/src/app/account/layout.tsx delete mode 100644 apps/web/src/app/account/page.tsx delete mode 100644 apps/web/src/app/account/projects/page.tsx delete mode 100644 apps/web/src/app/account/secrets/page.tsx delete mode 100644 apps/web/src/app/account/teams/page.tsx delete mode 100644 apps/web/src/app/account/tickets/page.tsx delete mode 100644 apps/web/src/app/api/auth/flows/[flowId]/route.ts delete mode 100644 apps/web/src/app/api/auth/providers/route.ts delete mode 100644 apps/web/src/app/api/auth/sign-out/route.ts delete mode 100644 apps/web/src/app/api/auth/social/route.ts delete mode 100644 apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts delete mode 100644 apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts delete mode 100644 apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts delete mode 100644 apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts delete mode 100644 apps/web/src/app/api/control-plane/orgs/route.ts delete mode 100644 apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts delete mode 100644 apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts delete mode 100644 apps/web/src/app/api/control-plane/projects/route.ts delete mode 100644 apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts delete mode 100644 apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts delete mode 100644 apps/web/src/app/api/control-plane/teams/route.ts delete mode 100644 apps/web/src/app/auth/account/page.tsx delete mode 100644 apps/web/src/app/auth/page.tsx delete mode 100644 apps/web/src/app/auth/sign-up/page.tsx delete mode 100644 apps/web/src/app/layout.tsx delete mode 100644 apps/web/src/app/page.tsx delete mode 100644 apps/web/src/app/providers.tsx delete mode 100644 apps/web/src/components/account-control-plane-sections.tsx delete mode 100644 apps/web/src/components/account-page-frame.tsx delete mode 100644 apps/web/src/components/account-shell-loading.tsx delete mode 100644 apps/web/src/components/account-shell-page.tsx delete mode 100644 apps/web/src/components/app-navbar.tsx delete mode 100644 apps/web/src/components/app-shell.tsx delete mode 100644 apps/web/src/components/app-sidebar.tsx delete mode 100644 apps/web/src/components/auth-entrypoint.tsx delete mode 100644 apps/web/src/components/big-logo.tsx delete mode 100644 apps/web/src/components/control-plane-shell.tsx delete mode 100644 apps/web/src/components/custom-sidebar-trigger.tsx delete mode 100644 apps/web/src/components/dashboard-skeleton.tsx delete mode 100644 apps/web/src/components/github-icon.tsx delete mode 100644 apps/web/src/components/leatest-change.tsx delete mode 100644 apps/web/src/components/linear-management-section.tsx delete mode 100644 apps/web/src/components/logo.tsx delete mode 100644 apps/web/src/components/marketing-chrome.tsx delete mode 100644 apps/web/src/components/mode-toggle.tsx delete mode 100644 apps/web/src/components/nav-user.tsx delete mode 100644 apps/web/src/components/organization-switcher.tsx delete mode 100644 apps/web/src/components/theme-provider.tsx delete mode 100644 apps/web/src/components/ui/avatar.tsx delete mode 100644 apps/web/src/components/ui/breadcrumb.tsx delete mode 100644 apps/web/src/components/ui/button.tsx delete mode 100644 apps/web/src/components/ui/card.tsx delete mode 100644 apps/web/src/components/ui/dropdown-menu.tsx delete mode 100644 apps/web/src/components/ui/input.tsx delete mode 100644 apps/web/src/components/ui/kbd.tsx delete mode 100644 apps/web/src/components/ui/label.tsx delete mode 100644 apps/web/src/components/ui/separator.tsx delete mode 100644 apps/web/src/components/ui/sheet.tsx delete mode 100644 apps/web/src/components/ui/sidebar.tsx delete mode 100644 apps/web/src/components/ui/skeleton.tsx delete mode 100644 apps/web/src/components/ui/tooltip.tsx delete mode 100644 apps/web/src/hooks/use-mobile.ts delete mode 100644 apps/web/src/lib/account-control-plane.ts delete mode 100644 apps/web/src/lib/account-navigation.ts delete mode 100644 apps/web/src/lib/account-shell.ts delete mode 100644 apps/web/src/lib/auth-config.ts delete mode 100644 apps/web/src/lib/auth-handoff.ts delete mode 100644 apps/web/src/lib/browser-auth-session.ts delete mode 100644 apps/web/src/lib/browser-shared-project-scope.ts delete mode 100644 apps/web/src/lib/control-plane-shell.ts delete mode 100644 apps/web/src/lib/env-management.ts delete mode 100644 apps/web/src/lib/github-management.ts delete mode 100644 apps/web/src/lib/linear-management.ts delete mode 100644 apps/web/src/lib/next-dev-origins.ts delete mode 100644 apps/web/src/lib/server-navigation.ts delete mode 100644 apps/web/src/lib/utils.ts delete mode 100644 apps/web/src/styles/globals.css delete mode 100644 apps/web/tests/account-app-shell.test.tsx delete mode 100644 apps/web/tests/account-control-plane-routes.test.ts delete mode 100644 apps/web/tests/account-control-plane.test.ts delete mode 100644 apps/web/tests/account-shell-hydration.test.tsx delete mode 100644 apps/web/tests/account-shell.test.tsx delete mode 100644 apps/web/tests/auth-config.test.ts delete mode 100644 apps/web/tests/auth-entrypoints.test.tsx delete mode 100644 apps/web/tests/auth-handoff.test.ts delete mode 100644 apps/web/tests/control-plane-shell.test.tsx delete mode 100644 apps/web/tests/env-management.test.ts delete mode 100644 apps/web/tests/linear-audit-fixtures.ts delete mode 100644 apps/web/tests/linear-management.test.ts delete mode 100644 apps/web/tests/next-dev-origins.test.ts delete mode 100644 apps/web/tests/package-manifest.test.ts delete mode 100644 apps/web/tests/project-control-plane-render.test.tsx delete mode 100644 apps/web/tests/project-control-plane-routes.test.ts delete mode 100644 apps/web/tests/project-control-plane.test.ts delete mode 100644 apps/web/tsconfig.json create mode 100644 automation-verification/runtime-qa-digest.md delete mode 100644 docs/guides/auth-broker-callbacks.md delete mode 100644 docs/guides/auth-broker-neon-bootstrap.md delete mode 100644 docs/guides/github-workflows.md delete mode 100644 docs/guides/linear-integration-architecture.md delete mode 100644 docs/guides/tickets-normalization.md delete mode 100644 docs/guides/web-control-plane-local-dev.md delete mode 100644 packages/auth-contract/package.json delete mode 100644 packages/auth-contract/src/index.ts delete mode 100644 packages/auth-contract/tests/shared-auth-contract.test.ts delete mode 100644 packages/auth-contract/tsconfig.json delete mode 100644 scripts/setup-auth-broker-neon.test.ts delete mode 100644 scripts/setup-auth-broker-neon.ts delete mode 100644 services/auth-broker/.env.example delete mode 100644 services/auth-broker/Dockerfile delete mode 100644 services/auth-broker/README.md delete mode 100644 services/auth-broker/package.json delete mode 100644 services/auth-broker/railway.json delete mode 100644 services/auth-broker/src/app.ts delete mode 100644 services/auth-broker/src/better-auth-link.ts delete mode 100644 services/auth-broker/src/better-auth.ts delete mode 100644 services/auth-broker/src/config.ts delete mode 100644 services/auth-broker/src/db.ts delete mode 100644 services/auth-broker/src/db/ensure-columns.ts delete mode 100644 services/auth-broker/src/db/schema.ts delete mode 100644 services/auth-broker/src/flow-store.ts delete mode 100644 services/auth-broker/src/github.ts delete mode 100644 services/auth-broker/src/index.ts delete mode 100644 services/auth-broker/src/linear.ts delete mode 100644 services/auth-broker/src/modules/better-auth/management-token.ts delete mode 100644 services/auth-broker/src/modules/better-auth/plugin.ts delete mode 100644 services/auth-broker/src/modules/better-auth/session.ts delete mode 100644 services/auth-broker/src/modules/better-auth/shell-plugin.ts delete mode 100644 services/auth-broker/src/modules/core/plugin.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/callback-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/flow-status-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/model.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/plugin.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/service.ts delete mode 100644 services/auth-broker/src/modules/github-oauth/start-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-agent/plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-autosync/plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-autosync/service.ts delete mode 100644 services/auth-broker/src/modules/linear-connections/local-access.ts delete mode 100644 services/auth-broker/src/modules/linear-connections/plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-connections/service.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/callback-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/flow-status-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/model.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/refresh-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/service.ts delete mode 100644 services/auth-broker/src/modules/linear-oauth/start-routes-plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-sync-store/plugin.ts delete mode 100644 services/auth-broker/src/modules/linear-sync-store/service.ts delete mode 100644 services/auth-broker/src/modules/orgs/db-store.ts delete mode 100644 services/auth-broker/src/modules/orgs/plugin.ts delete mode 100644 services/auth-broker/src/modules/orgs/service.ts delete mode 100644 services/auth-broker/src/modules/projects/db-store.ts delete mode 100644 services/auth-broker/src/modules/projects/plugin.ts delete mode 100644 services/auth-broker/src/modules/projects/service.ts delete mode 100644 services/auth-broker/src/modules/providers/plugin.ts delete mode 100644 services/auth-broker/src/plugins/shared-middleware.ts delete mode 100644 services/auth-broker/src/types.ts delete mode 100644 services/auth-broker/tests/better-auth-config.test.ts delete mode 100644 services/auth-broker/tests/better-auth-link.test.ts delete mode 100644 services/auth-broker/tests/better-auth-schema.test.ts delete mode 100644 services/auth-broker/tests/config.test.ts delete mode 100644 services/auth-broker/tests/deeplink.test.ts delete mode 100644 services/auth-broker/tests/ensure-columns.test.ts delete mode 100644 services/auth-broker/tests/flow-store.test.ts delete mode 100644 services/auth-broker/tests/index.test.ts delete mode 100644 services/auth-broker/tests/linear-auth-header.test.ts delete mode 100644 services/auth-broker/tests/linear-ownership-compat.test.ts delete mode 100644 services/auth-broker/tests/linear-store-schema.test.ts delete mode 100644 services/auth-broker/tests/linear-token-custody.test.ts delete mode 100644 services/auth-broker/tests/org-team-membership.test.ts delete mode 100644 services/auth-broker/tests/org-team-store-schema.test.ts delete mode 100644 services/auth-broker/tests/package-manifest.test.ts delete mode 100644 services/auth-broker/tests/project-admin.test.ts delete mode 100644 services/auth-broker/tests/project-store-schema.test.ts delete mode 100644 services/auth-broker/tests/session-auth.test.ts delete mode 100644 services/auth-broker/tests/test-env.ts delete mode 100644 services/auth-broker/tsconfig.json create mode 100644 src/commands/removed-surface.ts delete mode 100644 src/control-plane/extensions/github/auth.ts delete mode 100644 src/control-plane/extensions/github/client.ts delete mode 100644 src/control-plane/extensions/github/commands.ts delete mode 100644 src/control-plane/extensions/github/extension.ts delete mode 100644 src/control-plane/extensions/linear/audit.ts delete mode 100644 src/control-plane/extensions/linear/auth.ts delete mode 100644 src/control-plane/extensions/linear/client.ts delete mode 100644 src/control-plane/extensions/linear/closeout.ts delete mode 100644 src/control-plane/extensions/linear/commands.ts delete mode 100644 src/control-plane/extensions/linear/extension.ts delete mode 100644 src/control-plane/extensions/linear/project-artifacts.ts delete mode 100644 src/control-plane/extensions/tickets/normalized-model.ts delete mode 100644 src/daemon/routes/auth.ts delete mode 100644 src/lib/auth-broker-client.ts delete mode 100644 src/lib/auth-session.ts create mode 100644 src/lib/git-worktree.ts delete mode 100644 src/lib/integration-project-scope.ts create mode 100644 src/lib/project-lifecycle-hygiene.ts create mode 100644 src/lib/project-lifecycle-processes.ts create mode 100644 src/lib/project-runtime-hygiene.ts delete mode 100644 tests/apps/web.test.ts delete mode 100644 tests/auth-command.test.ts delete mode 100644 tests/cli-offline-optionality-matrix.test.ts delete mode 100644 tests/daemon-auth-routes.test.ts delete mode 100644 tests/github-auth.test.ts delete mode 100644 tests/github-client.test.ts delete mode 100644 tests/github-commands.test.ts delete mode 100644 tests/linear-auth.test.ts delete mode 100644 tests/linear-client.test.ts delete mode 100644 tests/linear-command-alias.test.ts delete mode 100644 tests/linear-commands.test.ts delete mode 100644 tests/linear-project-artifacts.test.ts delete mode 100644 tests/node-workspace-bootstrap-auth.test.ts delete mode 100644 tests/org-team-command.test.ts delete mode 100644 tests/privacy-check.test.ts create mode 100644 tests/project-lifecycle-hygiene.test.ts delete mode 100644 tests/project-owner-broker.test.ts create mode 100644 tests/project-runtime-hygiene.test.ts delete mode 100644 tests/tickets-normalized-model.test.ts diff --git a/.hack/docker-compose.yml b/.hack/docker-compose.yml index ed1e7464..756e374c 100644 --- a/.hack/docker-compose.yml +++ b/.hack/docker-compose.yml @@ -10,48 +10,6 @@ services: networks: - default - web: - image: imbios/bun-node:latest - working_dir: /app - volumes: - - ..:/app - - node_modules:/app/node_modules - depends_on: - deps: - condition: service_completed_successfully - command: bun run --cwd apps/web dev -- --hostname 0.0.0.0 --port 3000 - environment: - CHOKIDAR_USEPOLLING: "true" - WATCHPACK_POLLING: "true" - labels: - caddy: "hack-cli.hack, hack-cli.hack.gy" - caddy.reverse_proxy: "{{upstreams 3000}}" - caddy.tls: internal - networks: - - hack-dev - - default - - auth-broker: - image: imbios/bun-node:latest - working_dir: /app - volumes: - - ..:/app - - node_modules:/app/node_modules - depends_on: - deps: - condition: service_completed_successfully - command: bun run --cwd services/auth-broker start - environment: - PORT: "8080" - HOST: "0.0.0.0" - labels: - caddy: "auth.hack-cli.hack, auth.hack-cli.hack.gy" - caddy.reverse_proxy: "{{upstreams 8080}}" - caddy.tls: internal - networks: - - hack-dev - - default - networks: hack-dev: external: true diff --git a/.hack/hack.env.remote.yaml b/.hack/hack.env.remote.yaml index 6a3deeb6..877b2566 100644 --- a/.hack/hack.env.remote.yaml +++ b/.hack/hack.env.remote.yaml @@ -2,14 +2,4 @@ version: 1 environment: remote secretsprovider: project_key values: - web: - HACK_AUTH_BROKER_URL: https://auth.hack.broker - NEXT_PUBLIC_HACK_AUTH_BROKER_URL: https://auth.hack.broker - auth-broker: - AUTH_BROKER_PUBLIC_BASE_URL: https://auth.hack.broker - BETTER_AUTH_URL: https://auth.hack.broker - # Broker custom GitHub OAuth callback for Hack-owned flows. - # Better Auth browser social login uses: - # ${BETTER_AUTH_URL}/api/auth/callback/github - GITHUB_REDIRECT_URI: https://auth.hack.broker/gh/callback - HACK_LINEAR_REDIRECT_URI: https://auth.hack.broker/linear/callback + global: {} diff --git a/README.md b/README.md index 8ae8a6a2..1468eb25 100644 --- a/README.md +++ b/README.md @@ -1,160 +1,99 @@

- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - + Hack

-Hack is environment orchestration for software projects. +# Hack -For parallel work across agents, teams, and machines. -Portable environments, routed hostnames, TLS, ticket sync, and remote execution. +Hack is a local-first developer runtime for running repo environments cleanly on one machine. -It lets agents and teams run parallel work across projects, branches, local machines, and remote hosts without splitting envs, routing, tickets, and execution across separate systems. +Hack v3 narrows the product to the local core: -Hack keeps portable envs and secrets, routed hostnames and TLS, ticket workflows, durable sessions, and remote execution inside one project model. +- project init and runtime orchestration +- stable local routing and TLS +- env management and host/container env injection +- persistent sessions +- diagnostics and recovery +- optional repo-local tickets +- a slim macOS companion for local status, control, and logs -Parallel software work usually fails in the same places: ports collide, branches stomp on each other, env setup turns into tribal knowledge, ticket state fragments, and remote runs stop behaving like local ones. Hack makes those failures boring. +Hack no longer treats hosted auth, the web dashboard, built-in GitHub workflows, or Linear sync as supported product surfaces. -## Core Capabilities - -### 1. Parallel environments with stable routing - -Hack lets each repo or branch run as its own isolated local environment with stable routing instead of fighting over `localhost`, shared Docker networks, or ad-hoc port maps. - -- Run multiple repos at the same time. -- Run multiple branches or worktrees of the same repo at the same time. -- Keep service defaults like Postgres and Redis on their normal container ports. -- Open stable HTTPS hostnames like `https://.hack` and `https://api..hack`. - -Core commands: +## Core workflow ```bash +hack global install +hack init hack up --detach -hack up --branch feature-x --detach hack open hack logs --pretty ``` -### 2. Ticket workflows across git and Linear - -Hack keeps a local-first, git-backed ticket substrate in your repo, then lets external systems like Linear sync into that substrate instead of forcing your workflow to live in five different places. - -- Track work in git with lightweight hack tickets. -- Sync issues and projects with Linear when you need shared coordination. -- Preserve ownership and provenance so local and synced tickets stay understandable. -- Give the CLI, agents, and the macOS app one normalized ticket model to work against. - -Core commands: +Useful follow-ups: ```bash -hack tickets setup -hack tickets create --title "Investigate flaky auth flow" +hack restart +hack doctor +hack session +hack env list +hack host exec --scope api -- bun test hack tickets list -hack linear project-bind --project-id "" -hack linear sync-project --from linear --project-id "" ``` -Docs: +## What stays in v3 -- [Tickets guide](docs/guides/tickets.md) -- [Linear integration architecture](docs/guides/linear-integration-architecture.md) +- `hack init` +- `hack up`, `hack down`, `hack restart` +- `hack open`, `hack logs`, `hack ps` +- `hack env`, `hack host exec`, `hack host shell` +- `hack session` +- `hack doctor`, `hack daemon`, `hack crash-capture` +- `hack tickets` +- macOS app for local project/global status and Ghostty-backed bottom logs panel -### 3. Portable envs and secrets +## What was removed -Hack separates shareable env structure from secret values so teams and agents can understand what a project needs without leaking sensitive material into git. +- hosted Hack auth and account/org/team management +- built-in GitHub integration flows +- built-in Linear integration flows +- web dashboard and browser control plane +- auth-broker service and hosted control-plane dependency -- Commit `.hack/hack.env.default.yaml` and optional `.hack/hack.env..yaml`. -- Keep `.hack.secret.key` out of git, or provide `HACK_ENV_SECRET_KEY` in CI. -- Let Hack inject resolved env directly into runtime commands by default. -- Materialize `.hack/.env` only when you explicitly need a compatibility file. +Removed commands remain as compatibility stubs and print migration guidance. -Core commands: +## Env model -```bash -hack env list -hack env add AWS_PROFILE dev -hack env add --secret DATABASE_URL postgres://... -hack host exec --env qa --scope api -- bun db:migrate -hack host exec --env qa --scope api --target compose -- bun test -hack host shell --env qa --scope api -hack exec api -- bun test -``` - -`hack host exec` and `hack host shell` run on your host machine with Hack-resolved env injected. -Use `--scope` when you want service-scoped values without running inside that service container. -Use `--target compose` when you explicitly want the container-oriented addresses from the compose -view instead of the default host-local rewrites. +Hack’s canonical env files live in `.hack/`: -Docs: +- shared repo files: + - `.hack/hack.env.default.yaml` + - `.hack/hack.env..yaml` +- worktree-local override files: + - `.hack/hack.env.local.yaml` + - `.hack/hack.env..local.yaml` -- [Env and secrets](docs/env.md) +Resolution order: -## First Run +1. shared default +2. shared selected overlay +3. worktree-local default +4. worktree-local selected overlay -### Prerequisites +Secrets use the project key provider. Key lookup order is: -- macOS -- Docker + Compose via [OrbStack](https://docs.orbstack.dev/quick-start) or [Docker Desktop](https://www.docker.com/get-started/) +1. current checkout `.hack.secret.key` +2. shared key under the git common dir for linked worktrees +3. `HACK_ENV_SECRET_KEY` -### 1. Install Hack +## Install -Recommended: Homebrew +Homebrew: ```bash brew tap hack-dance/tap brew install hack-dance/tap/hack ``` -Release installer script: +Shell installer: ```bash curl -fsSL \ @@ -162,225 +101,26 @@ curl -fsSL \ | bash ``` -Managed Codex or CI container: - -```bash -curl -fsSL \ - https://github.com/hack-dance/hack/releases/latest/download/hack-codex-install.sh \ - | bash -``` - -This slim install path skips `hack global install`, Caddy/CoreDNS, and Loki/Grafana. - -Beta macOS app and bundled CLI: - -- Download the latest DMG from [GitHub Releases](https://github.com/hack-dance/hack/releases/latest) -- Install the CLI from the DMG -- Optionally keep the macOS app for the beta desktop surface - -### 2. Set up your machine - -```bash -hack global install -``` - -### 3. Start your first project - -```bash -cd /path/to/your-repo -hack init -hack up --detach -hack open -``` - -What those commands do: - -- `hack global install` boots the machine-level DNS, TLS, and logging services -- `hack init` creates the `.hack/` project config -- `hack up --detach` starts your stack on an isolated network -- `hack open` opens the project URL in your browser - -Useful next commands: - -```bash -hack logs --pretty -hack run -hack status -hack down -``` - -If you want the shortest non-interactive path for an existing repo, use `hack init --auto`. - -### 4. Optional: install agent integrations - -Use these when you want Cursor, Claude, or Codex to understand Hack workflows directly. - -```bash -hack setup cursor -hack setup claude -hack setup codex -hack setup sync --all-scopes -hack agent init --client codex -``` - -If your agent does not have shell access, use MCP instead: - -```bash -hack mcp install --all --scope project -hack setup mcp -hack mcp serve -``` - -Docs: - -- [Initialize a project](docs/guides/init-project.md) -- [CLI reference](docs/cli.md) -- [GitHub workflows](docs/guides/github-workflows.md) - -## Adjacent capabilities at a glance - -Not every repo needs every `hack` capability on day one. These are the common adjacent tools people run -into after `hack init`, `hack up`, and `hack open` are already working: - -- **GitHub**: Connect GitHub when you want `hack` to help with pull requests and branch handoff from the - same place you run local work. It becomes relevant once you are ready to open, update, or share PRs. - Docs: [`docs/guides/github-workflows.md`](docs/guides/github-workflows.md) -- **Linear**: Connect Linear when your team plans work there and you want Hack to bridge repo work with - project and issue tracking. It becomes relevant when a repo maps to a Linear project or when you want - sync between Linear issues and repo-local work. Docs: - [`docs/guides/linear-integration-architecture.md`](docs/guides/linear-integration-architecture.md) -- **Tickets**: Use tickets when you want lightweight task tracking that stays with the repo and works - well for solo work, small teams, or agents. It becomes relevant as soon as you want to capture work - items without pushing everyone into a separate tracker. Docs: - [`docs/guides/tickets.md`](docs/guides/tickets.md) -- **Sessions**: Use sessions when you want a terminal workspace you can come back to, share over SSH, - or hand off to an agent without rebuilding context. It becomes relevant when you keep long-running - shells, multiple projects, or multiple branches alive at once. Docs: - [`docs/sessions.md`](docs/sessions.md) -- **Env**: Use env when a project needs local configuration like API keys, database URLs, or per-service - settings that should be explicit instead of tribal knowledge. It becomes relevant as soon as `hack up`, - `hack run`, or remote workflows depend on values that should not be hardcoded into the repo. Docs: - [`docs/env.md`](docs/env.md) -- **Browser control plane**: Use the routed account surface when you want browser-based visibility into - auth context, org/team/project administration, integration status, and env management without giving - up CLI parity. It becomes relevant after `hack up` and `hack open` are already working and you want a - local web UI at `https://.hack/account` alongside the existing repo-bound CLI flows. - -## Supporting Capabilities - -### Sessions - -Sessions keep terminal work durable. They are not the headline promise, but they are a first-class support capability for parallel project work, remote access, and long-running agents. - -- Backed by `tmux` or `zellij` -- Started from the project root, not from `.hack/` -- Useful for remote attach, agent execution, and multi-terminal workflows - -```bash -hack session -hack session start my-project --up -hack session start my-project --env qa --service api --detach -hack session attach my-project -hack session exec my-project "bun test" -``` - -Docs: - -- [Sessions](docs/sessions.md) - -### Integrations - -Integrations make the core promises more usable without changing what Hack is. - -- Agent integrations via `hack setup` -- MCP for no-shell clients via `hack mcp` -- Linear as a ticket-normalization extension, not the center of the product -- GitHub when you want PR automation, PR updates, and private-repo bootstrap help - -If your agent has shell access, prefer the CLI. Use MCP only when shell access is unavailable. - -Docs: - -- [Extensions](docs/extensions.md) -- [Gateway overview](docs/gateway.md) - -## Beta Capabilities - -> Beta surfaces are real and useful, but they are not the core promise of Hack yet. - -### Remote control plane - -The gateway, supervisor, and remote shell flows let you control Hack over HTTP and remote transports. This is the foundation for off-device control, but it is still a secondary surface. - -Docs: - -- [Gateway overview](docs/gateway.md) -- [Gateway API](docs/gateway-api.md) -- [Supervisor](docs/supervisor.md) - -### Multi-node - -Hack can pair remote nodes, bootstrap workspaces, and dispatch work across machines. This is useful when your local machine is not enough, but it is still beta-level product surface area. - -Docs: - -- [Remote node quickstart](docs/guides/remote-node-quickstart.md) -- [Remote node container guide](docs/guides/remote-node-container.md) -- [Remote supervisor guide](docs/guides/remote-supervisor.md) - -### macOS app - -Hack Desktop gives you a native dashboard for projects, tickets, sessions, and integrations. It is a beta companion to the CLI, not the primary entry point. - -Docs: - -- [macOS app README](apps/macos/README.md) - -## How Hack Works - -- Project config lives in `.hack/`. -- Each project or branch instance gets its own isolated Docker network and compose identity. -- A global Caddy proxy gives routed services stable local HTTPS hostnames. -- `hack logs` uses Compose for the fast path and Loki/Grafana for history and querying. -- `hackd` is an optional daemon for faster `projects` and `ps` snapshots. - -Useful commands: - -```bash -hack help -hack projects -hack status -hack daemon start -hack open logs -``` - -## Documentation +## macOS app -Docs are split into three paths: +The macOS app is a thin local companion. It keeps: -- [Docs hub](docs/README.md) -- [Core docs](docs/core.md) for the default local product story -- [Beta workflows](docs/beta.md) for remote and control-plane flows -- [Extensions & reference](docs/reference.md) for commands, APIs, and integrations +- project list and project detail +- global and daemon status +- start/stop/restart/open actions +- log entrypoints +- trust and doctor guidance +- menu bar quick actions +- the Ghostty-backed bottom panel for local logs and terminal output -## Develop From Source +## Experimental remote -```bash -bun install -bun run install:dev -hack --help -``` - -Useful repo commands: - -```bash -bun test -bun x ultracite check -``` +Remote, gateway, node, and dispatch remain in the repo as unsupported experimental surfaces. They are not part of the default v3 product story and are not release blockers unless they break the local core. -Useful repo-level commands: +## Docs -- `bun run build` -- `bun run test` -- `bun run check` -- `bun run macos:build` +- [CLI reference](./docs/cli.md) +- [Environment model](./docs/env.md) +- [Architecture](./docs/architecture.md) +- [Sessions](./docs/sessions.md) +- [Tickets](./docs/guides/tickets.md) diff --git a/WORKFLOW.md b/WORKFLOW.md index b4a04782..81e04752 100644 --- a/WORKFLOW.md +++ b/WORKFLOW.md @@ -1,102 +1,62 @@ # Hack CLI Workflow -This repository is the source tree for the `hack` CLI and related services. +This repository is the source tree for the Hack CLI and the local-first macOS companion. ## Core expectations - Use Bun, not npm, pnpm, or yarn. -- Prefer targeted package or service commands before broad workspace runs. -- Keep changes scoped to the ticket. Do not mix unrelated cleanup into the same branch. -- Work inside the ticket worktree and ticket branch created by Symphony. -- Commit useful checkpoints as you make progress. Do not wait until the very end to create the first commit if the change is substantial. -- Before finishing, make sure the branch is in a state that can be pushed and reviewed cleanly. +- Prefer targeted checks before broad workspace runs. +- Keep changes scoped and reviewable. +- Capture real verification for CLI/runtime/env changes. ## Project structure -- `index.ts`, `src/**`: root CLI entrypoints and orchestration logic +- `index.ts`, `src/**`: CLI and runtime orchestration - `packages/cli/**`: packaged CLI build/install scripts and macOS helper commands -- `services/auth-broker/**`: auth broker service -- `packages/db/**`: database package +- `packages/db/**`: internal database package - `apps/macos/**`: native desktop app -- `docs/**`, `README.md`: user/operator documentation +- `docs/**`, `README.md`: current product docs ## Default validation -Start with the narrowest checks that match the files you changed, then run broader checks before handoff. - -### Common commands - ```bash bun run typecheck bun run test bun run check ``` -### Package and service focused commands +Focused checks: ```bash bun run --cwd packages/cli test bun run --cwd packages/cli typecheck -bun run --cwd packages/cli check - -bun run --cwd services/auth-broker test -bun run --cwd services/auth-broker typecheck -bun run --cwd services/auth-broker check - bun run --cwd packages/db test -bun run --cwd packages/db typecheck -bun run --cwd packages/db check +bun run macos:test ``` -### Local install / runtime smoke checks - -Use these when the ticket changes install, packaging, startup, or command behavior. +## Local runtime smoke checks ```bash bun run install:status -bun run install:dev hack --help ``` -### Desktop / macOS work - -Only run these when the ticket touches `apps/macos` or macOS-specific packaging. - -```bash -bun run macos:test -bun run macos:build -``` - -## Development guidance - -- Prefer root `bun run` scripts for workspace-wide behavior. -- Prefer package-local commands when you are changing a single area. -- If you change CLI behavior, capture at least one realistic command path in tests or a documented smoke check. -- If you change docs or workflow behavior, keep examples accurate with the current CLI surface. -- If you touch build, install, release, or runtime image code, include a smoke check that proves the command still executes. - -## Git and PR workflow - -- The ticket worktree should already be on a dedicated ticket branch. -- Commit incrementally with clear messages. -- Keep the branch rebased or merge-ready. -- When preparing for merge: - - make sure validation commands are recorded - - summarize any residual risk - - do not mark the work done if CI-critical checks are still unverified +## Product boundary -## What to avoid +Supported: -- Do not introduce Node-only workflows when Bun already covers the use case. -- Do not hand-edit generated artifacts unless the ticket is specifically about generation outputs. -- Do not run broad destructive cleanup across unrelated packages. -- Do not assume macOS-only paths are safe to change without validation. +- local runtime orchestration +- env management +- sessions +- diagnostics +- optional local tickets +- slim macOS companion -## Completion checklist +Removed: -Before handing work back for review: +- hosted auth/account/org/team flows +- built-in GitHub workflows +- built-in Linear workflows +- web dashboard -1. Run targeted validation for the touched area. -2. Run broader validation if the change affects shared runtime, install, orchestration, or release behavior. -3. Make sure the working tree changes are intentional and reviewable. -4. Leave a concise summary of what changed, how it was validated, and any remaining follow-up. +Remote/gateway/node/dispatch stay unsupported experimental. diff --git a/apps/macos/App/HackDesktopApp.swift b/apps/macos/App/HackDesktopApp.swift index 724d2d85..b7806b9c 100644 --- a/apps/macos/App/HackDesktopApp.swift +++ b/apps/macos/App/HackDesktopApp.swift @@ -108,10 +108,9 @@ struct HackDesktopApp: App { @MainActor private func handleIncomingDeepLink(url: URL) { - let handledHackAuth = model.ingestHackAuthDeepLink(url: url) - let handledGitHub = model.ingestGitHubOAuthDeepLink(url: url) - let handledLinear = model.ingestLinearOAuthDeepLink(url: url) - guard handledHackAuth || handledGitHub || handledLinear else { return } + guard url.scheme != nil else { return } + + model.statusMessage = "Hosted auth and integration callbacks are no longer supported in Hack Desktop v3." #if os(macOS) NSApp.activate(ignoringOtherApps: true) @@ -120,7 +119,7 @@ struct HackDesktopApp: App { name: .hackSettingsRequested, object: nil, userInfo: [ - "pane": handledHackAuth ? "account" : (handledLinear ? "linear" : "github"), + "pane": "runtime", ] ) } diff --git a/apps/macos/App/Info.plist b/apps/macos/App/Info.plist index 224217e8..1dcd575d 100644 --- a/apps/macos/App/Info.plist +++ b/apps/macos/App/Info.plist @@ -25,12 +25,6 @@ - HackAuthCompletionURL - $(HACK_URL_SCHEME)://auth/complete - HackGitHubOAuthCallbackURL - $(HACK_URL_SCHEME)://auth/github/callback - HackLinearOAuthCallbackURL - $(HACK_URL_SCHEME)://auth/linear/callback CFBundlePackageType APPL CFBundleShortVersionString @@ -44,6 +38,6 @@ NSAppleEventsUsageDescription Hack Desktop can open Terminal to run hack setup commands (some require sudo) so your local dev environment works. NSLocalNetworkUsageDescription - Hack Desktop can access your local network to show LAN gateway status and open local development URLs. + Hack Desktop can access your local network to open local development URLs and inspect local runtime services on this machine. diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift index 79898a57..2f55ebb9 100644 --- a/apps/macos/Package.swift +++ b/apps/macos/Package.swift @@ -37,7 +37,10 @@ let package = Package( .product(name: "MarkdownUI", package: "swift-markdown-ui"), .product(name: "Highlightr", package: "Highlightr") ], - path: "Packages/Features/DashboardFeature/Sources/DashboardFeature" + path: "Packages/Features/DashboardFeature/Sources/DashboardFeature", + exclude: [ + "TicketMarkdownCodeSyntaxHighlighter.swift", + ] ), .testTarget( name: "HackDesktopModelsTests", diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift index 2d0b9096..89ae80e4 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/CommandPalette.swift @@ -7,8 +7,6 @@ extension Notification.Name { public static let hackCommandPaletteRequested = Notification.Name("hack.commandPalette.requested") public static let hackRefreshRequested = Notification.Name("hack.refresh.requested") public static let hackProjectNavigationRequested = Notification.Name("hack.projectNavigation.requested") - public static let hackTicketReviewQueueRequested = Notification.Name("hack.ticketReviewQueue.requested") - public static let hackProjectRoutingRequested = Notification.Name("hack.projectRouting.requested") } enum ProjectNavigationRequest { @@ -17,14 +15,6 @@ enum ProjectNavigationRequest { static let sidebarKey = "sidebar" } -enum TicketReviewQueueRequest { - static let projectIdKey = "projectId" -} - -enum ProjectRoutingRequest { - static let projectIdKey = "projectId" -} - struct CommandPaletteAction: Identifiable { let id = UUID() let title: String @@ -91,16 +81,6 @@ struct CommandPaletteView: View { } ) - actions.append( - CommandPaletteAction(title: "Go to Linear Settings", subtitle: "Connected accounts + sync policy") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.linear.rawValue] - ) - } - ) - actions.append( CommandPaletteAction(title: "Go to Runtime Settings", subtitle: "System status + daemon") { NotificationCenter.default.post( @@ -111,29 +91,20 @@ struct CommandPaletteView: View { } ) actions.append( - CommandPaletteAction(title: "Go to Topology Settings", subtitle: "Node + gateway network topology") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.topology.rawValue] - ) - } - ) - actions.append( - CommandPaletteAction(title: "Go to Gateway Settings", subtitle: "Gateway configuration") { + CommandPaletteAction(title: "Go to Global Settings", subtitle: "Local runtime services + diagnostics") { NotificationCenter.default.post( name: .hackSettingsRequested, object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.gateway.rawValue] + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.global.rawValue] ) } ) actions.append( - CommandPaletteAction(title: "Go to Railway Settings", subtitle: "Railway provider + bootstrap controls") { + CommandPaletteAction(title: "Go to Trust Guidance", subtitle: "Permissions, certificates, and local trust setup") { NotificationCenter.default.post( name: .hackSettingsRequested, object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.railway.rawValue] + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.permissions.rawValue] ) } ) @@ -161,32 +132,6 @@ struct CommandPaletteView: View { model.selectedItem = .project(project.id) } ) - if project.supportsTickets { - actions.append( - CommandPaletteAction( - title: "Open Tickets: \(project.name)", - subtitle: "Ticket board + manual sync tools" - ) { - openProjectTickets(project) - } - ) - actions.append( - CommandPaletteAction( - title: "Open Review Queue: \(project.name)", - subtitle: "Conflicts, comments, and merge follow-up" - ) { - openProjectTicketReviewQueue(project) - } - ) - actions.append( - CommandPaletteAction( - title: "Open Linear routing: \(project.name)", - subtitle: "Project account + bound Linear project" - ) { - openProjectRouting(project) - } - ) - } } if let project = model.selectedProject { @@ -205,11 +150,6 @@ struct CommandPaletteView: View { openProjectOverview(project) } ) - actions.append( - CommandPaletteAction(title: "Project: Linear routing", subtitle: project.name) { - openProjectRouting(project) - } - ) if project.isRuntimeConfigured { actions.append( CommandPaletteAction(title: "Project: Logs", subtitle: project.name) { @@ -236,22 +176,6 @@ struct CommandPaletteView: View { } ) } - if project.supportsTickets { - actions.append( - CommandPaletteAction(title: "Project: Tickets", subtitle: project.name) { - openProjectTickets(project) - } - ) - actions.append( - CommandPaletteAction( - title: "Project: Review queue", - subtitle: project.name - ) { - openProjectTicketReviewQueue(project) - } - ) - } - actions.append( CommandPaletteAction(title: "Project: Refresh", subtitle: project.name) { Task { await model.refresh() } @@ -276,39 +200,6 @@ struct CommandPaletteView: View { } ) } - if project.supportsTickets { - actions.append( - CommandPaletteAction(title: "Tickets: Sync", subtitle: project.name) { - Task { _ = await model.syncTickets(for: project) } - } - ) - actions.append( - CommandPaletteAction(title: "Tickets: Pull from Linear", subtitle: project.name) { - Task { - _ = await model.syncLinearProject( - for: project, - from: "linear" - ) - } - } - ) - actions.append( - CommandPaletteAction(title: "Tickets: Push Hack to Linear", subtitle: project.name) { - Task { - _ = await model.syncLinearProject( - for: project, - from: "hack", - ownerMode: "hack" - ) - } - } - ) - actions.append( - CommandPaletteAction(title: "Tickets: Setup", subtitle: project.name) { - Task { _ = await model.setupTickets(for: project) } - } - ) - } } return actions @@ -336,33 +227,6 @@ struct CommandPaletteView: View { model.selectedProjectTab = .overview } - private func openProjectTickets(_ project: ProjectSummary) { - model.selectedItem = .project(project.id) - model.selectedProjectTab = .tickets - } - - private func openProjectTicketReviewQueue(_ project: ProjectSummary) { - openProjectTickets(project) - DispatchQueue.main.async { - NotificationCenter.default.post( - name: .hackTicketReviewQueueRequested, - object: nil, - userInfo: [TicketReviewQueueRequest.projectIdKey: project.id] - ) - } - } - - private func openProjectRouting(_ project: ProjectSummary) { - model.selectedItem = .project(project.id) - NotificationCenter.default.post( - name: .hackProjectRoutingRequested, - object: nil, - userInfo: [ - ProjectRoutingRequest.projectIdKey: project.id - ] - ) - } - private func openPreferredCodingAgent(for project: ProjectSummary, path: String) { let command = CodingAgentIntegration.launchCommand( projectPath: path, diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift index 250a6830..7eb33f27 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -7,7 +7,6 @@ import HackDesktopModels public enum SidebarItem: Hashable, Identifiable { case home case runtime - case gateway case project(String) public var id: String { @@ -16,8 +15,6 @@ public enum SidebarItem: Hashable, Identifiable { return "home" case .runtime: return "runtime" - case .gateway: - return "gateway" case let .project(id): return "project:\(id)" } @@ -30,7 +27,6 @@ public enum ProjectTab: String, CaseIterable { case sessions = "Sessions" case logs = "Logs" case shell = "Shell" - case tickets = "Tickets" } public enum ProjectLifecycleAction { @@ -50,37 +46,6 @@ public enum RuntimeHealthState { case unknown } -public struct GitHubOAuthDeepLinkContext: Hashable { - public let flowId: String - public let profileId: String? - public let status: String? - public let installationId: String? - - public init( - flowId: String, - profileId: String?, - status: String?, - installationId: String? - ) { - self.flowId = flowId - self.profileId = profileId - self.status = status - self.installationId = installationId - } -} - -public struct LinearOAuthDeepLinkContext: Hashable { - public let flowId: String - public let profileId: String? - public let status: String? - - public init(flowId: String, profileId: String?, status: String?) { - self.flowId = flowId - self.profileId = profileId - self.status = status - } -} - @Observable @MainActor public final class DashboardModel { @@ -94,8 +59,6 @@ public final class DashboardModel { public private(set) var runtimeResetAt: String? = nil public private(set) var runtimeResetCount: Int? = nil public private(set) var lastUpdated: Date? = nil - public private(set) var hackAccountState: HackAccountSettingsState? = nil - public private(set) var isLoadingHackAccountState = false public var selectedItem: SidebarItem? = .home { didSet { handleSelectedItemChange(previous: oldValue, current: selectedItem) @@ -104,25 +67,17 @@ public final class DashboardModel { public var selectedProjectTab: ProjectTab = .overview public var errorMessage: String? = nil public var statusMessage: String? = nil - public private(set) var githubOAuthDeepLinkContext: GitHubOAuthDeepLinkContext? = nil - public private(set) var linearOAuthDeepLinkContext: LinearOAuthDeepLinkContext? = nil public var isRefreshing = false public private(set) var projectLifecycleActions: [String: ProjectLifecycleAction] = [:] public private(set) var globalLifecycleAction: GlobalLifecycleAction? = nil private let client: HackCLIClient - // Tickets should not be blocked by global refresh/status calls. - private let ticketsClient: HackCLIClient private var lastSelectedProjectId: String? = nil private var refreshTask: Task? = nil private var statusClearTask: Task? = nil - private var lastHackAccountRefreshAt: Date? = nil - private static let hackAccountRefreshTTL: TimeInterval = 20 - - public init(client: HackCLIClient, ticketsClient: HackCLIClient = HackCLIClient()) { + public init(client: HackCLIClient) { self.client = client - self.ticketsClient = ticketsClient } public var selectedProject: ProjectSummary? { @@ -130,10 +85,6 @@ public final class DashboardModel { return projects.first { $0.id == id } } - public var gatewayExposures: [GatewayExposure] { - globalStatus?.gateway?.exposures ?? [] - } - public var runtimeOverallOk: Bool? { if runtimeOk == false { return false } if let summaryOk = globalStatus?.summary.ok { return summaryOk } @@ -155,18 +106,6 @@ public final class DashboardModel { globalInfraRunningState == false } - var gatewaySummaryState: GatewaySummaryState? { - let gatewayEnabled = globalStatus?.gateway?.gatewayEnabled ?? globalStatus?.summary.gatewayEnabled - if globalStatus?.gateway == nil && gatewayEnabled == nil && gatewayExposures.isEmpty { - return nil - } - return GatewaySummaryState.resolve( - exposures: gatewayExposures, - gatewayEnabled: gatewayEnabled, - globalInfraRunning: globalInfraRunningState - ) - } - public func start() { guard refreshTask == nil else { return } refreshTask = Task { [weak self] in @@ -190,13 +129,7 @@ public final class DashboardModel { async let projectsTask = fetchProjects() async let daemonTask = fetchDaemonStatus() async let globalTask = fetchGlobalStatus() - async let hackAccountTask: Void = refreshHackAccountState( - force: false, - updateErrorMessage: false - ) - let errors = await [projectsTask, daemonTask, globalTask].compactMap { $0 } - _ = await hackAccountTask if !errors.isEmpty { errorMessage = errors.joined(separator: "\n") } @@ -286,168 +219,6 @@ public final class DashboardModel { ) } - public func showTickets(for project: ProjectSummary) { - selectedItem = .project(project.id) - selectedProjectTab = .tickets - } - - public func listTickets(for project: ProjectSummary) async throws -> [TicketSummary] { - guard let path = resolveProjectPath(project) else { - throw HackCLIError.commandFailed(exitCode: 1, stderr: "Missing project path for \(project.name)") - } - let response = try await ticketsClient.listTickets(path: path) - return response.tickets - } - - public func showTicket(for project: ProjectSummary, ticketId: String) async throws -> TicketDetailResponse { - guard let path = resolveProjectPath(project) else { - throw HackCLIError.commandFailed(exitCode: 1, stderr: "Missing project path for \(project.name)") - } - return try await ticketsClient.showTicket(path: path, ticketId: ticketId) - } - - public func createTicket( - for project: ProjectSummary, - title: String, - body: String?, - dependsOn: [String], - blocks: [String] - ) async -> TicketSummary? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - let response = await runActionResult( - message: "Creating ticket…", - refreshDashboard: false - ) { - try await self.ticketsClient.createTicket( - path: path, - title: title, - body: body, - dependsOn: dependsOn, - blocks: blocks - ) - } - return response?.ticket - } - - public func setTicketStatus( - for project: ProjectSummary, - ticketId: String, - status: TicketStatus - ) async -> TicketStatusResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Updating ticket status…", - refreshDashboard: false - ) { - try await self.ticketsClient.setTicketStatus(path: path, ticketId: ticketId, status: status) - } - } - - public func appendTicketComment( - for project: ProjectSummary, - ticketId: String, - body: String, - source: String? = nil, - actor: String? = nil - ) async -> TicketCommentAppendResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Appending ticket comment…", - refreshDashboard: false - ) { - try await self.ticketsClient.appendTicketComment( - path: path, - ticketId: ticketId, - body: body, - source: source, - actor: actor - ) - } - } - - public func appendTicketReviewNote( - for project: ProjectSummary, - ticketId: String, - body: String, - actor: String? = nil - ) async -> TicketReviewNoteAppendResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Appending review note…", - refreshDashboard: false - ) { - try await self.ticketsClient.appendTicketReviewNote( - path: path, - ticketId: ticketId, - body: body, - actor: actor - ) - } - } - - public func syncTickets(for project: ProjectSummary) async -> TicketsSyncResult? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - let response = await runActionResult( - message: "Syncing tickets…", - refreshDashboard: false - ) { - try await self.ticketsClient.syncTickets(path: path) - } - return response?.sync - } - - public func resolveTicketConflict( - for project: ProjectSummary, - ticketId: String, - conflictId: String, - resolution: TicketSyncConflictResolution, - summary: String? = nil - ) async -> TicketConflictResolutionResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Resolving sync conflict…", - refreshDashboard: false - ) { - try await self.ticketsClient.resolveTicketConflict( - path: path, - ticketId: ticketId, - conflictId: conflictId, - resolution: resolution, - summary: summary - ) - } - } - - public func setupTickets(for project: ProjectSummary) async -> Bool { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return false - } - let result = await runActionResult(message: "Setting up tickets…") { - try await self.ticketsClient.setupTickets(path: path) - return true - } - return result ?? false - } - public func startBranch(for project: ProjectSummary, branch: String) async { guard let path = resolveProjectPath(project) else { errorMessage = "Missing project path for \(project.name)" @@ -553,34 +324,6 @@ public final class DashboardModel { } } - public func fetchGatewayTokens() async -> [GatewayTokenRecord] { - do { - return try await client.listGatewayTokens().tokens - } catch { - errorMessage = error.localizedDescription - return [] - } - } - - public func createGatewayToken( - scope: GatewayTokenScope, - label: String? - ) async -> GatewayTokenCreateResponse? { - await runActionResult(message: "Creating gateway token…") { - try await self.client.createGatewayToken(scope: scope, label: label) - } - } - - public func revokeGatewayToken(id: String) async -> Bool { - let response: GatewayTokenRevokeResponse? = await runActionResult(message: "Revoking gateway token…") { - try await self.client.revokeGatewayToken(id: id) - } - guard let response else { - return false - } - return response.revoked - } - public func startCloudflareTunnel() async -> Bool { let result: Bool? = await runActionResult(message: "Starting cloudflared tunnel…") { try await self.client.startCloudflareTunnel() @@ -597,632 +340,6 @@ public final class DashboardModel { return result ?? false } - public func inspectTailscale() async -> TailscaleInspectResponse? { - do { - return try await client.inspectTailscale() - } catch { - let message = error.localizedDescription - errorMessage = message - return TailscaleInspectResponse( - installed: false, - binaryPath: nil, - connected: false, - backendState: nil, - tailnetName: nil, - magicDnsSuffix: nil, - authUrl: nil, - currentExitNodeId: nil, - currentExitNodeName: nil, - selfDevice: nil, - peers: [], - onlinePeerCount: 0, - exitNodes: [], - health: [], - error: message - ) - } - } - - public func inspectTailscaleOAuthStatus( - validate: Bool = false - ) async -> TailscaleOAuthStatusResponse? { - do { - return try await client.inspectTailscaleOAuthStatus(validate: validate) - } catch { - let message = error.localizedDescription - errorMessage = message - return TailscaleOAuthStatusResponse( - configured: false, - clientId: nil, - authRef: nil, - tailnet: nil, - keyExpirySeconds: nil, - validated: nil, - checkedAt: nil, - tokenExpiresAt: nil, - deleted: nil, - error: message - ) - } - } - - public func connectTailscaleOAuth( - request: TailscaleOAuthConnectRequest - ) async -> TailscaleOAuthStatusResponse? { - await runActionResult(message: "Saving Tailscale OAuth credentials…") { - try await self.client.connectTailscaleOAuth(request: request) - } - } - - public func disconnectTailscaleOAuth( - authRef: String? = nil - ) async -> TailscaleOAuthStatusResponse? { - await runActionResult(message: "Clearing Tailscale OAuth credentials…") { - try await self.client.disconnectTailscaleOAuth(authRef: authRef) - } - } - - public func inspectRailway() async -> RailwayInspectResponse? { - do { - return try await client.inspectRailway() - } catch { - let message = error.localizedDescription - errorMessage = message - return RailwayInspectResponse( - installed: false, - binaryPath: nil, - version: nil, - authenticated: false, - whoami: nil, - error: message - ) - } - } - - public func inspectGitHubProfiles() async -> GitHubProfilesResponse? { - do { - return try await client.inspectGitHubProfiles() - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func inspectGitHubStatus(profileId: String? = nil) async -> GitHubStatusResponse? { - do { - return try await client.inspectGitHubStatus(profileId: profileId) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func inspectSystemGitIdentity(projectPath: String? = nil) async -> GitSystemIdentity? { - do { - return try await client.inspectSystemGitIdentity(projectPath: projectPath) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func inspectHackAccountSettingsState( - force: Bool = false, - updateErrorMessage: Bool = true - ) async -> HackAccountSettingsState? { - await refreshHackAccountState( - force: force, - updateErrorMessage: updateErrorMessage - ) - return hackAccountState - } - - public func refreshHackAccountState( - force: Bool = false, - updateErrorMessage: Bool = false - ) async { - if !force, - let lastHackAccountRefreshAt, - let hackAccountState, - Date().timeIntervalSince(lastHackAccountRefreshAt) < Self.hackAccountRefreshTTL - { - _ = hackAccountState - return - } - - guard !isLoadingHackAccountState else { - return - } - - isLoadingHackAccountState = true - defer { - isLoadingHackAccountState = false - } - - do { - hackAccountState = try await client.inspectHackAccountSettingsState() - lastHackAccountRefreshAt = Date() - } catch { - if updateErrorMessage { - errorMessage = error.localizedDescription - } - } - } - - public func loginHackAccount() async -> HackAccountSettingsState? { - let didLogin: Bool? = await runActionResult(message: "Signing in to Hack…") { - try await self.client.loginHackAccount() - return true - } - guard didLogin == true else { - return nil - } - await refreshHackAccountState(force: true, updateErrorMessage: true) - return hackAccountState - } - - public func logoutHackAccount() async -> HackAccountSettingsState? { - let didLogout: Bool? = await runActionResult(message: "Signing out of Hack…") { - try await self.client.logoutHackAccount() - return true - } - guard didLogout == true else { - return nil - } - await refreshHackAccountState(force: true, updateErrorMessage: true) - return hackAccountState - } - - public func startGitHubOAuthFlow( - profileId: String, - setDefault: Bool - ) async -> GitHubOAuthFlowStartResponse? { - do { - return try await client.startGitHubOAuthFlow( - profileId: profileId, - setDefault: setDefault - ) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func fetchGitHubOAuthFlowStatus( - statusURL: String - ) async -> GitHubOAuthFlowStatusResponse? { - do { - return try await client.fetchGitHubOAuthFlowStatus(statusURL: statusURL) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func inspectLinearProfiles() async -> LinearProfilesResponse? { - do { - return try await client.inspectLinearProfiles() - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func listLinearConnections( - profileId: String? = nil, - organizationId: String? = nil - ) async -> LinearConnectionsResponse? { - do { - return try await client.listLinearConnections( - profileId: profileId, - organizationId: organizationId - ) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func seedLinearLocalAccess(profileId: String) async -> LinearLocalAccessSeedResponse? { - do { - return try await client.seedLinearLocalAccess(profileId: profileId) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func inspectLinearStatus(profileId: String? = nil) async -> LinearStatusResponse? { - do { - return try await client.inspectLinearStatus(profileId: profileId) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func disconnectLinear(profileId: String) async -> Bool { - do { - try await client.disconnectLinear(profileId: profileId) - return true - } catch { - errorMessage = error.localizedDescription - return false - } - } - - public func listLinearProjects(profileId: String? = nil) async -> LinearProjectsResponse? { - do { - return try await client.listLinearProjects(profileId: profileId) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func listLinearAssigneeMappings( - profileId: String? = nil, - teamId: String? = nil - ) async -> LinearAssigneeMappingsResponse? { - do { - return try await client.listLinearAssigneeMappings( - profileId: profileId, - teamId: teamId - ) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func setLinearAssigneeMapping( - profileId: String? = nil, - teamId: String? = nil, - localAssignee: String, - linearUserId: String? = nil, - linearUserName: String? = nil, - linearUserEmail: String? = nil - ) async -> LinearAssigneeMappingMutationResponse? { - return await runActionResult(message: "Saving Linear assignee mapping…") { - try await self.client.setLinearAssigneeMapping( - profileId: profileId, - teamId: teamId, - localAssignee: localAssignee, - linearUserId: linearUserId, - linearUserName: linearUserName, - linearUserEmail: linearUserEmail - ) - } - } - - public func removeLinearAssigneeMapping( - profileId: String? = nil, - teamId: String? = nil, - localAssignee: String - ) async -> LinearAssigneeMappingRemovalResponse? { - return await runActionResult(message: "Removing Linear assignee mapping…") { - try await self.client.removeLinearAssigneeMapping( - profileId: profileId, - teamId: teamId, - localAssignee: localAssignee - ) - } - } - - public func listLinearAutosyncSubscriptions( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil - ) async -> LinearAutosyncSubscriptionsResponse? { - do { - return try await client.listLinearAutosyncSubscriptions( - profileId: profileId, - projectId: projectId, - teamId: teamId - ) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func setLinearAutosyncSubscription( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - mode: String = "auto_apply", - status: String = "active" - ) async -> LinearAutosyncSubscriptionMutationResponse? { - return await runActionResult(message: "Saving Linear autosync…") { - try await self.client.setLinearAutosyncSubscription( - profileId: profileId, - projectId: projectId, - teamId: teamId, - mode: mode, - status: status - ) - } - } - - public func removeLinearAutosyncSubscription( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil - ) async -> LinearAutosyncSubscriptionMutationResponse? { - return await runActionResult(message: "Removing Linear autosync…") { - try await self.client.removeLinearAutosyncSubscription( - profileId: profileId, - projectId: projectId, - teamId: teamId - ) - } - } - - public func bindLinearProject( - for project: ProjectSummary, - profileId: String?, - projectId: String?, - projectName: String?, - teamId: String?, - clear: Bool - ) async -> LinearProjectBindingResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult(message: clear ? "Clearing Linear project binding…" : "Saving Linear project binding…") { - try await self.client.bindLinearProject( - path: path, - profileId: profileId, - projectId: projectId, - projectName: projectName, - teamId: teamId, - clear: clear - ) - } - } - - public func inspectLinearProjectBinding( - for project: ProjectSummary - ) async -> LinearProjectBindingResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - do { - return try await client.inspectLinearProjectBinding(path: path) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func linkLinearProject( - for project: ProjectSummary, - profileId: String?, - projectId: String, - projectName: String?, - teamId: String? - ) async -> LinearProjectBindingResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Adding Linear project to sync scope…", - refreshDashboard: false - ) { - try await self.client.linkLinearProject( - path: path, - profileId: profileId, - projectId: projectId, - projectName: projectName, - teamId: teamId - ) - } - } - - public func unlinkLinearProject( - for project: ProjectSummary, - projectId: String - ) async -> LinearProjectBindingResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Removing Linear project from sync scope…", - refreshDashboard: false - ) { - try await self.client.unlinkLinearProject(path: path, projectId: projectId) - } - } - - public func syncLinearProject( - for project: ProjectSummary, - from direction: String, - profileId: String? = nil, - ownerMode: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - limit: Int? = nil, - syncLabels: Bool? = nil - ) async -> LinearProjectSyncResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - let message = direction == "linear" - ? "Syncing Linear issues into tickets…" - : "Syncing tickets into Linear…" - return await runActionResult(message: message, refreshDashboard: false) { - try await self.client.syncLinearProject( - path: path, - from: direction, - profileId: profileId, - ownerMode: ownerMode, - projectId: projectId, - teamId: teamId, - limit: limit, - syncLabels: syncLabels - ) - } - } - - public func runLinearAutosync( - for project: ProjectSummary, - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - limit: Int? = nil - ) async -> LinearAutosyncRunResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - return await runActionResult( - message: "Running Linear autosync…", - refreshDashboard: false - ) { - try await self.client.runLinearAutosync( - path: path, - profileId: profileId, - projectId: projectId, - teamId: teamId, - limit: limit - ) - } - } - - public func syncLinearIssue( - for project: ProjectSummary, - from direction: String, - profileId: String? = nil, - issueIdentifier: String? = nil, - ticketId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - syncLabels: Bool? = nil - ) async -> LinearIssueSyncResponse? { - guard let path = resolveProjectPath(project) else { - errorMessage = "Missing project path for \(project.name)" - return nil - } - let message = direction == "linear" - ? "Refreshing ticket from Linear…" - : "Syncing ticket to Linear…" - return await runActionResult(message: message, refreshDashboard: false) { - try await self.client.syncLinearIssue( - path: path, - from: direction, - profileId: profileId, - issueIdentifier: issueIdentifier, - ticketId: ticketId, - projectId: projectId, - teamId: teamId, - syncLabels: syncLabels - ) - } - } - - public func startLinearOAuthFlow( - profileId: String, - setDefault: Bool - ) async -> LinearOAuthFlowStartResponse? { - do { - return try await client.startLinearOAuthFlow( - profileId: profileId, - setDefault: setDefault - ) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func fetchLinearOAuthFlowStatus( - statusURL: String - ) async -> LinearOAuthFlowStatusResponse? { - do { - return try await client.fetchLinearOAuthFlowStatus(statusURL: statusURL) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func bootstrapRailwayNode( - request: RailwayBootstrapRequest - ) async -> RailwayBootstrapResponse? { - await runActionResult(message: "Bootstrapping Railway node…") { - try await self.client.bootstrapRailwayNode(request: request) - } - } - - public func listNodes() async -> NodeRegistryListResponse? { - do { - return try await client.listNodes() - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func probeNodes(nodeId: String? = nil) async -> NodeStatusResponse? { - do { - return try await client.probeNodes(nodeId: nodeId) - } catch { - errorMessage = error.localizedDescription - return nil - } - } - - public func useNode(id: String) async -> Bool { - let response: NodeUseResponse? = await runActionResult(message: "Setting default node…") { - try await self.client.useNode(id: id) - } - return response?.defaultNodeId == id - } - - public func removeNode(id: String) async -> Bool { - let response: NodeRemoveResponse? = await runActionResult(message: "Removing node…") { - try await self.client.removeNode(id: id) - } - return response?.removed == true - } - - public func cancelNodePairSession(sessionId: String) async -> Bool { - let response: NodePairCancelResponse? = await runActionResult(message: "Cancelling pairing session…") { - try await self.client.cancelNodePairSession(sessionId: sessionId) - } - return response?.cancelled == true - } - - public func listNodePairSessions(status: String = "pending") async -> [NodePairingSession] { - do { - return try await client.listNodePairSessions(status: status).sessions - } catch { - errorMessage = error.localizedDescription - return [] - } - } - - public func fulfillNodePairSession( - sessionId: String, - code: String, - defaultNode: Bool, - sshPort: Int? - ) async -> NodePairFulfillResponse? { - await runActionResult(message: "Approving pairing request…") { - try await self.client.fulfillNodePairSession( - sessionId: sessionId, - code: code, - defaultNode: defaultNode, - sshPort: sshPort - ) - } - } - public func toggleGlobalInfrastructure() async { if globalInfraRunning { await globalDown() @@ -1231,219 +348,6 @@ public final class DashboardModel { } } - @discardableResult - public func ingestHackAuthDeepLink(url: URL) -> Bool { - guard isHackAuthCompletionDeepLink(url: url) else { - return false - } - errorMessage = nil - statusMessage = "Hack auth callback received. Finalizing…" - Task { @MainActor [weak self] in - guard let self else { return } - for attempt in 0..<6 { - await self.refreshHackAccountState(force: true, updateErrorMessage: false) - if let state = self.hackAccountState, - state.authenticated || state.tokenStored - { - self.statusMessage = state.authenticated - ? "Hack account connected." - : "Hack auth session stored locally." - return - } - if attempt < 5 { - try? await Task.sleep(for: .seconds(1)) - } - } - } - return true - } - - @discardableResult - public func ingestGitHubOAuthDeepLink(url: URL) -> Bool { - guard let context = parseGitHubOAuthDeepLink(url: url) else { - return false - } - githubOAuthDeepLinkContext = context - if let profileId = context.profileId { - statusMessage = "GitHub callback received for profile \(profileId). Finalizing…" - } else { - statusMessage = "GitHub callback received. Finalizing…" - } - return true - } - - public func clearGitHubOAuthDeepLink(flowId: String? = nil) { - guard let current = githubOAuthDeepLinkContext else { - return - } - if let flowId, current.flowId != flowId { - return - } - githubOAuthDeepLinkContext = nil - } - - @discardableResult - public func ingestLinearOAuthDeepLink(url: URL) -> Bool { - guard let context = parseLinearOAuthDeepLink(url: url) else { - return false - } - linearOAuthDeepLinkContext = context - if let profileId = context.profileId { - statusMessage = "Linear callback received for profile \(profileId). Finalizing…" - } else { - statusMessage = "Linear callback received. Finalizing…" - } - return true - } - - public func clearLinearOAuthDeepLink(flowId: String? = nil) { - guard let current = linearOAuthDeepLinkContext else { - return - } - if let flowId, current.flowId != flowId { - return - } - linearOAuthDeepLinkContext = nil - } - - private func isHackAuthCompletionDeepLink(url: URL) -> Bool { - guard isRegisteredHackDeepLinkScheme(url.scheme) else { - return false - } - - let host = url.host?.lowercased() ?? "" - let path = normalizedPath(url.path) - return (host == "auth" && path == "/complete") - || (host == "complete" && path == "/") - || (host == "complete" && path.isEmpty) - } - - private func parseGitHubOAuthDeepLink(url: URL) -> GitHubOAuthDeepLinkContext? { - guard isRegisteredHackDeepLinkScheme(url.scheme) else { - return nil - } - - let host = url.host?.lowercased() ?? "" - let path = normalizedPath(url.path) - let isGitHubCallbackRoute = - (host == "auth" && path == "/github/callback") - || (host == "github" && path == "/callback") - || (host == "auth" && path == "/callback") - guard isGitHubCallbackRoute else { - return nil - } - - guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let items = components.queryItems - else { - return nil - } - - let flowId = - normalizedQueryValue(named: "flowId", items: items) - ?? normalizedQueryValue(named: "flow_id", items: items) - guard let flowId else { - return nil - } - - return GitHubOAuthDeepLinkContext( - flowId: flowId, - profileId: normalizedQueryValue(named: "profileId", items: items) - ?? normalizedQueryValue(named: "profile", items: items), - status: normalizedQueryValue(named: "status", items: items), - installationId: normalizedQueryValue(named: "installationId", items: items) - ?? normalizedQueryValue(named: "installation_id", items: items) - ) - } - - private func parseLinearOAuthDeepLink(url: URL) -> LinearOAuthDeepLinkContext? { - guard isRegisteredHackDeepLinkScheme(url.scheme) else { - return nil - } - - let host = url.host?.lowercased() ?? "" - let path = normalizedPath(url.path) - let isLinearCallbackRoute = - (host == "auth" && path == "/linear/callback") - || (host == "linear" && path == "/callback") - guard isLinearCallbackRoute else { - return nil - } - - guard - let components = URLComponents(url: url, resolvingAgainstBaseURL: false), - let items = components.queryItems - else { - return nil - } - - let flowId = - normalizedQueryValue(named: "flowId", items: items) - ?? normalizedQueryValue(named: "flow_id", items: items) - guard let flowId else { - return nil - } - - return LinearOAuthDeepLinkContext( - flowId: flowId, - profileId: normalizedQueryValue(named: "profileId", items: items) - ?? normalizedQueryValue(named: "profile", items: items), - status: normalizedQueryValue(named: "status", items: items) - ) - } - - private func isRegisteredHackDeepLinkScheme(_ scheme: String?) -> Bool { - guard - let normalizedScheme = scheme? - .trimmingCharacters(in: .whitespacesAndNewlines) - .lowercased(), - !normalizedScheme.isEmpty - else { - return false - } - return registeredHackDeepLinkSchemes.contains(normalizedScheme) - } - - private var registeredHackDeepLinkSchemes: Set { - if - let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") - as? [[String: Any]] - { - let schemes = urlTypes - .flatMap { $0["CFBundleURLSchemes"] as? [String] ?? [] } - .compactMap { - let trimmed = $0.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - return trimmed.isEmpty ? nil : trimmed - } - if !schemes.isEmpty { - return Set(schemes) - } - } - return ["hack", "hack-dev"] - } - - private func normalizedPath(_ path: String) -> String { - let trimmed = path.trimmingCharacters(in: .whitespacesAndNewlines) - if trimmed.hasSuffix("/") && trimmed.count > 1 { - return String(trimmed.dropLast()) - } - return trimmed - } - - private func normalizedQueryValue( - named name: String, - items: [URLQueryItem] - ) -> String? { - guard - let raw = items.first(where: { $0.name == name })?.value? - .trimmingCharacters(in: .whitespacesAndNewlines), - !raw.isEmpty - else { - return nil - } - return raw - } public func globalUp() async { globalLifecycleAction = .starting diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift index 1c583994..39371a25 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -12,7 +12,7 @@ public struct DashboardView: View { @State private var showCommandPalette = false @State private var showTerminalDrawer = false @State private var showSettingsOverlay = false - @State private var selectedSettingsItem: SettingsSidebarItem = .account + @State private var selectedSettingsItem: SettingsSidebarItem = .runtime @State private var terminalDrawerHeight: CGFloat = 360 @State private var terminalDrawerInitialHeight: CGFloat? = nil @State private var terminalDrawerModel = TerminalDrawerModel(globalShellProject: Self.makeGlobalShellProject()) @@ -129,7 +129,6 @@ public struct DashboardView: View { .tuneWindowToolbar() .task { model.start() - await model.refreshHackAccountState(force: false, updateErrorMessage: false) } .onReceive(NotificationCenter.default.publisher(for: .hackCommandPaletteRequested)) { _ in showCommandPalette = true @@ -182,12 +181,6 @@ public struct DashboardView: View { openSettings(.runtime) } } - .onChange(of: showSettingsOverlay) { _, isVisible in - guard isVisible else { return } - Task { - await model.refreshHackAccountState(force: false, updateErrorMessage: false) - } - } .sheet(isPresented: $showCommandPalette) { CommandPaletteView() .environment(model) @@ -301,7 +294,7 @@ public struct DashboardView: View { .help("Dismiss") } - Text("Hackd is running, but Caddy/logging/gateway are not fully healthy. Restart global infra to recover local DNS/TLS routing.") + Text("Hackd is running, but local routing services are not fully healthy. Restart global infra to recover local DNS/TLS routing.") .font(.mono(.caption)) .foregroundStyle(.secondary) @@ -514,12 +507,6 @@ public struct DashboardView: View { subtitle: "Open Settings to view runtime health, daemon controls, and global services.", pane: .runtime ) - case .gateway: - settingsRedirectView( - title: "Gateway moved to Settings", - subtitle: "Open Settings to manage gateway status, exposures, and gateway configuration.", - pane: .gateway - ) case let .project(id): if let project = model.projects.first(where: { $0.id == id }) { ProjectDetailView(project: project) @@ -560,93 +547,47 @@ public struct DashboardView: View { private var toolbarAccountMenu: some View { Menu { - if let state = model.hackAccountState { - if let title = toolbarAccountPrimaryLabel { - Text(title) - } - if let subtitle = toolbarAccountSecondaryLabel { - Text(subtitle) - } - if toolbarAccountPrimaryLabel != nil || toolbarAccountSecondaryLabel != nil { - Divider() - } - Button { - openSettings(.account) - } label: { - Label("Account settings", systemImage: "person.crop.circle") - } - if let accountURL = state.accountURL, let url = URL(string: accountURL) { - Button { - NSWorkspace.shared.open(url) - } label: { - Label("Manage account in browser", systemImage: "globe") - } - } - Divider() - if state.authenticated { - Button { - Task { - _ = await model.logoutHackAccount() - } - } label: { - Label("Sign out", systemImage: "rectangle.portrait.and.arrow.right") - } - } else { - Button { - Task { - _ = await model.loginHackAccount() - } - } label: { - Label("Sign in to Hack", systemImage: "person.badge.key") - } - } - Divider() - } else if model.isLoadingHackAccountState { - Text("Loading account…") - } else { - Button { - Task { - _ = await model.loginHackAccount() - } - } label: { - Label("Sign in to Hack", systemImage: "person.badge.key") - } - Divider() - } - Button { openSettings(.runtime) } label: { Label("Open runtime settings", systemImage: "gearshape") } + Button { + openSettings(.permissions) + } label: { + Label("Open trust guidance", systemImage: "checkmark.shield") + } + Button { guard !globalToggleIsBusy else { return } Task { await model.toggleGlobalInfrastructure() } } label: { Label(globalToggleMenuTitle, systemImage: globalToggleIcon) } + + Divider() + + Button { + Task { await model.refresh() } + } label: { + Label("Refresh status", systemImage: "arrow.triangle.2.circlepath") + } } label: { toolbarAccountLabel } .menuStyle(.borderlessButton) .menuIndicator(.hidden) - .help(model.hackAccountState?.authenticated == true ? "Hack account" : "Hack account and app actions") + .help("App actions") } private var toolbarAccountLabel: some View { ZStack { Circle() .fill(toolbarAvatarBackgroundColor) - if let initials = toolbarAccountInitials { - Text(initials) - .font(.system(size: 11, weight: .semibold, design: .rounded)) - .foregroundStyle(toolbarAvatarForegroundColor) - } else { - Image(systemName: "person.crop.circle.fill") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(toolbarAvatarForegroundColor) - } + Image(systemName: "gearshape.fill") + .font(.system(size: 13, weight: .semibold)) + .foregroundStyle(toolbarAvatarForegroundColor) } .frame(width: 28, height: 28) .overlay( @@ -654,53 +595,15 @@ public struct DashboardView: View { .stroke(Color.primary.opacity(0.10), lineWidth: 1) ) .contentShape(Circle()) - .accessibilityLabel("Hack account") - } - - private var toolbarAccountPrimaryLabel: String? { - if let name = model.hackAccountState?.userDisplayName, !name.isEmpty { - return name - } - if let email = model.hackAccountState?.userEmail, !email.isEmpty { - return email - } - return model.hackAccountState?.authenticated == true ? "Hack account" : nil - } - - private var toolbarAccountSecondaryLabel: String? { - if let organization = model.hackAccountState?.organizationName, !organization.isEmpty { - if let team = model.hackAccountState?.teamName, !team.isEmpty { - return "\(organization) • \(team)" - } - return organization - } - if model.hackAccountState?.authenticated == false { - return "Signed out" - } - return nil - } - - private var toolbarAccountInitials: String? { - guard model.hackAccountState?.authenticated == true else { - return nil - } - let source = model.hackAccountState?.userDisplayName ?? model.hackAccountState?.userEmail ?? "" - let tokens = source - .split(whereSeparator: { $0 == " " || $0 == "." || $0 == "@" || $0 == "_" || $0 == "-" }) - .prefix(2) - let initials = tokens.compactMap { $0.first.map(String.init) }.joined().uppercased() - return initials.isEmpty ? nil : initials + .accessibilityLabel("App actions") } private var toolbarAvatarBackgroundColor: Color { - guard model.hackAccountState?.authenticated == true else { - return Color.primary.opacity(0.08) - } - return Color.accentColor.opacity(colorScheme == .dark ? 0.30 : 0.18) + Color.primary.opacity(0.08) } private var toolbarAvatarForegroundColor: Color { - model.hackAccountState?.authenticated == true ? .accentColor : .secondary + .secondary } private var globalToggleMenuTitle: String { @@ -761,63 +664,6 @@ public struct DashboardView: View { daemonIsRunning || daemonIsStarting } - @ViewBuilder - private var runtimeContextMenu: some View { - if canStopDaemon { - Button { - Task { await model.stopDaemon() } - } label: { - Label("Stop hackd", systemImage: "stop.fill") - } - } else { - Button { - Task { await model.startDaemon() } - } label: { - Label("Start hackd", systemImage: "play.fill") - } - } - - Button { - Task { await model.restartDaemon() } - } label: { - Label("Restart hackd", systemImage: "arrow.clockwise") - } - .disabled(!daemonIsRunning) - - Divider() - - Button { - Task { await model.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.triangle.2.circlepath") - } - } - - @ViewBuilder - private var gatewayContextMenu: some View { - Button { - Task { await model.refresh() } - } label: { - Label("Refresh", systemImage: "arrow.triangle.2.circlepath") - } - - if let configPath = gatewayConfigPath { - Divider() - - Button { - NSWorkspace.shared.selectFile(configPath, inFileViewerRootedAtPath: "") - } label: { - Label("Show Config in Finder", systemImage: "folder") - } - } - } - - private var gatewayConfigPath: String? { - let home = FileManager.default.homeDirectoryForCurrentUser - let configPath = home.appendingPathComponent(".hack/gateway.yml").path - return FileManager.default.fileExists(atPath: configPath) ? configPath : nil - } - @ViewBuilder private func projectContextMenu(for project: ProjectSummary) -> some View { let isRunning = project.runtimeStatus == .running || project.status == .running @@ -850,14 +696,6 @@ public struct DashboardView: View { Label("Open Shell", systemImage: "terminal") } - if project.supportsTickets { - Button { - model.showTickets(for: project) - } label: { - Label("Open Tickets", systemImage: "ticket") - } - } - if let devHost = project.devHost, let url = URL(string: "https://\(devHost)") { Divider() diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift deleted file mode 100644 index 8b71b080..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift +++ /dev/null @@ -1,597 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -struct GatewayDetailView: View { - @Environment(DashboardModel.self) private var model - @Environment(\.openURL) private var openURL - @AppStorage("hackDesktop.setupGuidance.gateway.dismissed") private var setupDismissed = false - @State private var showSetupAssistant = false - @State private var gatewayTokens: [GatewayTokenRecord] = [] - @State private var isLoadingTokens = false - @State private var latestIssuedToken: String? = nil - @State private var issuingTokenScope: GatewayTokenScope = .read - @State private var newTokenLabel = "" - - var body: some View { - NavigationStack { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - header - if shouldShowSetupGuidance { - setupNudge - } - overviewCard - exposuresCard - gatewayDiagnosticsCard - tokensCard - warningsCard - } - .padding(16) - } - .navigationDestination(for: GatewayExposure.self) { exposure in - GatewayExposureDetailView(exposure: exposure) - } - .sheet(isPresented: $showSetupAssistant) { - SetupAssistantView(initialSection: .gateway) - .environment(model) - } - .task { - await refreshGatewayTokens() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { - await refreshGatewayTokens() - } - } - } - } - - private var shouldShowSetupGuidance: Bool { - if ProcessInfo.processInfo.environment["HACK_DESKTOP_FORCE_SETUP_GUIDANCE"] == "1" { return true } - if setupDismissed { return false } - - // If gateway/global status isn't available yet (fresh machine), show quick-start guidance here too. - if model.globalStatus == nil { return true } - if model.gatewaySummaryState == nil { return true } - return false - } - - private var setupNudge: some View { - SetupNudgeCard( - title: "Gateway setup", - subtitle: "On a fresh machine, install global services and run the gateway setup once.", - primaryActionLabel: "Setup…", - onPrimaryAction: { showSetupAssistant = true }, - onDismiss: { setupDismissed = true } - ) - .opacity(setupDismissed ? 0 : 1) - .animation(.easeInOut(duration: 0.15), value: setupDismissed) - .allowsHitTesting(!setupDismissed) - } - - private var header: some View { - SectionHeader( - breadcrumb: "System / Gateway", - title: "Gateway", - subtitle: "Remote gateway configuration and exposures", - status: { StatusPill(text: gatewayStatusText, tone: gatewayStatusTone) }, - actions: { - Menu { - Button("Refresh") { - Task { await model.refresh() } - } - Button("Setup…") { - showSetupAssistant = true - } - if let configUrl { - Button("Open Config") { - openURL(configUrl) - } - } - } label: { - Image(systemName: "ellipsis.circle") - } - .buttonStyle(.plain) - } - ) - } - - private var overviewCard: some View { - GlassCard(title: "Overview", systemImage: "network") { - DetailRows(rows: overviewRows) - } - } - - private var exposuresCard: some View { - GlassCard(title: "Exposures", systemImage: "point.3.filled.connected.trianglepath.dotted") { - lanCalloutSection - if exposures.isEmpty { - Text("No gateway exposures configured") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 12) { - ForEach(Array(exposures.enumerated()), id: \.element.id) { index, exposure in - if let destination = settingsItem(for: exposure) { - Button { - openSettings(destination) - } label: { - exposureRow(exposure: exposure, showChevron: true) - } - .buttonStyle(.plain) - } else { - NavigationLink(value: exposure) { - exposureRow(exposure: exposure, showChevron: true) - } - .buttonStyle(.plain) - } - if index < exposures.count - 1 { - Divider() - } - } - } - } - } - } - - private var gatewayDiagnosticsCard: some View { - GlassCard(title: "Gateway diagnostics", systemImage: "text.alignleft") { - Text("Tail ingress flow and supporting gateway logs in terminal tabs.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - HStack(spacing: 10) { - Button { - openGlobalCommandInTerminalPanel( - command: "hack global logs caddy --tail 200 --follow", - title: "gateway ingress logs" - ) - } label: { - Label("Ingress logs", systemImage: "arrow.left.arrow.right") - } - .adaptiveToolbarButtonProminent() - - Button { - openGlobalCommandInTerminalPanel( - command: "docker compose -f \"$HOME/.hack/caddy/docker-compose.yml\" logs --tail 200 -f coredns", - title: "gateway dns logs" - ) - } label: { - Label("DNS logs", systemImage: "network") - } - .adaptiveToolbarButton() - - Button { - openGlobalCommandInTerminalPanel( - command: "tail -n 200 -F \"$HOME/.hack/daemon/hackd.log\" | grep --line-buffered -Ei \"gateway|token|remote|request|write|exposure\"", - title: "gateway daemon events" - ) - } label: { - Label("Daemon gateway events", systemImage: "waveform.path.ecg") - } - .adaptiveToolbarButton() - - Button { - openGlobalCommandInTerminalPanel( - command: "docker network inspect hack-dev hack-logging 2>/dev/null || docker network ls", - title: "gateway network diagnostics" - ) - } label: { - Label("Network diagnostics", systemImage: "point.3.filled.connected.trianglepath.dotted") - } - .adaptiveToolbarButton() - - Spacer() - } - } - } - - @ViewBuilder - private var lanCalloutSection: some View { - if let lan = exposures.first(where: { $0.id == "lan" }), - lan.resolvedState == .blocked, - (lan.detail ?? "").lowercased().contains("loopback") { - InlineCallout( - tone: .neutral, - title: "LAN access is local-only (127.0.0.1)", - message: "This is the default. The gateway is reachable from this machine only. If you want other devices on your LAN to reach it, bind to 0.0.0.0 and restart hackd.", - actions: [ - InlineCalloutAction(label: "Copy command", systemImage: "doc.on.doc") { - TerminalIntegration.copyToClipboard(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - }, - InlineCalloutAction(label: "Enable LAN access", systemImage: "terminal") { - TerminalIntegration.openTerminalWithCommand(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - } - ] - ) - Divider() - .opacity(0.35) - } - } - - private var tokensCard: some View { - GlassCard(title: "Tokens", systemImage: "key") { - VStack(alignment: .leading, spacing: 12) { - if let latestIssuedToken { - InlineCallout( - tone: .good, - title: "New token issued", - message: "Store this token now. It will not be shown again.", - actions: [ - InlineCalloutAction(label: "Copy token", systemImage: "doc.on.doc") { - TerminalIntegration.copyToClipboard(latestIssuedToken) - } - ] - ) - } - - HStack(spacing: 10) { - Picker("Scope", selection: $issuingTokenScope) { - Text("Read").tag(GatewayTokenScope.read) - Text("Write").tag(GatewayTokenScope.write) - } - .pickerStyle(.segmented) - .frame(width: 180) - - TextField("Optional label", text: $newTokenLabel) - .textFieldStyle(.roundedBorder) - .font(.mono(.caption)) - - Button { - Task { await createGatewayToken() } - } label: { - Label("Create token", systemImage: "plus") - } - .adaptiveToolbarButtonProminent() - - Button { - Task { await refreshGatewayTokens() } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - - Spacer() - } - - if isLoadingTokens { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text("Loading tokens…") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - - tokenSummaryRow - - if sortedTokens.isEmpty { - Text("No tokens found.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - tokenTableHeader - ForEach(sortedTokens) { token in - tokenTableRow(token) - Divider() - .opacity(0.18) - } - } - } - } - } - - private var warningsCard: some View { - let warnings = gatewayWarnings - return Group { - if warnings.isEmpty { - EmptyView() - } else { - GlassCard(title: "Warnings", systemImage: "exclamationmark.triangle") { - VStack(alignment: .leading, spacing: 4) { - ForEach(warnings, id: \.self) { warning in - Text("• \(warning)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - } - } - } - } - - private var gateway: GatewayStatus? { - model.globalStatus?.gateway - } - - private var exposures: [GatewayExposure] { - model.gatewayExposures - } - - private var gatewaySummaryState: GatewaySummaryState? { - model.gatewaySummaryState - } - - private var gatewayStatusText: String { - gatewaySummaryState?.label ?? "Unknown" - } - - private var gatewayStatusTone: StatusTone { - gatewaySummaryState?.tone ?? .neutral - } - - private var overviewRows: [DetailRowItem] { - let configured = gateway?.gatewayEnabled ?? model.globalStatus?.summary.gatewayEnabled - return [ - DetailRowItem(label: "Status", value: gatewaySummaryState?.label ?? "—"), - DetailRowItem(label: "Configured", value: yesNo(configured)), - DetailRowItem(label: "Gateway URL", value: gateway?.gatewayUrl ?? "—"), - DetailRowItem(label: "Bind", value: gateway?.gatewayBind ?? "—"), - DetailRowItem(label: "Port", value: gateway?.gatewayPort.map(String.init) ?? "—"), - DetailRowItem(label: "Allow writes", value: yesNo(gateway?.allowWrites)), - DetailRowItem(label: "Projects enabled", value: gateway?.gatewayProjectsEnabled.map(String.init) ?? "—"), - DetailRowItem(label: "Projects", value: gateway?.gatewayProjects ?? "—"), - DetailRowItem(label: "Config path", value: gateway?.configPath ?? "—") - ] - } - - private var sortedTokens: [GatewayTokenRecord] { - gatewayTokens.sorted { lhs, rhs in - if lhs.revokedAt == nil, rhs.revokedAt != nil { - return true - } - if lhs.revokedAt != nil, rhs.revokedAt == nil { - return false - } - return lhs.createdAt > rhs.createdAt - } - } - - private var gatewayWarnings: [String] { - gateway?.warnings ?? [] - } - - private var configUrl: URL? { - guard let path = gateway?.configPath, !path.isEmpty else { return nil } - return URL(fileURLWithPath: path) - } - - private func yesNo(_ value: Bool?) -> String { - guard let value else { return "—" } - return value ? "Yes" : "No" - } - - private func exposureIcon(_ exposure: GatewayExposure) -> String { - switch exposure.id { - case "lan": - return "wifi" - case "tailscale": - return "link" - case "cloudflare": - return "cloud" - default: - return "network" - } - } - - private func exposureRow(exposure: GatewayExposure, showChevron: Bool) -> some View { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Label(exposure.label, systemImage: exposureIcon(exposure)) - .font(.mono(.subheadline, weight: .medium)) - Spacer() - if let dependencyLabel = exposure.dependencyStatusLabel, - let dependencyColor = exposure.dependencyStatusColor { - BadgePill(label: dependencyLabel, tint: dependencyColor) - } - StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) - if showChevron { - Image(systemName: "chevron.right") - .font(.mono(.caption)) - .foregroundStyle(.tertiary) - } - } - if let detail = exposure.detail, !detail.isEmpty { - Text(detail) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if let url = exposure.url, !url.isEmpty { - Text(url) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - .padding(.vertical, 4) - } - - @ViewBuilder - private var tokenSummaryRow: some View { - HStack(spacing: 8) { - tokenSummaryBadge(label: "Active", value: gateway?.tokensActive ?? activeTokenCount) - tokenSummaryBadge(label: "Read", value: gateway?.tokensRead ?? readTokenCount) - tokenSummaryBadge(label: "Write", value: gateway?.tokensWrite ?? writeTokenCount) - tokenSummaryBadge(label: "Revoked", value: gateway?.tokensRevoked ?? revokedTokenCount) - Spacer() - } - } - - private var tokenTableHeader: some View { - HStack(spacing: 10) { - Text("ID") - .frame(minWidth: 160, alignment: .leading) - Text("Scope") - .frame(width: 54, alignment: .leading) - Text("Label") - .frame(minWidth: 120, alignment: .leading) - Text("Created") - .frame(width: 160, alignment: .leading) - Text("Last used") - .frame(width: 120, alignment: .leading) - Text("State") - .frame(width: 76, alignment: .leading) - Spacer(minLength: 0) - Text("Actions") - .frame(width: 128, alignment: .trailing) - } - .font(.mono(.caption2, weight: .semibold)) - .foregroundStyle(.secondary) - } - - private func tokenTableRow(_ token: GatewayTokenRecord) -> some View { - HStack(spacing: 10) { - Text(token.id) - .font(.mono(.caption2)) - .lineLimit(1) - .truncationMode(.middle) - .frame(minWidth: 160, alignment: .leading) - Text(token.scope.rawValue) - .font(.mono(.caption2)) - .frame(width: 54, alignment: .leading) - Text(token.label ?? "—") - .font(.mono(.caption2)) - .lineLimit(1) - .truncationMode(.tail) - .frame(minWidth: 120, alignment: .leading) - Text(formatTimestamp(token.createdAt)) - .font(.mono(.caption2)) - .frame(width: 160, alignment: .leading) - Text(formatRelativeTimestamp(token.lastUsedAt)) - .font(.mono(.caption2)) - .frame(width: 120, alignment: .leading) - Text(token.revokedAt == nil ? "Active" : "Revoked") - .font(.mono(.caption2)) - .foregroundStyle(token.revokedAt == nil ? Color.green : Color.orange) - .frame(width: 76, alignment: .leading) - Spacer(minLength: 0) - HStack(spacing: 8) { - Button("Copy ID") { - TerminalIntegration.copyToClipboard(token.id) - } - .adaptiveToolbarButton() - if token.revokedAt == nil { - Button("Revoke") { - Task { await revokeToken(token) } - } - .adaptiveToolbarButton() - } - } - .frame(width: 128, alignment: .trailing) - } - } - - private func tokenSummaryBadge(label: String, value: Int) -> some View { - HStack(spacing: 6) { - Text(label) - .font(.mono(.caption2)) - Text(String(value)) - .font(.mono(.caption2, weight: .semibold)) - } - .padding(.horizontal, 10) - .padding(.vertical, 5) - .background( - Capsule(style: .continuous) - .fill(.thinMaterial) - ) - .overlay( - Capsule(style: .continuous) - .stroke(Color.primary.opacity(0.14), lineWidth: 1) - ) - } - - private var activeTokenCount: Int { - gatewayTokens.filter { $0.revokedAt == nil }.count - } - - private var revokedTokenCount: Int { - gatewayTokens.filter { $0.revokedAt != nil }.count - } - - private var readTokenCount: Int { - gatewayTokens.filter { $0.revokedAt == nil && $0.scope == .read }.count - } - - private var writeTokenCount: Int { - gatewayTokens.filter { $0.revokedAt == nil && $0.scope == .write }.count - } - - private func refreshGatewayTokens() async { - isLoadingTokens = true - defer { isLoadingTokens = false } - gatewayTokens = await model.fetchGatewayTokens() - } - - private func createGatewayToken() async { - let trimmedLabel = newTokenLabel.trimmingCharacters(in: .whitespacesAndNewlines) - guard let issued = await model.createGatewayToken( - scope: issuingTokenScope, - label: trimmedLabel.isEmpty ? nil : trimmedLabel - ) else { - return - } - newTokenLabel = "" - latestIssuedToken = issued.token - await refreshGatewayTokens() - } - - private func revokeToken(_ token: GatewayTokenRecord) async { - let revoked = await model.revokeGatewayToken(id: token.id) - guard revoked else { return } - await refreshGatewayTokens() - } - - private func formatTimestamp(_ value: String?) -> String { - guard let value, let date = ISO8601DateFormatter().date(from: value) else { - return value ?? "—" - } - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - return formatter.string(from: date) - } - - private func formatRelativeTimestamp(_ value: String?) -> String { - guard let value, let date = ISO8601DateFormatter().date(from: value) else { - return "—" - } - let formatter = RelativeDateTimeFormatter() - formatter.unitsStyle = .short - return formatter.localizedString(for: date, relativeTo: Date()) - } - - private func settingsItem(for exposure: GatewayExposure) -> SettingsSidebarItem? { - switch exposure.id { - case "cloudflare": - return .cloudflare - case "tailscale": - return .tailscale - default: - return nil - } - } - - private func openSettings(_ item: SettingsSidebarItem) { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: item.rawValue] - ) - } -} - -#if DEBUG -import HackCLIService - -#Preview("Gateway (Setup Guidance)") { - let model = DashboardModel(client: HackCLIClient()) - return GatewayDetailView() - .environment(model) -} -#endif diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift deleted file mode 100644 index b639caa3..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift +++ /dev/null @@ -1,268 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -extension GatewayExposure { - var resolvedState: State { - state ?? (enabled ? .configured : .disabled) - } - - var statusLabel: String { - if isLanLoopbackLocalOnly { - return "Local only" - } - switch resolvedState { - case .running: - return "Running" - case .configured: - return "Configured" - case .needsConfig: - return "Needs setup" - case .blocked: - return "Blocked" - case .disabled: - return "Disabled" - case .unknown: - return "Unknown" - } - } - - var statusTone: StatusTone { - if isLanLoopbackLocalOnly { - return .good - } - switch resolvedState { - case .running: - return .good - case .configured, .needsConfig, .blocked: - return .warn - case .disabled, .unknown: - return .neutral - } - } - - var statusColor: Color { - if isLanLoopbackLocalOnly { - return .green - } - switch resolvedState { - case .running: - return .green - case .configured, .needsConfig, .blocked: - return .orange - case .disabled, .unknown: - return .secondary - } - } - - var dependencyStatusLabel: String? { - switch dependencyStatus { - case .builtIn: - return "Built-in" - case .installed: - guard let dependencyName else { return nil } - return "\(dependencyName) ok" - case .missing: - guard let dependencyName else { return nil } - return "\(dependencyName) missing" - case .unknown: - guard let dependencyName else { return nil } - return "\(dependencyName) unknown" - case .none: - return nil - } - } - - var dependencyStatusTone: StatusTone? { - switch dependencyStatus { - case .builtIn: - return .neutral - case .installed: - return .good - case .missing: - return .warn - case .unknown: - return .neutral - case .none: - return nil - } - } - - var dependencyStatusColor: Color? { - guard let dependencyStatusTone else { return nil } - switch dependencyStatusTone { - case .good: - return .green - case .warn: - return .orange - case .neutral: - return .secondary - } - } - - var isVisibleInSidebar: Bool { - resolvedState != .disabled - } - - private enum DependencyStatus { - case builtIn - case installed - case missing - case unknown - } - - private var dependencyStatus: DependencyStatus? { - let normalizedDetail = (detail ?? "").lowercased() - if normalizedDetail.contains("extension disabled") { - return nil - } - - switch id { - case "lan": - return .builtIn - case "tailscale": - if normalizedDetail.contains("not installed") { - return .missing - } - if normalizedDetail.contains("status failed") { - return .unknown - } - return .installed - case "cloudflare": - if normalizedDetail.contains("cloudflared not installed") { - return .missing - } - if resolvedState == .needsConfig && normalizedDetail.contains("missing hostname") { - return .unknown - } - return .installed - default: - return nil - } - } - - private var dependencyName: String? { - switch id { - case "tailscale": - return "tailscale" - case "cloudflare": - return "cloudflared" - default: - return nil - } - } - - fileprivate var isLanLoopbackLocalOnly: Bool { - id == "lan" - && resolvedState == .blocked - && (detail ?? "").lowercased().contains("loopback") - } -} - -enum GatewaySummaryState { - case localOnly - case lan - case tailscale - case cloudflare - case mixed - case needsSetup - case disabled - case down - case unknown - - static func resolve( - exposures: [GatewayExposure], - gatewayEnabled: Bool?, - globalInfraRunning: Bool? - ) -> GatewaySummaryState { - if gatewayEnabled == false { - return .disabled - } - - if globalInfraRunning == false { - return .down - } - - if exposures.isEmpty { - if gatewayEnabled == true { return .needsSetup } - return .unknown - } - - let runningExposureIds = Set( - exposures - .filter { $0.resolvedState == .running } - .map(\.id) - ) - if runningExposureIds.contains("cloudflare") { - return .cloudflare - } - if runningExposureIds.contains("tailscale") { - return .tailscale - } - if runningExposureIds.contains("lan") { - return .lan - } - if !runningExposureIds.isEmpty { - return .mixed - } - - if exposures.contains(where: \.isLanLoopbackLocalOnly) { - return .localOnly - } - - if exposures.contains(where: { [.configured, .needsConfig, .blocked].contains($0.resolvedState) }) { - return .needsSetup - } - - if exposures.allSatisfy({ $0.resolvedState == .disabled }) { - return .disabled - } - - return .unknown - } - - var label: String { - switch self { - case .localOnly: - return "Local-only" - case .lan: - return "LAN" - case .tailscale: - return "Tailscale" - case .cloudflare: - return "Cloudflare" - case .mixed: - return "Multi" - case .needsSetup: - return "Needs setup" - case .disabled: - return "Disabled" - case .down: - return "Down" - case .unknown: - return "Unknown" - } - } - - var tone: StatusTone { - switch self { - case .localOnly, .lan, .tailscale, .cloudflare, .mixed: - return .good - case .needsSetup, .disabled, .down: - return .warn - case .unknown: - return .neutral - } - } - - var statusDotColor: Color? { - switch self { - case .localOnly, .lan, .tailscale, .cloudflare, .mixed: - return .green - case .needsSetup, .disabled, .down: - return .orange - case .unknown: - return nil - } - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift deleted file mode 100644 index e5e1d3e8..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift +++ /dev/null @@ -1,123 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -struct GatewayExposureDetailView: View { - @Environment(DashboardModel.self) private var model - @Environment(\.openURL) private var openURL - - let exposure: GatewayExposure - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - header - lanLoopbackCallout - overviewCard - gatewayCard - } - .padding(24) - } - } - - @ViewBuilder - private var lanLoopbackCallout: some View { - if exposure.id == "lan", - exposure.resolvedState == .blocked, - (exposure.detail ?? "").lowercased().contains("loopback") { - InlineCallout( - tone: .neutral, - title: "LAN access is local-only (127.0.0.1)", - message: "This is the default. The gateway can still be running locally. If you want other devices on your LAN to reach the gateway, bind to 0.0.0.0 and restart hackd.", - actions: [ - InlineCalloutAction(label: "Copy command", systemImage: "doc.on.doc") { - TerminalIntegration.copyToClipboard(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - }, - InlineCalloutAction(label: "Enable LAN access", systemImage: "terminal") { - TerminalIntegration.openTerminalWithCommand(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - } - ] - ) - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 12) { - Label(exposure.label, systemImage: iconName) - .font(.mono(.title2, weight: .semibold)) - StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) - Spacer() - if let url = exposureUrl { - Button("Open URL") { - openURL(url) - } - .adaptiveToolbarButton() - } - } - if let detail = exposure.detail, !detail.isEmpty { - Text(detail) - .font(.mono(.subheadline)) - .foregroundStyle(.secondary) - } - } - } - - private var overviewCard: some View { - GlassCard(title: "Overview", systemImage: "rectangle.stack") { - DetailRows(rows: [ - DetailRowItem(label: "Status", value: exposure.statusLabel), - DetailRowItem(label: "Dependency", value: exposure.dependencyStatusLabel ?? "—"), - DetailRowItem(label: "URL", value: exposure.url ?? "—"), - DetailRowItem(label: "Detail", value: exposure.detail ?? "—") - ]) - } - } - - private var gatewayCard: some View { - let rows = gatewayRows - return Group { - if rows.isEmpty { - EmptyView() - } else { - GlassCard(title: "Gateway", systemImage: "arrow.triangle.branch") { - DetailRows(rows: rows) - } - } - } - } - - private var gatewayRows: [DetailRowItem] { - guard let gateway = model.globalStatus?.gateway else { return [] } - return [ - DetailRowItem(label: "Gateway URL", value: gateway.gatewayUrl ?? "—"), - DetailRowItem(label: "Bind", value: gateway.gatewayBind ?? "—"), - DetailRowItem(label: "Port", value: gateway.gatewayPort.map(String.init) ?? "—"), - DetailRowItem(label: "Config path", value: gateway.configPath ?? "—") - ] - } - - private var exposureUrl: URL? { - guard let url = exposure.url, !url.isEmpty else { return nil } - return URL(string: url) - } - - private var iconName: String { - switch exposure.id { - case "lan": - return "wifi" - case "tailscale": - return "link" - case "cloudflare": - return "cloud" - default: - return "network" - } - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift index 9afb9952..68329898 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlobalStatusStrip.swift @@ -82,13 +82,6 @@ struct GlobalStatusStrip: View { openTerminal(project: project, kind: .shell) } - if project.supportsTickets { - Divider() - Button("Open Tickets") { - model.selectedItem = .project(project.id) - model.selectedProjectTab = .tickets - } - } } } @@ -199,11 +192,11 @@ struct GlobalStatusStrip: View { userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.runtime.rawValue] ) } - Button("Settings: Gateway") { + Button("Settings: Trust Guidance") { NotificationCenter.default.post( name: .hackSettingsRequested, object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.gateway.rawValue] + userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.permissions.rawValue] ) } Divider() @@ -426,15 +419,13 @@ struct GlobalStatusStrip: View { case .home: HStack(spacing: 8) { StatusPill(text: runtimeLabel, tone: runtimeTone) - StatusPill(text: gatewayLabel, tone: gatewayTone) + StatusPill(text: globalLabel, tone: globalTone) } case .runtime: HStack(spacing: 8) { StatusPill(text: runtimeLabel, tone: runtimeTone) StatusPill(text: daemonLabel, tone: daemonTone) } - case .gateway: - StatusPill(text: gatewayLabel, tone: gatewayTone) case let .project(id): if let project = model.projects.first(where: { $0.id == id }) { if project.isRuntimeConfigured { @@ -557,15 +548,24 @@ struct GlobalStatusStrip: View { } } - private var gatewayLabel: String { - if let label = model.gatewaySummaryState?.label, !label.isEmpty { - return "Gateway: \(label)" + private var globalLabel: String { + if model.globalInfraRunning { + return "Global: running" } - return "Gateway: unknown" + if model.globalInfraDown { + return "Global: down" + } + return "Global: unknown" } - private var gatewayTone: StatusTone { - model.gatewaySummaryState?.tone ?? .neutral + private var globalTone: StatusTone { + if model.globalInfraRunning { + return .good + } + if model.globalInfraDown { + return .warn + } + return .neutral } private var lastUpdatedText: String? { @@ -610,8 +610,6 @@ struct GlobalStatusStrip: View { "home" case .runtime: "runtime" - case .gateway: - "gateway" case let .project(id): "project:\(id)" case .none: @@ -669,9 +667,6 @@ struct GlobalStatusStrip: View { if model.selectedItem == .home { return "Dashboard" } - if model.selectedItem == .gateway { - return "Gateway" - } return "System" } @@ -679,8 +674,6 @@ struct GlobalStatusStrip: View { switch model.selectedItem { case .home: return "square.grid.2x2" - case .gateway: - return "dot.radiowaves.left.and.right" case .runtime: return "gauge.with.dots.needle.50percent" case .project: diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift index db00155a..b72c6d53 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/HomeDashboardView.swift @@ -27,7 +27,6 @@ struct HomeDashboardView: View { HStack(spacing: 10) { HealthMetricChip(title: "Runtime", value: runtimeState.label, tone: runtimeState.tone) HealthMetricChip(title: "Daemon", value: daemonState.label, tone: daemonState.tone) - HealthMetricChip(title: "Gateway", value: gatewayState.label, tone: gatewayState.tone) HealthMetricChip(title: "Global", value: globalState.label, tone: globalState.tone) Spacer(minLength: 0) } @@ -421,20 +420,6 @@ struct HomeDashboardView: View { } } - private var gatewayState: (label: String, tone: HealthMetricChip.Tone) { - if let state = model.gatewaySummaryState { - switch state.tone { - case .good: - return (state.label, .good) - case .warn: - return (state.label, .warn) - case .neutral: - return (state.label, .neutral) - } - } - return ("Unknown", .neutral) - } - private var globalState: (label: String, tone: HealthMetricChip.Tone) { if model.globalInfraRunning { return ("Running", .good) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift index 5d20ff5a..a6f20a5e 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift @@ -52,7 +52,6 @@ public struct MenuBarView: View { @ViewBuilder private var systemStatusSection: some View { let runtimeIcon = runtimeStatusIcon - let gatewayIcon = gatewayStatusIcon let daemonIcon = daemonStatusIcon Button { @@ -62,13 +61,6 @@ public struct MenuBarView: View { } .disabled(true) - Button { - // No action - informational - } label: { - Label("Gateway: \(gatewayStatusText)", systemImage: gatewayIcon) - } - .disabled(true) - // hackd submenu Menu { if canStartDaemon { @@ -196,26 +188,6 @@ public struct MenuBarView: View { } } - private var gatewayStatusText: String { - model.gatewaySummaryState?.label ?? "Unknown" - } - - private var gatewayStatusIcon: String { - guard let state = model.gatewaySummaryState else { return "questionmark.circle" } - switch state { - case .localOnly, .lan, .tailscale, .cloudflare, .mixed: - return "checkmark.circle.fill" - case .needsSetup: - return "exclamationmark.triangle.fill" - case .disabled: - return "minus.circle" - case .down: - return "xmark.circle.fill" - case .unknown: - return "questionmark.circle" - } - } - private var daemonStatusText: String { switch model.daemonStatus?.resolvedLabel { case .running: return "Running" diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift deleted file mode 100644 index f2c48499..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/NodeTopologySettingsView.swift +++ /dev/null @@ -1,2025 +0,0 @@ -import Foundation -import SwiftUI - -import HackDesktopModels - -struct NodeTopologySettingsView: View { - private static let localTopologyNodeId = "__local_controller__" - - @Environment(DashboardModel.self) private var model - @AppStorage("hackDesktop.topology.isControllerHost") private var isControllerHost = true - @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredTerminalRaw = - TerminalIntegration.ExternalTerminalApp.terminal.rawValue - @AppStorage("hackDesktop.sessions.preferredExternalTerminal") - private var legacyPreferredTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue - @State private var registry: NodeRegistryListResponse? = nil - @State private var probeByNodeId: [String: NodeStatusProbe] = [:] - @State private var tailscale: TailscaleInspectResponse? = nil - @State private var isLoading = false - @State private var pendingRemoval: NodeRegistryRecord? = nil - @State private var pairingSessions: [NodePairingSession] = [] - @State private var pairingCodeBySessionId: [String: String] = [:] - @State private var pairingSshPortOverride = "" - @State private var pairingDefault = true - @State private var pairingBusySessionId: String? = nil - @State private var lastTopologyRefreshAt: Date? = nil - @State private var nodeMutationInFlight: String? = nil - @State private var topologyRefreshTask: Task? = nil - @State private var selectedTopologyNodeId = Self.localTopologyNodeId - @State private var topologyExpanded = false - @State private var controllerLayoutProfile = "local:default" - @State private var topologyLayoutOverrides: [String: CGPoint] = [:] - @State private var compactTopologyCanvasSize: CGSize = .zero - @State private var expandedTopologyCanvasSize: CGSize = .zero - @AppStorage("hackDesktop.topology.layoutByController.v1") - private var topologyLayoutByControllerRaw = "" - - private let topologyRefreshThrottleSeconds: TimeInterval = 30 - private let nodeStatusProbeTimeoutSeconds: TimeInterval = 10 - private let tailscaleInspectTimeoutSeconds: TimeInterval = 6 - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / System / Topology", - title: "Node + Gateway Topology", - subtitle: "Controller/node role, default routing, and authorized remote nodes" - ) - - localRoleCard - networkTopologyCard - - if isControllerHost { - pairingRequestsCard - } else { - nodeModeGuidanceCard - } - - authorizedNodesCard - trustModelCard - } - .padding(16) - } - .task { - queueTopologyRefresh(force: true) - } - .onChange(of: model.lastUpdated) { _, _ in - queueTopologyRefresh(force: false) - } - .onDisappear { - topologyRefreshTask?.cancel() - topologyRefreshTask = nil - } - .sheet(isPresented: $topologyExpanded) { - topologyExpandedSheet - } - .alert( - "Remove node?", - isPresented: Binding( - get: { pendingRemoval != nil }, - set: { isPresented in - if !isPresented { - pendingRemoval = nil - } - } - ), - presenting: pendingRemoval - ) { node in - Button("Cancel", role: .cancel) {} - Button("Remove", role: .destructive) { - Task { - beginNodeMutation(nodeId: node.id) - defer { endNodeMutation(nodeId: node.id) } - let removed = await model.removeNode(id: node.id) - if removed { - removeNodeFromLocalState(nodeId: node.id) - } - pendingRemoval = nil - await refreshTopology(force: true) - } - } - } message: { node in - Text("This removes \(node.name) from the controller registry and deletes its stored auth token reference.") - } - } - - private var localRoleCard: some View { - GlassCard(title: "Local role", systemImage: "point.3.connected.trianglepath.dotted") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: localRoleLabel, tone: localRoleTone) - if isControllerHost { - if let defaultNode { - StatusPill( - text: endpointMatchesLocalDevice(defaultNode.endpoint) - ? "This device is default node" - : "Remote default node", - tone: endpointMatchesLocalDevice(defaultNode.endpoint) ? .good : .neutral - ) - } else { - StatusPill(text: "No default node selected", tone: .warn) - } - if !pairingSessions.isEmpty { - StatusPill( - text: "\(pairingSessions.count) pending request\(pairingSessions.count == 1 ? "" : "s")", - tone: .warn - ) - } - } else { - StatusPill(text: "Controller mode disabled", tone: .neutral) - } - Spacer() - if isControllerHost { - Button { - openRailwaySettings() - } label: { - Label("Add remote node", systemImage: "plus.circle") - } - .adaptiveToolbarButton() - } - Button { - Task { await refreshTopology(force: true) } - } label: { - Label("Refresh topology", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButtonProminent() - .disabled(isLoading) - } - - Toggle("This Mac is the controller host", isOn: $isControllerHost) - .font(.mono(.subheadline)) - .onChange(of: isControllerHost) { _, isEnabled in - if !isEnabled { - pairingSessions = [] - pairingCodeBySessionId = [:] - } - queueTopologyRefresh(force: true) - } - - Text( - isControllerHost - ? "Host mode enables pairing inbox approval, default node assignment, and node registry writes from this device." - : "Node mode keeps this device as a runtime-only participant. Pairing approval happens on a host controller device." - ) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - if let defaultNode { - Text("Default node: \(defaultNode.name) (\(defaultNode.id))") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } else { - Text("Default node: none. Use “Set default” on a registered node.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - Text("Default node is the fallback execution target when dispatch/project routing does not pin a node.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - Text("Controller host and default node are separate roles; host controls trust + routing, default picks fallback execution target.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - - Text("Controller registry: \(sortedNodes.count) node\(sortedNodes.count == 1 ? "" : "s") authorized") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - - private var networkTopologyCard: some View { - GlassCard(title: "Hack network topology", systemImage: "network") { - VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 8) { - StatusPill( - text: "\(topologyGraphNodes.count) node\(topologyGraphNodes.count == 1 ? "" : "s")", - tone: topologyGraphNodes.count > 1 ? .good : .warn - ) - StatusPill( - text: "\(topologyGraphEdges.count) edge\(topologyGraphEdges.count == 1 ? "" : "s")", - tone: topologyGraphEdges.isEmpty ? .warn : .neutral - ) - if isControllerHost { - Menu { - Button("Add via Railway") { - openRailwaySettings() - } - Button("Open Tailscale") { - openTailscaleSettings() - } - Button("Open account") { - openAccountSettings() - } - } label: { - Label("Add node", systemImage: "plus.circle") - } - .adaptiveToolbarButton() - } - Spacer() - Button { - topologyExpanded = true - } label: { - Label("Expand", systemImage: "arrow.up.left.and.arrow.down.right") - } - .adaptiveToolbarButton() - Button { - autoTidyTopologyLayout() - } label: { - Label("Auto tidy", systemImage: "wand.and.stars") - } - .adaptiveToolbarButton() - Button { - resetTopologyLayout() - } label: { - Label("Reset layout", systemImage: "arrow.counterclockwise") - } - .adaptiveToolbarButton() - } - - TopologyGraphCanvas( - nodes: topologyGraphNodes, - edges: topologyGraphEdges, - layoutOverrides: topologyLayoutOverrides, - selectedNodeId: $selectedTopologyNodeId, - minHeight: 300, - onNodeMoved: handleTopologyNodeMoved, - onSizeChanged: handleCompactTopologyCanvasSizeChanged - ) - .overlay(alignment: .topTrailing) { - if isControllerHost { - Button { - openRailwaySettings() - } label: { - Image(systemName: "plus") - .font(.system(size: 12, weight: .semibold)) - .frame(width: 26, height: 26) - } - .buttonStyle(.plain) - .background( - Circle() - .fill(Color.blue.opacity(0.92)) - ) - .foregroundStyle(Color.white) - .padding(8) - .help("Add a new remote node") - } - } - - topologyLegend - Text("Drag nodes to arrange map layout. Use Auto tidy to re-space crowded graphs. Positions are saved per controller profile.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - Text("Layout profile: \(controllerLayoutProfile)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - - if let selectedNode = selectedTopologyNode { - topologyNodeDetails(selectedNode) - } else { - Text("Select a node in the graph to inspect endpoint, role, and runtime details.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } - } - - private var topologyExpandedSheet: some View { - VStack(spacing: 0) { - HStack(spacing: 10) { - Label("Hack topology map", systemImage: "network") - .font(.mono(.subheadline, weight: .semibold)) - Spacer() - if isControllerHost { - Menu { - Button("Add via Railway") { - topologyExpanded = false - openRailwaySettings() - } - Button("Open Tailscale") { - topologyExpanded = false - openTailscaleSettings() - } - Button("Open account") { - topologyExpanded = false - openAccountSettings() - } - } label: { - Label("Add node", systemImage: "plus.circle") - } - .adaptiveToolbarButton() - } - Button { - autoTidyTopologyLayout() - } label: { - Label("Auto tidy", systemImage: "wand.and.stars") - } - .adaptiveToolbarButton() - Button { - resetTopologyLayout() - } label: { - Label("Reset layout", systemImage: "arrow.counterclockwise") - } - .adaptiveToolbarButton() - Button("Done") { - topologyExpanded = false - } - .adaptiveToolbarButtonProminent() - } - .padding(.horizontal, 16) - .padding(.vertical, 12) - - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 12) { - TopologyGraphCanvas( - nodes: topologyGraphNodes, - edges: topologyGraphEdges, - layoutOverrides: topologyLayoutOverrides, - selectedNodeId: $selectedTopologyNodeId, - minHeight: 520, - onNodeMoved: handleTopologyNodeMoved, - onSizeChanged: handleExpandedTopologyCanvasSizeChanged - ) - - topologyLegend - Text("Layout profile: \(controllerLayoutProfile)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - - if let selectedNode = selectedTopologyNode { - topologyNodeDetails(selectedNode) - } - } - .padding(16) - } - .frame(minWidth: 980, minHeight: 760) - .background(Color.black.opacity(0.04)) - } - - private var pairingRequestsCard: some View { - GlassCard(title: "Pairing requests", systemImage: "person.badge.key.fill") { - VStack(alignment: .leading, spacing: 10) { - Text("Approve pending node pairing requests from this gateway host.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - Toggle("Set approved node as default node", isOn: $pairingDefault) - .font(.mono(.caption)) - TextField("SSH port override (optional)", text: $pairingSshPortOverride) - .textFieldStyle(.roundedBorder) - .frame(maxWidth: 240) - Button { - Task { await refreshTopology(force: true) } - } label: { - Label("Refresh requests", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - .disabled(isLoading) - Spacer() - } - - if !pairingSshPortOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, parsedPairingSshPortOverride == nil { - Text("SSH port override must be an integer in range 1-65535.") - .font(.mono(.caption2)) - .foregroundStyle(.orange) - } - - if pairingSessions.isEmpty { - Text("No pending pairing requests.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(pairingSessions, id: \.id) { session in - pairingRequestRow(session: session) - if session.id != pairingSessions.last?.id { - Divider() - .opacity(0.2) - } - } - } - } - } - } - } - - private func pairingRequestRow(session: NodePairingSession) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(session.source) - .font(.mono(.subheadline, weight: .semibold)) - StatusPill(text: session.status.capitalized, tone: .warn) - Spacer() - Text("expires: \(session.expiresAt)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - - Text(session.endpoint) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - Text(session.id) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - - TextField( - "Enter one-time code from requester", - text: Binding( - get: { pairingCodeBySessionId[session.id] ?? "" }, - set: { pairingCodeBySessionId[session.id] = $0 } - ) - ) - .textFieldStyle(.roundedBorder) - - HStack(spacing: 8) { - Button { - Task { await approvePairing(session: session) } - } label: { - Label("Approve + register", systemImage: "checkmark.seal") - } - .adaptiveToolbarButtonProminent() - .disabled(!canApprovePairing(session: session)) - - Button { - Task { await cancelPairing(session: session) } - } label: { - Label("Reject", systemImage: "xmark.circle") - } - .adaptiveToolbarButton() - .disabled(pairingBusySessionId != nil) - - if let oneLiner = buildFulfillOneLiner(session: session) { - Button { - TerminalIntegration.copyToClipboard(oneLiner) - } label: { - Label("Copy command", systemImage: "doc.on.doc") - } - .adaptiveToolbarButton() - - Button { - TerminalIntegration.openExternalTerminalWithCommand(oneLiner, app: preferredTerminal) - } label: { - Label("Run in \(preferredTerminal.displayName)", systemImage: "terminal") - } - .adaptiveToolbarButton() - } - Spacer() - } - } - } - - private var nodeModeGuidanceCard: some View { - GlassCard(title: "Node mode workflow", systemImage: "server.rack") { - VStack(alignment: .leading, spacing: 8) { - Text("This machine is configured as a node-only runtime. Pairing approval happens on the controller host.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("1) Prepare gateway on this node: hack gateway setup") - .font(.mono(.caption2)) - .textSelection(.enabled) - Text("2) Open controller host Settings → Topology to review pending pairing requests.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Text("3) Fallback from controller CLI: hack node pair --source --endpoint --default") - .font(.mono(.caption2)) - .textSelection(.enabled) - } - } - } - - private var authorizedNodesCard: some View { - GlassCard(title: "Authorized nodes", systemImage: "server.rack") { - if !isControllerHost { - Text("Controller host mode is disabled. Registry changes are hidden until host mode is re-enabled.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - - if sortedNodes.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("No nodes registered yet.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - if isControllerHost { - Text("Pairing requests will appear above when sessions are pending.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - Text("Enable host mode on the controller machine to pair and register new nodes.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } else { - LazyVStack(alignment: .leading, spacing: 10) { - ForEach(sortedNodes) { node in - nodeRow(node) - if node.id != sortedNodes.last?.id { - Divider() - .opacity(0.2) - } - } - } - } - } - } - - private var trustModelCard: some View { - GlassCard(title: "Pairing + trust model", systemImage: "key.fill") { - VStack(alignment: .leading, spacing: 8) { - Text("1) Request appears in controller pairing inbox with source + endpoint.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("2) Host enters one-time code and approves from request row.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("3) Node returns attested bundle; host completes registration and stores token in secret backend.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("4) Dispatch probes node via gateway bearer auth before remote execution.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("5) Tailscale is a transport boundary, not an implicit trust grant.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - Divider() - .opacity(0.2) - - StatusPill( - text: tailnetStatusLabel.text, - tone: tailnetStatusLabel.tone - ) - - Text("Nodes are trusted only after explicit pairing/session verification and valid gateway token checks.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } - - private var sortedNodes: [NodeRegistryRecord] { - let nodes = registry?.nodes ?? [] - return nodes.sorted { left, right in - let leftIsDefault = left.id == registry?.defaultNodeId - let rightIsDefault = right.id == registry?.defaultNodeId - if leftIsDefault != rightIsDefault { - return leftIsDefault - } - return left.name.localizedCaseInsensitiveCompare(right.name) == .orderedAscending - } - } - - private var defaultNode: NodeRegistryRecord? { - guard let defaultNodeId = registry?.defaultNodeId else { - return nil - } - return sortedNodes.first(where: { $0.id == defaultNodeId }) - } - - private var endpointMatchesLocalDefault: Bool { - guard let defaultNode else { - return false - } - return endpointMatchesLocalDevice(defaultNode.endpoint) - } - - private var tailnetStatusLabel: (text: String, tone: StatusTone) { - if tailscale?.connected == true { - return ("Tailnet connected", .good) - } - if hasHealthyTailnetRoute { - return ("Tailnet route active", .good) - } - if let tailscale, tailscale.error == nil { - return ("Tailnet not connected", .warn) - } - return ("Tailnet status unavailable", .neutral) - } - - private var hasHealthyTailnetRoute: Bool { - for node in sortedNodes { - if connectionType(for: node.endpoint) != .tailscale { - continue - } - if probeByNodeId[node.id]?.ok == true { - return true - } - } - return false - } - - private var topologyGraphNodes: [TopologyGraphNodeModel] { - var nodes = [TopologyGraphNodeModel]( - [ - TopologyGraphNodeModel( - id: Self.localTopologyNodeId, - title: "This Mac", - subtitle: localRoleLabel, - role: isControllerHost ? "Controller host" : "Node runtime", - endpoint: nil, - statusText: isControllerHost ? "Host online" : "Node mode", - statusTone: endpointMatchesLocalDefault ? .good : .neutral, - connectionTypeText: "Local", - labels: [], - platform: "macOS", - arch: nil, - version: nil, - lastSeenAt: nil, - isLocal: true, - isDefault: false - ), - ] - ) - for node in sortedNodes { - let probe = probeByNodeId[node.id] - let type = connectionType(for: node.endpoint) - let statusText: String - let statusTone: StatusTone - if let probe { - statusText = probe.ok ? "Connected" : "Disconnected" - statusTone = probe.ok ? .good : .warn - } else { - statusText = "Unknown" - statusTone = .neutral - } - let role = node.id == registry?.defaultNodeId ? "Default execution node" : "Authorized node" - nodes.append( - TopologyGraphNodeModel( - id: node.id, - title: node.name, - subtitle: node.endpoint, - role: role, - endpoint: node.endpoint, - statusText: statusText, - statusTone: statusTone, - connectionTypeText: type.label, - labels: node.labels, - platform: node.platform, - arch: node.arch, - version: node.version, - lastSeenAt: node.lastSeenAt, - isLocal: false, - isDefault: node.id == registry?.defaultNodeId - ) - ) - } - return nodes - } - - private var topologyGraphEdges: [TopologyGraphEdgeModel] { - var edges: [TopologyGraphEdgeModel] = [] - let remoteNodes = sortedNodes - let nonDefaultNodes = remoteNodes.filter { $0.id != registry?.defaultNodeId } - - for node in remoteNodes { - let isDefault = node.id == registry?.defaultNodeId - let type = connectionType(for: node.endpoint) - let isHealthy = probeByNodeId[node.id]?.ok ?? false - let lane: Int - let labelPosition: CGFloat - if isDefault { - lane = -1 - labelPosition = 0.34 - } else if let nonDefaultIndex = nonDefaultNodes.firstIndex(where: { $0.id == node.id }) { - lane = alternatingLane(for: nonDefaultIndex, base: 3) - labelPosition = lane > 0 ? 0.64 : 0.36 - } else { - lane = 3 - labelPosition = 0.6 - } - edges.append( - TopologyGraphEdgeModel( - id: "host-\(node.id)", - sourceId: Self.localTopologyNodeId, - targetId: node.id, - label: edgeLabel(connectionType: type, isHealthy: isHealthy), - tone: isHealthy ? .good : .warn, - dashed: !isDefault, - lane: lane, - labelPosition: labelPosition - ) - ) - } - return edges - } - - private func edgeLabel(connectionType: TopologyConnectionType, isHealthy: Bool) -> String { - if isHealthy { - return connectionType.label - } - return "\(connectionType.label) · degraded" - } - - private func alternatingLane(for index: Int, base: Int) -> Int { - let magnitude = base + (index / 2) - return index.isMultiple(of: 2) ? magnitude : -magnitude - } - - private var selectedTopologyNode: TopologyGraphNodeModel? { - let fallback = topologyGraphNodes.first(where: { $0.id == Self.localTopologyNodeId }) - let selected = topologyGraphNodes.first(where: { $0.id == selectedTopologyNodeId }) - return selected ?? fallback - } - - private var topologyLegend: some View { - HStack(spacing: 12) { - topologyLegendItem(color: .green, text: "Healthy") - topologyLegendItem(color: .orange, text: "Degraded") - topologyLegendItem(color: .blue, text: "Tailscale link") - Spacer() - } - } - - private func topologyLegendItem(color: Color, text: String) -> some View { - HStack(spacing: 6) { - Circle() - .fill(color) - .frame(width: 7, height: 7) - Text(text) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - - private func topologyNodeDetails(_ node: TopologyGraphNodeModel) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(node.title) - .font(.mono(.subheadline, weight: .semibold)) - StatusPill(text: node.statusText, tone: node.statusTone) - if node.isDefault { - StatusPill(text: "Default execution", tone: .good) - } - Spacer() - } - Text(node.role) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - if let endpoint = node.endpoint { - Text(endpoint) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - HStack(spacing: 8) { - StatusPill(text: node.connectionTypeText, tone: connectionTone(for: node.connectionTypeText)) - if let platform = node.platform, !platform.isEmpty { - StatusPill(text: platform, tone: .neutral) - } - if let arch = node.arch, !arch.isEmpty { - StatusPill(text: arch, tone: .neutral) - } - if let version = node.version, !version.isEmpty { - StatusPill(text: version, tone: .neutral) - } - } - if !node.labels.isEmpty { - Text("labels: \(node.labels.joined(separator: ", "))") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if let seen = node.lastSeenAt, !seen.isEmpty { - Text("last seen: \(seen)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - .padding(10) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(Color.secondary.opacity(0.1)) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) - } - - private var localRoleLabel: String { - if !isControllerHost { - return endpointMatchesLocalDefault ? "Node (default)" : "Node" - } - if sortedNodes.isEmpty { - return "Host only" - } - if endpointMatchesLocalDefault { - return "Host + Node" - } - return "Controller" - } - - private var localRoleTone: StatusTone { - if !isControllerHost { - return endpointMatchesLocalDefault ? .good : .neutral - } - if sortedNodes.isEmpty { - return .warn - } - return endpointMatchesLocalDefault ? .good : .neutral - } - - private var parsedPairingSshPortOverride: Int? { - let trimmed = pairingSshPortOverride.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty, let value = Int(trimmed), (1...65_535).contains(value) else { - return nil - } - return value - } - - private var preferredTerminal: TerminalIntegration.ExternalTerminalApp { - if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredTerminalRaw) { - return explicit - } - if let legacy = TerminalIntegration.ExternalTerminalApp(rawValue: legacyPreferredTerminalRaw) { - return legacy - } - return .terminal - } - - private func pairingCode(sessionId: String) -> String { - (pairingCodeBySessionId[sessionId] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - } - - private func canApprovePairing(session: NodePairingSession) -> Bool { - let code = pairingCode(sessionId: session.id) - if pairingBusySessionId != nil { - return false - } - if code.isEmpty { - return false - } - if !pairingSshPortOverride.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, parsedPairingSshPortOverride == nil { - return false - } - return true - } - - private func approvePairing(session: NodePairingSession) async { - guard canApprovePairing(session: session) else { - return - } - pairingBusySessionId = session.id - defer { pairingBusySessionId = nil } - - let response = await model.fulfillNodePairSession( - sessionId: session.id, - code: pairingCode(sessionId: session.id), - defaultNode: pairingDefault, - sshPort: parsedPairingSshPortOverride - ) - if response != nil { - pairingCodeBySessionId[session.id] = nil - await refreshTopology(force: true) - } - } - - private func cancelPairing(session: NodePairingSession) async { - guard pairingBusySessionId == nil else { - return - } - pairingBusySessionId = session.id - defer { pairingBusySessionId = nil } - let cancelled = await model.cancelNodePairSession(sessionId: session.id) - if cancelled { - pairingCodeBySessionId[session.id] = nil - await refreshTopology(force: true) - } - } - - private func buildFulfillOneLiner(session: NodePairingSession) -> String? { - let code = pairingCode(sessionId: session.id) - guard !code.isEmpty else { - return nil - } - let pairingName = derivePairingName(source: session.source) - let remote = renderShellCommand( - args: [ - "hack", - "node", - "pair", - "approve", - "--session", - session.id, - "--code", - code, - "--endpoint", - session.endpoint, - "--name", - pairingName, - "--json", - ] - ) - var completeArgs = [ - "hack", - "node", - "pair", - "complete", - "--session", - session.id, - "--bundle", - "-", - ] - if pairingDefault { - completeArgs.append("--default") - } - let complete = renderShellCommand(args: completeArgs) - let sshPrefix = - if let port = parsedPairingSshPortOverride { - "ssh -p \(port)" - } else { - "ssh" - } - return "\(sshPrefix) \(session.source) \(remote) | \(complete)" - } - - private func renderShellCommand(args: [String]) -> String { - args.map(shellQuote).joined(separator: " ") - } - - private func derivePairingName(source: String) -> String { - let trimmed = source.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return Host.current().localizedName ?? "node" - } - let hostPart = trimmed.split(separator: "@").last.map(String.init) ?? trimmed - if let range = hostPart.range(of: ":", options: .backwards) { - let withoutPort = String(hostPart[.. NodeStatusResponse? { - let task = Task { - await model.probeNodes() - } - let timeoutTask = Task { - try? await Task.sleep( - nanoseconds: UInt64(nodeStatusProbeTimeoutSeconds * 1_000_000_000) - ) - task.cancel() - } - let result = await task.value - timeoutTask.cancel() - return result - } - - /** - * Tailnet inspection should remain best-effort and never block topology UI. - */ - private func inspectTailscaleWithTimeout() async -> TailscaleInspectResponse? { - let task = Task { - await model.inspectTailscale() - } - let timeoutTask = Task { - try? await Task.sleep( - nanoseconds: UInt64(tailscaleInspectTimeoutSeconds * 1_000_000_000) - ) - task.cancel() - } - let result = await task.value - timeoutTask.cancel() - return result - } - - private func queueTopologyRefresh(force: Bool) { - topologyRefreshTask?.cancel() - topologyRefreshTask = Task { - await refreshTopology(force: force) - } - } - - private func nodeRow(_ node: NodeRegistryRecord) -> some View { - HStack(alignment: .top, spacing: 10) { - VStack(alignment: .leading, spacing: 4) { - HStack(spacing: 8) { - Text(node.name) - .font(.mono(.subheadline, weight: .semibold)) - if node.id == registry?.defaultNodeId { - StatusPill(text: "Default", tone: .good) - } - if let probe = probeByNodeId[node.id] { - StatusPill(text: probe.ok ? "Connected" : "Disconnected", tone: probe.ok ? .good : .warn) - } else { - StatusPill(text: "Unknown", tone: .neutral) - } - StatusPill(text: "Authorized", tone: .neutral) - } - Text(node.endpoint) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - Text(node.id) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - if !node.labels.isEmpty { - Text("Labels: \(node.labels.joined(separator: ", "))") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - - Spacer() - - if isControllerHost { - VStack(alignment: .trailing, spacing: 6) { - if node.id != registry?.defaultNodeId { - Button { - Task { - beginNodeMutation(nodeId: node.id) - defer { endNodeMutation(nodeId: node.id) } - _ = await model.useNode(id: node.id) - await refreshTopology(force: true) - } - } label: { - Label("Set default", systemImage: "star") - } - .adaptiveToolbarButton() - .disabled(isNodeMutationInFlight(nodeId: node.id)) - } - - Button(role: .destructive) { - pendingRemoval = node - } label: { - Label("Remove", systemImage: "trash") - } - .adaptiveToolbarButton() - .disabled(isNodeMutationInFlight(nodeId: node.id)) - } - } - } - } - - private func endpointMatchesLocalDevice(_ endpoint: String) -> Bool { - guard let url = URL(string: endpoint), let host = url.host?.lowercased() else { - return false - } - if localEndpointHosts.contains(host) { - return true - } - if let deviceIP = tailscale?.selfDevice?.tailscaleIp?.lowercased(), host == deviceIP { - return true - } - if let dnsName = tailscale?.selfDevice?.dnsName?.lowercased(), host == dnsName { - return true - } - return false - } - - private var localEndpointHosts: Set { - var hosts: Set = ["127.0.0.1", "localhost", "::1"] - if let localName = Host.current().localizedName?.lowercased(), !localName.isEmpty { - hosts.insert(localName) - hosts.insert("\(localName).local") - } - return hosts - } - - private func connectionType(for endpoint: String) -> TopologyConnectionType { - guard - let url = URL(string: endpoint), - let scheme = url.scheme?.lowercased(), - let rawHost = url.host?.lowercased() - else { - return .custom - } - let host = normalizedHost(rawHost) - if localEndpointHosts.contains(host) { - return .loopback - } - if knownTailnetHosts.contains(host) || host.hasSuffix(".ts.net") { - return .tailscale - } - if scheme == "https" { - return .https - } - if scheme == "http" { - return .http - } - return .custom - } - - private var knownTailnetHosts: Set { - var hosts: Set = [] - if let selfDevice = tailscale?.selfDevice { - if let ip = selfDevice.tailscaleIp { - hosts.insert(normalizedHost(ip)) - } - if let dnsName = selfDevice.dnsName { - hosts.insert(normalizedHost(dnsName)) - } - } - for peer in tailscale?.peers ?? [] { - if let ip = peer.tailscaleIp { - hosts.insert(normalizedHost(ip)) - } - if let dnsName = peer.dnsName { - hosts.insert(normalizedHost(dnsName)) - } - } - return hosts - } - - private func normalizedHost(_ host: String) -> String { - var value = host.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - while value.hasSuffix(".") { - value.removeLast() - } - return value - } - - private func deduplicatedNodeRecords(_ records: [NodeRegistryRecord]) -> [NodeRegistryRecord] { - var seenIds = Set() - var deduplicated: [NodeRegistryRecord] = [] - deduplicated.reserveCapacity(records.count) - for record in records { - if seenIds.contains(record.id) { - continue - } - seenIds.insert(record.id) - deduplicated.append(record) - } - return deduplicated - } - - private func connectionTone(for connection: String) -> StatusTone { - switch connection.lowercased() { - case "tailscale", "loopback": - return .good - case "https", "http": - return .neutral - default: - return .warn - } - } - - private func handleTopologyNodeMoved(nodeId: String, position: CGPoint, commit: Bool) { - guard topologyGraphNodes.contains(where: { $0.id == nodeId }) else { - return - } - topologyLayoutOverrides[nodeId] = position - if commit { - persistTopologyLayoutOverridesIfNeeded() - } - } - - private func handleCompactTopologyCanvasSizeChanged(_ size: CGSize) { - if hasMeaningfulCanvasSizeChange(current: compactTopologyCanvasSize, next: size) { - compactTopologyCanvasSize = size - } - } - - private func handleExpandedTopologyCanvasSizeChanged(_ size: CGSize) { - if hasMeaningfulCanvasSizeChange(current: expandedTopologyCanvasSize, next: size) { - expandedTopologyCanvasSize = size - } - } - - private func hasMeaningfulCanvasSizeChange(current: CGSize, next: CGSize) -> Bool { - abs(current.width - next.width) > 1 || abs(current.height - next.height) > 1 - } - - private func autoTidyTopologyLayout() { - let canvasSize = - if topologyExpanded, expandedTopologyCanvasSize.width > 10, expandedTopologyCanvasSize.height > 10 { - expandedTopologyCanvasSize - } else if compactTopologyCanvasSize.width > 10, compactTopologyCanvasSize.height > 10 { - compactTopologyCanvasSize - } else { - CGSize( - width: topologyExpanded ? 980 : 860, - height: topologyExpanded ? 520 : 300 - ) - } - topologyLayoutOverrides = TopologyGraphLayout.autoTidyOverrides( - nodes: topologyGraphNodes, - size: canvasSize - ) - persistTopologyLayoutOverridesIfNeeded() - } - - private func resetTopologyLayout() { - topologyLayoutOverrides = [:] - persistTopologyLayoutOverridesIfNeeded() - } - - private func resolveControllerLayoutProfile( - sourceProjectId: String?, - tailscale: TailscaleInspectResponse? - ) -> String { - let source = (sourceProjectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - if !source.isEmpty { - return "source:\(source.lowercased())" - } - if let dnsName = tailscale?.selfDevice?.dnsName, !dnsName.isEmpty { - return "tailnet:\(normalizedHost(dnsName))" - } - if let hostName = Host.current().localizedName?.trimmingCharacters(in: .whitespacesAndNewlines), - !hostName.isEmpty - { - return "local:\(hostName.lowercased())" - } - return "local:default" - } - - private func loadTopologyLayoutOverrides(profile: String) -> [String: CGPoint] { - let store = decodeTopologyLayoutStore(raw: topologyLayoutByControllerRaw) - let entries = store.profiles[profile] ?? [:] - var points: [String: CGPoint] = [:] - for (nodeId, point) in entries { - points[nodeId] = CGPoint(x: point.x, y: point.y) - } - return points - } - - private func persistTopologyLayoutOverridesIfNeeded() { - guard !controllerLayoutProfile.isEmpty else { - return - } - var store = decodeTopologyLayoutStore(raw: topologyLayoutByControllerRaw) - let encodedPoints = topologyLayoutOverrides.reduce(into: [String: TopologyLayoutPoint]()) { - partialResult, - entry in - partialResult[entry.key] = TopologyLayoutPoint( - x: entry.value.x, - y: entry.value.y - ) - } - if encodedPoints.isEmpty { - store.profiles.removeValue(forKey: controllerLayoutProfile) - } else { - store.profiles[controllerLayoutProfile] = encodedPoints - } - - if let encoded = encodeTopologyLayoutStore(store), encoded != topologyLayoutByControllerRaw { - topologyLayoutByControllerRaw = encoded - } - } - - private func decodeTopologyLayoutStore(raw: String) -> TopologyLayoutStore { - guard !raw.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty, - let data = raw.data(using: .utf8), - let decoded = try? JSONDecoder().decode(TopologyLayoutStore.self, from: data) - else { - return TopologyLayoutStore(version: 1, profiles: [:]) - } - return decoded - } - - private func encodeTopologyLayoutStore(_ store: TopologyLayoutStore) -> String? { - let encoder = JSONEncoder() - encoder.outputFormatting = [.sortedKeys] - guard let data = try? encoder.encode(store) else { - return nil - } - return String(data: data, encoding: .utf8) - } - - private func shellQuote(_ value: String) -> String { - let escaped = value.replacingOccurrences(of: "'", with: "'\"'\"'") - return "'\(escaped)'" - } - - private func openRailwaySettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.railway.rawValue] - ) - } - - private func openTailscaleSettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.tailscale.rawValue] - ) - } - - private func openAccountSettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.account.rawValue] - ) - } - - /// Track a single row-level mutation to avoid duplicate remove/use actions. - private func beginNodeMutation(nodeId: String) { - nodeMutationInFlight = nodeId - } - - private func endNodeMutation(nodeId: String) { - if nodeMutationInFlight == nodeId { - nodeMutationInFlight = nil - } - } - - private func isNodeMutationInFlight(nodeId: String) -> Bool { - nodeMutationInFlight == nodeId - } - - /// Optimistically remove a node from local state so stale rows disappear instantly. - private func removeNodeFromLocalState(nodeId: String) { - guard let current = registry else { - return - } - let nextNodes = current.nodes.filter { $0.id != nodeId } - let nextDefault = current.defaultNodeId == nodeId ? nil : current.defaultNodeId - registry = NodeRegistryListResponse(defaultNodeId: nextDefault, nodes: nextNodes) - probeByNodeId[nodeId] = nil - if selectedTopologyNodeId == nodeId { - selectedTopologyNodeId = Self.localTopologyNodeId - } - } -} - -private enum TopologyConnectionType { - case loopback - case tailscale - case https - case http - case custom - - var label: String { - switch self { - case .loopback: - return "Loopback" - case .tailscale: - return "Tailscale" - case .https: - return "HTTPS" - case .http: - return "HTTP" - case .custom: - return "Custom" - } - } -} - -private struct TopologyGraphNodeModel: Identifiable { - let id: String - let title: String - let subtitle: String - let role: String - let endpoint: String? - let statusText: String - let statusTone: StatusTone - let connectionTypeText: String - let labels: [String] - let platform: String? - let arch: String? - let version: String? - let lastSeenAt: String? - let isLocal: Bool - let isDefault: Bool -} - -private struct TopologyGraphEdgeModel: Identifiable { - let id: String - let sourceId: String - let targetId: String - let label: String - let tone: StatusTone - let dashed: Bool - let lane: Int - let labelPosition: CGFloat -} - -private struct TopologyLayoutStore: Codable { - let version: Int - var profiles: [String: [String: TopologyLayoutPoint]] -} - -private struct TopologyLayoutPoint: Codable { - let x: CGFloat - let y: CGFloat -} - -private struct TopologyGraphCanvas: View { - let nodes: [TopologyGraphNodeModel] - let edges: [TopologyGraphEdgeModel] - let layoutOverrides: [String: CGPoint] - @Binding var selectedNodeId: String - @State private var dragStartPositions: [String: CGPoint] = [:] - @State private var draggingNodeId: String? = nil - @State private var dragLivePositions: [String: CGPoint] = [:] - let minHeight: CGFloat - let onNodeMoved: (_ nodeId: String, _ position: CGPoint, _ commit: Bool) -> Void - let onSizeChanged: (_ size: CGSize) -> Void - - var body: some View { - GeometryReader { geometry in - let effectiveOverrides = layoutOverrides.merging(dragLivePositions) { _, live in - live - } - let layout = TopologyGraphLayout.compute( - nodes: nodes, - size: geometry.size, - layoutOverrides: effectiveOverrides - ) - - ZStack { - RoundedRectangle(cornerRadius: 14, style: .continuous) - .fill(Color.secondary.opacity(0.06)) - .overlay( - RoundedRectangle(cornerRadius: 14, style: .continuous) - .stroke(Color.secondary.opacity(0.15), lineWidth: 1) - ) - - ForEach(edges) { edge in - if let source = layout.positions[edge.sourceId], let target = layout.positions[edge.targetId] { - TopologyGraphEdgeView(from: source, to: target, edge: edge) - .allowsHitTesting(false) - } - } - - ForEach(nodes) { node in - if let position = layout.positions[node.id] { - topologyNodeInteraction( - node: node, - position: position, - canvasSize: geometry.size - ) - } - } - } - .onAppear { - onSizeChanged(geometry.size) - } - .onChange(of: geometry.size) { _, size in - onSizeChanged(size) - } - } - .frame(minHeight: minHeight) - } - - private func clampPosition(_ point: CGPoint, canvasSize: CGSize) -> CGPoint { - let minX: CGFloat = 112 - let maxX = max(minX, canvasSize.width - 112) - let minY: CGFloat = 58 - let maxY = max(minY, canvasSize.height - 58) - return CGPoint( - x: min(max(point.x, minX), maxX), - y: min(max(point.y, minY), maxY) - ) - } - - /** - Renders a selectable/draggable node with explicit hit testing. - This keeps every bubble interactive even when graph chrome or overlapping - nodes are present. - */ - @ViewBuilder - private func topologyNodeInteraction( - node: TopologyGraphNodeModel, - position: CGPoint, - canvasSize: CGSize - ) -> some View { - ZStack { - TopologyGraphNodeBubble(node: node, isSelected: selectedNodeId == node.id) - .allowsHitTesting(false) - - Rectangle() - .fill(Color.clear) - .contentShape(Rectangle()) - .onTapGesture { - selectedNodeId = node.id - } - .highPriorityGesture( - DragGesture(minimumDistance: 2) - .onChanged { value in - if selectedNodeId != node.id { - selectedNodeId = node.id - } - if draggingNodeId != node.id { - draggingNodeId = node.id - } - let dragStart = dragStartPositions[node.id] ?? position - if dragStartPositions[node.id] == nil { - dragStartPositions[node.id] = position - } - let next = CGPoint( - x: dragStart.x + value.translation.width, - y: dragStart.y + value.translation.height - ) - let clamped = clampPosition(next, canvasSize: canvasSize) - setDragLivePosition(nodeId: node.id, position: clamped) - } - .onEnded { value in - let dragStart = dragStartPositions[node.id] ?? position - let next = CGPoint( - x: dragStart.x + value.translation.width, - y: dragStart.y + value.translation.height - ) - let clamped = dragLivePositions[node.id] - ?? clampPosition(next, canvasSize: canvasSize) - onNodeMoved( - node.id, - clamped, - true - ) - dragStartPositions[node.id] = nil - draggingNodeId = nil - dragLivePositions.removeValue(forKey: node.id) - } - ) - } - .frame(width: 208, height: 98, alignment: .center) - .position(position) - .allowsHitTesting(true) - .zIndex(nodeZIndex(nodeId: node.id)) - } - - /** - Applies transient drag positions without animation to avoid visual stutter. - */ - private func setDragLivePosition(nodeId: String, position: CGPoint) { - if let current = dragLivePositions[nodeId], - abs(current.x - position.x) < 0.25, - abs(current.y - position.y) < 0.25 - { - return - } - var transaction = Transaction() - transaction.disablesAnimations = true - withTransaction(transaction) { - dragLivePositions[nodeId] = position - } - } - - private func nodeZIndex(nodeId: String) -> Double { - if draggingNodeId == nodeId { - return 4 - } - if selectedNodeId == nodeId { - return 3 - } - return 2 - } -} - -private struct TopologyGraphLayout { - private static let bubbleWidth: CGFloat = 190 - private static let bubbleHeight: CGFloat = 76 - private static let horizontalSpacing: CGFloat = 48 - private static let verticalSpacing: CGFloat = 34 - - let positions: [String: CGPoint] - - static func compute( - nodes: [TopologyGraphNodeModel], - size: CGSize, - layoutOverrides: [String: CGPoint] - ) -> TopologyGraphLayout { - guard size.width > 10, size.height > 10 else { - return TopologyGraphLayout(positions: [:]) - } - - var positions: [String: CGPoint] = [:] - let remoteNodes = nodes.filter { !$0.isLocal } - if let localNode = nodes.first(where: \.isLocal) { - positions[localNode.id] = CGPoint( - x: max(115, size.width * 0.18), - y: size.height * 0.5 - ) - } - - guard !remoteNodes.isEmpty else { - return TopologyGraphLayout(positions: positions) - } - - let anchorNode = remoteNodes.first(where: \.isDefault) ?? remoteNodes[0] - positions[anchorNode.id] = CGPoint( - x: max(260, size.width * 0.46), - y: max(92, size.height * 0.35) - ) - - let secondaryNodes = remoteNodes.filter { $0.id != anchorNode.id } - guard !secondaryNodes.isEmpty else { - return TopologyGraphLayout(positions: positions) - } - - let center = CGPoint(x: size.width * 0.76, y: size.height * 0.54) - let radiusX = max(120, min(size.width * 0.2, 260)) - let radiusY = max(95, min(size.height * 0.25, 185)) - let angleStep = (2 * Double.pi) / Double(max(secondaryNodes.count, 1)) - let baseAngle = -Double.pi / 2 - - for (index, node) in secondaryNodes.enumerated() { - let angle = baseAngle + (Double(index) * angleStep) - let rawX = center.x + (cos(angle) * radiusX) - let rawY = center.y + (sin(angle) * radiusY) - let clampedX = min(max(rawX, 120), size.width - 120) - let clampedY = min(max(rawY, 64), size.height - 64) - positions[node.id] = CGPoint(x: clampedX, y: clampedY) - } - - for (nodeId, overridePoint) in layoutOverrides where positions[nodeId] != nil { - positions[nodeId] = CGPoint( - x: min(max(overridePoint.x, 112), max(112, size.width - 112)), - y: min(max(overridePoint.y, 58), max(58, size.height - 58)) - ) - } - - return TopologyGraphLayout(positions: positions) - } - - /** - Produces a deterministic, non-overlapping override map for crowded graphs. - Layout keeps the local/controller node on the left, default node near center-left, - and packs secondary nodes into a right-side grid. - */ - static func autoTidyOverrides( - nodes: [TopologyGraphNodeModel], - size: CGSize - ) -> [String: CGPoint] { - guard size.width > 10, size.height > 10 else { - return [:] - } - - var positions = TopologyGraphLayout.compute( - nodes: nodes, - size: size, - layoutOverrides: [:] - ).positions - let remoteNodes = nodes.filter { !$0.isLocal } - guard !remoteNodes.isEmpty else { - return positions - } - - let anchorNode = remoteNodes.first(where: \.isDefault) ?? remoteNodes[0] - positions[anchorNode.id] = CGPoint( - x: clampX(value: max(260, size.width * 0.43), size: size), - y: clampY(value: max(92, size.height * 0.34), size: size) - ) - - let secondaries = remoteNodes.filter { $0.id != anchorNode.id } - if secondaries.isEmpty { - return positions - } - - let rowPitch = bubbleHeight + verticalSpacing - let columnPitch = bubbleWidth + horizontalSpacing - let usableTop: CGFloat = 78 - let usableBottom = max(usableTop + bubbleHeight, size.height - 78) - let usableHeight = max(bubbleHeight, usableBottom - usableTop) - let maxRows = max(1, Int(floor(usableHeight / rowPitch))) - let columns = Int(ceil(Double(secondaries.count) / Double(maxRows))) - let rightLimit = max(120, size.width - 120) - let startX = max(340, rightLimit - (CGFloat(max(columns - 1, 0)) * columnPitch)) - - let rowsUsed = min(maxRows, secondaries.count) - let gridHeight = CGFloat(max(rowsUsed - 1, 0)) * rowPitch - let centeredTop = max(usableTop, (size.height - gridHeight) / 2) - - for (index, node) in secondaries.enumerated() { - let row = index % maxRows - let column = index / maxRows - let rawX = startX + (CGFloat(column) * columnPitch) - let rawY = centeredTop + (CGFloat(row) * rowPitch) - positions[node.id] = CGPoint( - x: clampX(value: rawX, size: size), - y: clampY(value: rawY, size: size) - ) - } - - return positions - } - - private static func clampX(value: CGFloat, size: CGSize) -> CGFloat { - min(max(value, 112), max(112, size.width - 112)) - } - - private static func clampY(value: CGFloat, size: CGSize) -> CGFloat { - min(max(value, 58), max(58, size.height - 58)) - } -} - -private struct TopologyGraphEdgeView: View { - let from: CGPoint - let to: CGPoint - let edge: TopologyGraphEdgeModel - - var body: some View { - let metrics = edgeMetrics(from: from, to: to) - ZStack { - Path { path in - path.move(to: metrics.start) - path.addQuadCurve(to: metrics.end, control: metrics.control) - } - .stroke( - toneColor(edge.tone).opacity(edge.dashed ? 0.65 : 0.88), - style: StrokeStyle(lineWidth: 1.8, lineCap: .round, dash: edge.dashed ? [8, 6] : []) - ) - - Path { path in - path.move(to: metrics.end) - path.addLine(to: metrics.arrowLeft) - path.move(to: metrics.end) - path.addLine(to: metrics.arrowRight) - } - .stroke( - toneColor(edge.tone).opacity(0.88), - style: StrokeStyle(lineWidth: 1.6, lineCap: .round) - ) - - Text(edge.label) - .font(.mono(.caption2)) - .lineLimit(1) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(Color.black.opacity(0.42)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(toneColor(edge.tone).opacity(0.25), lineWidth: 1) - ) - .position(metrics.labelPoint) - } - } - - private func edgeMetrics(from: CGPoint, to: CGPoint) -> ( - start: CGPoint, - control: CGPoint, - end: CGPoint, - labelPoint: CGPoint, - arrowLeft: CGPoint, - arrowRight: CGPoint - ) { - let dx = to.x - from.x - let dy = to.y - from.y - let length = max(sqrt((dx * dx) + (dy * dy)), 1) - let ux = dx / length - let uy = dy / length - let nx = -uy - let ny = ux - - let startOffset: CGFloat = 72 - let endOffset: CGFloat = 80 - let start = CGPoint(x: from.x + (ux * startOffset), y: from.y + (uy * startOffset * 0.55)) - let end = CGPoint(x: to.x - (ux * endOffset), y: to.y - (uy * endOffset * 0.55)) - let midpoint = CGPoint(x: (start.x + end.x) / 2, y: (start.y + end.y) / 2) - let curveOffset = CGFloat(edge.lane) * 20 - let control = CGPoint( - x: midpoint.x + (nx * curveOffset), - y: midpoint.y + (ny * curveOffset) - ) - - let clampedT = min(max(edge.labelPosition, 0.18), 0.82) - let curvePoint = quadraticPoint(start: start, control: control, end: end, t: clampedT) - let labelDrift = max(14, abs(CGFloat(edge.lane)) * 5) - let labelPoint = CGPoint( - x: curvePoint.x + (nx * labelDrift), - y: curvePoint.y + (ny * labelDrift) - ) - - let arrowT: CGFloat = 0.94 - let arrowBase = quadraticPoint(start: start, control: control, end: end, t: arrowT) - let arrowNext = quadraticPoint(start: start, control: control, end: end, t: min(arrowT + 0.01, 1)) - let arrowVectorX = arrowNext.x - arrowBase.x - let arrowVectorY = arrowNext.y - arrowBase.y - let arrowLengthNorm = max(sqrt((arrowVectorX * arrowVectorX) + (arrowVectorY * arrowVectorY)), 1) - let auX = arrowVectorX / arrowLengthNorm - let auY = arrowVectorY / arrowLengthNorm - let anX = -auY - let anY = auX - - let arrowLength: CGFloat = 9 - let arrowWidth: CGFloat = 5 - let left = CGPoint( - x: end.x - (auX * arrowLength) + (anX * arrowWidth), - y: end.y - (auY * arrowLength) + (anY * arrowWidth) - ) - let right = CGPoint( - x: end.x - (auX * arrowLength) - (anX * arrowWidth), - y: end.y - (auY * arrowLength) - (anY * arrowWidth) - ) - return (start, control, end, labelPoint, left, right) - } - - private func quadraticPoint(start: CGPoint, control: CGPoint, end: CGPoint, t: CGFloat) -> CGPoint { - let oneMinusT = 1 - t - let x = (oneMinusT * oneMinusT * start.x) + (2 * oneMinusT * t * control.x) + (t * t * end.x) - let y = (oneMinusT * oneMinusT * start.y) + (2 * oneMinusT * t * control.y) + (t * t * end.y) - return CGPoint(x: x, y: y) - } -} - -private struct TopologyGraphNodeBubble: View { - let node: TopologyGraphNodeModel - let isSelected: Bool - - var body: some View { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 6) { - Circle() - .fill(toneColor(node.statusTone)) - .frame(width: 7, height: 7) - Text(node.title) - .font(.mono(.caption, weight: .semibold)) - .lineLimit(1) - Spacer(minLength: 0) - if node.isDefault { - Image(systemName: "scope") - .font(.system(size: 10, weight: .bold)) - .foregroundStyle(Color.green) - } - } - - Text(node.subtitle) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .lineLimit(1) - .truncationMode(.middle) - - Text(node.role) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .lineLimit(1) - } - .padding(.horizontal, 10) - .padding(.vertical, 9) - .frame(width: 190, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(nodeFillColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(isSelected ? Color.blue.opacity(0.9) : Color.secondary.opacity(0.28), lineWidth: isSelected ? 2 : 1) - ) - .shadow(color: Color.black.opacity(isSelected ? 0.25 : 0.12), radius: isSelected ? 8 : 3, y: isSelected ? 4 : 2) - } - - private var nodeFillColor: Color { - if node.isLocal { - return Color.blue.opacity(0.13) - } - if node.isDefault { - return Color.green.opacity(0.13) - } - return Color.secondary.opacity(0.1) - } -} - -private func toneColor(_ tone: StatusTone) -> Color { - switch tone { - case .good: - return .green - case .warn: - return .orange - case .neutral: - return .secondary - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift index 072c128e..87a87912 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -4,57 +4,16 @@ import SwiftUI import HackCLIService import HackDesktopModels -private enum RemoteExecutionMode: String, CaseIterable, Identifiable { - case local = "local" - case localEditRemoteRun = "local_edit_remote_run" - case remoteDevcontainer = "remote_devcontainer" - - var id: String { rawValue } - - var title: String { - switch self { - case .local: - return "Local" - case .localEditRemoteRun: - return "Local edit + remote run" - case .remoteDevcontainer: - return "Remote devcontainer" - } - } - - var summary: String { - switch self { - case .local: - return "Run and edit on this Mac." - case .localEditRemoteRun: - return "Edit locally, run lifecycle on remote node." - case .remoteDevcontainer: - return "Use a remote devcontainer workspace." - } - } - - static func fromConfig(_ raw: String?) -> RemoteExecutionMode? { - guard let raw else { - return nil - } - return RemoteExecutionMode(rawValue: raw.trimmingCharacters(in: .whitespacesAndNewlines)) - } -} - private enum ProjectSidebarItem: String, CaseIterable, Identifiable { - case remoteExecution case services case lifecycle case branches case sessions - case tickets var id: String { rawValue } var title: String { switch self { - case .remoteExecution: - return "Project settings" case .services: return "Services" case .lifecycle: @@ -63,15 +22,11 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { return "Branches" case .sessions: return "Sessions" - case .tickets: - return "Tickets" } } var icon: String { switch self { - case .remoteExecution: - return "slider.horizontal.3" case .services: return "shippingbox" case .lifecycle: @@ -80,8 +35,6 @@ private enum ProjectSidebarItem: String, CaseIterable, Identifiable { return "arrow.triangle.branch" case .sessions: return "rectangle.3.group.bubble.left" - case .tickets: - return "ticket" } } } @@ -107,41 +60,6 @@ struct ProjectDetailView: View { @State private var showAddBranchSheet = false @State private var newBranchName = "" @State private var newBranchNote = "" - @State private var executionMode: RemoteExecutionMode = .local - @State private var executionTargetNodeId = "" - @State private var executionDefaultNodeId = "" - @State private var executionTargetNodes: [NodeRegistryRecord] = [] - @State private var executionTargetLoading = false - @State private var executionTargetSaving = false - @State private var executionTargetMessage = "" - @State private var githubProjectProfile = "" - @State private var githubDefaultProfile = "" - @State private var githubProfileOptions: [String] = [] - @State private var githubProfilesById: [String: GitHubProfileSummary] = [:] - @State private var githubProfileStatusById: [String: GitHubStatusResponse] = [:] - @State private var githubResolvedProfile = "" - @State private var githubProfileMessage = "" - @State private var linearProjectProfile = "" - @State private var linearDefaultProfile = "" - @State private var linearProfileOptions: [String] = [] - @State private var linearProfilesById: [String: LinearProfileSummary] = [:] - @State private var linearProfileStatusById: [String: LinearStatusResponse] = [:] - @State private var linearResolvedProfile = "" - @State private var linearBoundProjectId = "" - @State private var linearBoundProjectName = "" - @State private var linearBoundTeamId = "" - @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] - @State private var linearAutosyncRouteKeys: Set = [] - @State private var togglingLinearAutosyncRouteKeys: Set = [] - @State private var linearProjectOptions: [LinearProjectSummary] = [] - @State private var selectedLinearProjectId = "" - @State private var selectedAdditionalLinearProjectId = "" - @State private var linearSyncFromLinearEnabled = true - @State private var linearCreateHackTicketsEnabled = false - @State private var linearProjectMessage = "" - @State private var projectSystemGitIdentity: GitSystemIdentity? = nil - @State private var executionTargetReloadTask: Task? = nil - @State private var linearProjectOptionsLoading = false var body: some View { @Bindable var model = model @@ -155,14 +73,12 @@ struct ProjectDetailView: View { syncSidebarSelectionFromTab() ensureSidebarSelection() selectedService = nil - queueExecutionTargetReload() } .onChange(of: project.id) { _, _ in ensureSelectedTab() syncSidebarSelectionFromTab() ensureSidebarSelection() selectedService = nil - queueExecutionTargetReload() } .onChange(of: model.selectedProjectTab) { _, _ in ensureSelectedTab() @@ -196,23 +112,6 @@ struct ProjectDetailView: View { } ensureSidebarSelection() } - .onReceive(NotificationCenter.default.publisher(for: .hackProjectRoutingRequested)) { notification in - guard - let userInfo = notification.userInfo, - let requestedProjectId = userInfo[ProjectRoutingRequest.projectIdKey] as? String, - requestedProjectId == project.id - else { - return - } - model.selectedProjectTab = .overview - selectedSidebarItem = .remoteExecution - ensureSidebarSelection() - queueExecutionTargetReload() - } - .onDisappear { - executionTargetReloadTask?.cancel() - executionTargetReloadTask = nil - } .sheet(isPresented: $showAddBranchSheet) { addBranchSheet } @@ -591,22 +490,18 @@ struct ProjectDetailView: View { terminalMovedCard(kind: .logs) case .shell: terminalMovedCard(kind: .shell) - case .tickets: - TicketsView(project: project) } } @ViewBuilder private var projectOverviewContent: some View { switch selectedSidebarItem { - case .remoteExecution: - remoteExecutionContent case .services: servicesContent case .lifecycle: lifecycleContent - case .branches, .sessions, .tickets: - remoteExecutionContent + case .branches, .sessions: + servicesContent } } @@ -625,19 +520,6 @@ struct ProjectDetailView: View { .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) } - private var remoteExecutionContent: some View { - ProjectRemoteExecutionView( - showsRuntimeNotConfigured: !project.isRuntimeConfigured, - showsInfoPanel: showInfoPanel - ) { - runtimeNotConfiguredCard - } sectionContent: { - remoteExecutionSection - } infoPanel: { - infoSection - } - } - private var servicesContent: some View { ProjectServicesView( showOverviewSidebar: showOverviewSidebar, @@ -684,544 +566,6 @@ struct ProjectDetailView: View { } } - private var remoteExecutionSection: some View { - VStack(alignment: .leading, spacing: 18) { - HStack(alignment: .top, spacing: 12) { - Image(systemName: "slider.horizontal.3") - .foregroundStyle(.secondary) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 4) { - Text("Project settings") - .font(.system(size: 20, weight: .semibold)) - } - Spacer() - if executionTargetLoading || executionTargetSaving { - ProgressView() - .controlSize(.small) - } - } - - VStack(alignment: .leading, spacing: 16) { - projectSettingsField( - title: "Execution mode", - help: "Choose whether this repo defaults to local work on this Mac or routes execution to a remote node.", - footnote: executionMode.summary - ) { - Menu { - ForEach(RemoteExecutionMode.allCases) { mode in - Button { - guard executionMode != mode else { - return - } - executionMode = mode - guard !executionTargetLoading else { - return - } - Task { await persistExecutionModeSelection() } - } label: { - if executionMode == mode { - Label(mode.title, systemImage: "checkmark") - } else { - Text(mode.title) - } - } - } - } label: { - projectSettingsMenuLabel(executionMode.title) - } - .buttonStyle(.plain) - .accessibilityLabel("Execution mode") - } - - Divider() - .opacity(0.12) - - projectSettingsField( - title: "Default node", - help: "Choose the default node when execution mode is set to remote.", - footnote: selectedNodeSummary - ) { - if executionMode == .local { - projectSettingsStaticValue("Uses this Mac while execution mode stays local") - .accessibilityLabel("Default node") - } else { - Menu { - Button { - guard !executionTargetNodeId.isEmpty else { - return - } - executionTargetNodeId = "" - guard !executionTargetLoading else { - return - } - Task { await persistSimpleDefaultNodeSelection() } - } label: { - if executionTargetNodeId.isEmpty { - Label("Local", systemImage: "checkmark") - } else { - Text("Local") - } - } - ForEach(executionTargetNodes, id: \.id) { node in - Button { - guard executionTargetNodeId != node.id else { - return - } - executionTargetNodeId = node.id - guard !executionTargetLoading else { - return - } - Task { await persistSimpleDefaultNodeSelection() } - } label: { - if executionTargetNodeId == node.id { - Label(node.name, systemImage: "checkmark") - } else { - Text(node.name) - } - } - } - } label: { - projectSettingsMenuLabel(selectedNodeSummary) - } - .buttonStyle(.plain) - .accessibilityLabel("Default node") - } - } - - Divider() - .opacity(0.12) - - projectSettingsField( - title: "GitHub profile", - help: "Pick the saved GitHub profile this repo should use for remote Git operations.", - footnote: selectedGitSummary - ) { - Menu { - Button { - guard !githubProjectProfile.isEmpty else { - return - } - githubProjectProfile = "" - guard !executionTargetLoading else { - return - } - Task { await persistGitCredentialsSelection() } - } label: { - if githubProjectProfile.isEmpty { - Label("Local", systemImage: "checkmark") - } else { - Text("Local") - } - } - ForEach(githubProfileOptions, id: \.self) { profile in - Button { - guard githubProjectProfile != profile else { - return - } - githubProjectProfile = profile - guard !executionTargetLoading else { - return - } - Task { await persistGitCredentialsSelection() } - } label: { - let label = githubProfileLabel(profileId: profile) - if githubProjectProfile == profile { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel(selectedGitSummary) - } - .buttonStyle(.plain) - .accessibilityLabel("GitHub profile") - } - } - .padding(16) - .background(projectSettingsCardBackground) - - linearRoutingSummaryCard - - if let projectSystemGitIdentity { - let systemAccountLabel = projectSystemGitIdentity.githubLogin.map { "@\($0)" } ?? "unavailable" - Text("Local system Git: \(systemAccountLabel)\(projectSystemGitIdentity.gitEmail.map { " • \($0)" } ?? "")") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - - if !remoteConfigMessage.isEmpty { - InlineCallout( - tone: remoteConfigTone, - title: remoteConfigTone == .warn ? "Settings issue" : "Settings updated", - message: remoteConfigMessage, - actions: remoteConfigActions - ) - } - } - } - - private var linearRoutingSummaryCard: some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 4) { - Text("Ticket sync") - .font(.system(size: 15, weight: .semibold)) - Text(linearRoutingSummaryLine) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - } - Spacer() - if !hasConfiguredLinearProfile { - Button("Open Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - .adaptiveToolbarButtonProminent() - } - } - - if hasConfiguredLinearProfile { - VStack(alignment: .leading, spacing: 14) { - if linearProfileOptions.count > 1 { - projectSettingsField( - title: "Linear account", - help: "Use a different saved Linear account for this repo when needed.", - footnote: selectedLinearProfileSummary - ) { - linearAccountPickerControl(accessibilityLabel: "Linear account") - } - } - - projectSettingsField( - title: "Linear project", - help: "Choose the main Linear project this repo should sync with.", - footnote: selectedLinearProjectFootnote - ) { - linearProjectPickerControl(accessibilityLabel: "Linear project") - } - - projectSettingsControlGroup( - footnote: linearSyncPolicySummary - ) { - VStack(alignment: .leading, spacing: 10) { - projectSettingsToggleRow( - title: "Sync from Linear", - subtitle: "Receive webhook-backed updates from the bound Linear project.", - isOn: Binding( - get: { linearSyncFromLinearEnabled }, - set: { enabled in - linearSyncFromLinearEnabled = enabled - Task { await persistLinearSyncFromLinearSetting(enabled: enabled) } - } - ), - isBusy: togglingLinearAutosyncRouteKeys.contains(defaultLinearRouteKey), - isEnabled: defaultLinearRouteTarget != nil - ) - - projectSettingsToggleRow( - title: "Create Hack tickets in Linear", - subtitle: "Automatically create linked Linear issues for new Hack tickets in this repo.", - isOn: Binding( - get: { linearCreateHackTicketsEnabled }, - set: { enabled in - linearCreateHackTicketsEnabled = enabled - Task { await persistLinearCreateHackTicketsSetting(enabled: enabled) } - } - ), - isBusy: executionTargetSaving, - isEnabled: defaultLinearRouteTarget != nil - ) - - if !linearAdditionalProjects.isEmpty { - Divider() - .opacity(0.12) - - projectSettingsFieldLabel( - "Additional projects", - help: "Advanced multi-project sync scope already linked for this repo." - ) - - VStack(alignment: .leading, spacing: 8) { - ForEach(linearAdditionalProjects) { target in - linearScopeRouteRow(target: target, isDefault: false) - } - } - } - } - } - } - } else { - HStack(spacing: 8) { - StatusPill(text: "Needs connection", tone: .warn) - Spacer(minLength: 0) - } - } - - if !linearProjectMessage.isEmpty { - Text(linearProjectMessage) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(remoteConfigTone == .warn ? Color.orange : .secondary) - } - } - .padding(16) - .background(projectSettingsCardBackground) - .task(id: resolvedLinearRoutingProfileId ?? "") { - await loadLinearRoutingProjectOptionsIfNeeded() - } - } - - private var defaultLinearRouteTarget: LinearProjectBindingTarget? { - guard !linearBoundProjectId.isEmpty else { - return nil - } - return LinearProjectBindingTarget( - profileId: resolvedLinearRoutingProfileId, - projectId: linearBoundProjectId, - projectName: linearBoundProjectName.isEmpty ? nil : linearBoundProjectName, - teamId: linearBoundTeamId.isEmpty ? nil : linearBoundTeamId - ) - } - - private var resolvedLinearRoutingProfileId: String? { - let resolved = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !resolved.isEmpty { - return resolved - } - let explicit = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !explicit.isEmpty { - return explicit - } - let inherited = linearDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - return inherited.isEmpty ? nil : inherited - } - - private var hasConfiguredLinearProfile: Bool { - let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if !resolvedProfile.isEmpty { - return true - } - return !linearProfileOptions.isEmpty || !linearProfilesById.isEmpty - } - - private var linearRoutingSummaryLine: String { - if !hasConfiguredLinearProfile { - return "Connect Linear in Settings, then choose the project this repo should sync with." - } - if !hasAnyLinearProjectRouting { - return "Linear is connected. Choose the main Linear project for this repo." - } - if !linearSyncFromLinearEnabled { - return "Linear · \(selectedLinearProjectSummary) · inbound sync off" - } - if linearCreateHackTicketsEnabled { - return "Linear · \(selectedLinearProjectSummary) · inbound on · create in Linear on" - } - return "Linear · \(selectedLinearProjectSummary) · inbound on" - } - - private var selectedLinearProjectFootnote: String { - if linearProjectOptionsLoading { - return "Loading projects…" - } - if resolvedLinearProjectId.isEmpty { - return "Choose the main Linear project for this repo." - } - return selectedLinearProjectSummary - } - - private var linearSyncPolicySummary: String { - guard defaultLinearRouteTarget != nil else { - return "Choose a Linear project before sync can start." - } - if linearSyncFromLinearEnabled && linearCreateHackTicketsEnabled { - return "Inbound sync is on. New Hack tickets will also create linked Linear issues." - } - if linearSyncFromLinearEnabled { - return "Inbound sync is on for the bound Linear project. Hack tickets stay local unless you turn on outbound creation." - } - if linearCreateHackTicketsEnabled { - return "Inbound sync is off. New Hack tickets will still create linked Linear issues." - } - return "Sync is manual until you enable one of the project sync toggles." - } - - private var defaultLinearRouteKey: String { - guard let defaultLinearRouteTarget else { - return "__no_linear_route__" - } - return linearRouteKey(for: defaultLinearRouteTarget) - } - - private var projectSettingsCardBackground: some View { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(Color.primary.opacity(colorScheme == .dark ? 0.045 : 0.03)) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(Color.primary.opacity(colorScheme == .dark ? 0.1 : 0.08), lineWidth: 1) - ) - } - - private func projectSettingsFootnote(_ text: String) -> some View { - Text(text) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.tertiary) - } - - private func projectSettingsField( - title: String, - help: String? = nil, - footnote: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 10) { - projectSettingsFieldLabel(title, help: help) - projectSettingsControlShell(maxWidth: 360) { - content() - } - projectSettingsFootnote(footnote) - } - } - - private func projectSettingsControlGroup( - footnote: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 8) { - content() - projectSettingsFootnote(footnote) - } - } - - private func projectSettingsToggleRow( - title: String, - subtitle: String, - isOn: Binding, - isBusy: Bool, - isEnabled: Bool = true - ) -> some View { - projectSettingsControlShell(maxWidth: 520) { - HStack(alignment: .center, spacing: 12) { - VStack(alignment: .leading, spacing: 2) { - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isEnabled ? .primary : .secondary) - Text(subtitle) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.tertiary) - } - Spacer(minLength: 12) - if isBusy { - ProgressView() - .controlSize(.small) - } - Toggle(title, isOn: isOn) - .labelsHidden() - .toggleStyle(.switch) - .disabled(!isEnabled || isBusy) - } - } - } - - private func projectSettingsFieldLabel(_ text: String, help: String? = nil) -> some View { - HStack(alignment: .center, spacing: 6) { - Text(text) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - .textCase(.uppercase) - if let help, !help.isEmpty { - Image(systemName: "info.circle") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.tertiary) - .help(help) - } - } - } - - private func projectSettingsMenuLabel( - _ text: String, - isDisabled: Bool = false - ) -> some View { - HStack(spacing: 10) { - Text(text) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(isDisabled ? .tertiary : .primary) - .lineLimit(1) - Spacer(minLength: 8) - Image(systemName: "chevron.up.chevron.down") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(isDisabled ? .tertiary : .secondary) - } - .frame(maxWidth: .infinity, alignment: .leading) - .contentShape(Rectangle()) - } - - private func projectSettingsStaticValue(_ text: String) -> some View { - Text(text) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, alignment: .leading) - } - - private func projectSettingsControlShell( - maxWidth: CGFloat? = nil, - @ViewBuilder content: () -> Content - ) -> some View { - HStack(spacing: 0) { - content() - Spacer(minLength: 0) - } - .frame(maxWidth: maxWidth, alignment: .leading) - .padding(.horizontal, 12) - .padding(.vertical, 10) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(colorScheme == .dark ? 0.05 : 0.04)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(colorScheme == .dark ? 0.12 : 0.08), lineWidth: 1) - ) - } - - private func additionalProjectMenuLabel(_ projectId: String) -> String { - let trimmed = projectId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return "Choose project" - } - guard let project = availableAdditionalLinearProjects.first(where: { $0.id == trimmed }) else { - return trimmed - } - return linearProjectMenuLabel(project) - } - - private func routingSheetSection( - title: String, - help: String? = nil, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 12) { - HStack(alignment: .center, spacing: 6) { - Text(title) - .font(.system(size: 15, weight: .semibold)) - if let help, !help.isEmpty { - Image(systemName: "info.circle") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.tertiary) - .help(help) - } - } - - content() - } - .padding(18) - .background(projectSettingsCardBackground) - } - private var servicesSection: some View { VStack(alignment: .leading, spacing: 0) { HStack(spacing: 10) { @@ -1690,1204 +1034,31 @@ struct ProjectDetailView: View { project.features ?? project.extensionsEnabled ?? [] } - private var selectedNodeSummary: String { - if executionMode == .local { - return "Local" - } - let selectedNodeId = executionTargetNodeId.trimmingCharacters(in: .whitespacesAndNewlines) - if !selectedNodeId.isEmpty { - if let node = executionTargetNodes.first(where: { $0.id == selectedNodeId }) { - return node.name - } - return selectedNodeId - } - let inheritedDefaultNodeId = executionDefaultNodeId.trimmingCharacters(in: .whitespacesAndNewlines) - if inheritedDefaultNodeId.isEmpty { - return "Inherited (no global default)" + private struct LifecycleSummaryCounts { + let startupHookCount: Int + let shutdownHookCount: Int + let processCount: Int + let persistentHookCount: Int + + var persistentCount: Int { + persistentHookCount + processCount } - if let defaultNode = executionTargetNodes.first(where: { $0.id == inheritedDefaultNodeId }) { - return "Inherited (\(defaultNode.name))" + + var hasEntries: Bool { + startupHookCount > 0 || shutdownHookCount > 0 || processCount > 0 } - return "Inherited (\(inheritedDefaultNodeId))" } - private var selectedGitSummary: String { - let selectedProfileId = githubProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !selectedProfileId.isEmpty else { - return "Local" - } - return githubProfileLabel(profileId: selectedProfileId) - } - - @ViewBuilder - private func linearScopeRouteRow( - target: LinearProjectBindingTarget, - isDefault: Bool - ) -> some View { - let normalizedTarget = normalizedLinearRouteTarget(target) - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 8) { - Text(linearProjectBindingTargetLabel(normalizedTarget)) - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.primary) - if isDefault { - StatusPill(text: "Default", tone: .good) - } else { - StatusPill(text: "Linked", tone: .neutral) - } - if isLinearAutosyncEnabled(for: normalizedTarget) { - StatusPill(text: "Autosync", tone: .good) - } - Spacer() - if isLinearAutosyncBusy(for: normalizedTarget) { - ProgressView() - .controlSize(.small) - } - Toggle( - isOn: Binding( - get: { isLinearAutosyncEnabled(for: normalizedTarget) }, - set: { enabled in - Task { - await toggleLinearAutosync(for: normalizedTarget, enabled: enabled) - } - } - ) - ) { - Text("Autosync") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - .disabled(isLinearAutosyncBusy(for: normalizedTarget)) - - if !isDefault { - Menu { - Button("Make default") { - Task { await makeAdditionalLinearProjectDefault(normalizedTarget) } - } - Button(role: .destructive) { - Task { await removeAdditionalLinearProject(normalizedTarget) } - } label: { - Text("Remove") - } - } label: { - Label("Actions", systemImage: "ellipsis.circle") - } - .buttonStyle(PressableIconButtonStyle()) - } - } - - HStack(spacing: 8) { - if let profileId = normalizedTarget.profileId, !profileId.isEmpty { - Text("Profile \(linearProfileLabel(profileId: profileId))") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if let teamId = normalizedTarget.teamId, !teamId.isEmpty { - Text("Team \(teamId)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } - .padding(10) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(Color.primary.opacity(colorScheme == .dark ? 0.05 : 0.035)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - } - - private var selectedLinearProfileSummary: String { - let selectedProfileId = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !selectedProfileId.isEmpty else { - if linearDefaultProfile.isEmpty { - return "Inherited" - } - return linearProfileLabel(profileId: linearDefaultProfile) - } - return linearProfileLabel(profileId: selectedProfileId) - } - - private var selectedLinearProjectSummary: String { - let selectedProjectId = resolvedLinearProjectId - guard !selectedProjectId.isEmpty else { - return "Unbound" - } - if let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) { - return linearProjectMenuLabel(linearProject) - } - if !linearBoundProjectName.isEmpty { - return linearBoundProjectName - } - return selectedProjectId - } - - private var availableAdditionalLinearProjects: [LinearProjectSummary] { - let linkedProjectIds = Set(linearAdditionalProjects.map(\.projectId)) - return linearProjectOptions.filter { linearProject in - linearProject.id != linearBoundProjectId && !linkedProjectIds.contains(linearProject.id) - } - } - - private var effectiveDefaultLinearTarget: LinearProjectBindingTarget? { - guard !linearBoundProjectId.isEmpty else { - return nil - } - return LinearProjectBindingTarget( - profileId: linearBoundProfileId, - projectId: linearBoundProjectId, - projectName: linearBoundProjectName.isEmpty ? nil : linearBoundProjectName, - teamId: linearBoundTeamId.isEmpty ? nil : linearBoundTeamId - ) - } - - private var linearBoundProfileId: String? { - let projectProfile = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !projectProfile.isEmpty { - return projectProfile - } - let resolvedProfile = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) - return resolvedProfile.isEmpty ? nil : resolvedProfile - } - - private var allLinearRouteTargets: [LinearProjectBindingTarget] { - var targets: [LinearProjectBindingTarget] = [] - if let effectiveDefaultLinearTarget { - targets.append(effectiveDefaultLinearTarget) - } - targets.append(contentsOf: linearAdditionalProjects.map(normalizedLinearRouteTarget(_:))) - return targets - } - - private func normalizedLinearRouteTarget(_ target: LinearProjectBindingTarget) -> LinearProjectBindingTarget { - if let profileId = target.profileId?.trimmingCharacters(in: .whitespacesAndNewlines), - !profileId.isEmpty { - return target - } - return LinearProjectBindingTarget( - profileId: linearBoundProfileId, - projectId: target.projectId, - projectName: target.projectName, - teamId: target.teamId - ) - } - - private func linearRouteKey(for target: LinearProjectBindingTarget) -> String { - let normalized = normalizedLinearRouteTarget(target) - let profileId = normalized.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" - let teamId = normalized.teamId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" - return [profileId, normalized.projectId, teamId].joined(separator: "::") - } - - private func isLinearAutosyncEnabled(for target: LinearProjectBindingTarget) -> Bool { - linearAutosyncRouteKeys.contains(linearRouteKey(for: target)) - } - - private var hasAnyLinearAutosyncRoutes: Bool { - !linearAutosyncRouteKeys.isEmpty - } - - private func isLinearAutosyncBusy(for target: LinearProjectBindingTarget) -> Bool { - togglingLinearAutosyncRouteKeys.contains(linearRouteKey(for: target)) - } - - private var linearResolvedAccountName: String? { - let trimmed = linearResolvedProfile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return linearAccountLabel(profileId: trimmed) - } - - private var resolvedLinearProjectId: String { - let selected = selectedLinearProjectId.trimmingCharacters(in: .whitespacesAndNewlines) - if !selected.isEmpty { - return selected - } - return linearBoundProjectId.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var hasAnyLinearProjectRouting: Bool { - if !resolvedLinearProjectId.isEmpty { - return true - } - return !linearAdditionalProjects.isEmpty - } - - private var remoteConfigMessage: String { - if !executionTargetMessage.isEmpty { - return executionTargetMessage - } - if !githubProfileMessage.isEmpty { - return githubProfileMessage - } - if !linearProjectMessage.isEmpty { - return linearProjectMessage - } - return "" - } - - private var remoteConfigTone: StatusTone { - if remoteConfigMessage.hasPrefix("Failed") { - return .warn - } - return .good - } - - private var remoteConfigActions: [InlineCalloutAction] { - if remoteConfigTone == .warn { - return [ - InlineCalloutAction( - label: "Linear settings", - systemImage: "point.3.connected.trianglepath.dotted" - ) { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - ] - } - return [ - InlineCalloutAction(label: "Open tickets", systemImage: "ticket") { - NotificationCenter.default.post( - name: .hackProjectNavigationRequested, - object: nil, - userInfo: [ - ProjectNavigationRequest.projectIdKey: project.id, - ProjectNavigationRequest.tabKey: ProjectTab.tickets.rawValue, - ProjectNavigationRequest.sidebarKey: ProjectSidebarItem.tickets.rawValue, - ] - ) - } - ] - } - - private func githubAccountLogin(profileId: String) -> String? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let summaryLogin = githubProfilesById[trimmed]?.accountLogin? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let summaryLogin, !summaryLogin.isEmpty { - return summaryLogin - } - let statusLogin = githubProfileStatusById[trimmed]?.accountLogin? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let statusLogin, !statusLogin.isEmpty { - return statusLogin - } - return nil - } - - private func githubAccountName(profileId: String) -> String? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let summaryName = githubProfilesById[trimmed]?.accountName? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let summaryName, !summaryName.isEmpty { - return summaryName - } - let statusName = githubProfileStatusById[trimmed]?.accountName? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let statusName, !statusName.isEmpty { - return statusName - } - return nil - } - - private func githubProfileLabel(profileId: String) -> String { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return "Inherited" - } - guard let account = githubAccountLogin(profileId: trimmed) else { - return trimmed - } - return "@\(account) (\(trimmed))" - } - - private func linearAccountName(profileId: String) -> String? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let summaryName = linearProfilesById[trimmed]?.accountName? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let summaryName, !summaryName.isEmpty { - return summaryName - } - let statusName = linearProfileStatusById[trimmed]?.accountName? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let statusName, !statusName.isEmpty { - return statusName - } - return nil - } - - private func loadLinearRoutingProjectOptionsIfNeeded() async { - let resolvedProfile = resolvedLinearRoutingProfileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !resolvedProfile.isEmpty else { - linearProjectOptions = [] - linearProjectOptionsLoading = false - return - } - linearProjectOptionsLoading = true - defer { linearProjectOptionsLoading = false } - if let projectCatalog = await model.listLinearProjects(profileId: resolvedProfile) { - linearProjectOptions = projectCatalog.projects.sorted { lhs, rhs in - lhs.name.localizedCaseInsensitiveCompare(rhs.name) == .orderedAscending - } - } else { - linearProjectOptions = [] - } - } - - @ViewBuilder - private func linearAccountPickerControl(accessibilityLabel: String) -> some View { - Menu { - Button { - guard !linearProjectProfile.isEmpty else { - return - } - linearProjectProfile = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } label: { - if linearProjectProfile.isEmpty { - Label("Inherited", systemImage: "checkmark") - } else { - Text("Inherited") - } - } - ForEach(linearProfileOptions, id: \.self) { profile in - Button { - guard linearProjectProfile != profile else { - return - } - linearProjectProfile = profile - guard !executionTargetLoading else { - return - } - Task { await persistLinearProfileOverride() } - } label: { - let label = linearProfileLabel(profileId: profile) - if linearProjectProfile == profile { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel(selectedLinearProfileSummary) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - } - - @ViewBuilder - private func linearProjectPickerControl(accessibilityLabel: String) -> some View { - if linearProjectOptionsLoading { - projectSettingsStaticValue("Loading projects…") - .accessibilityLabel(accessibilityLabel) - } else if linearProjectOptions.isEmpty { - projectSettingsStaticValue("No projects available") - .accessibilityLabel(accessibilityLabel) - } else { - Menu { - Button { - guard !selectedLinearProjectId.isEmpty else { - return - } - selectedLinearProjectId = "" - guard !executionTargetLoading else { - return - } - Task { await persistLinearProjectBindingSelection() } - } label: { - if selectedLinearProjectId.isEmpty { - Label("Unbound", systemImage: "checkmark") - } else { - Text("Unbound") - } - } - ForEach(linearProjectOptions) { linearProject in - Button { - guard selectedLinearProjectId != linearProject.id else { - return - } - selectedLinearProjectId = linearProject.id - guard !executionTargetLoading else { - return - } - Task { await persistLinearProjectBindingSelection() } - } label: { - let label = linearProjectMenuLabel(linearProject) - if selectedLinearProjectId == linearProject.id { - Label(label, systemImage: "checkmark") - } else { - Text(label) - } - } - } - } label: { - projectSettingsMenuLabel(selectedLinearProjectSummary) - } - .buttonStyle(.plain) - .accessibilityLabel(accessibilityLabel) - } - } - - private func linearAccountEmail(profileId: String) -> String? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let summaryEmail = linearProfilesById[trimmed]?.accountEmail? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let summaryEmail, !summaryEmail.isEmpty { - return summaryEmail - } - let statusEmail = linearProfileStatusById[trimmed]?.accountEmail? - .trimmingCharacters(in: .whitespacesAndNewlines) - if let statusEmail, !statusEmail.isEmpty { - return statusEmail - } - return nil - } - - private func linearAccountLabel(profileId: String) -> String? { - linearAccountName(profileId: profileId) ?? linearAccountEmail(profileId: profileId) - } - - private func linearProfileLabel(profileId: String) -> String { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return "Inherited" - } - guard let label = linearAccountLabel(profileId: trimmed) else { - return trimmed - } - return "\(label) (\(trimmed))" - } - - private func linearProjectMenuLabel(_ project: LinearProjectSummary) -> String { - if let teamKey = project.teamKey, !teamKey.isEmpty { - return "\(project.name) (\(teamKey))" - } - if let teamName = project.teamName, !teamName.isEmpty { - return "\(project.name) (\(teamName))" - } - return project.name - } - - private func linearProjectBindingTargetLabel(_ target: LinearProjectBindingTarget) -> String { - if let matchingProject = linearProjectOptions.first(where: { $0.id == target.projectId }) { - let projectLabel = linearProjectMenuLabel(matchingProject) - if let profileId = target.profileId, - !profileId.isEmpty, - profileId != linearResolvedProfile, - profileId != linearProjectProfile { - return "\(linearProfileLabel(profileId: profileId)) • \(projectLabel)" - } - return projectLabel - } - - let projectLabel: String - if let projectName = target.projectName, !projectName.isEmpty { - projectLabel = projectName - } else { - projectLabel = target.projectId - } - if let profileId = target.profileId, !profileId.isEmpty { - return "\(linearProfileLabel(profileId: profileId)) • \(projectLabel)" - } - return projectLabel - } - - /** - Reload project-level remote execution defaults and Git credential routing. - */ - private func reloadExecutionTargetState() async { - if Task.isCancelled { - return - } - executionTargetLoading = true - defer { executionTargetLoading = false } - let identityProjectPath = project.repoRoot ?? project.projectDir - let hackAccountAuthenticated = await MainActor.run { - model.hackAccountState?.authenticated == true - } - - async let nodeList = model.listNodes() - async let executionModeRaw = model.getProjectConfig( - for: project, - key: "controlPlane.execution.mode" - ) - async let executionNodeId = model.getProjectConfig( - for: project, - key: "controlPlane.execution.nodeId" - ) - async let projectNodeId = model.getProjectConfig( - for: project, - key: "controlPlane.nodeId" - ) - async let projectGitHubProfile = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.github.profile" - ) - async let linearSyncFromLinearConfig = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.syncFromLinear" - ) - async let linearCreateHackTicketsConfig = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear" - ) - async let linearBinding = model.inspectLinearProjectBinding(for: project) - async let defaultGitHubProfile = model.getGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.github\"].config.defaultProfile" - ) - async let defaultLinearProfile = model.getGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" - ) - async let githubProfiles = model.inspectGitHubProfiles() - async let linearProfiles = model.inspectLinearProfiles() - async let remoteLinearConnections: LinearConnectionsResponse? = { - guard hackAccountAuthenticated else { - return nil - } - return await model.listLinearConnections() - }() - async let systemGitIdentity = model.inspectSystemGitIdentity( - projectPath: identityProjectPath - ) - - let resolvedNodeList = await nodeList - let resolvedExecutionModeRaw = await executionModeRaw - let resolvedExecutionNodeId = await executionNodeId - let resolvedProjectNodeId = await projectNodeId - let resolvedProjectGitHubProfile = await projectGitHubProfile - let resolvedLinearSyncFromLinearConfig = await linearSyncFromLinearConfig - let resolvedLinearCreateHackTicketsConfig = await linearCreateHackTicketsConfig - let resolvedLinearBinding = await linearBinding - let resolvedDefaultGitHubProfile = await defaultGitHubProfile - let resolvedDefaultLinearProfile = await defaultLinearProfile - let resolvedGitHubProfiles = await githubProfiles - var resolvedLinearProfiles = await linearProfiles - let resolvedRemoteLinearConnections = await remoteLinearConnections - let resolvedSystemGitIdentity = await systemGitIdentity - - if Task.isCancelled { - return - } - - executionTargetNodes = resolvedNodeList?.nodes ?? [] - executionDefaultNodeId = (resolvedNodeList?.defaultNodeId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let executionNodeIdValue = (resolvedExecutionNodeId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let projectNodeIdValue = (resolvedProjectNodeId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - executionTargetNodeId = !executionNodeIdValue.isEmpty - ? executionNodeIdValue - : projectNodeIdValue - githubProjectProfile = (resolvedProjectGitHubProfile ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let fallbackDefaultGitHubProfile = (resolvedDefaultGitHubProfile ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - githubDefaultProfile = resolvedGitHubProfiles?.defaultProfile ?? fallbackDefaultGitHubProfile - githubProfilesById = mapGitHubProfilesById(response: resolvedGitHubProfiles) - githubProfileOptions = (resolvedGitHubProfiles?.profiles ?? []) - .map(\.id) - .sorted { lhs, rhs in - lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending - } - githubProfileStatusById = [:] - let resolvedGitHubProfile = githubProjectProfile.isEmpty - ? githubDefaultProfile - : githubProjectProfile - githubResolvedProfile = resolvedGitHubProfile - projectSystemGitIdentity = resolvedSystemGitIdentity - - linearProjectProfile = (resolvedLinearBinding?.profileId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundProjectId = (resolvedLinearBinding?.projectId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundProjectName = (resolvedLinearBinding?.projectName ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - linearBoundTeamId = (resolvedLinearBinding?.teamId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - linearAdditionalProjects = resolvedLinearBinding?.additionalProjects ?? [] - let fallbackDefaultLinearProfile = (resolvedDefaultLinearProfile ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let initialLinearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile - let explicitLinearProfile = (resolvedLinearBinding?.profileId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let initialResolvedLinearProfile = - explicitLinearProfile.isEmpty ? initialLinearDefaultProfile : explicitLinearProfile - if !initialResolvedLinearProfile.isEmpty, - !linearProfilesResponse(resolvedLinearProfiles, containsProfileId: initialResolvedLinearProfile), - remoteLinearConnection( - profileId: initialResolvedLinearProfile, - in: resolvedRemoteLinearConnections - )?.localAccessAvailable == true, - await model.seedLinearLocalAccess(profileId: initialResolvedLinearProfile) != nil - { - resolvedLinearProfiles = await model.inspectLinearProfiles() - } - linearDefaultProfile = resolvedLinearProfiles?.defaultProfile ?? fallbackDefaultLinearProfile - linearProfilesById = mapLinearProfilesById(response: resolvedLinearProfiles) - linearProfileOptions = (resolvedLinearProfiles?.profiles ?? []) - .map(\.id) - .sorted { lhs, rhs in - lhs.localizedCaseInsensitiveCompare(rhs) == .orderedAscending - } - linearProfileStatusById = [:] - let resolvedLinearProfile = linearProjectProfile.isEmpty - ? linearDefaultProfile - : linearProjectProfile - linearResolvedProfile = resolvedLinearProfile - linearSyncFromLinearEnabled = parseProjectBooleanConfig( - resolvedLinearSyncFromLinearConfig, - default: true - ) - linearCreateHackTicketsEnabled = parseProjectBooleanConfig( - resolvedLinearCreateHackTicketsConfig, - default: false - ) - selectedLinearProjectId = linearBoundProjectId - if linearAdditionalProjects.contains(where: { $0.projectId == selectedAdditionalLinearProjectId }) { - selectedAdditionalLinearProjectId = "" - } - await loadLinearRoutingProjectOptionsIfNeeded() - linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) - executionMode = RemoteExecutionMode.fromConfig(resolvedExecutionModeRaw) - ?? (executionTargetNodeId.isEmpty ? .local : .localEditRemoteRun) - executionTargetMessage = "" - githubProfileMessage = "" - linearProjectMessage = "" - } - - private func queueExecutionTargetReload() { - executionTargetReloadTask?.cancel() - executionTargetReloadTask = Task { - await reloadExecutionTargetState() - } - } - - private func linearProfilesResponse( - _ response: LinearProfilesResponse?, - containsProfileId profileId: String - ) -> Bool { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return false - } - return response?.profiles.contains(where: { - $0.id.caseInsensitiveCompare(trimmed) == .orderedSame - }) == true - } - - private func remoteLinearConnection( - profileId: String, - in response: LinearConnectionsResponse? - ) -> LinearRemoteConnection? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return response?.connections.first(where: { connection in - connection.profileId?.caseInsensitiveCompare(trimmed) == .orderedSame - }) - } - - /** - Persist project execution mode, including local-mode safety resets. - */ - private func persistExecutionModeSelection() async { - executionTargetSaving = true - defer { executionTargetSaving = false } - - let modeValue = executionMode.rawValue - let didSaveMode = await model.setProjectConfig( - for: project, - key: "controlPlane.execution.mode", - value: modeValue - ) - if !didSaveMode { - executionTargetMessage = "Failed to save execution mode." - return - } - - if executionMode == .local { - let clearWrites: [(String, String)] = [ - ("controlPlane.execution.nodeId", ""), - ("controlPlane.nodeId", ""), - ] - for (key, value) in clearWrites { - let didSave = await model.setProjectConfig( - for: project, - key: key, - value: value - ) - if !didSave { - executionTargetMessage = "Failed to reset project node for local mode." - return - } - } - executionTargetNodeId = "" - } - - executionTargetMessage = "Execution mode set to \(executionMode.title)." - githubProfileMessage = "" - queueExecutionTargetReload() - } - - /** - Save simplified default-node selection. - * - Selecting "Local" clears project node affinity and falls back to global node selection. - */ - private func persistSimpleDefaultNodeSelection() async { - executionTargetSaving = true - defer { executionTargetSaving = false } - - let selectedNodeId = executionTargetNodeId.trimmingCharacters(in: .whitespacesAndNewlines) - let writes: [(String, String)] = [ - ("controlPlane.execution.nodeId", selectedNodeId), - ("controlPlane.nodeId", selectedNodeId), - ("controlPlane.routing.provider", ""), - ("controlPlane.routing.profile", ""), - ] - - for (key, value) in writes { - let didSave = await model.setProjectConfig( - for: project, - key: key, - value: value - ) - if !didSave { - executionTargetMessage = "Failed to save default node." - return - } - } - - if executionMode == .localEditRemoteRun || executionMode == .remoteDevcontainer, - selectedNodeId.isEmpty, - executionDefaultNodeId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - executionTargetMessage = "No global default node found. Choose a node or run `hack node use `." - githubProfileMessage = "" - return - } - - if selectedNodeId.isEmpty { - executionTargetMessage = "Default node set to inherited." - } else if let node = executionTargetNodes.first(where: { $0.id == selectedNodeId }) { - executionTargetMessage = "Default node set to \(node.name)." - } else { - executionTargetMessage = "Default node updated." - } - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private func persistGitCredentialsSelection() async { - await persistGitHubProfileOverride() - } - - private func mapGitHubProfilesById(response: GitHubProfilesResponse?) -> [String: GitHubProfileSummary] { - guard let response else { - return [:] - } - return response.profiles.reduce(into: [:]) { partialResult, profile in - partialResult[profile.id] = profile - } - } - - private func mapLinearProfilesById(response: LinearProfilesResponse?) -> [String: LinearProfileSummary] { - guard let response else { - return [:] - } - return response.profiles.reduce(into: [:]) { partialResult, profile in - partialResult[profile.id] = profile - } - } - - private func parseProjectBooleanConfig(_ value: String?, default defaultValue: Bool) -> Bool { - guard let value else { - return defaultValue - } - switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "true", "1", "yes", "on": - return true - case "false", "0", "no", "off": - return false - default: - return defaultValue - } - } - - private func persistGitHubProfileOverride() async { - executionTargetSaving = true - defer { executionTargetSaving = false } - - let trimmed = githubProjectProfile - .trimmingCharacters(in: .whitespacesAndNewlines) - let didSave = await model.setProjectConfig( - for: project, - key: "controlPlane.routing.overrides.github.profile", - value: trimmed - ) - if !didSave { - githubProfileMessage = "Failed to save GitHub profile." - return - } - githubProfileMessage = trimmed.isEmpty - ? "GitHub profile set to Local." - : "GitHub profile set to \(githubProfileLabel(profileId: trimmed))." - executionTargetMessage = "" - queueExecutionTargetReload() - } - - private func persistLinearProfileOverride() async { - executionTargetSaving = true - defer { executionTargetSaving = false } - - let trimmed = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let didEnable = await model.setProjectConfig( - for: project, - key: "controlPlane.extensions[\"dance.hack.linear\"].enabled", - value: "true" - ) - if !didEnable { - linearProjectMessage = "Failed to enable Linear for this project." - return - } - let didSave = await model.setProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.profile", - value: trimmed - ) - if !didSave { - linearProjectMessage = "Failed to save Linear profile routing." - return - } - linearProjectMessage = trimmed.isEmpty - ? "Linear profile set to inherited." - : "Linear profile set to \(linearProfileLabel(profileId: trimmed))." - executionTargetMessage = "" - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private func persistLinearSyncFromLinearSetting( - enabled: Bool, - refreshAfterSave: Bool = true - ) async { - let didSave = await model.setProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.syncFromLinear", - value: enabled ? "true" : "false" - ) - guard didSave else { - linearSyncFromLinearEnabled.toggle() - linearProjectMessage = "Failed to save inbound Linear sync." - return - } - - if let defaultTarget = defaultLinearRouteTarget { - await toggleLinearAutosync( - for: defaultTarget, - enabled: enabled, - refreshAfterSave: false - ) - } - - linearProjectMessage = enabled - ? "Inbound Linear sync enabled." - : "Inbound Linear sync disabled." - if refreshAfterSave { - queueExecutionTargetReload() - } - } - - private func persistLinearCreateHackTicketsSetting(enabled: Bool) async { - let didSave = await model.setProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear", - value: enabled ? "true" : "false" - ) - guard didSave else { - linearCreateHackTicketsEnabled.toggle() - linearProjectMessage = "Failed to save outbound Linear creation." - return - } - linearProjectMessage = enabled - ? "New Hack tickets will now create linked Linear issues." - : "New Hack tickets will stay local by default." - } - - private func persistLinearProjectBindingSelection() async { - executionTargetSaving = true - defer { executionTargetSaving = false } - - let selectedProjectId = selectedLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - if selectedProjectId.isEmpty { - await clearLinearProjectBinding() - return - } - - guard let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) else { - linearProjectMessage = "Choose a valid Linear project before saving." - return - } - - let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let response = await model.bindLinearProject( - for: project, - profileId: profileOverride.isEmpty ? nil : profileOverride, - projectId: linearProject.id, - projectName: linearProject.name, - teamId: linearProject.teamId, - clear: false - ) - guard response?.ok == true else { - linearProjectMessage = model.errorMessage ?? "Failed to save Linear project binding." - return - } - - linearBoundProjectId = linearProject.id - linearBoundProjectName = linearProject.name - linearBoundTeamId = linearProject.teamId - selectedLinearProjectId = linearProject.id - if linearSyncFromLinearEnabled { - await toggleLinearAutosync( - for: normalizedLinearRouteTarget( - LinearProjectBindingTarget( - profileId: profileOverride.isEmpty ? nil : profileOverride, - projectId: linearProject.id, - projectName: linearProject.name, - teamId: linearProject.teamId - ) - ), - enabled: true, - refreshAfterSave: false - ) - } - linearProjectMessage = "Linear project bound to \(linearProjectMenuLabel(linearProject))." - executionTargetMessage = "" - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private func persistAdditionalLinearProjectSelection() async { - let selectedProjectId = selectedAdditionalLinearProjectId - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !selectedProjectId.isEmpty else { - linearProjectMessage = "Choose a Linear project to add." - return - } - guard let linearProject = linearProjectOptions.first(where: { $0.id == selectedProjectId }) else { - linearProjectMessage = "Choose a valid Linear project before adding it." - return - } - - let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let response = await model.linkLinearProject( - for: project, - profileId: profileOverride.isEmpty ? nil : profileOverride, - projectId: linearProject.id, - projectName: linearProject.name, - teamId: linearProject.teamId - ) - guard response?.ok == true else { - linearProjectMessage = model.errorMessage ?? "Failed to add Linear project to sync scope." - return - } - - selectedAdditionalLinearProjectId = "" - linearProjectMessage = "Added \(linearProjectMenuLabel(linearProject)) to the sync scope." - executionTargetMessage = "" - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private func loadLinearAutosyncRouteKeys( - targets: [LinearProjectBindingTarget] - ) async -> Set { - var nextKeys: Set = [] - for target in targets { - let normalizedTarget = normalizedLinearRouteTarget(target) - guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { - continue - } - guard let subscriptions = await model.listLinearAutosyncSubscriptions( - profileId: profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId - ) else { - continue - } - if subscriptions.subscriptions.contains(where: { - $0.projectId == normalizedTarget.projectId && - $0.teamId == normalizedTarget.teamId && - $0.mode == "auto_apply" && - $0.status == "active" - }) { - nextKeys.insert(linearRouteKey(for: normalizedTarget)) - } - } - return nextKeys - } - - private func removeAdditionalLinearProject(_ target: LinearProjectBindingTarget) async { - let response = await model.unlinkLinearProject( - for: project, - projectId: target.projectId - ) - guard response?.ok == true else { - linearProjectMessage = model.errorMessage ?? "Failed to remove linked Linear project." - return - } - - linearProjectMessage = "Removed \(linearProjectBindingTargetLabel(target)) from the sync scope." - executionTargetMessage = "" - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private func toggleLinearAutosync( - for target: LinearProjectBindingTarget, - enabled: Bool, - refreshAfterSave: Bool = true - ) async { - let routeKey = linearRouteKey(for: target) - togglingLinearAutosyncRouteKeys.insert(routeKey) - defer { togglingLinearAutosyncRouteKeys.remove(routeKey) } - let normalizedTarget = normalizedLinearRouteTarget(target) - guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { - linearProjectMessage = "Choose a Linear profile before changing autosync." - return - } - if enabled { - let response = await model.setLinearAutosyncSubscription( - profileId: profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId, - mode: "auto_apply", - status: "active" - ) - guard response != nil else { - linearProjectMessage = model.errorMessage ?? "Failed to enable Linear autosync." - return - } - linearAutosyncRouteKeys.insert(routeKey) - linearProjectMessage = "Autosync enabled for \(linearProjectBindingTargetLabel(normalizedTarget))." - if refreshAfterSave { - queueExecutionTargetReload() - } - return - } - - let response = await model.removeLinearAutosyncSubscription( - profileId: profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId - ) - guard response != nil else { - linearProjectMessage = model.errorMessage ?? "Failed to disable Linear autosync." - return - } - linearAutosyncRouteKeys.remove(routeKey) - linearProjectMessage = "Autosync disabled for \(linearProjectBindingTargetLabel(normalizedTarget))." - if refreshAfterSave { - queueExecutionTargetReload() - } - } - - private func makeAdditionalLinearProjectDefault(_ target: LinearProjectBindingTarget) async { - let normalizedTarget = normalizedLinearRouteTarget(target) - let response = await model.bindLinearProject( - for: project, - profileId: normalizedTarget.profileId, - projectId: normalizedTarget.projectId, - projectName: normalizedTarget.projectName, - teamId: normalizedTarget.teamId, - clear: false - ) - guard response?.ok == true else { - linearProjectMessage = model.errorMessage ?? "Failed to make the linked Linear project the default route." - return - } - - linearProjectMessage = "Default Linear route set to \(linearProjectBindingTargetLabel(normalizedTarget))." - queueExecutionTargetReload() - } - - private func clearLinearProjectBinding() async { - let profileOverride = linearProjectProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let response = await model.bindLinearProject( - for: project, - profileId: profileOverride.isEmpty ? nil : profileOverride, - projectId: nil, - projectName: nil, - teamId: nil, - clear: true - ) - guard response?.ok == true else { - linearProjectMessage = model.errorMessage ?? "Failed to clear Linear project binding." - return - } - - linearBoundProjectId = "" - linearBoundProjectName = "" - linearBoundTeamId = "" - selectedLinearProjectId = "" - linearProjectMessage = "Cleared project-level Linear binding." - executionTargetMessage = "" - githubProfileMessage = "" - queueExecutionTargetReload() - } - - private struct LifecycleSummaryCounts { - let startupHookCount: Int - let shutdownHookCount: Int - let processCount: Int - let persistentHookCount: Int - - var persistentCount: Int { - persistentHookCount + processCount - } - - var hasEntries: Bool { - startupHookCount > 0 || shutdownHookCount > 0 || processCount > 0 - } - } - - private var lifecycleSummary: LifecycleSummaryCounts { - let lifecycle = project.lifecycle - let persistentHooks = lifecycleHooks.filter { $0.command.persistent == true } - return LifecycleSummaryCounts( - startupHookCount: (lifecycle?.upBefore.count ?? 0) + (lifecycle?.upAfter.count ?? 0), - shutdownHookCount: (lifecycle?.downBefore.count ?? 0) + (lifecycle?.downAfter.count ?? 0), - processCount: lifecycle?.processes.count ?? 0, - persistentHookCount: persistentHooks.count - ) + private var lifecycleSummary: LifecycleSummaryCounts { + let lifecycle = project.lifecycle + let persistentHooks = lifecycleHooks.filter { $0.command.persistent == true } + return LifecycleSummaryCounts( + startupHookCount: (lifecycle?.upBefore.count ?? 0) + (lifecycle?.upAfter.count ?? 0), + shutdownHookCount: (lifecycle?.downBefore.count ?? 0) + (lifecycle?.downAfter.count ?? 0), + processCount: lifecycle?.processes.count ?? 0, + persistentHookCount: persistentHooks.count + ) } private var lifecycleHooks: [(phase: String, command: ProjectLifecycleCommandSummary)] { @@ -3517,9 +1688,6 @@ struct ProjectDetailView: View { tabs.append(.branches) tabs.append(.sessions) } - if project.supportsTickets { - tabs.append(.tickets) - } return tabs } @@ -3531,7 +1699,7 @@ struct ProjectDetailView: View { } private var overviewSidebarItems: [ProjectSidebarItem] { - var items: [ProjectSidebarItem] = [.services, .remoteExecution] + var items: [ProjectSidebarItem] = [.services] if lifecycleSummary.hasEntries { items.append(.lifecycle) } @@ -3544,9 +1712,6 @@ struct ProjectDetailView: View { items.append(.branches) items.append(.sessions) } - if project.supportsTickets { - items.append(.tickets) - } return items } @@ -3560,8 +1725,6 @@ struct ProjectDetailView: View { private func sidebarItemCountLabel(_ item: ProjectSidebarItem) -> String? { switch item { - case .remoteExecution: - return nil case .services: return "\(serviceEntries.count)" case .lifecycle: @@ -3570,8 +1733,6 @@ struct ProjectDetailView: View { return "\(branchEntries.count)" case .sessions: return "\(sessionEntries.count)" - case .tickets: - return project.supportsTickets ? "On" : nil } } @@ -3597,8 +1758,6 @@ struct ProjectDetailView: View { selectedSidebarItem = .branches case .sessions: selectedSidebarItem = .sessions - case .tickets: - selectedSidebarItem = .tickets case .logs, .shell: if !overviewSidebarItems.contains(selectedSidebarItem) { selectedSidebarItem = .services @@ -3609,8 +1768,6 @@ struct ProjectDetailView: View { private func selectSidebarItem(_ item: ProjectSidebarItem) { selectedSidebarItem = item switch item { - case .remoteExecution: - model.selectedProjectTab = .overview case .services: model.selectedProjectTab = .overview case .lifecycle: @@ -3619,8 +1776,6 @@ struct ProjectDetailView: View { model.selectedProjectTab = .branches case .sessions: model.selectedProjectTab = .sessions - case .tickets: - model.selectedProjectTab = .tickets } } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRemoteExecutionView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRemoteExecutionView.swift deleted file mode 100644 index 9e480a9b..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRemoteExecutionView.swift +++ /dev/null @@ -1,46 +0,0 @@ -import SwiftUI - -/// Layout container for the project remote-execution pane. -/// -/// This keeps scrolling/padding behavior isolated from `ProjectDetailView` -/// so remote execution UX can evolve independently. -struct ProjectRemoteExecutionView: View { - let showsRuntimeNotConfigured: Bool - let showsInfoPanel: Bool - let runtimeCard: () -> RuntimeCard - let sectionContent: () -> SectionContent - let infoPanel: () -> InfoPanel - - init( - showsRuntimeNotConfigured: Bool, - showsInfoPanel: Bool, - @ViewBuilder runtimeCard: @escaping () -> RuntimeCard, - @ViewBuilder sectionContent: @escaping () -> SectionContent, - @ViewBuilder infoPanel: @escaping () -> InfoPanel - ) { - self.showsRuntimeNotConfigured = showsRuntimeNotConfigured - self.showsInfoPanel = showsInfoPanel - self.runtimeCard = runtimeCard - self.sectionContent = sectionContent - self.infoPanel = infoPanel - } - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - if showsRuntimeNotConfigured { - runtimeCard() - } - - sectionContent() - - if showsInfoPanel { - infoPanel() - .transition(.move(edge: .bottom).combined(with: .opacity)) - } - } - .padding(20) - .frame(maxWidth: .infinity, alignment: .topLeading) - } - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift index e13af705..0e5fedd8 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift @@ -194,7 +194,7 @@ struct RuntimeDetailView: View { Text("Daemon not running") .font(.mono(.subheadline, weight: .medium)) } - Text("The hack daemon manages your local development environment. Start it to enable project monitoring, logs, and gateway access.") + Text("The hack daemon manages your local development environment. Start it to enable project monitoring and logs.") .font(.mono(.caption)) .foregroundStyle(.secondary) Button { diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift index 40da800d..04e95684 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SettingsOverlayView.swift @@ -1,26 +1,14 @@ import AppKit -import Darwin import SwiftUI -import GhosttyTerminal -import HackCLIService import HackDesktopModels enum SettingsSidebarItem: String, Hashable, Identifiable { - case account case preferences case updates - case topology case runtime - case gateway case global - case supervisor case permissions - case linear - case github - case cloudflare - case railway - case tailscale case certificates case logging @@ -28,34 +16,16 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { var title: String { switch self { - case .account: - return "Account" case .preferences: return "Preferences" case .updates: return "Updates" - case .topology: - return "Topology" case .runtime: return "Runtime" - case .gateway: - return "Gateway" case .global: return "Global" - case .supervisor: - return "Supervisor" case .permissions: return "Permissions" - case .linear: - return "Linear" - case .github: - return "GitHub" - case .cloudflare: - return "Cloudflare" - case .railway: - return "Railway" - case .tailscale: - return "Tailscale" case .certificates: return "Certificates" case .logging: @@ -65,34 +35,16 @@ enum SettingsSidebarItem: String, Hashable, Identifiable { var icon: String { switch self { - case .account: - return "person.crop.circle" case .preferences: return "slider.horizontal.3" case .updates: return "arrow.triangle.2.circlepath" - case .topology: - return "point.3.connected.trianglepath.dotted" case .runtime: return "gauge.with.dots.needle.50percent" - case .gateway: - return "dot.radiowaves.left.and.right" case .global: - return "slider.horizontal.3" - case .supervisor: - return "cpu" + return "shippingbox" case .permissions: return "hand.raised.fill" - case .linear: - return "line.3.horizontal.decrease.circle" - case .github: - return "chevron.left.forwardslash.chevron.right" - case .cloudflare: - return "cloud" - case .railway: - return "tram.fill.tunnel" - case .tailscale: - return "network" case .certificates: return "checkmark.shield" case .logging: @@ -111,11 +63,16 @@ enum SettingsNavigationRequest { } struct SettingsOverlayView: View { - @Environment(DashboardModel.self) private var model @Environment(\.colorScheme) private var colorScheme @Binding var selection: SettingsSidebarItem let onClose: () -> Void + private let supportedSections: [(title: String, items: [SettingsSidebarItem])] = [ + ("Preferences", [.preferences, .updates]), + ("System", [.runtime]), + ("Local Runtime", [.global, .permissions, .certificates, .logging]) + ] + var body: some View { VStack(spacing: 0) { topBar @@ -147,9 +104,6 @@ struct SettingsOverlayView: View { .onExitCommand { onClose() } - .task { - await model.refreshHackAccountState(force: false, updateErrorMessage: false) - } } private var topBar: some View { @@ -182,31 +136,12 @@ struct SettingsOverlayView: View { private var settingsSidebar: some View { List(selection: $selection) { - Section("Account") { - settingsRow(.account) - } - Section("Preferences") { - settingsRow(.preferences) - settingsRow(.updates) - } - Section("System") { - settingsRow(.topology) - settingsRow(.runtime) - settingsRow(.gateway) - } - Section("Control Plane") { - settingsRow(.global) - settingsRow(.supervisor) - settingsRow(.permissions) - settingsRow(.logging) - settingsRow(.certificates) - } - Section("Integrations") { - settingsRow(.linear) - settingsRow(.github) - settingsRow(.cloudflare) - settingsRow(.railway) - settingsRow(.tailscale) + ForEach(supportedSections, id: \.title) { section in + Section(section.title) { + ForEach(section.items) { item in + settingsRow(item) + } + } } } .listStyle(.sidebar) @@ -226,34 +161,16 @@ struct SettingsOverlayView: View { private var settingsDetail: some View { Group { switch selection { - case .account: - AccountSettingsView() case .preferences: PreferencesSettingsView() case .updates: UpdatesSettingsView() - case .topology: - NodeTopologySettingsView() case .runtime: RuntimeDetailView() - case .gateway: - GatewayDetailView() case .global: GlobalSettingsView() - case .supervisor: - SupervisorSettingsView() case .permissions: PermissionsSettingsView() - case .linear: - LinearExtensionSettingsView() - case .github: - GitHubExtensionSettingsView() - case .cloudflare: - CloudflareExtensionSettingsView() - case .railway: - RailwayExtensionSettingsView() - case .tailscale: - TailscaleExtensionSettingsView() case .certificates: CertificatesSettingsView() case .logging: @@ -302,6 +219,198 @@ struct SettingsOverlayView: View { } } +struct SettingsSectionHeader: View { + let breadcrumb: String + let title: String + let subtitle: String? + + var body: some View { + VStack(alignment: .leading, spacing: 6) { + Text(breadcrumb) + .font(.mono(.caption2)) + .foregroundStyle(.secondary) + Text(title) + .font(.mono(.headline, weight: .semibold)) + if let subtitle, !subtitle.isEmpty { + Text(subtitle) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + } + } + } +} + +private struct PreferencesSettingsView: View { + @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredTerminalRaw = + TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.sessions.preferredExternalTerminal") private var legacyPreferredTerminalRaw = + TerminalIntegration.ExternalTerminalApp.terminal.rawValue + @AppStorage("hackDesktop.preferences.defaultIDE") private var preferredEditorRaw = + EditorIntegration.EditorApp.cursor.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgent") private var preferredCodingAgentRaw = + CodingAgentIntegration.AgentApp.codex.rawValue + @AppStorage("hackDesktop.preferences.defaultCodingAgentBinaryPath") private var preferredCodingAgentBinaryPathRaw = "" + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Preferences", + title: "Preferences", + subtitle: "Defaults for local editors, terminals, and coding agents" + ) + + GlassCard(title: "Workspace defaults", systemImage: "slider.horizontal.3") { + VStack(alignment: .leading, spacing: 14) { + settingsPicker( + title: "Default terminal", + subtitle: "Used when opening external shells or launching editor commands.", + selection: $preferredTerminalRaw, + options: terminalOptions.map { ($0.rawValue, $0.displayName) } + ) + + settingsPicker( + title: "Default editor", + subtitle: "Used when opening a project in an external editor.", + selection: $preferredEditorRaw, + options: editorOptions.map { ($0.rawValue, $0.displayName) } + ) + + settingsPicker( + title: "Default coding agent", + subtitle: "Used for quick-open agent actions from the dashboard.", + selection: $preferredCodingAgentRaw, + options: codingAgentOptions.map { ($0.rawValue, $0.displayName) } + ) + + VStack(alignment: .leading, spacing: 6) { + Text("Custom coding agent binary") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + TextField("/opt/homebrew/bin/codex", text: $preferredCodingAgentBinaryPathRaw) + .textFieldStyle(.roundedBorder) + .font(.mono(.caption)) + Text("Leave blank to auto-detect the preferred agent in PATH.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + } + + GlassCard(title: "Detected tools", systemImage: "magnifyingglass") { + DetailRows( + rows: [ + DetailRowItem( + label: "Terminal", + value: resolvedTerminalSummary + ), + DetailRowItem( + label: "Editor", + value: resolvedEditorSummary + ), + DetailRowItem( + label: "Coding agent", + value: resolvedCodingAgentSummary + ) + ] + ) + } + } + .padding(16) + } + .onChange(of: preferredTerminalRaw) { _, value in + legacyPreferredTerminalRaw = value + } + } + + private var terminalOptions: [TerminalIntegration.ExternalTerminalApp] { + let installed = TerminalIntegration.installedExternalTerminalApps() + let base: [TerminalIntegration.ExternalTerminalApp] = [.terminal] + return uniquePreservingOrder(base + installed) + } + + private var editorOptions: [EditorIntegration.EditorApp] { + let installed = EditorIntegration.installedEditors() + return uniquePreservingOrder([.cursor, .vscode, .zed, .neovim] + installed) + } + + private var codingAgentOptions: [CodingAgentIntegration.AgentApp] { + let installed = CodingAgentIntegration.installedAgents() + return uniquePreservingOrder([.codex, .cursor, .gemini, .opencode] + installed) + } + + private var resolvedTerminalSummary: String { + let terminal = resolvedTerminal + let path = TerminalIntegration.resolvedExternalTerminalPath(for: terminal) ?? "PATH or AppleScript" + return "\(terminal.displayName) • \(path)" + } + + private var resolvedEditorSummary: String { + let editor = resolvedEditor + let path = EditorIntegration.resolvedLocation(for: editor) ?? "Not found" + return "\(editor.displayName) • \(path)" + } + + private var resolvedCodingAgentSummary: String { + let agent = resolvedCodingAgent + let path = CodingAgentIntegration.resolvedBinaryPath( + for: agent, + overridePath: preferredCodingAgentBinaryPathRaw + ) ?? "Not found" + return "\(agent.displayName) • \(path)" + } + + private var resolvedTerminal: TerminalIntegration.ExternalTerminalApp { + if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredTerminalRaw) { + return explicit + } + if let legacy = TerminalIntegration.ExternalTerminalApp(rawValue: legacyPreferredTerminalRaw) { + return legacy + } + return .terminal + } + + private var resolvedEditor: EditorIntegration.EditorApp { + EditorIntegration.EditorApp(rawValue: preferredEditorRaw) ?? .cursor + } + + private var resolvedCodingAgent: CodingAgentIntegration.AgentApp { + CodingAgentIntegration.AgentApp(rawValue: preferredCodingAgentRaw) ?? .codex + } + + private func settingsPicker( + title: String, + subtitle: String, + selection: Binding, + options: [(id: String, label: String)] + ) -> some View { + VStack(alignment: .leading, spacing: 6) { + Text(title) + .font(.mono(.caption)) + .foregroundStyle(.secondary) + Picker(title, selection: selection) { + ForEach(options, id: \.id) { option in + Text(option.label) + .tag(option.id) + } + } + .labelsHidden() + Text(subtitle) + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) + } + } + + private func uniquePreservingOrder(_ values: [Value]) -> [Value] { + var seen = Set() + var ordered: [Value] = [] + for value in values where seen.insert(value).inserted { + ordered.append(value) + } + return ordered + } +} + private struct UpdatesSettingsView: View { @AppStorage("SUEnableAutomaticChecks") private var automaticallyCheckForUpdates = true @AppStorage("hackDesktop.update.available") private var updateAvailable = false @@ -318,50 +427,42 @@ private struct UpdatesSettingsView: View { ) GlassCard(title: "Current build", systemImage: "shippingbox") { - VStack(alignment: .leading, spacing: 10) { - metadataRow(title: "Version", value: appVersion) - metadataRow(title: "Build", value: appBuild) - metadataRow(title: "Status", value: updateAvailable ? "Update available" : "Up to date") - if !latestKnownReleaseVersion.isEmpty { - metadataRow(title: "Latest release", value: latestKnownReleaseVersion) - } - if !updateLastCheckedAt.isEmpty { - metadataRow(title: "Last checked", value: formattedTimestamp(updateLastCheckedAt)) - } - metadataRow(title: "Bundle ID", value: bundleIdentifier) - if let appcast = appcastURL { - metadataRow(title: "Appcast", value: appcast) - } - } + DetailRows( + rows: [ + DetailRowItem(label: "Version", value: appVersion), + DetailRowItem(label: "Build", value: appBuild), + DetailRowItem(label: "Status", value: updateAvailable ? "Update available" : "Up to date"), + DetailRowItem(label: "Latest release", value: latestKnownReleaseVersion.isEmpty ? "Unknown" : latestKnownReleaseVersion), + DetailRowItem(label: "Last checked", value: updateLastCheckedAt.isEmpty ? "Unknown" : formattedTimestamp(updateLastCheckedAt)), + DetailRowItem(label: "Bundle ID", value: bundleIdentifier), + DetailRowItem(label: "Appcast", value: appcastURL ?? "Not configured") + ] + ) } GlassCard(title: "Update behavior", systemImage: "arrow.clockwise.circle") { - VStack(alignment: .leading, spacing: 12) { - Toggle("Automatically check for updates", isOn: $automaticallyCheckForUpdates) - .font(.mono(.subheadline)) + Toggle("Automatically check for updates", isOn: $automaticallyCheckForUpdates) + .font(.mono(.subheadline)) + + Text("Automatic checks apply to signed release builds that include Sparkle.") + .font(.mono(.caption2)) + .foregroundStyle(.tertiary) - Text("Automatic checks apply to signed release builds that include Sparkle.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) + HStack(spacing: 10) { + Button { + NotificationCenter.default.post(name: .hackCheckForUpdatesRequested, object: nil) + } label: { + Label("Check now", systemImage: "arrow.triangle.2.circlepath") + } + .adaptiveToolbarButtonProminent() - HStack(spacing: 10) { + if let appcastURL, let url = URL(string: appcastURL) { Button { - NotificationCenter.default.post(name: .hackCheckForUpdatesRequested, object: nil) + NSWorkspace.shared.open(url) } label: { - Label("Check for updates now", systemImage: "arrow.triangle.2.circlepath") + Label("Open appcast", systemImage: "link") } - .adaptiveToolbarButtonProminent() - - if let appcast = appcastURL, let url = URL(string: appcast) { - Button { - NSWorkspace.shared.open(url) - } label: { - Label("Open appcast", systemImage: "link") - } - .adaptiveToolbarButton() - } - - Spacer() + .adaptiveToolbarButton() } } } @@ -371,8 +472,7 @@ private struct UpdatesSettingsView: View { } private var appVersion: String { - Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String - ?? "Unknown" + Bundle.main.object(forInfoDictionaryKey: "CFBundleShortVersionString") as? String ?? "Unknown" } private var appBuild: String { @@ -387,5195 +487,234 @@ private struct UpdatesSettingsView: View { Bundle.main.object(forInfoDictionaryKey: "SUFeedURL") as? String } - private func metadataRow(title: String, value: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text(value) - .font(.mono(.caption, weight: .semibold)) - .textSelection(.enabled) - } - } - private func formattedTimestamp(_ value: String) -> String { - let formatter = ISO8601DateFormatter() - guard let date = formatter.date(from: value) else { + guard let date = ISO8601DateFormatter().date(from: value) else { return value } return date.formatted(date: .abbreviated, time: .shortened) } } -struct SettingsSectionHeader: View { - let breadcrumb: String - let title: String - let subtitle: String? - - var body: some View { - VStack(alignment: .leading, spacing: 6) { - Text(breadcrumb) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Text(title) - .font(.mono(.headline, weight: .semibold)) - if let subtitle, !subtitle.isEmpty { - Text(subtitle) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - } -} - -private struct AccountSettingsView: View { +private struct GlobalSettingsView: View { @Environment(DashboardModel.self) private var model var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( - breadcrumb: "Settings / Account", - title: "Hack account", - subtitle: nil - ) - HackAccountSettingsCard( - state: model.hackAccountState, - isLoading: model.isLoadingHackAccountState + breadcrumb: "Settings / Global", + title: "Global runtime", + subtitle: "Control shared local services and inspect machine-wide runtime state" ) - } - .padding(24) - } - } -} - -private struct HackAccountSettingsCard: View { - @Environment(DashboardModel.self) private var model - let state: HackAccountSettingsState? - let isLoading: Bool - @State private var isSubmitting = false - var body: some View { - GlassCard(title: "Status", systemImage: "person.crop.circle.badge.checkmark") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: primaryStatusText, tone: primaryStatusTone) - Spacer() - if let manageURL = resolvedManageURL, state?.authenticated == true { - Button("Manage account") { - openResolvedManageURL(manageURL) - } - .adaptiveToolbarButton() - } - if let state, state.authenticated { - Button("Sign out") { - Task { await handleSignOut() } + GlassCard(title: "Global services", systemImage: "network") { + HStack(spacing: 8) { + StatusPill(text: globalStatusText, tone: globalStatusTone) + Spacer() } - .adaptiveToolbarButton() - .disabled(isBusy) - } else { - Button("Sign in to Hack") { - Task { await handleSignIn() } + + DetailRows(rows: globalRows) + + HStack(spacing: 10) { + Button { + Task { await model.toggleGlobalInfrastructure() } + } label: { + Label(globalActionTitle, systemImage: model.globalInfraRunning ? "stop.fill" : "play.fill") + } + .adaptiveToolbarButtonProminent() + + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + + Button { + TerminalIntegration.openTerminalWithCommand("hack global status") + } label: { + Label("Open CLI status", systemImage: "terminal") + } + .adaptiveToolbarButton() } - .adaptiveToolbarButtonProminent() - .disabled(isBusy || state?.authEnabled == false) - } - if isBusy { - ProgressView() - .controlSize(.small) } - } - Text(summaryMessage) - .font(.mono(.caption)) - .foregroundStyle(.secondary) + GlassCard(title: "Daemon quick actions", systemImage: "bolt.horizontal.circle") { + DetailRows(rows: daemonRows) - if let state, state.authenticated { - detailRow(title: "Hack user", value: state.userDisplayName ?? "Authenticated session") - if let email = state.userEmail, !email.isEmpty { - detailRow(title: "Email", value: email, selectable: true) - } - if let organizationName = state.organizationName, !organizationName.isEmpty { - detailRow(title: "Organization", value: organizationName) - } - if let teamName = state.teamName, !teamName.isEmpty { - detailRow(title: "Team", value: teamName) + HStack(spacing: 10) { + Button { + Task { await model.startDaemon() } + } label: { + Label("Start hackd", systemImage: "play.fill") + } + .adaptiveToolbarButtonProminent() + .disabled(model.daemonStatus?.running == true) + + Button { + Task { await model.restartDaemon() } + } label: { + Label("Restart", systemImage: "arrow.triangle.2.circlepath") + } + .adaptiveToolbarButton() + + Button { + Task { await model.clearDaemon() } + } label: { + Label("Clear state", systemImage: "trash") + } + .adaptiveToolbarButton() + } } - } else if let sessionHint { - Text(sessionHint) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) } + .padding(16) } } - private var primaryStatusText: String { - guard let state else { - return "Status unavailable" - } - if !state.authEnabled { - return "Unavailable" + private var globalStatusText: String { + if model.globalInfraRunning { + return "Running" } - if state.authenticated { - return "Signed in" + if model.globalInfraDown { + return "Stopped" } - if state.tokenStored && !state.validated { - return "Session stale" - } - return "Sign in required" + return "Unknown" } - private var primaryStatusTone: StatusTone { - guard let state else { - return .warn - } - if !state.authEnabled { - return .warn - } - if state.authenticated { + private var globalStatusTone: StatusTone { + if model.globalInfraRunning { return .good } - if state.tokenStored && !state.validated { + if model.globalInfraDown { return .warn } return .neutral } - private var summaryMessage: String { - if let state, state.authenticated { - return "Shared provider connections and broker-backed features use this Hack account." - } - return "Sign in to use shared provider connections and other broker-backed features." + private var globalActionTitle: String { + model.globalInfraRunning ? "Stop global services" : "Start global services" } - private var isBusy: Bool { - isLoading || isSubmitting + private var globalRows: [DetailRowItem] { + [ + DetailRowItem(label: "Runtime", value: runtimeValue), + DetailRowItem(label: "Generated", value: model.globalStatus?.generatedAt ?? "Unknown"), + DetailRowItem(label: "Caddy", value: serviceState(ok: model.globalStatus?.summary.caddyOk)), + DetailRowItem(label: "Logging", value: serviceState(ok: model.globalStatus?.summary.loggingOk)), + DetailRowItem(label: "Networks", value: serviceState(ok: model.globalStatus?.summary.networksOk)) + ] } - private var resolvedManageURL: String? { - guard let state, state.authenticated else { - return nil - } - return state.accountURL ?? state.shellURL + private var daemonRows: [DetailRowItem] { + [ + DetailRowItem(label: "hackd", value: model.daemonStatus?.status?.rawValue ?? daemonStatusFallback), + DetailRowItem(label: "PID", value: model.daemonStatus?.pid.map(String.init) ?? "Unknown"), + DetailRowItem(label: "Socket", value: model.daemonStatus?.socketPath ?? "Unknown"), + DetailRowItem(label: "Log path", value: model.daemonStatus?.logPath ?? "Unknown") + ] } - private var sessionHint: String? { - guard let state else { - return "Hack Desktop has not loaded account state yet." - } - if state.tokenStored && !state.validated { - return "A stored session could not be validated. Sign in again to refresh it." + private var runtimeValue: String { + if model.runtimeOverallOk == true { + return "Healthy" } - if !state.authEnabled { - return "Hack auth is not available on the broker yet, so shared remote features are unavailable from this Mac." + if model.runtimeOverallOk == false { + return "Degraded" } - return "Local-only workflows still work without signing in." + return "Unknown" } - private func handleSignIn() async { - isSubmitting = true - defer { isSubmitting = false } - _ = await model.loginHackAccount() - } - - private func handleSignOut() async { - isSubmitting = true - defer { isSubmitting = false } - _ = await model.logoutHackAccount() - } - - private func openResolvedManageURL(_ urlString: String) { - guard let url = URL(string: urlString) else { - return + private var daemonStatusFallback: String { + if model.daemonStatus?.running == true { + return "running" } - NSWorkspace.shared.open(url) - } - - private func detailRow(title: String, value: String, selectable: Bool = false) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - if selectable { - Text(value) - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } else { - Text(value) - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.primary) - } + if model.daemonStatus?.running == false { + return "stopped" } + return "unknown" } -} - -private enum AppearanceThemeOption: String, CaseIterable, Identifiable { - case system - case light - case dark - var id: String { rawValue } - - var label: String { - switch self { - case .system: - return "System" - case .light: - return "Light" - case .dark: - return "Dark" + private func serviceState(ok: Bool?) -> String { + guard let ok else { + return "Unknown" } + return ok ? "Ready" : "Needs attention" } } -private enum SessionProviderOption: String, CaseIterable, Identifiable { - case tmux - case zellij +private struct PermissionsSettingsView: View { + @State private var terminalAutomationGranted = false - var id: String { rawValue } + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + SettingsSectionHeader( + breadcrumb: "Settings / Permissions", + title: "Permissions", + subtitle: "Local trust and automation access needed for the best desktop workflow" + ) - var label: String { - rawValue.uppercased() - } + GlassCard(title: "Terminal automation", systemImage: "terminal") { + HStack(spacing: 8) { + StatusPill( + text: terminalAutomationGranted ? "Granted" : "Needs check", + tone: terminalAutomationGranted ? .good : .warn + ) + Spacer() + } - var executableCandidates: [String] { - switch self { - case .tmux: - return ["tmux"] - case .zellij: - return ["zellij"] - } - } -} - -private enum ContainerRuntimeOption: String, CaseIterable, Identifiable { - case docker - case orbstack - case dockerDesktop = "docker-desktop" - case colima - case rancherDesktop = "rancher-desktop" - case podman - - var id: String { rawValue } - - var label: String { - switch self { - case .docker: - return "Docker CLI" - case .orbstack: - return "OrbStack" - case .dockerDesktop: - return "Docker Desktop" - case .colima: - return "Colima" - case .rancherDesktop: - return "Rancher Desktop" - case .podman: - return "Podman" - } - } - - var executableCandidates: [String] { - switch self { - case .docker, .orbstack, .dockerDesktop, .rancherDesktop: - return ["docker"] - case .colima: - return ["colima"] - case .podman: - return ["podman"] - } - } - - var appBundleIdentifiers: [String] { - switch self { - case .orbstack: - return ["dev.kdrag0n.OrbStack"] - case .dockerDesktop: - return ["com.docker.docker"] - case .rancherDesktop: - return ["io.rancherdesktop.app"] - case .docker, .colima, .podman: - return [] - } - } - - var appFallbackPaths: [String] { - switch self { - case .orbstack: - return ["/Applications/OrbStack.app"] - case .dockerDesktop: - return ["/Applications/Docker.app"] - case .rancherDesktop: - return ["/Applications/Rancher Desktop.app"] - case .docker, .colima, .podman: - return [] - } - } -} - -private struct PreferencesSettingsView: View { - @Environment(DashboardModel.self) private var model - - @AppStorage("hackDesktop.preferences.theme") private var appearanceThemeRaw = AppearanceThemeOption.system.rawValue - @AppStorage("hackDesktop.preferences.defaultTerminal") private var preferredTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue - @AppStorage("hackDesktop.sessions.preferredExternalTerminal") private var legacyPreferredTerminalRaw = TerminalIntegration.ExternalTerminalApp.terminal.rawValue - @AppStorage("hackDesktop.preferences.defaultIDE") private var preferredEditorRaw = EditorIntegration.EditorApp.cursor.rawValue - @AppStorage("hackDesktop.preferences.defaultCodingAgent") private var preferredCodingAgentRaw = CodingAgentIntegration.AgentApp.codex.rawValue - @AppStorage("hackDesktop.preferences.defaultCodingAgentBinaryPath") private var preferredCodingAgentBinaryPathRaw = "" - - @State private var isLoadingConfig = false - @State private var sessionProvider: SessionProviderOption = .tmux - @State private var sessionBinaryPath = "" - @State private var containerRuntime: ContainerRuntimeOption = .docker - @State private var containerBinaryPath = "" - @State private var codingAgentBinaryPath = "" - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Preferences", - title: "Preferences", - subtitle: "UI defaults for theme, terminal, editor, sessions, containers, and coding agents" - ) - - GlassCard(title: "Defaults", systemImage: "slider.horizontal.3") { - VStack(alignment: .leading, spacing: 12) { - preferencePicker( - title: "Appearance", - helper: "Choose whether the desktop UI follows system, light, or dark mode.", - selectionLabel: appearanceTheme.label - ) { - Picker("Appearance", selection: $appearanceThemeRaw) { - ForEach(AppearanceThemeOption.allCases) { option in - Text(option.label).tag(option.rawValue) - } - } - .pickerStyle(.segmented) - } - - preferencePicker( - title: "Preferred terminal", - helper: "Used for Open In terminal actions and session attach flows.", - selectionLabel: preferredTerminal.displayName - ) { - Picker("Preferred terminal", selection: $preferredTerminalRaw) { - ForEach(terminalOptions, id: \.rawValue) { app in - Text(app.displayName).tag(app.rawValue) - } - } - .pickerStyle(.menu) - } - - preferencePicker( - title: "Preferred IDE", - helper: "Used by Project header Open In actions.", - selectionLabel: preferredEditor.displayName - ) { - Picker("Preferred IDE", selection: $preferredEditorRaw) { - ForEach(editorOptions, id: \.rawValue) { app in - Text(app.displayName).tag(app.rawValue) - } - } - .pickerStyle(.menu) - } - } - } - - GlassCard(title: "Session multiplexer", systemImage: "rectangle.3.group.bubble.left") { - VStack(alignment: .leading, spacing: 12) { - preferencePicker( - title: "Provider", - helper: "Default backend when creating or attaching session workflows.", - selectionLabel: sessionProvider.label - ) { - Picker("Session provider", selection: $sessionProvider) { - ForEach(SessionProviderOption.allCases) { option in - Text(option.label).tag(option) - } - } - .pickerStyle(.segmented) - } - pathEditor( - title: "Binary path override", - helper: "Leave empty to auto-detect from PATH.", - text: $sessionBinaryPath, - detectedPath: detectedSessionBinaryPath - ) - } - } - - GlassCard(title: "Container runtime", systemImage: "shippingbox") { - VStack(alignment: .leading, spacing: 12) { - preferencePicker( - title: "Provider", - helper: "Controls how the app and future CLI tooling interpret your container stack defaults.", - selectionLabel: containerRuntime.label - ) { - Picker("Container runtime", selection: $containerRuntime) { - ForEach(ContainerRuntimeOption.allCases) { option in - Text(option.label).tag(option) - } - } - .pickerStyle(.menu) - } - pathEditor( - title: "Binary path override", - helper: "Leave empty to auto-detect from PATH.", - text: $containerBinaryPath, - detectedPath: detectedContainerBinaryPath - ) - if let appPath = detectedContainerAppPath { - Text("Detected app: \(appPath)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - } - } - - GlassCard(title: "Coding agent", systemImage: "sparkles") { - VStack(alignment: .leading, spacing: 12) { - preferencePicker( - title: "Default coding agent", - helper: "Used when opening agent-assisted workflows from Desktop and future CLI integrations.", - selectionLabel: preferredCodingAgent.displayName - ) { - Picker("Default coding agent", selection: $preferredCodingAgentRaw) { - ForEach(codingAgentOptions) { option in - Text(option.displayName).tag(option.rawValue) - } - } - .pickerStyle(.menu) - } - pathEditor( - title: "Binary path override", - helper: "Leave empty to auto-detect from PATH for the selected coding agent.", - text: $codingAgentBinaryPath, - detectedPath: detectedCodingAgentBinaryPath - ) - } - } - - HStack(spacing: 10) { - Button { - Task { await savePreferences() } - } label: { - Label("Save preferences", systemImage: "checkmark") - } - .adaptiveToolbarButtonProminent() - - Button { - Task { await loadConfigFromDisk() } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - - if isLoadingConfig { - ProgressView() - .controlSize(.small) - } - Spacer() - } - - InlineCallout( - tone: .neutral, - title: "Preference scope", - message: "These defaults are saved in global config and in desktop app storage so project actions and future CLI/runtime integrations can share a single preference source.", - actions: [] - ) - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } - } - } - - private var appearanceTheme: AppearanceThemeOption { - AppearanceThemeOption(rawValue: appearanceThemeRaw) ?? .system - } - - private var preferredTerminal: TerminalIntegration.ExternalTerminalApp { - if let explicit = TerminalIntegration.ExternalTerminalApp(rawValue: preferredTerminalRaw) { - return explicit - } - return .terminal - } - - private var preferredEditor: EditorIntegration.EditorApp { - if let explicit = EditorIntegration.EditorApp(rawValue: preferredEditorRaw) { - return explicit - } - return .cursor - } - - private var preferredCodingAgent: CodingAgentIntegration.AgentApp { - if let explicit = CodingAgentIntegration.AgentApp(rawValue: preferredCodingAgentRaw) { - return explicit - } - return .codex - } - - private var terminalOptions: [TerminalIntegration.ExternalTerminalApp] { - let installed = TerminalIntegration.installedExternalTerminalApps() - let fallback = [TerminalIntegration.ExternalTerminalApp.terminal] - return dedupePreservingOrder([preferredTerminal] + installed + fallback) - } - - private var editorOptions: [EditorIntegration.EditorApp] { - let installed = EditorIntegration.installedEditors() - let fallback = [EditorIntegration.EditorApp.cursor, .vscode, .zed, .neovim, .vim] - return dedupePreservingOrder([preferredEditor] + installed + fallback) - } - - private var codingAgentOptions: [CodingAgentIntegration.AgentApp] { - let installed = CodingAgentIntegration.installedAgents() - return dedupePreservingOrder( - [preferredCodingAgent] + installed + CodingAgentIntegration.AgentApp.allCases - ) - } - - private var detectedSessionBinaryPath: String? { - if let manual = normalizedPath(sessionBinaryPath) { - return manual - } - return resolveExecutablePath(candidates: sessionProvider.executableCandidates) - } - - private var detectedContainerBinaryPath: String? { - if let manual = normalizedPath(containerBinaryPath) { - return manual - } - return resolveExecutablePath(candidates: containerRuntime.executableCandidates) - } - - private var detectedContainerAppPath: String? { - resolveInstalledApplicationPath( - bundleIdentifiers: containerRuntime.appBundleIdentifiers, - fallbackPaths: containerRuntime.appFallbackPaths - ) - } - - private var detectedCodingAgentBinaryPath: String? { - CodingAgentIntegration.resolvedBinaryPath( - for: preferredCodingAgent, - overridePath: normalizedPath(codingAgentBinaryPath) - ) - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - if let theme = snapshot.preferencesTheme, !theme.isEmpty { - appearanceThemeRaw = theme - } - if let terminalApp = snapshot.preferencesTerminalApp, !terminalApp.isEmpty { - preferredTerminalRaw = terminalApp - legacyPreferredTerminalRaw = terminalApp - } else { - legacyPreferredTerminalRaw = preferredTerminalRaw - } - if let editorApp = snapshot.preferencesEditorApp, !editorApp.isEmpty { - preferredEditorRaw = editorApp - } - if let codingAgentApp = snapshot.preferencesCodingAgentApp, !codingAgentApp.isEmpty { - preferredCodingAgentRaw = codingAgentApp - } - if let providerRaw = snapshot.preferencesSessionProvider, let provider = SessionProviderOption(rawValue: providerRaw) { - sessionProvider = provider - } - if let binary = snapshot.preferencesSessionBinaryPath { - sessionBinaryPath = binary - } - if let containerRaw = snapshot.preferencesContainerProvider, let provider = ContainerRuntimeOption(rawValue: containerRaw) { - containerRuntime = provider - } - if let binary = snapshot.preferencesContainerBinaryPath { - containerBinaryPath = binary - } - if let binary = snapshot.preferencesCodingAgentBinaryPath { - codingAgentBinaryPath = binary - preferredCodingAgentBinaryPathRaw = binary - } else if !preferredCodingAgentBinaryPathRaw.isEmpty { - codingAgentBinaryPath = preferredCodingAgentBinaryPathRaw - } - } - - private func savePreferences() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - legacyPreferredTerminalRaw = preferredTerminalRaw - preferredCodingAgentBinaryPathRaw = normalizedPath(codingAgentBinaryPath) ?? "" - - let didSaveTheme = await model.setGlobalConfig( - key: "controlPlane.preferences.appearance.theme", - value: appearanceThemeRaw - ) - let didSaveTerminal = await model.setGlobalConfig( - key: "controlPlane.preferences.terminal.defaultApp", - value: preferredTerminalRaw - ) - let didSaveEditor = await model.setGlobalConfig( - key: "controlPlane.preferences.editor.defaultApp", - value: preferredEditorRaw - ) - let didSaveAgent = await model.setGlobalConfig( - key: "controlPlane.preferences.agents.defaultApp", - value: preferredCodingAgentRaw - ) - let didSaveSessionProvider = await model.setGlobalConfig( - key: "controlPlane.preferences.sessions.provider", - value: sessionProvider.rawValue - ) - let didSaveSessionBinaryPath = await model.setGlobalConfig( - key: "controlPlane.preferences.sessions.binaryPath", - value: normalizedPath(sessionBinaryPath) ?? "" - ) - let didSaveContainerProvider = await model.setGlobalConfig( - key: "controlPlane.preferences.containers.provider", - value: containerRuntime.rawValue - ) - let didSaveContainerBinaryPath = await model.setGlobalConfig( - key: "controlPlane.preferences.containers.binaryPath", - value: normalizedPath(containerBinaryPath) ?? "" - ) - let didSaveAgentBinaryPath = await model.setGlobalConfig( - key: "controlPlane.preferences.agents.binaryPath", - value: preferredCodingAgentBinaryPathRaw - ) - - guard [ - didSaveTheme, - didSaveTerminal, - didSaveEditor, - didSaveAgent, - didSaveSessionProvider, - didSaveSessionBinaryPath, - didSaveContainerProvider, - didSaveContainerBinaryPath, - didSaveAgentBinaryPath - ].allSatisfy({ $0 }) else { - return - } - - await model.refresh() - await loadConfigFromDisk() - } - - private func preferencePicker( - title: String, - helper: String, - selectionLabel: String, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(title) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text(selectionLabel) - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.primary) - } - content() - Text(helper) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - - private func pathEditor( - title: String, - helper: String, - text: Binding, - detectedPath: String? - ) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - HStack(spacing: 8) { - TextField("Optional custom path", text: text) - .textFieldStyle(.roundedBorder) - .font(.mono(.caption)) - Button("Browse") { - if let path = openExecutablePanel() { - text.wrappedValue = path - } - } - .adaptiveToolbarButton() - } - Text("Detected: \(detectedPath ?? "Not found")") - .font(.mono(.caption2)) - .foregroundStyle(detectedPath == nil ? Color.orange : Color.secondary) - .textSelection(.enabled) - Text(helper) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - - private func normalizedPath(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func dedupePreservingOrder(_ values: [T]) -> [T] { - var seen: Set = [] - var ordered: [T] = [] - for value in values { - if seen.insert(value).inserted { - ordered.append(value) - } - } - return ordered - } -} - -private struct GlobalSettingsView: View { - @Environment(DashboardModel.self) private var model - @State private var isLoadingConfig = false - @State private var launchAtLoad = false - @State private var gatewayBind = "127.0.0.1" - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Global", - title: "Global Configuration", - subtitle: "Machine-wide defaults for daemon startup and local gateway networking" - ) - globalDefaultsCard - InlineCallout( - tone: .neutral, - title: "Extension settings moved", - message: "Cloudflare, Railway, and Tailscale settings now live on dedicated pages so each integration has focused setup and diagnostics.", - actions: [] - ) - quickActionsCard - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } - } - } - - private var globalDefaultsCard: some View { - GlassCard(title: "Editable settings", systemImage: "slider.horizontal.3") { - VStack(alignment: .leading, spacing: 12) { - Text("These controls apply globally to this machine.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - Toggle("Launch daemon at login", isOn: $launchAtLoad) - .font(.mono(.subheadline)) - - Divider() - .opacity(0.24) - - VStack(alignment: .leading, spacing: 8) { - Text("Gateway bind") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Text("Local only keeps access on this Mac. LAN exposes gateway access to your local network.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - Picker("Gateway bind", selection: $gatewayBind) { - Text("Local only (127.0.0.1)").tag("127.0.0.1") - Text("LAN (0.0.0.0)").tag("0.0.0.0") - } - .pickerStyle(.segmented) - } - - HStack(spacing: 10) { - Button { - Task { - await applyGlobalSettings() - } - } label: { - Label("Save settings", systemImage: "checkmark") - } - .adaptiveToolbarButtonProminent() - - Button { - Task { - await loadConfigFromDisk() - } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - - if isLoadingConfig { - ProgressView() - .controlSize(.small) - } - Spacer() - } - } - } - } - - private var quickActionsCard: some View { - InlineCallout( - tone: .neutral, - title: "Global setup + trust", - message: "Use these commands when setting up a machine or rotating local TLS trust.", - actions: [ - InlineCalloutAction(label: "Copy install", systemImage: "doc.on.doc") { - TerminalIntegration.copyToClipboard("hack global install") - }, - InlineCalloutAction(label: "Run trust", systemImage: "terminal") { - TerminalIntegration.openTerminalWithCommand("hack global trust") - }, - InlineCalloutAction(label: "Restart daemon", systemImage: "arrow.clockwise") { - TerminalIntegration.openTerminalWithCommand("hack daemon restart") - } - ] - ) - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - if let launchd = snapshot.daemonLaunchdRunAtLoad { - launchAtLoad = launchd - } - if let bind = snapshot.gatewayBind, !bind.isEmpty { - gatewayBind = bind - } else if let fallbackBind = model.globalStatus?.gateway?.gatewayBind, !fallbackBind.isEmpty { - gatewayBind = fallbackBind - } - } - - private func applyGlobalSettings() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - await model.setGlobalConfig( - key: "controlPlane.daemon.launchd.runAtLoad", - value: launchAtLoad ? "true" : "false" - ) - await model.setGlobalConfig( - key: "controlPlane.gateway.bind", - value: gatewayBind - ) - - await model.refresh() - await loadConfigFromDisk() - } -} - -private struct SupervisorSettingsView: View { - @Environment(DashboardModel.self) private var model - @State private var isLoadingConfig = false - @State private var enabled = true - @State private var maxConcurrentJobs = 4 - @State private var logsMaxMegabytes = 5 - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Supervisor", - title: "Supervisor", - subtitle: "Global queue + worker controls for background control-plane jobs" - ) - GlassCard(title: "Supervisor status", systemImage: "cpu") { - HStack(spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .warn) - Spacer() - if isLoadingConfig { - ProgressView() - .controlSize(.small) - } - } - Text("Supervisor coordinates background jobs used by control-plane operations. Disable only for troubleshooting.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - GlassCard(title: "Runtime controls", systemImage: "slider.horizontal.3") { - VStack(alignment: .leading, spacing: 12) { - Toggle("Enable supervisor globally", isOn: $enabled) - .font(.mono(.subheadline)) - - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Max concurrent jobs") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Spacer() - Text("\(maxConcurrentJobs)") - .font(.mono(.caption)) - .foregroundStyle(.tertiary) - } - Stepper(value: $maxConcurrentJobs, in: 1...32) { - Text("Worker slots") - .font(.mono(.subheadline)) - } - } - - VStack(alignment: .leading, spacing: 8) { - HStack { - Text("Log retention budget") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Spacer() - Text(logBudgetLabel) - .font(.mono(.caption)) - .foregroundStyle(.tertiary) - } - Stepper(value: $logsMaxMegabytes, in: 1...100, step: 1) { - Text("Max log cache (MB)") - .font(.mono(.subheadline)) - } - } - - HStack(spacing: 10) { - Button { - Task { await saveSupervisorSettings() } - } label: { - Label("Save supervisor settings", systemImage: "checkmark") - } - .adaptiveToolbarButtonProminent() - - Button { - Task { await loadConfigFromDisk() } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - - Spacer() - } - } - } - InlineCallout( - tone: .neutral, - title: "Need more diagnostics?", - message: "Open daemon logs when debugging queue stalls or job retries.", - actions: [ - InlineCalloutAction(label: "Open daemon logs", systemImage: "terminal") { - openGlobalCommandInTerminalPanel( - command: "tail -n 200 -F \"$HOME/.hack/daemon/hackd.log\"", - title: "daemon log tail" - ) - }, - InlineCalloutAction(label: "Restart daemon", systemImage: "arrow.clockwise") { - TerminalIntegration.openTerminalWithCommand("hack daemon restart") - } - ] - ) - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } - } - } - - private var logBudgetLabel: String { - "\(logsMaxMegabytes) MB (\(logsMaxBytesValue) bytes)" - } - - private var logsMaxBytesValue: Int { - max(1, logsMaxMegabytes) * 1_000_000 - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - enabled = snapshot.supervisorEnabled ?? true - maxConcurrentJobs = max(1, snapshot.supervisorMaxConcurrentJobs ?? 4) - let rawBytes = max(1, snapshot.supervisorLogsMaxBytes ?? 5_000_000) - logsMaxMegabytes = max(1, (rawBytes + 999_999) / 1_000_000) - } - - private func saveSupervisorSettings() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - await model.setGlobalConfig( - key: "controlPlane.supervisor.enabled", - value: enabled ? "true" : "false" - ) - await model.setGlobalConfig( - key: "controlPlane.supervisor.maxConcurrentJobs", - value: String(maxConcurrentJobs) - ) - await model.setGlobalConfig( - key: "controlPlane.supervisor.logsMaxBytes", - value: String(logsMaxBytesValue) - ) - - await model.refresh() - await loadConfigFromDisk() - } -} - -private struct CloudflareExtensionSettingsView: View { - private enum FocusedField: Hashable { - case hostname - case sshHostname - } - - @Environment(DashboardModel.self) private var model - @State private var isLoadingConfig = false - @State private var isSavingToggle = false - @State private var isTunnelActionInFlight = false - @State private var suppressEnabledToggleChange = false - @State private var enabled = false - @State private var hostname = "" - @State private var sshHostname = "" - @State private var tunnelIsRunning = false - @State private var tunnelPid: Int? = nil - @State private var loadedEnabled = false - @State private var loadedHostname = "" - @State private var loadedSSHHostname = "" - @FocusState private var focusedField: FocusedField? - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Cloudflare", - title: "Cloudflare", - subtitle: "Public tunnel exposure via cloudflared for HTTPS and SSH entrypoints" - ) - GlassCard(title: "Extension status", systemImage: "cloud") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) - StatusPill( - text: tunnelIsRunning ? "Tunnel running" : "Tunnel stopped", - tone: tunnelStatusTone - ) - Spacer() - Toggle("Enabled", isOn: $enabled) - .labelsHidden() - .toggleStyle(.switch) - .onChange(of: enabled) { _, newValue in - guard !suppressEnabledToggleChange else { return } - Task { - await applyCloudflareEnabledToggle(newValue) - } - } - if isLoadingConfig || isTunnelActionInFlight { - ProgressView() - .controlSize(.small) - } - if isSavingToggle { - ProgressView() - .controlSize(.small) - } - } - if let configuredHost = normalizedHost(hostname) { - Text("Hostname: \(configuredHost)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Text("Hostname not configured") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if tunnelIsRunning, let tunnelPid { - Text("Tunnel PID: \(tunnelPid)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } else { - Text("Tunnel process is not running") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if let detail = cloudflareExposure?.detail, !detail.isEmpty { - Text(detail) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - GlassCard(title: "Configuration", systemImage: "slider.horizontal.3") { - VStack(alignment: .leading, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text("cloudflared binary") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - StatusPill( - text: cloudflaredInstalled ? "Installed" : "Missing", - tone: cloudflaredInstalled ? .good : .warn - ) - } - Text(cloudflaredPathLabel) - .font(.mono(.caption2)) - .foregroundStyle(cloudflaredInstalled ? .secondary : Color.orange) - .textSelection(.enabled) - } - - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 6) { - Text("Primary hostname") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - TextField("gateway.example.com", text: $hostname) - .textFieldStyle(.roundedBorder) - .font(.mono(.subheadline)) - .focused($focusedField, equals: .hostname) - Text("Used for gateway HTTPS routes.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - - VStack(alignment: .leading, spacing: 6) { - Text("SSH hostname (optional)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - TextField("ssh.example.com", text: $sshHostname) - .textFieldStyle(.roundedBorder) - .font(.mono(.subheadline)) - .focused($focusedField, equals: .sshHostname) - Text("Used for SSH routing when remote workflows are enabled.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - - HStack(spacing: 10) { - Button { - Task { await saveCloudflareSettings() } - } label: { - Label("Save Cloudflare settings", systemImage: "checkmark") - } - .adaptiveToolbarButtonProminent() - - Button { - Task { await loadConfigFromDisk() } - } label: { - Label("Reload", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButton() - - Button { - Task { await startCloudflareTunnel() } - } label: { - Label("Start tunnel", systemImage: "play.fill") - } - .adaptiveToolbarButton() - .disabled(!enabled || !cloudflaredInstalled || tunnelIsRunning || isTunnelActionInFlight) - - Button { - Task { await stopCloudflareTunnel() } - } label: { - Label("Stop tunnel", systemImage: "stop.fill") - } - .adaptiveToolbarButton() - .disabled((!tunnelIsRunning && !isTunnelActionInFlight) || isLoadingConfig) - - Spacer() - } - } - } - InlineCallout( - tone: .neutral, - title: "Cloudflare requirements", - message: "Install `cloudflared` locally and set hostnames before enabling. Missing hostname or binary will keep status at Needs setup.", - actions: [ - InlineCalloutAction(label: "Open tunnel logs", systemImage: "terminal") { - openGlobalCommandInTerminalPanel( - command: "hack global status --json && hack global logs caddy --tail 200 --follow", - title: "cloudflare diagnostics" - ) - } - ] - ) - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.lastUpdated) { _, _ in - guard shouldReloadFromRefresh else { return } - Task { await loadConfigFromDisk() } - } - } - - private var cloudflareExposure: GatewayExposure? { - model.globalStatus?.gateway?.exposures?.first(where: { $0.id == "cloudflare" }) - } - - private var tunnelStatusTone: StatusTone { - if tunnelIsRunning { - return .good - } - if enabled { - return .warn - } - return .neutral - } - - private var cloudflaredPath: String? { - resolveExecutablePath(candidates: ["cloudflared"]) - } - - private var cloudflaredInstalled: Bool { - cloudflaredPath != nil - } - - private var cloudflaredPathLabel: String { - "cloudflared path: \(cloudflaredPath ?? "Not found in PATH")" - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - let nextEnabled = snapshot.cloudflareExtensionEnabled ?? false - let nextHostname = snapshot.cloudflareHostname ?? "" - let nextSSHHostname = snapshot.cloudflareSSHHostname ?? "" - suppressEnabledToggleChange = true - enabled = nextEnabled - hostname = nextHostname - sshHostname = nextSSHHostname - suppressEnabledToggleChange = false - loadedEnabled = nextEnabled - loadedHostname = nextHostname - loadedSSHHostname = nextSSHHostname - refreshCloudflareTunnelState() - } - - private func applyCloudflareEnabledToggle(_ isEnabled: Bool) async { - isSavingToggle = true - defer { isSavingToggle = false } - - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled", - value: isEnabled ? "true" : "false" - ) - guard didUpdate else { - await loadConfigFromDisk() - return - } - if !isEnabled, tunnelIsRunning { - isTunnelActionInFlight = true - _ = await model.stopCloudflareTunnel() - isTunnelActionInFlight = false - } - await loadConfigFromDisk() - } - - private func saveCloudflareSettings() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let didSaveEnabled = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.cloudflare\"].enabled", - value: enabled ? "true" : "false" - ) - guard didSaveEnabled else { - await loadConfigFromDisk() - return - } - let didSaveHostname = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.cloudflare\"].config.hostname", - value: hostname.trimmingCharacters(in: .whitespacesAndNewlines) - ) - guard didSaveHostname else { - await loadConfigFromDisk() - return - } - let didSaveSSHHostname = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.cloudflare\"].config.sshHostname", - value: sshHostname.trimmingCharacters(in: .whitespacesAndNewlines) - ) - guard didSaveSSHHostname else { - await loadConfigFromDisk() - return - } - - await loadConfigFromDisk() - } - - private func startCloudflareTunnel() async { - guard enabled, cloudflaredInstalled else { return } - isTunnelActionInFlight = true - defer { isTunnelActionInFlight = false } - let started = await model.startCloudflareTunnel() - guard started else { - await loadConfigFromDisk() - return - } - await model.refresh() - await loadConfigFromDisk() - } - - private func stopCloudflareTunnel() async { - isTunnelActionInFlight = true - defer { isTunnelActionInFlight = false } - let stopped = await model.stopCloudflareTunnel() - guard stopped else { - await loadConfigFromDisk() - return - } - await model.refresh() - await loadConfigFromDisk() - } - - private func refreshCloudflareTunnelState() { - let pidPath = cloudflaredPidPath - guard let pidRaw = try? String(contentsOfFile: pidPath, encoding: .utf8) else { - tunnelPid = nil - tunnelIsRunning = false - return - } - let trimmed = pidRaw.trimmingCharacters(in: .whitespacesAndNewlines) - guard let pid = Int(trimmed), pid > 0 else { - tunnelPid = nil - tunnelIsRunning = false - return - } - tunnelPid = pid - tunnelIsRunning = isProcessRunning(pid: pid) - if !tunnelIsRunning { - tunnelPid = nil - } - } - - private var cloudflaredPidPath: String { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".hack/cloudflare/cloudflared.pid") - .path - } - - private func isProcessRunning(pid: Int) -> Bool { - guard pid > 0 else { return false } - if kill(pid_t(pid), 0) == 0 { - return true - } - return errno == EPERM - } - - private func normalizedHost(_ value: String) -> String? { - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private var shouldReloadFromRefresh: Bool { - if isLoadingConfig || isSavingToggle || isTunnelActionInFlight { - return false - } - if focusedField != nil { - return false - } - return !hasUnsavedChanges - } - - private var hasUnsavedChanges: Bool { - if enabled != loadedEnabled { - return true - } - if normalizedValue(hostname) != normalizedValue(loadedHostname) { - return true - } - if normalizedValue(sshHostname) != normalizedValue(loadedSSHHostname) { - return true - } - return false - } - - private func normalizedValue(_ value: String) -> String { - value.trimmingCharacters(in: .whitespacesAndNewlines) - } -} - -/// Railway provider diagnostics and bootstrap workflow for remote node onboarding. -private struct RailwayExtensionSettingsView: 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 tailscaleExtensionEnabled = false - @State private var diagnostics: RailwayInspectResponse? = nil - @State private var tailscaleDiagnostics: TailscaleInspectResponse? = nil - @State private var lastDiagnosticsRefreshAt: Date? = nil - @State private var bootstrapResult: RailwayBootstrapResponse? = nil - @State private var railwayProject = "" - @State private var railwayService = "" - @State private var railwayEnvironment = "production" - @State private var railwayWorkspace = "" - @State private var nodeName = "" - @State private var labelsCsv = "railway,linux,container" - @State private var endpoint = "" - @State private var createService = true - @State private var railwayImage = "hackdance/hack:latest" - @State private var railwayPrivate = true - @State private var defaultNode = false - @State private var privateAuthSourceLabel = "Missing auth source" - @State private var privateBootstrapReady = false - @State private var tailscaleHostname = "" - @State private var tailscaleTagsCsv = "" - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Railway", - title: "Railway", - subtitle: "Bootstrap remote nodes with minimal input; advanced options stay optional" - ) - - GlassCard(title: "Provider status", systemImage: "tram.fill.tunnel") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) - StatusPill( - text: railwayInstalled ? "Railway CLI installed" : "Railway CLI missing", - tone: railwayInstalled ? .good : .warn - ) - StatusPill( - text: railwayAuthenticated ? "CLI authenticated" : "CLI not authenticated", - tone: railwayAuthenticated ? .good : .warn - ) - if railwayPrivate { - StatusPill( - text: tailscaleExtensionEnabled ? "Tailscale extension enabled" : "Tailscale extension disabled", - tone: tailscaleExtensionEnabled ? .good : .warn - ) - StatusPill( - text: tailscaleControllerReady ? "Controller tailscale ready" : "Controller tailscale not ready", - tone: tailscaleControllerReady ? .good : .warn - ) - StatusPill( - text: privateCredentialSourceLabel, - tone: privateBootstrapReady ? .good : .warn - ) - } else { - StatusPill(text: "Public endpoint mode", tone: .neutral) - } - Spacer() - Toggle("Enabled", isOn: $enabled) - .labelsHidden() - .toggleStyle(.switch) - .onChange(of: enabled) { _, newValue in - guard !suppressEnabledToggleChange else { return } - Task { await applyRailwayEnabledToggle(newValue) } - } - if isLoadingConfig || isLoadingDiagnostics || isSavingConfig || isBootstrapping { - ProgressView() - .controlSize(.small) - } - } - - Text("railway path: \(railwayPathLabel)") - .font(.mono(.caption2)) - .foregroundStyle(railwayInstalled ? .secondary : Color.orange) - .textSelection(.enabled) - if let version = diagnostics?.version, !version.isEmpty { - Text("version: \(version)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if let whoami = diagnostics?.whoami, !whoami.isEmpty { - Text("whoami: \(whoami)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if let error = diagnostics?.error, !error.isEmpty { - Text(error) - .font(.mono(.caption)) - .foregroundStyle(Color.orange) - } - - HStack(spacing: 10) { - Button { - openTailscaleSettings() - } label: { - Label("Open Tailscale", systemImage: "network") - } - .adaptiveToolbarButton() - - Button { - openGlobalCommandInTerminalPanel( - command: "railway whoami", - title: "railway whoami" - ) - } label: { - Label("Open whoami", systemImage: "person.crop.circle") - } - .adaptiveToolbarButton() - .disabled(!railwayInstalled) - - Button { - openGlobalCommandInTerminalPanel( - command: "railway login", - title: "railway login" - ) - } label: { - Label("Run login", systemImage: "person.badge.key") - } - .adaptiveToolbarButton() - .disabled(!railwayInstalled) - - Spacer() - } - } - - GlassCard(title: "Bootstrap configuration", systemImage: "gearshape.2") { - VStack(alignment: .leading, spacing: 12) { - Text("Project is optional. Leave it blank to use saved provider/global defaults.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - TextField("Railway project (optional)", text: $railwayProject) - .textFieldStyle(.roundedBorder) - - HStack(spacing: 10) { - TextField("Node display name", text: $nodeName) - .textFieldStyle(.roundedBorder) - } - - Text(serviceModeSummary) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - HStack(spacing: 10) { - Toggle("Private tailscale mode", isOn: $railwayPrivate) - .font(.mono(.caption)) - Toggle("Set as default after bootstrap", isOn: $defaultNode) - .font(.mono(.caption)) - Spacer() - } - - if railwayPrivate { - Text("Private mode uses embedded tailscale on the remote node and reads auth from Tailscale settings.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - if !privateBootstrapReady { - Text("Private mode needs a TS auth key. Set it in Tailscale settings or pass --tailscale-auth-key.") - .font(.mono(.caption2)) - .foregroundStyle(Color.orange) - } - } - - HStack(spacing: 10) { - Button { - Task { await saveRailwayDefaults() } - } label: { - Label("Save defaults", systemImage: "square.and.arrow.down") - } - .adaptiveToolbarButtonProminent() - .disabled(isSavingConfig || isBootstrapping) - - if railwayInstalled && railwayAuthenticated && canBootstrapRailwayNode { - Button { - Task { await bootstrapRailwayNode() } - } label: { - Label("Bootstrap node now", systemImage: "play.fill") - } - .adaptiveToolbarButton() - .disabled(isBootstrapping) - } - - Button { - openGlobalCommandInTerminalPanel( - command: "hack node provider railway bootstrap --help", - title: "hack railway 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) { - HStack(spacing: 10) { - TextField("Existing service (optional)", text: $railwayService) - .textFieldStyle(.roundedBorder) - TextField("Environment", text: $railwayEnvironment) - .textFieldStyle(.roundedBorder) - TextField("Workspace", text: $railwayWorkspace) - .textFieldStyle(.roundedBorder) - TextField("Labels (comma-separated)", text: $labelsCsv) - .textFieldStyle(.roundedBorder) - } - - HStack(spacing: 10) { - Toggle("Auto-create service when missing", isOn: $createService) - .font(.mono(.caption)) - TextField("Endpoint override", text: $endpoint) - .textFieldStyle(.roundedBorder) - TextField("Runtime image", text: $railwayImage) - .textFieldStyle(.roundedBorder) - .disabled(!effectiveCreateService) - } - - if railwayPrivate { - Divider() - .opacity(0.2) - Text("Tailscale runtime overrides") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - HStack(spacing: 10) { - TextField("Tailscale tags", text: $tailscaleTagsCsv) - .textFieldStyle(.roundedBorder) - TextField("Hostname override", text: $tailscaleHostname) - .textFieldStyle(.roundedBorder) - } - - Text("Auth key is managed in Settings → Extensions → Tailscale. CLI one-offs can still pass --tailscale-auth-key.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - .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 - ) - if let network = bootstrapResult.railway.network, !network.isEmpty { - StatusPill(text: network, tone: .neutral) - } - Spacer() - } - 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 loadRailwayConfigFromDisk() - } - } - - private var railwayPath: String? { - diagnostics?.binaryPath ?? resolveExecutablePath(candidates: ["railway"]) - } - - private var railwayPathLabel: String { - railwayPath ?? "Not found in PATH" - } - - private var railwayInstalled: Bool { - diagnostics?.installed ?? (railwayPath != nil) - } - - private var railwayAuthenticated: Bool { - diagnostics?.authenticated == true - } - - private var tailscaleControllerReady: Bool { - tailscaleDiagnostics?.installed == true && tailscaleDiagnostics?.connected == true - } - - private var effectiveCreateService: Bool { - createService || normalizedOrNil(railwayService) == nil - } - - private var serviceModeSummary: String { - if let service = normalizedOrNil(railwayService), !effectiveCreateService { - return "Service mode: use existing service '\(service)'." - } - if let service = normalizedOrNil(railwayService), effectiveCreateService { - return "Service mode: ensure service '\(service)' exists (create if needed)." - } - return "Service mode: auto-create service name from node name." - } - - private var privateCredentialSourceLabel: String { - privateAuthSourceLabel - } - - private var canBootstrapRailwayNode: Bool { - if !railwayInstalled || !railwayAuthenticated { - return false - } - if railwayPrivate, !privateBootstrapReady { - return false - } - return true - } - - private func loadRailwayConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - suppressEnabledToggleChange = true - enabled = snapshot.railwayExtensionEnabled ?? false - suppressEnabledToggleChange = false - tailscaleExtensionEnabled = snapshot.tailscaleExtensionEnabled ?? false - railwayProject = snapshot.railwayProject ?? "" - railwayService = snapshot.railwayService ?? "" - railwayEnvironment = snapshot.railwayEnvironment ?? "production" - railwayWorkspace = snapshot.railwayWorkspace ?? "" - nodeName = snapshot.railwayNodeName ?? "" - labelsCsv = snapshot.railwayLabelsCsv ?? "railway,linux,container" - endpoint = snapshot.railwayEndpoint ?? "" - createService = snapshot.railwayCreateService ?? true - railwayImage = snapshot.railwayImage ?? "hackdance/hack:latest" - railwayPrivate = snapshot.railwayPrivate ?? true - tailscaleTagsCsv = snapshot.railwayTailscaleTagsCsv ?? "" - applyPrivateAuthState(snapshot: snapshot) - await refreshRailwayDiagnostics() - } - - private func refreshRailwayDiagnostics() async { - isLoadingDiagnostics = true - defer { isLoadingDiagnostics = false } - async let railwayStatus = model.inspectRailway() - async let tailscaleStatus = model.inspectTailscale() - diagnostics = await railwayStatus - tailscaleDiagnostics = await tailscaleStatus - lastDiagnosticsRefreshAt = Date() - } - - private func applyRailwayEnabledToggle(_ isEnabled: Bool) async { - isSavingConfig = true - defer { isSavingConfig = false } - - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.railway\"].enabled", - value: isEnabled ? "true" : "false" - ) - guard didUpdate else { - await loadRailwayConfigFromDisk() - return - } - await loadRailwayConfigFromDisk() - } - - private func saveRailwayDefaults() async { - isSavingConfig = true - defer { isSavingConfig = false } - - let writes: [(String, String)] = [ - ("controlPlane.extensions[\"dance.hack.railway\"].enabled", enabled ? "true" : "false"), - ("controlPlane.extensions[\"dance.hack.railway\"].config.project", railwayProject), - ("controlPlane.extensions[\"dance.hack.railway\"].config.service", railwayService), - ("controlPlane.extensions[\"dance.hack.railway\"].config.environment", railwayEnvironment), - ("controlPlane.extensions[\"dance.hack.railway\"].config.workspace", railwayWorkspace), - ("controlPlane.extensions[\"dance.hack.railway\"].config.nodeName", nodeName), - ("controlPlane.extensions[\"dance.hack.railway\"].config.labelsCsv", labelsCsv), - ("controlPlane.extensions[\"dance.hack.railway\"].config.endpoint", endpoint), - ("controlPlane.extensions[\"dance.hack.railway\"].config.createService", createService ? "true" : "false"), - ("controlPlane.extensions[\"dance.hack.railway\"].config.image", railwayImage), - ("controlPlane.extensions[\"dance.hack.railway\"].config.private", railwayPrivate ? "true" : "false"), - ("controlPlane.extensions[\"dance.hack.railway\"].config.tailscaleTagsCsv", tailscaleTagsCsv), - ] - - for (key, value) in writes { - _ = await model.setGlobalConfig(key: key, value: value) - } - - await loadRailwayConfigFromDisk() - } - - /// Executes non-interactive Railway bootstrap and records the latest run output in-view. - private func bootstrapRailwayNode() async { - guard canBootstrapRailwayNode else { - return - } - isBootstrapping = true - defer { isBootstrapping = false } - - let request = RailwayBootstrapRequest( - railwayProject: normalizedOrNil(railwayProject), - railwayService: normalizedOrNil(railwayService), - railwayEnvironment: normalizedOrNil(railwayEnvironment), - railwayWorkspace: normalizedOrNil(railwayWorkspace), - createService: effectiveCreateService, - railwayImage: normalizedOrNil(railwayImage), - railwayBin: nil, - nodeName: normalizedOrNil(nodeName), - endpoint: normalizedOrNil(endpoint), - labels: parseCSV(labelsCsv), - defaultNode: defaultNode, - domainPort: nil, - initRetries: nil, - privateNetworking: railwayPrivate, - tailscaleAuthKey: nil, - tailscaleHostname: normalizedOrNil(tailscaleHostname), - tailscaleTags: parseCSV(tailscaleTagsCsv) - ) - bootstrapResult = await model.bootstrapRailwayNode(request: request) - await refreshRailwayDiagnostics() - } - - 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 } - } - - /** - Derives private bootstrap auth readiness from shared config with compatibility fallback. - */ - private func applyPrivateAuthState(snapshot: GlobalConfigSnapshot) { - let tailscaleConfigAuth = normalizedOrNil(snapshot.tailscaleAuthKey ?? "") - let railwayCompatAuth = normalizedOrNil(snapshot.railwayTailscaleAuthKey ?? "") - let envAuth = normalizedOrNil( - ProcessInfo.processInfo.environment["HACK_TAILSCALE_AUTH_KEY"] ?? "" - ) - if tailscaleConfigAuth != nil { - privateBootstrapReady = true - privateAuthSourceLabel = "Auth key configured" - return - } - if railwayCompatAuth != nil { - privateBootstrapReady = true - privateAuthSourceLabel = "Auth key configured (compat)" - return - } - if envAuth != nil { - privateBootstrapReady = true - privateAuthSourceLabel = "Auth key from env" - return - } - privateBootstrapReady = false - privateAuthSourceLabel = "Missing auth source" - } - - private func openTailscaleSettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.tailscale.rawValue] - ) - } - - private func openTopologySettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.topology.rawValue] - ) - } -} - -private struct TailscaleOAuthCredentialControl: View { - enum Mode: Equatable { - case compact - case standard - } - - private static let oauthCredentialsURL = "https://login.tailscale.com/admin/settings/oauth" - private static let oauthDocsURL = "https://tailscale.com/kb/1215/oauth-clients" - private static let defaultAuthRef = "tailscale.oauth.default" - private static let defaultTailnet = "-" - private static let defaultKeyExpirySeconds = 3600 - private static let clipboardPollTimeoutSeconds: TimeInterval = 120 - private static let clipboardPollIntervalNanoseconds: UInt64 = 750_000_000 - - @Environment(DashboardModel.self) private var model - let mode: Mode - let onStatusChanged: ((TailscaleOAuthStatusResponse?) -> Void)? - - @State private var status: TailscaleOAuthStatusResponse? = nil - @State private var isLoadingStatus = false - @State private var isAuthenticating = false - @State private var isDisconnecting = false - @State private var authMessage = "" - @State private var authMessageTone: StatusTone = .neutral - @State private var authPollingTask: Task? = nil - - var body: some View { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: oauthStatusLabel, tone: oauthStatusTone) - if let tokenExpires = tokenExpiresLabel { - StatusPill(text: "token expires \(tokenExpires)", tone: .neutral) - } - Spacer() - if isLoadingStatus || isAuthenticating || isDisconnecting { - ProgressView() - .controlSize(.small) - } - - if mode == .standard { - Button { - Task { await refreshStatus(validate: true) } - } label: { - Label("Validate", systemImage: "checkmark.shield") - } - .adaptiveToolbarButton() - .disabled(isLoadingStatus || isAuthenticating || isDisconnecting) - } - - Button { - toggleAuthenticationFlow() - } label: { - Label( - isAuthenticating - ? "Cancel auth" - : (status?.configured == true ? "Renew in browser" : "Authenticate in browser"), - systemImage: isAuthenticating ? "xmark.circle" : "safari" - ) - } - .adaptiveToolbarButtonProminent() - .disabled(isLoadingStatus || isDisconnecting) - - if mode == .standard { - Button { - openOAuthCredentialsPage() - } label: { - Label("Open OAuth page", systemImage: "link") - } - .adaptiveToolbarButton() - .disabled(isAuthenticating || isDisconnecting) - } - - if status?.configured == true { - Button(role: .destructive) { - Task { await disconnect() } - } label: { - Label("Disconnect", systemImage: "trash") - } - .adaptiveToolbarButton() - .disabled(isLoadingStatus || isAuthenticating || isDisconnecting) - } - } - - Text( - "Authentication opens Tailscale in your browser. After creating an OAuth client, click copy in Tailscale and this control imports credentials from clipboard automatically." - ) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - if !authMessage.isEmpty { - Text(authMessage) - .font(.mono(.caption2)) - .foregroundStyle(colorForStatusTone(authMessageTone)) - } - - if let checkedAt = checkedAtLabel { - Text("Last checked \(checkedAt)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if let authRef = status?.authRef, !authRef.isEmpty { - Text("Auth ref: \(authRef)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .textSelection(.enabled) - } - if let error = status?.error, !error.isEmpty { - Text(error) - .font(.mono(.caption2)) - .foregroundStyle(Color.orange) - } - - if mode == .standard { - Button { - openOAuthDocsPage() - } label: { - Label("Open OAuth docs", systemImage: "book") - } - .adaptiveToolbarButton() - .disabled(isAuthenticating) - } - } - .task { - await refreshStatus(validate: false) - } - .onDisappear { - cancelAuthenticationFlow(userInitiated: false) - } - } - - private var oauthStatusLabel: String { - guard let status else { - return "Checking OAuth state" - } - if status.configured { - if status.validated == false { - return "OAuth configured (validation failed)" - } - if status.validated == true { - return "Tailscale authenticated" - } - return "OAuth configured" - } - return "OAuth not configured" - } - - private var oauthStatusTone: StatusTone { - guard let status else { - return .neutral - } - if status.configured { - return status.validated == false ? .warn : .good - } - return .warn - } - - private var checkedAtLabel: String? { - formatTimestamp(status?.checkedAt) - } - - private var tokenExpiresLabel: String? { - formatTimestamp(status?.tokenExpiresAt) - } - - private func toggleAuthenticationFlow() { - if isAuthenticating { - cancelAuthenticationFlow(userInitiated: true) - return - } - authPollingTask = Task { - await authenticateViaBrowserFlow() - } - } - - private func cancelAuthenticationFlow(userInitiated: Bool) { - authPollingTask?.cancel() - authPollingTask = nil - if userInitiated { - authMessage = "Authentication canceled." - authMessageTone = .neutral - } - isAuthenticating = false - } - - private func refreshStatus(validate: Bool) async { - isLoadingStatus = true - defer { isLoadingStatus = false } - let latest = await model.inspectTailscaleOAuthStatus(validate: validate) - status = latest - onStatusChanged?(latest) - } - - /// Browser-first OAuth setup: - /// 1) opens Tailscale credentials page - /// 2) waits for copied credentials on clipboard - /// 3) stores credentials through hack CLI keychain flow - private func authenticateViaBrowserFlow() async { - defer { - authPollingTask = nil - isAuthenticating = false - } - - if let existingError = status?.error, existingError.contains("does not support tailscale OAuth") { - authMessage = existingError - authMessageTone = .warn - return - } - - isAuthenticating = true - - authMessage = "Opened Tailscale OAuth page. Create a credential and click copy in the browser." - authMessageTone = .neutral - let baselineClipboard = NSPasteboard.general.string(forType: .string) - openOAuthCredentialsPage() - - guard let credentials = await waitForClipboardCredentials(previousClipboard: baselineClipboard) - else { - if Task.isCancelled { - return - } - authMessage = - "Could not detect OAuth credentials on clipboard. Copy client id + secret from Tailscale, then authenticate again." - authMessageTone = .warn - return - } - - let request = TailscaleOAuthConnectRequest( - clientId: credentials.clientId, - clientSecret: credentials.clientSecret, - authRef: resolvedAuthRef, - tailnet: resolvedTailnet, - keyExpirySeconds: resolvedKeyExpirySeconds - ) - let response = await model.connectTailscaleOAuth(request: request) - status = response - onStatusChanged?(response) - if let responseError = response?.error, !responseError.isEmpty { - authMessage = responseError - authMessageTone = .warn - return - } - - authMessage = "Tailscale OAuth credentials imported successfully." - authMessageTone = .good - await refreshStatus(validate: true) - } - - private func disconnect() async { - isDisconnecting = true - defer { isDisconnecting = false } - let response = await model.disconnectTailscaleOAuth(authRef: status?.authRef) - status = response - onStatusChanged?(response) - if response?.configured == false { - authMessage = "Tailscale OAuth credentials were cleared." - authMessageTone = .neutral - } - } - - private var resolvedAuthRef: String { - let candidate = status?.authRef?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return candidate.isEmpty ? Self.defaultAuthRef : candidate - } - - private var resolvedTailnet: String { - let candidate = status?.tailnet?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - return candidate.isEmpty ? Self.defaultTailnet : candidate - } - - private var resolvedKeyExpirySeconds: Int { - if let keyExpiry = status?.keyExpirySeconds, keyExpiry > 0 { - return keyExpiry - } - return Self.defaultKeyExpirySeconds - } - - private func waitForClipboardCredentials( - previousClipboard: String? - ) async -> OAuthClipboardCredentials? { - let deadline = Date().addingTimeInterval(Self.clipboardPollTimeoutSeconds) - while Date() < deadline { - try? await Task.sleep(nanoseconds: Self.clipboardPollIntervalNanoseconds) - if Task.isCancelled { - return nil - } - if let detected = parseOAuthCredentialsFromClipboard( - previousClipboard: previousClipboard - ) { - return detected - } - } - - return nil - } - - private func parseOAuthCredentialsFromClipboard( - previousClipboard: String? - ) -> OAuthClipboardCredentials? { - guard let clipboard = NSPasteboard.general.string(forType: .string) else { - return nil - } - if let previousClipboard, clipboard == previousClipboard { - return nil - } - return parseOAuthCredentials(text: clipboard) - } - - private func parseOAuthCredentials(text: String) -> OAuthClipboardCredentials? { - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - - if let object = tryParseJsonObject(from: trimmed), - let credentials = findOAuthCredentials(in: object) - { - return credentials - } - - return parseOAuthCredentialsFromKeyValueText(trimmed) - } - - private func tryParseJsonObject(from text: String) -> Any? { - guard let data = text.data(using: .utf8) else { - return nil - } - return try? JSONSerialization.jsonObject(with: data) - } - - private func findOAuthCredentials(in object: Any) -> OAuthClipboardCredentials? { - if let dictionary = object as? [String: Any] { - var clientId: String? - var clientSecret: String? - for (rawKey, rawValue) in dictionary { - if let nested = findOAuthCredentials(in: rawValue) { - return nested - } - guard let value = rawValue as? String else { - continue - } - let key = normalizeCredentialKey(rawKey) - if isClientIdKey(key) { - clientId = normalizeCredentialValue(value) - continue - } - if isClientSecretKey(key) { - clientSecret = normalizeCredentialValue(value) - } - } - if let clientId, let clientSecret { - return OAuthClipboardCredentials(clientId: clientId, clientSecret: clientSecret) - } - return nil - } - - if let array = object as? [Any] { - for value in array { - if let nested = findOAuthCredentials(in: value) { - return nested - } - } - } - - return nil - } - - private func parseOAuthCredentialsFromKeyValueText(_ text: String) -> OAuthClipboardCredentials? - { - var clientId: String? - var clientSecret: String? - let lines = text.split(whereSeparator: \.isNewline).map(String.init) - for line in lines { - if let (rawKey, rawValue) = parseKeyValuePair(line: line) { - let key = normalizeCredentialKey(rawKey) - if isClientIdKey(key) { - clientId = normalizeCredentialValue(rawValue) - continue - } - if isClientSecretKey(key) { - clientSecret = normalizeCredentialValue(rawValue) - } - } - } - if let clientId, let clientSecret { - return OAuthClipboardCredentials(clientId: clientId, clientSecret: clientSecret) - } - - let regexClientId = captureRegexValue( - pattern: #"(?im)(?:oauth\s+)?client\s*id\s*[:=]\s*([^\s"']+)"#, - text: text - ) - let regexClientSecret = captureRegexValue( - pattern: #"(?im)(?:oauth\s+)?client\s*secret\s*[:=]\s*([^\s"']+)"#, - text: text - ) - guard let regexClientId, let regexClientSecret else { - return nil - } - return OAuthClipboardCredentials(clientId: regexClientId, clientSecret: regexClientSecret) - } - - private func captureRegexValue(pattern: String, text: String) -> String? { - guard let expression = try? NSRegularExpression(pattern: pattern) else { - return nil - } - let range = NSRange(text.startIndex.. 1, - let valueRange = Range(match.range(at: 1), in: text) - else { - return nil - } - return normalizeCredentialValue(String(text[valueRange])) - } - - private func parseKeyValuePair(line: String) -> (String, String)? { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - for separator in ["=", ":"] { - if let index = trimmed.firstIndex(of: Character(separator)) { - let key = String(trimmed[.. String { - key - .lowercased() - .filter { $0.isLetter || $0.isNumber } - } - - private func normalizeCredentialValue(_ value: String) -> String { - value - .trimmingCharacters(in: .whitespacesAndNewlines) - .trimmingCharacters(in: CharacterSet(charactersIn: "\"'")) - } - - private func isClientIdKey(_ key: String) -> Bool { - [ - "clientid", - "oauthclientid", - "tailscaleoauthclientid", - "tsapiclientid", - "clientidentifier", - ].contains(key) - } - - private func isClientSecretKey(_ key: String) -> Bool { - [ - "clientsecret", - "oauthclientsecret", - "tailscaleoauthclientsecret", - "tsapiclientsecret", - "secret", - ].contains(key) - } - - private func openOAuthCredentialsPage() { - guard let url = URL(string: Self.oauthCredentialsURL) else { - return - } - NSWorkspace.shared.open(url) - } - - private func openOAuthDocsPage() { - guard let url = URL(string: Self.oauthDocsURL) else { - return - } - NSWorkspace.shared.open(url) - } - - private func colorForStatusTone(_ tone: StatusTone) -> Color { - switch tone { - case .good: - return .green - case .warn: - return .orange - case .neutral: - return .secondary - } - } - - private func formatTimestamp(_ value: String?) -> String? { - guard let value, !value.isEmpty else { - return nil - } - let formatter = ISO8601DateFormatter() - guard let date = formatter.date(from: value) else { - return value - } - return date.formatted(date: .abbreviated, time: .shortened) - } - - private struct OAuthClipboardCredentials { - let clientId: String - let clientSecret: String - } -} - -private struct LinearExtensionSettingsView: View { - @Environment(DashboardModel.self) private var model - @Environment(\.colorScheme) private var colorScheme - @State private var isLoadingConfig = false - @State private var isSavingConfig = false - @State private var isLoadingDiagnostics = false - @State private var suppressEnabledToggleChange = false - @State private var suppressSyncToggleChange = false - @State private var enabled = false - @State private var diagnostics: LinearProfilesResponse? = nil - @State private var remoteConnections: LinearConnectionsResponse? = nil - @State private var profileStatusById: [String: LinearStatusResponse] = [:] - @State private var defaultProfile = "" - @State private var syncLabels = false - @State private var syncStatuses = true - @State private var syncDependencies = true - @State private var syncProjects = true - @State private var isAuthenticating = false - @State private var authPollingTask: Task? = nil - @State private var authFlowStatus: LinearOAuthFlowStatusResponse? = nil - @State private var activeAuthFlowId: String? = nil - @State private var activeAuthStatusURL: String? = nil - @State private var disconnectingLinearProfiles: Set = [] - @State private var repairingLinearProfiles: Set = [] - @State private var assigneeMappings: [LinearAssigneeMapping] = [] - @State private var assigneeMappingProfile = "" - @State private var assigneeMappingTeamId = "" - @State private var assigneeMappingLocalAssignee = "" - @State private var assigneeMappingLinearUserId = "" - @State private var assigneeMappingLinearUserName = "" - @State private var assigneeMappingLinearUserEmail = "" - @State private var isLoadingAssigneeMappings = false - @State private var isSavingAssigneeMapping = false - @State private var message = "" - @State private var lastDiagnosticsRefreshAt: Date? = nil - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Linear", - title: "Linear", - subtitle: nil - ) - GlassCard(title: "Extension status", systemImage: "line.3.horizontal.decrease.circle") { - HStack(alignment: .center, spacing: 8) { - StatusPill( - text: linearConnectionStateLabel, - tone: linearConnectionStateTone - ) - if !remoteLinearConnections.isEmpty { - StatusPill( - text: "\(remoteLinearConnections.count) account\(remoteLinearConnections.count == 1 ? "" : "s")", - tone: .neutral - ) - } - if !resolvedDefaultProfile.isEmpty { - StatusPill( - text: "Default profile: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", - tone: .neutral - ) - } - Spacer() - Toggle("Enabled", isOn: $enabled) - .labelsHidden() - .toggleStyle(.switch) - .onChange(of: enabled) { _, newValue in - guard !suppressEnabledToggleChange else { return } - Task { - await applyLinearEnabledToggle(newValue) - } - } - if isLoadingConfig || isSavingConfig || isLoadingDiagnostics || isAuthenticating - || !repairingLinearProfiles.isEmpty - { - ProgressView() - .controlSize(.small) - } - } - } - - GlassCard(title: "Accounts", systemImage: "point.3.connected.trianglepath.dotted") { - HStack(alignment: .center, spacing: 10) { - Spacer() - if model.hackAccountState?.authenticated == true { - Button { - toggleLinearAuthFlow() - } label: { - Label( - isAuthenticating ? "Cancel connect" : "Connect account", - systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" - ) - } - .adaptiveToolbarButtonProminent() - .disabled(!isAuthenticating && !repairingLinearProfiles.isEmpty) - } else { - Button("Sign in to Hack") { - Task { _ = await model.loginHackAccount() } - } - .adaptiveToolbarButtonProminent() - } - } - - if isAuthenticating { - StatusPill( - text: LinearConnectionPresentationState.connecting.label, - tone: .neutral - ) - } else if let authFlowStatus { - switch authFlowStatus.status { - case "complete": - let state = linearConnectionNeedsAttention - ? LinearConnectionPresentationState.needsAttention - : LinearConnectionPresentationState.connected - StatusPill(text: state.label, tone: linearTone(for: state)) - case "error", "expired": - let state = LinearConnectionPresentationState.needsAttention - StatusPill(text: state.label, tone: linearTone(for: state)) - default: - StatusPill( - text: LinearConnectionPresentationState.connecting.label, - tone: .neutral - ) - } - } - - if model.hackAccountState?.authenticated == true { - if remoteLinearConnections.isEmpty && localOnlyLinearProfiles.isEmpty { - Text("No connected accounts yet.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - - if !remoteLinearConnections.isEmpty { - VStack(alignment: .leading, spacing: 10) { - ForEach(remoteLinearConnections) { connection in - let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let localProfile = localLinearProfile(profileId: profileId) - let rowState = linearRemoteConnectionState(connection) - let needsRepair = rowState == .needsAttention - let rowActionProfileId = profileId.isEmpty ? nextLinearProfileId() : profileId - let accountLabel = - connection.accountName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? connection.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? connection.accountId?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? profileId - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(accountLabel) - .font(.mono(.subheadline, weight: .semibold)) - if !profileId.isEmpty { - StatusPill(text: "Profile \(profileId)", tone: .neutral) - } - if !profileId.isEmpty && resolvedDefaultProfile == profileId { - StatusPill(text: "Default profile", tone: .good) - } - StatusPill( - text: rowState.label, - tone: linearTone(for: rowState) - ) - Spacer() - if !profileId.isEmpty && resolvedDefaultProfile != profileId { - Button { - Task { await saveLinearDefaultProfile(profileId) } - } label: { - Label("Set default", systemImage: "star") - } - .adaptiveToolbarButton() - } - if needsRepair { - Button { - Task { - await repairLinearLocalAccess( - profileId: rowActionProfileId, - setDefault: resolvedDefaultProfile == profileId - ) - } - } label: { - Label("Repair access", systemImage: "arrow.clockwise.circle") - } - .adaptiveToolbarButtonProminent() - .disabled( - isAuthenticating || - repairingLinearProfiles.contains(rowActionProfileId) || - (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) - ) - } else { - Button { - Task { - await reconnectLinearProfile( - rowActionProfileId, - setDefault: resolvedDefaultProfile == profileId - ) - } - } label: { - Label("Reconnect", systemImage: "arrow.clockwise.circle") - } - .adaptiveToolbarButton() - .disabled( - isAuthenticating || - repairingLinearProfiles.contains(rowActionProfileId) || - (!profileId.isEmpty && disconnectingLinearProfiles.contains(profileId)) - ) - } - if let localProfile { - Button(role: .destructive) { - Task { await disconnectLinearProfile(localProfile.id) } - } label: { - Label("Remove access", systemImage: "trash") - } - .adaptiveToolbarButton() - .disabled( - isAuthenticating || - disconnectingLinearProfiles.contains(localProfile.id) - ) - } - } - - if let email = connection.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines), - !email.isEmpty - { - Text(email) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if let teamId = connection.teamId?.trimmingCharacters(in: .whitespacesAndNewlines), - !teamId.isEmpty - { - Text("Linear team \(teamId)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else if let organizationId = connection.organizationId?.trimmingCharacters(in: .whitespacesAndNewlines), - !organizationId.isEmpty - { - Text("Linear organization \(organizationId)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - if connection.id != remoteLinearConnections.last?.id { - Divider() - .opacity(0.2) - } - } - } - } - - if !localOnlyLinearProfiles.isEmpty { - VStack(alignment: .leading, spacing: 8) { - Text("Needs attention") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - ForEach(localOnlyLinearProfiles, id: \.id) { profile in - let profileStatus = profileStatusById[profile.id] - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(linearAccountLabel(profile: profile, status: profileStatus) ?? profile.id) - .font(.mono(.subheadline, weight: .semibold)) - StatusPill(text: "Profile \(profile.id)", tone: .neutral) - let rowState = LinearConnectionPresentationState.needsAttention - StatusPill(text: rowState.label, tone: linearTone(for: rowState)) - Spacer() - Button { - Task { - await reconnectLinearProfile( - profile.id, - setDefault: profile.isDefault - ) - } - } label: { - Label("Connect account", systemImage: "plus.circle") - } - .adaptiveToolbarButtonProminent() - .disabled( - isAuthenticating || - disconnectingLinearProfiles.contains(profile.id) - ) - } - if let accountEmail = linearAccountEmail(profile: profile, status: profileStatus), - !accountEmail.isEmpty - { - Text(accountEmail) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - } - } - } - } - } else if linearProfiles.isEmpty { - Text("Sign in to Hack to manage connected accounts.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Text("Sign in to Hack to finish managing Linear connections.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - - if !message.isEmpty { - Text(message) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .fixedSize(horizontal: false, vertical: true) - } - } - - if !remoteLinearConnections.isEmpty || !localOnlyLinearProfiles.isEmpty { - GlassCard(title: "Sync fields", systemImage: "arrow.triangle.branch") { - VStack(alignment: .leading, spacing: 12) { - syncToggleRow( - title: "Sync labels", - help: "Translate Linear labels and Hack categories during manual sync.", - isOn: $syncLabels - ) { newValue in - await saveLinearSyncToggle( - key: "labels", - value: newValue, - successMessage: newValue ? "Linear label sync enabled." : "Linear label sync disabled." - ) - } - syncToggleRow( - title: "Sync statuses", - help: "Translate workflow state when both sides expose a matching status.", - isOn: $syncStatuses - ) { newValue in - await saveLinearSyncToggle( - key: "statuses", - value: newValue, - successMessage: newValue ? "Linear status sync enabled." : "Linear status sync disabled." - ) - } - syncToggleRow( - title: "Sync dependencies", - help: "Translate dependency and sub-issue style relationships when the counterpart ticket exists.", - isOn: $syncDependencies - ) { newValue in - await saveLinearSyncToggle( - key: "dependencies", - value: newValue, - successMessage: newValue ? "Linear dependency sync enabled." : "Linear dependency sync disabled." - ) - } - syncToggleRow( - title: "Sync project mapping", - help: "Allow Hack projects to read and write the paired Linear project binding.", - isOn: $syncProjects - ) { newValue in - await saveLinearSyncToggle( - key: "projects", - value: newValue, - successMessage: newValue ? "Linear project sync enabled." : "Linear project sync disabled." - ) - } - HStack(alignment: .center, spacing: 6) { - Text("Review still stays manual") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - Image(systemName: "info.circle") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.tertiary) - .help(linearSyncReviewSummary) - Spacer() - } - } - } - } - - if !remoteLinearConnections.isEmpty || !localOnlyLinearProfiles.isEmpty { - GlassCard(title: "Assignee mapping", systemImage: "person.crop.rectangle.stack") { - VStack(alignment: .leading, spacing: 12) { - if linearProfiles.isEmpty { - Text("Connect an account to add assignee mappings.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 10) { - VStack(alignment: .leading, spacing: 6) { - Text("Profile") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - Picker("Profile", selection: $assigneeMappingProfile) { - ForEach(linearProfiles, id: \.id) { profile in - Text(displayNameForRemoteProfileId(profile.id)) - .tag(profile.id) - } - } - .pickerStyle(.menu) - } - - VStack(alignment: .leading, spacing: 6) { - Text("Team scope") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - TextField("Optional team id", text: $assigneeMappingTeamId) - .textFieldStyle(.roundedBorder) - } - - Spacer() - - if isLoadingAssigneeMappings || isSavingAssigneeMapping { - ProgressView() - .controlSize(.small) - } - } - - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text("Saved mappings") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - StatusPill( - text: "\(assigneeMappings.count) mapping\(assigneeMappings.count == 1 ? "" : "s")", - tone: assigneeMappings.isEmpty ? .neutral : .good - ) - } - - if assigneeMappings.isEmpty { - Text("No explicit mappings for this profile/team scope yet.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(assigneeMappings) { mapping in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Text(mapping.localAssignee) - .font(.mono(.subheadline, weight: .semibold)) - if let teamId = mapping.teamId, !teamId.isEmpty { - StatusPill(text: "Team \(teamId)", tone: .neutral) - } else { - StatusPill(text: "All teams", tone: .neutral) - } - Spacer() - Button { - populateAssigneeMappingDraft(from: mapping) - } label: { - Label("Use", systemImage: "arrow.down.left.circle") - } - .adaptiveToolbarButton() - Button(role: .destructive) { - Task { await removeLinearAssigneeMapping(mapping) } - } label: { - Label("Remove", systemImage: "trash") - } - .adaptiveToolbarButton() - .disabled(isSavingAssigneeMapping) - } - Text(mappingDestinationLabel(mapping)) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - } - } - } - } - - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 10) { - Text("Edit mapping") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - TextField("Local assignee (required)", text: $assigneeMappingLocalAssignee) - .textFieldStyle(.roundedBorder) - TextField("Linear user id", text: $assigneeMappingLinearUserId) - .textFieldStyle(.roundedBorder) - TextField("Linear user name", text: $assigneeMappingLinearUserName) - .textFieldStyle(.roundedBorder) - TextField("Linear user email", text: $assigneeMappingLinearUserEmail) - .textFieldStyle(.roundedBorder) - - HStack(spacing: 8) { - Button { - Task { await saveLinearAssigneeMapping() } - } label: { - Label("Save mapping", systemImage: "tray.and.arrow.down") - } - .adaptiveToolbarButtonProminent() - .disabled(!canSaveAssigneeMapping || isSavingAssigneeMapping) - - Button("Clear form") { - clearAssigneeMappingDraft() - } - .adaptiveToolbarButton() - .disabled(isSavingAssigneeMapping) - } - } - } - } - } - } - } - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.linearOAuthDeepLinkContext) { _, deepLink in - guard let deepLink else { return } - Task { - await handleLinearOAuthDeepLink(deepLink) - } - } - .onChange(of: assigneeMappingProfile) { _, _ in - Task { await refreshLinearAssigneeMappings() } - } - .onDisappear { - cancelLinearAuthFlow(userInitiated: false) - } - } - - private var resolvedDefaultProfile: String { - let trimmed = defaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - return diagnostics?.defaultProfile ?? "" - } - - private func syncToggleRow( - title: String, - help: String, - isOn: Binding, - save: @escaping @MainActor (Bool) async -> Void - ) -> some View { - HStack(alignment: .center, spacing: 10) { - Toggle(title, isOn: isOn) - .toggleStyle(.switch) - .onChange(of: isOn.wrappedValue) { _, newValue in - guard !suppressSyncToggleChange else { return } - Task { - await save(newValue) - } - } - Image(systemName: "info.circle") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.tertiary) - .help(help) - Spacer() - } - } - - private var linearProfiles: [LinearProfileSummary] { - (diagnostics?.profiles ?? []).sorted { lhs, rhs in - lhs.id.localizedCaseInsensitiveCompare(rhs.id) == .orderedAscending - } - } - - private var remoteLinearConnections: [LinearRemoteConnection] { - (remoteConnections?.connections ?? []).sorted { lhs, rhs in - let left = lhs.updatedAt.trimmingCharacters(in: .whitespacesAndNewlines) - let right = rhs.updatedAt.trimmingCharacters(in: .whitespacesAndNewlines) - return left.localizedCompare(right) == .orderedDescending - } - } - - private var localOnlyLinearProfiles: [LinearProfileSummary] { - linearProfiles.filter { profile in - !remoteLinearConnections.contains(where: { - $0.profileId?.caseInsensitiveCompare(profile.id) == .orderedSame - }) - } - } - - private func localLinearProfile(profileId: String) -> LinearProfileSummary? { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - return linearProfiles.first(where: { profile in - profile.id.caseInsensitiveCompare(trimmed) == .orderedSame - }) - } - - private func hasLocalLinearAccess(profileId: String) -> Bool { - guard localLinearProfile(profileId: profileId) != nil else { - return false - } - guard let status = profileStatusById[profileId] else { - return true - } - return status.tokenResolved - } - - private func linearRemoteConnectionState( - _ connection: LinearRemoteConnection - ) -> LinearConnectionPresentationState { - let profileId = connection.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let localProfilePresent = localLinearProfile(profileId: profileId) != nil - let localTokenResolved = profileStatusById[profileId]?.tokenResolved - return LinearConnectionStateResolver.presentationState( - profileId: profileId, - localProfilePresent: localProfilePresent, - localTokenResolved: localTokenResolved, - remoteLocalAccessAvailable: connection.localAccessAvailable - ) - } - - private var linearConnectionNeedsAttention: Bool { - !localOnlyLinearProfiles.isEmpty || - remoteLinearConnections.contains { connection in - linearRemoteConnectionState(connection) == .needsAttention - } - } - - private var linearConnectionState: LinearConnectionPresentationState { - if !enabled { - return .notConnected - } - if isAuthenticating { - return .connecting - } - if let authFlowStatus, authFlowStatus.status == "error" || authFlowStatus.status == "expired" { - return .needsAttention - } - if !remoteLinearConnections.isEmpty { - return linearConnectionNeedsAttention ? .needsAttention : .connected - } - if !localOnlyLinearProfiles.isEmpty { - return .needsAttention - } - return .notConnected - } - - private var linearConnectionStateLabel: String { - if !enabled { - return "Disabled" - } - return linearConnectionState.label - } - - private var linearConnectionStateTone: StatusTone { - linearTone(for: linearConnectionState) - } - - private func linearTone(for state: LinearConnectionPresentationState) -> StatusTone { - switch state { - case .connected: - .good - case .needsAttention: - .warn - case .connecting, .notConnected: - .neutral - } - } - - private func nextLinearProfileId() -> String { - let existing = Set( - linearProfiles.map { $0.id.lowercased() } + - remoteLinearConnections.compactMap { $0.profileId?.lowercased() } - ) - if !existing.contains("default") { - return "default" - } - var index = 2 - while existing.contains("account-\(index)") { - index += 1 - } - return "account-\(index)" - } - - private var defaultLinearProfileNeedsRepair: Bool { - let defaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !defaultProfileId.isEmpty else { - return false - } - if let remoteConnection = remoteLinearConnections.first(where: { connection in - connection.profileId?.caseInsensitiveCompare(defaultProfileId) == .orderedSame - }) { - return linearRemoteConnectionState(remoteConnection) == .needsAttention - } - guard linearProfiles.contains(where: { - $0.id.caseInsensitiveCompare(defaultProfileId) == .orderedSame - }) else { - return false - } - guard let status = profileStatusById[defaultProfileId] else { - return false - } - return !status.tokenResolved - } - - private var linearSyncPolicySummary: String { - "Authority follows ticket origin. Origin-owned fields such as title, body, and status stay authoritative on the side that created the ticket. Linear linkage metadata refreshes from Linear, while Hack project placement stays local. Assignees, labels, dependencies, and sub-issue links are best-effort mergeable when enabled. Comments are append-only and synced in FIFO order rather than edited in place." - } - - private var linearSyncReviewSummary: String { - "These toggles only enable translation during manual sync. Hack still flags review-needed cases when both sides changed an origin-owned field, when assignees cannot be mapped cleanly, or when mergeable fields do not have a safe one-to-one counterpart." - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - suppressEnabledToggleChange = true - enabled = snapshot.linearExtensionEnabled ?? false - defaultProfile = snapshot.linearDefaultProfile ?? "" - suppressSyncToggleChange = true - syncLabels = snapshot.linearSyncLabels ?? false - syncStatuses = snapshot.linearSyncStatuses ?? true - syncDependencies = snapshot.linearSyncDependencies ?? true - syncProjects = snapshot.linearSyncProjects ?? true - suppressSyncToggleChange = false - suppressEnabledToggleChange = false - message = "" - await refreshLinearDiagnostics() - } - - private func refreshLinearDiagnostics() async { - isLoadingDiagnostics = true - defer { isLoadingDiagnostics = false } - let hackAccountAuthenticated = model.hackAccountState?.authenticated == true - async let localProfilesTask = model.inspectLinearProfiles() - async let remoteConnectionsTask: LinearConnectionsResponse? = { - guard hackAccountAuthenticated else { - return nil - } - return await model.listLinearConnections() - }() - diagnostics = await localProfilesTask - remoteConnections = await remoteConnectionsTask - if let diagnostics { - defaultProfile = diagnostics.defaultProfile - } - await refreshLinearProfileStatuses() - refreshSelectedAssigneeMappingProfile() - await refreshLinearAssigneeMappings() - lastDiagnosticsRefreshAt = Date() - } - - private func refreshLinearProfileStatuses() async { - // Passive settings pages should not trigger keychain-backed token resolution. - profileStatusById = [:] - } - - private var normalizedAssigneeMappingProfile: String { - assigneeMappingProfile.trimmingCharacters(in: .whitespacesAndNewlines) - } - - private var normalizedAssigneeMappingTeamId: String? { - let trimmed = assigneeMappingTeamId.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private var canSaveAssigneeMapping: Bool { - guard !normalizedAssigneeMappingProfile.isEmpty else { - return false - } - let localAssignee = assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines) - guard !localAssignee.isEmpty else { - return false - } - let linearUserId = assigneeMappingLinearUserId.trimmingCharacters(in: .whitespacesAndNewlines) - let linearUserName = assigneeMappingLinearUserName.trimmingCharacters(in: .whitespacesAndNewlines) - let linearUserEmail = assigneeMappingLinearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines) - return !linearUserId.isEmpty || !linearUserName.isEmpty || !linearUserEmail.isEmpty - } - - private func refreshSelectedAssigneeMappingProfile() { - let current = normalizedAssigneeMappingProfile - if !current.isEmpty, linearProfiles.contains(where: { $0.id == current }) { - return - } - let fallback = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !fallback.isEmpty, linearProfiles.contains(where: { $0.id == fallback }) { - assigneeMappingProfile = fallback - return - } - assigneeMappingProfile = linearProfiles.first?.id ?? "" - } - - private func refreshLinearAssigneeMappings() async { - refreshSelectedAssigneeMappingProfile() - let profileId = normalizedAssigneeMappingProfile - guard !profileId.isEmpty else { - assigneeMappings = [] - return - } - isLoadingAssigneeMappings = true - defer { isLoadingAssigneeMappings = false } - let response = await model.listLinearAssigneeMappings( - profileId: profileId, - teamId: normalizedAssigneeMappingTeamId - ) - if let response { - assigneeMappings = response.mappings.sorted { - $0.localAssignee.localizedCaseInsensitiveCompare($1.localAssignee) == .orderedAscending - } - } else { - assigneeMappings = [] - } - } - - private func clearAssigneeMappingDraft() { - assigneeMappingLocalAssignee = "" - assigneeMappingLinearUserId = "" - assigneeMappingLinearUserName = "" - assigneeMappingLinearUserEmail = "" - } - - private func populateAssigneeMappingDraft(from mapping: LinearAssigneeMapping) { - assigneeMappingProfile = mapping.profileId - assigneeMappingTeamId = mapping.teamId ?? "" - assigneeMappingLocalAssignee = mapping.localAssignee - assigneeMappingLinearUserId = mapping.linearUserId ?? "" - assigneeMappingLinearUserName = mapping.linearUserName ?? "" - assigneeMappingLinearUserEmail = mapping.linearUserEmail ?? "" - } - - private func mappingDestinationLabel(_ mapping: LinearAssigneeMapping) -> String { - if let email = mapping.linearUserEmail, !email.isEmpty { - return "\(mapping.linearUserName ?? mapping.linearUserId ?? "Linear user") • \(email)" - } - if let name = mapping.linearUserName, !name.isEmpty { - return "\(name) • \(mapping.linearUserId ?? "no id")" - } - if let userId = mapping.linearUserId, !userId.isEmpty { - return userId - } - return "Missing Linear user details" - } - - private func saveLinearAssigneeMapping() async { - guard canSaveAssigneeMapping else { - message = "Fill in a local assignee and at least one Linear user field." - return - } - isSavingAssigneeMapping = true - defer { isSavingAssigneeMapping = false } - - let response = await model.setLinearAssigneeMapping( - profileId: normalizedAssigneeMappingProfile, - teamId: normalizedAssigneeMappingTeamId, - localAssignee: assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines), - linearUserId: assigneeMappingLinearUserId.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, - linearUserName: assigneeMappingLinearUserName.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty, - linearUserEmail: assigneeMappingLinearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ) - guard let response else { - message = model.errorMessage ?? "Failed to save Linear assignee mapping." - return - } - message = response.replacedExisting - ? "Updated the assignee mapping for \(response.mapping.localAssignee)." - : "Saved a new assignee mapping for \(response.mapping.localAssignee)." - await refreshLinearAssigneeMappings() - clearAssigneeMappingDraft() - } - - private func removeLinearAssigneeMapping(_ mapping: LinearAssigneeMapping) async { - isSavingAssigneeMapping = true - defer { isSavingAssigneeMapping = false } - - let response = await model.removeLinearAssigneeMapping( - profileId: mapping.profileId, - teamId: mapping.teamId, - localAssignee: mapping.localAssignee - ) - guard let response, response.removed else { - message = model.errorMessage ?? "Failed to remove Linear assignee mapping." - return - } - if assigneeMappingLocalAssignee.trimmingCharacters(in: .whitespacesAndNewlines) - .caseInsensitiveCompare(mapping.localAssignee) == .orderedSame - { - clearAssigneeMappingDraft() - } - message = "Removed the assignee mapping for \(mapping.localAssignee)." - await refreshLinearAssigneeMappings() - } - - private func applyLinearEnabledToggle(_ newValue: Bool) async { - isSavingConfig = true - defer { isSavingConfig = false } - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.linear\"].enabled", - value: newValue ? "true" : "false" - ) - if !didUpdate { - await loadConfigFromDisk() - message = "Failed to update extension enabled state." - return - } - message = newValue ? "Linear extension enabled." : "Linear extension disabled." - await model.refresh() - await refreshLinearDiagnostics() - } - - private func saveLinearSyncToggle( - key: String, - value: Bool, - successMessage: String - ) async { - isSavingConfig = true - defer { isSavingConfig = false } - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.linear\"].config.sync.\(key)", - value: value ? "true" : "false" - ) - if !didUpdate { - await loadConfigFromDisk() - message = "Failed to update Linear sync policy." - return - } - message = successMessage - await model.refresh() - await refreshLinearDiagnostics() - } - - private func linearAccountLabel( - profile: LinearProfileSummary, - status: LinearStatusResponse? - ) -> String? { - let profileName = profile.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileName, !profileName.isEmpty { - return profileName - } - let statusName = status?.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusName, !statusName.isEmpty { - return statusName - } - let statusHandle = status?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusHandle, !statusHandle.isEmpty { - return statusHandle - } - let profileEmail = profile.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileEmail, !profileEmail.isEmpty { - return profileEmail - } - return nil - } - - private func linearAccountEmail( - profile: LinearProfileSummary, - status: LinearStatusResponse? - ) -> String? { - let profileEmail = profile.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileEmail, !profileEmail.isEmpty { - return profileEmail - } - let statusEmail = status?.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusEmail, !statusEmail.isEmpty { - return statusEmail - } - return nil - } - - private func linearAccountId( - profile: LinearProfileSummary, - status: LinearStatusResponse? - ) -> String? { - let profileId = profile.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileId, !profileId.isEmpty { - return profileId - } - let statusId = status?.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusId, !statusId.isEmpty { - return statusId - } - return nil - } - - private func displayNameForRemoteProfileId(_ profileId: String) -> String { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return "none" - } - guard let profile = linearProfiles.first(where: { $0.id == trimmed }) else { - if let connection = remoteLinearConnections.first(where: { - $0.profileId?.caseInsensitiveCompare(trimmed) == .orderedSame - }) { - return connection.accountName - ?? connection.accountEmail - ?? connection.accountId - ?? trimmed - } - return trimmed - } - let status = profileStatusById[trimmed] - guard let label = linearAccountLabel(profile: profile, status: status) else { - return trimmed - } - return "\(label) (\(trimmed))" - } - - private func saveLinearDefaultProfile(_ profileId: String) async { - isSavingConfig = true - defer { isSavingConfig = false } - - let trimmedDefault = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - let didSaveDefault = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile", - value: trimmedDefault - ) - if !didSaveDefault { - message = "Failed to save Linear default profile." - return - } - - message = trimmedDefault.isEmpty - ? "Linear default profile cleared." - : "Linear default profile saved. Projects without their own override will now route through \(displayNameForRemoteProfileId(trimmedDefault))." - defaultProfile = trimmedDefault - await model.refresh() - await refreshLinearDiagnostics() - } - - private func shouldFallbackLinearRepairToReconnect(_ error: String) -> Bool { - let normalized = error.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - guard !normalized.isEmpty else { - return false - } - return normalized.contains("seed-local-access") - || normalized.contains("linear_local_access_") - || normalized.contains("provider_token_custody_not_configured") - || normalized.contains("local access needs repair") - } - - private func repairLinearLocalAccess( - profileId: String, - setDefault: Bool - ) async { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return - } - repairingLinearProfiles.insert(trimmed) - defer { repairingLinearProfiles.remove(trimmed) } - - if let repaired = await model.seedLinearLocalAccess(profileId: trimmed) { - let account = repaired.accountName?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? repaired.accountEmail?.trimmingCharacters(in: .whitespacesAndNewlines).nilIfEmpty - ?? displayNameForRemoteProfileId(trimmed) - message = repaired.refreshed - ? "Repaired local access for \(account)." - : "Local access restored for \(account)." - await model.refresh() - await refreshLinearDiagnostics() - return - } - - let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - if shouldFallbackLinearRepairToReconnect(detail) { - await reconnectLinearProfile(trimmed, setDefault: setDefault) - return - } - if !detail.isEmpty { - message = detail - } else { - message = "Unable to repair local access for \(displayNameForRemoteProfileId(trimmed)). Reconnect the account if the problem persists." - } - } - - private func reconnectLinearProfile( - _ profileId: String, - setDefault: Bool - ) async { - await connectLinearAccountViaBrowser( - profileId: profileId, - setDefault: setDefault - ) - } - - private func disconnectLinearProfile(_ profileId: String) async { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return - } - disconnectingLinearProfiles.insert(trimmed) - defer { disconnectingLinearProfiles.remove(trimmed) } - - let disconnected = await model.disconnectLinear(profileId: trimmed) - if !disconnected { - message = model.errorMessage ?? "Failed to disconnect the Linear provider account." - return - } - - if resolvedDefaultProfile == trimmed { - message = "Removed access for \(displayNameForRemoteProfileId(trimmed)). Reconnect it before using this default again." - } else { - message = "Removed access for \(displayNameForRemoteProfileId(trimmed)). Reconnect it before using it again." - } - await model.refresh() - await refreshLinearDiagnostics() - } - - private func toggleLinearAuthFlow() { - if isAuthenticating { - cancelLinearAuthFlow(userInitiated: true) - return - } - let repairDefaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let generatedProfileId = defaultLinearProfileNeedsRepair && !repairDefaultProfileId.isEmpty - ? repairDefaultProfileId - : nextLinearProfileId() - let setAsDefault = - (linearProfiles.isEmpty && remoteLinearConnections.isEmpty) || - defaultLinearProfileNeedsRepair - authPollingTask = Task { - await connectLinearAccountViaBrowser( - profileId: generatedProfileId, - setDefault: setAsDefault - ) - } - } - - private func cancelLinearAuthFlow(userInitiated: Bool) { - authPollingTask?.cancel() - authPollingTask = nil - isAuthenticating = false - activeAuthFlowId = nil - activeAuthStatusURL = nil - if userInitiated { - message = "Linear authentication canceled." - } - } - - private func connectLinearAccountViaBrowser( - profileId: String, - setDefault: Bool - ) async { - defer { - authPollingTask = nil - isAuthenticating = false - } - - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - let profile = trimmed.isEmpty ? "default" : trimmed - isAuthenticating = true - authFlowStatus = nil - message = "Starting Linear connection for \(profile)…" - - guard - let started = await model.startLinearOAuthFlow( - profileId: profile, - setDefault: setDefault - ) - else { - let detail = model.errorMessage?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - if let detail, !detail.isEmpty { - message = detail - } else { - message = - "Unable to start Linear auth. Confirm network access to the auth broker and retry." - } - return - } - - if setDefault { - defaultProfile = profile - } - activeAuthFlowId = started.flowId - activeAuthStatusURL = started.statusUrl - - guard let authorizeURL = URL(string: started.authorizeUrl) else { - message = "Auth start returned an invalid authorize URL." - return - } - - NSWorkspace.shared.open(authorizeURL) - message = "Approve access in the browser to finish connecting \(profile)." - - let formatter = ISO8601DateFormatter() - let expiresAtDate = formatter.date(from: started.expiresAt) - var pendingPollCount = 0 - - while !Task.isCancelled { - if let expiresAtDate, Date() >= expiresAtDate { - message = "Authentication flow expired. Start a new connection." - return - } - - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard !Task.isCancelled else { - return - } - - guard - let flowStatus = await model.fetchLinearOAuthFlowStatus( - statusURL: started.statusUrl - ) - else { - let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) - if let detail, !detail.isEmpty { - message = detail - return - } - continue - } - - authFlowStatus = flowStatus - if flowStatus.status == "pending" { - pendingPollCount += 1 - if pendingPollCount.isMultiple(of: 4), - model.hackAccountState?.authenticated == true, - let refreshedRemoteConnections = await model.listLinearConnections() - { - remoteConnections = refreshedRemoteConnections - } - let remoteConnectionVisible = remoteLinearConnections.contains(where: { - $0.profileId?.caseInsensitiveCompare(profile) == .orderedSame - }) - if remoteConnectionVisible && pendingPollCount >= 8 { - message = - "Hack already sees this account for \(profile). If this Mac still needs access, use Repair access." - await refreshLinearDiagnostics() - authFlowStatus = nil - return - } - if pendingPollCount == 8 { - message = - "Still waiting for Linear to finish connecting." - } - if pendingPollCount >= 16 { - message = - "Connection did not finish. If Linear says Hack is already installed, remove that authorization and reconnect once." - await refreshLinearDiagnostics() - authFlowStatus = nil - return - } - } - if await handleLinearOAuthFlowStatus(flowStatus) { - return - } - } - } - - private func handleLinearOAuthFlowStatus( - _ flowStatus: LinearOAuthFlowStatusResponse - ) async -> Bool { - switch flowStatus.status { - case "complete": - let account = flowStatus.accountName - ?? flowStatus.accountEmail - ?? flowStatus.accountHandle - ?? "Linear provider account" - await model.refresh() - await refreshLinearDiagnostics() - let resolvedConnection = remoteLinearConnections.first(where: { - $0.profileId?.caseInsensitiveCompare(flowStatus.profileId) == .orderedSame - }) - if let resolvedConnection, - linearRemoteConnectionState(resolvedConnection) == .needsAttention - { - message = "\(account) is connected on Hack. Use Repair access to finish setup on this Mac." - } else { - message = "Connected \(account)." - } - return true - case "error": - message = flowStatus.error ?? "Linear authentication failed." - return true - case "expired": - message = "Authentication flow expired. Start a new connection." - return true - default: - return false - } - } - - private func handleLinearOAuthDeepLink(_ deepLink: LinearOAuthDeepLinkContext) async { - defer { - model.clearLinearOAuthDeepLink(flowId: deepLink.flowId) - } - - guard - let activeAuthFlowId, - let activeAuthStatusURL - else { - message = "Linear callback received. Return to Linear settings to finish setup." - return - } - - guard activeAuthFlowId == deepLink.flowId else { - return - } - - message = "Browser callback received. Finalizing Linear connection…" - guard let flowStatus = await model.fetchLinearOAuthFlowStatus(statusURL: activeAuthStatusURL) else { - let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) - if let detail, !detail.isEmpty { - message = detail - } - return - } - authFlowStatus = flowStatus - _ = await handleLinearOAuthFlowStatus(flowStatus) - } -} - -private extension String { - var nilIfEmpty: String? { - isEmpty ? nil : self - } -} - -private struct GitHubExtensionSettingsView: View { - @Environment(DashboardModel.self) private var model - @State private var isLoadingConfig = false - @State private var isSavingConfig = false - @State private var isLoadingDiagnostics = false - @State private var suppressEnabledToggleChange = false - @State private var enabled = false - @State private var diagnostics: GitHubProfilesResponse? = nil - @State private var profileStatusById: [String: GitHubStatusResponse] = [:] - @State private var defaultProfile = "" - @State private var isAuthenticating = false - @State private var authPollingTask: Task? = nil - @State private var authFlowStatus: GitHubOAuthFlowStatusResponse? = nil - @State private var activeAuthFlowId: String? = nil - @State private var activeAuthStatusURL: String? = nil - @State private var authInstallURL: String? = nil - @State private var didAutoOpenInstallURL = false - @State private var message = "" - @State private var lastDiagnosticsRefreshAt: Date? = nil - @State private var systemIdentity: GitSystemIdentity? = nil - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / GitHub", - title: "GitHub", - subtitle: nil - ) - GlassCard(title: "Extension status", systemImage: "chevron.left.forwardslash.chevron.right") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) - StatusPill( - text: "\(githubProfiles.count) provider account\(githubProfiles.count == 1 ? "" : "s")", - tone: (diagnostics?.profiles.isEmpty == false) ? .good : .neutral - ) - if !resolvedDefaultProfile.isEmpty { - StatusPill( - text: "Default profile: \(displayNameForRemoteProfileId(resolvedDefaultProfile))", - tone: .neutral - ) - } - Spacer() - Toggle("Enabled", isOn: $enabled) - .labelsHidden() - .toggleStyle(.switch) - .onChange(of: enabled) { _, newValue in - guard !suppressEnabledToggleChange else { return } - Task { - await applyGitHubEnabledToggle(newValue) - } - } - if isLoadingConfig || isSavingConfig || isLoadingDiagnostics || isAuthenticating { - ProgressView() - .controlSize(.small) - } - } - } - - GlassCard(title: "System Git identity", systemImage: "person.crop.circle.badge.checkmark") { - HStack(spacing: 8) { - StatusPill(text: "Read-only", tone: .neutral) - StatusPill(text: "Inherited local Git", tone: .neutral) - if let login = systemIdentity?.githubLogin, !login.isEmpty { - StatusPill(text: "@\(login)", tone: .good) - } else { - StatusPill(text: "GitHub CLI account unknown", tone: .warn) - } - } - - if let systemIdentity { - if let gitName = systemIdentity.gitName, !gitName.isEmpty { - Text("git name: \(gitName)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if let gitEmail = systemIdentity.gitEmail, !gitEmail.isEmpty { - Text("git email: \(gitEmail)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if let login = systemIdentity.githubLogin, !login.isEmpty { - let accountNameSuffix = systemIdentity.githubName.map { " (\($0))" } ?? "" - Text("github cli: @\(login)\(accountNameSuffix)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } else { - Text("No GitHub CLI account detected for this machine.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } else { - Text("Unable to read local Git identity yet.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - - GlassCard(title: "Accounts", systemImage: "person.2.badge.gearshape") { - HStack(alignment: .center, spacing: 10) { - Spacer() - if model.hackAccountState?.authenticated == true { - Button { - toggleGitHubAuthFlow() - } label: { - Label( - isAuthenticating ? "Cancel connect" : "Connect account", - systemImage: isAuthenticating ? "xmark.circle" : "plus.circle" - ) - } - .adaptiveToolbarButtonProminent() - } else { - Button("Sign in to Hack") { - Task { _ = await model.loginHackAccount() } - } - .adaptiveToolbarButtonProminent() - } - } - - if isAuthenticating { - StatusPill(text: "Waiting for browser callback", tone: .good) - } else if let authFlowStatus { - switch authFlowStatus.status { - case "complete", "claimed": - if requiresGitHubAppInstall(authFlowStatus) { - StatusPill(text: "Install required", tone: .warn) - } else { - StatusPill(text: "Connected", tone: .good) - } - case "error", "expired": - StatusPill(text: "Connect failed", tone: .warn) - default: - StatusPill(text: "Connect pending", tone: .neutral) - } - } - - if githubProfiles.isEmpty { - Text("No accounts connected yet.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - ForEach(githubProfiles, id: \.id) { profile in - let profileStatus = profileStatusById[profile.id] - let accountHandle = remoteAccountHandle( - profile: profile, - status: profileStatus - ) - let accountName = remoteAccountName( - profile: profile, - status: profileStatus - ) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(accountHandle.map { "@\($0)" } ?? profile.id) - .font(.mono(.subheadline, weight: .semibold)) - if profile.isDefault { - StatusPill(text: "Default profile", tone: .good) - } - StatusPill( - text: profile.mode.lowercased() == "app" ? "Remote app" : "Remote OAuth", - tone: .neutral - ) - if profile.installationId?.isEmpty == false { - StatusPill(text: "App installed", tone: .good) - } else if profile.mode.lowercased() == "app" { - StatusPill(text: "Install missing", tone: .warn) - } - Spacer() - if !profile.isDefault { - if !(profile.mode.lowercased() == "app" && profile.installationId?.isEmpty != false) { - Button { - Task { await saveGitHubDefaultProfile(profile.id) } - } label: { - Label("Set default", systemImage: "star") - } - .adaptiveToolbarButton() - } - } - } - - if let accountHandle, !accountHandle.isEmpty { - let accountNameSuffix = accountName.map { " (\($0))" } ?? "" - Text("@\(accountHandle)\(accountNameSuffix)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - Text("Profile \(profile.id)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - if let profileStatus, - let tokenExpiresAt = profileStatus.tokenExpiresAt, - !tokenExpiresAt.isEmpty - { - Text("Token expires \(tokenExpiresAt)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if let installation = profile.installationId, !installation.isEmpty { - Text("Installation \(installation)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - if profile.id != githubProfiles.last?.id { - Divider() - .opacity(0.2) - } - } - } - } - } - - if !message.isEmpty { - InlineCallout( - tone: .neutral, - title: "GitHub settings", - message: message, - actions: [] - ) - } - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.githubOAuthDeepLinkContext) { _, deepLink in - guard let deepLink else { return } - Task { - await handleGitHubOAuthDeepLink(deepLink) - } - } - .onDisappear { - cancelGitHubAuthFlow(userInitiated: false) - } - } - - private var resolvedDefaultProfile: String { - let trimmed = defaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - return diagnostics?.defaultProfile ?? "" - } - - private var githubProfiles: [GitHubProfileSummary] { - (diagnostics?.profiles ?? []).sorted { lhs, rhs in - lhs.id.localizedCaseInsensitiveCompare(rhs.id) == .orderedAscending - } - } - - private func nextGitHubProfileId() -> String { - let existing = Set(githubProfiles.map { $0.id.lowercased() }) - if !existing.contains("default") { - return "default" - } - var index = 2 - while existing.contains("account-\(index)") { - index += 1 - } - return "account-\(index)" - } - - private var defaultGitHubProfileNeedsRepair: Bool { - let defaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - guard !defaultProfileId.isEmpty else { - return false - } - guard githubProfiles.contains(where: { $0.id == defaultProfileId }) else { - return false - } - guard let status = profileStatusById[defaultProfileId] else { - return false - } - return !status.tokenResolved - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - defer { isLoadingConfig = false } - - let snapshot = GlobalConfigSnapshot.load() - suppressEnabledToggleChange = true - enabled = snapshot.githubExtensionEnabled ?? false - defaultProfile = snapshot.githubDefaultProfile ?? "" - suppressEnabledToggleChange = false - message = "" - await refreshGitHubDiagnostics() - } - - private func refreshGitHubDiagnostics() async { - isLoadingDiagnostics = true - defer { isLoadingDiagnostics = false } - diagnostics = await model.inspectGitHubProfiles() - if let diagnostics { - defaultProfile = diagnostics.defaultProfile - } - systemIdentity = await model.inspectSystemGitIdentity() - await refreshGitHubProfileStatuses() - lastDiagnosticsRefreshAt = Date() - } - - private func refreshGitHubProfileStatuses() async { - // Passive settings pages should not trigger keychain-backed token resolution. - profileStatusById = [:] - } - - private func applyGitHubEnabledToggle(_ newValue: Bool) async { - isSavingConfig = true - defer { isSavingConfig = false } - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.github\"].enabled", - value: newValue ? "true" : "false" - ) - if !didUpdate { - await loadConfigFromDisk() - message = "Failed to update extension enabled state." - return - } - message = newValue ? "GitHub extension enabled." : "GitHub extension disabled." - await model.refresh() - await refreshGitHubDiagnostics() - } - - private func remoteAccountHandle( - profile: GitHubProfileSummary, - status: GitHubStatusResponse? - ) -> String? { - let profileHandle = profile.accountLogin?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileHandle, !profileHandle.isEmpty { - return profileHandle - } - let statusHandle = status?.accountLogin?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusHandle, !statusHandle.isEmpty { - return statusHandle - } - return nil - } - - private func remoteAccountName( - profile: GitHubProfileSummary, - status: GitHubStatusResponse? - ) -> String? { - let profileName = profile.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileName, !profileName.isEmpty { - return profileName - } - let statusName = status?.accountName?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusName, !statusName.isEmpty { - return statusName - } - return nil - } - - private func remoteAccountId( - profile: GitHubProfileSummary, - status: GitHubStatusResponse? - ) -> String? { - let profileId = profile.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) - if let profileId, !profileId.isEmpty { - return profileId - } - let statusId = status?.accountId?.trimmingCharacters(in: .whitespacesAndNewlines) - if let statusId, !statusId.isEmpty { - return statusId - } - return nil - } - - private func displayNameForRemoteProfileId(_ profileId: String) -> String { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return "none" - } - guard let profile = githubProfiles.first(where: { $0.id == trimmed }) else { - return trimmed - } - let status = profileStatusById[trimmed] - guard let handle = remoteAccountHandle(profile: profile, status: status) else { - return trimmed - } - return "@\(handle) (\(trimmed))" - } - - private func saveGitHubDefaultProfile(_ profileId: String) async { - isSavingConfig = true - defer { isSavingConfig = false } - - let trimmedDefault = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - let didSaveDefault = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.github\"].config.defaultProfile", - value: trimmedDefault - ) - if !didSaveDefault { - message = "Failed to save GitHub default profile." - return - } - - message = trimmedDefault.isEmpty - ? "GitHub default profile cleared." - : "GitHub default profile saved." - defaultProfile = trimmedDefault - await model.refresh() - await refreshGitHubDiagnostics() - } - - private func toggleGitHubAuthFlow() { - if isAuthenticating { - cancelGitHubAuthFlow(userInitiated: true) - return - } - let repairDefaultProfileId = resolvedDefaultProfile.trimmingCharacters(in: .whitespacesAndNewlines) - let generatedProfileId = defaultGitHubProfileNeedsRepair && !repairDefaultProfileId.isEmpty - ? repairDefaultProfileId - : nextGitHubProfileId() - let setAsDefault = githubProfiles.isEmpty || defaultGitHubProfileNeedsRepair - authPollingTask = Task { - await connectGitHubAccountViaBrowser( - profileId: generatedProfileId, - setDefault: setAsDefault - ) - } - } - - private func cancelGitHubAuthFlow(userInitiated: Bool) { - authPollingTask?.cancel() - authPollingTask = nil - isAuthenticating = false - activeAuthFlowId = nil - activeAuthStatusURL = nil - if userInitiated { - message = "GitHub authentication canceled." - } - } - - private func connectGitHubAccountViaBrowser( - profileId: String, - setDefault: Bool - ) async { - defer { - authPollingTask = nil - isAuthenticating = false - activeAuthFlowId = nil - activeAuthStatusURL = nil - } - - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - let profile = trimmed.isEmpty ? "default" : trimmed - isAuthenticating = true - authFlowStatus = nil - authInstallURL = nil - didAutoOpenInstallURL = false - message = "Starting GitHub browser auth for profile \(profile)…" - - guard - let started = await model.startGitHubOAuthFlow( - profileId: profile, - setDefault: setDefault - ) - else { - let detail = model.errorMessage?.trimmingCharacters( - in: .whitespacesAndNewlines - ) - if let detail, !detail.isEmpty { - message = detail - } else { - message = - "Unable to start GitHub auth. Confirm daemon is running and auth routing is available." - } - return - } - - if setDefault { - defaultProfile = profile - } - activeAuthFlowId = started.flowId - activeAuthStatusURL = started.statusUrl - authInstallURL = started.appInstallUrl - - guard let authorizeURL = URL(string: started.authorizeUrl) else { - message = "Auth start returned an invalid authorize URL." - return - } - - NSWorkspace.shared.open(authorizeURL) - message = "Browser opened. Approve access to finish connecting \(profile)." - - let formatter = ISO8601DateFormatter() - let expiresAtDate = formatter.date(from: started.expiresAt) - - while !Task.isCancelled { - if let expiresAtDate, Date() >= expiresAtDate { - message = "Authentication flow expired. Start a new connection." - return - } - - try? await Task.sleep(nanoseconds: 1_000_000_000) - guard !Task.isCancelled else { - return - } - - guard - let flowStatus = await model.fetchGitHubOAuthFlowStatus( - statusURL: started.statusUrl - ) - else { - let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) - if let detail, !detail.isEmpty { - message = detail - return - } - continue - } - - authFlowStatus = flowStatus - if await handleGitHubOAuthFlowStatus(flowStatus) { - return - } - } - } - - private func handleGitHubOAuthDeepLink(_ deepLink: GitHubOAuthDeepLinkContext) async { - defer { - model.clearGitHubOAuthDeepLink(flowId: deepLink.flowId) - } - - guard - let activeAuthFlowId, - let activeAuthStatusURL - else { - message = "GitHub callback received. Return to GitHub settings to finish setup." - return - } - - guard activeAuthFlowId == deepLink.flowId else { - return - } - - message = "Browser callback received. Finalizing GitHub connection…" - guard let flowStatus = await model.fetchGitHubOAuthFlowStatus(statusURL: activeAuthStatusURL) else { - let detail = model.errorMessage?.trimmingCharacters(in: .whitespacesAndNewlines) - if let detail, !detail.isEmpty { - message = detail - } - return - } - authFlowStatus = flowStatus - _ = await handleGitHubOAuthFlowStatus(flowStatus) - } - - private func handleGitHubOAuthFlowStatus(_ flowStatus: GitHubOAuthFlowStatusResponse) async -> Bool { - switch flowStatus.status { - case "complete", "claimed": - if requiresGitHubAppInstall(flowStatus) { - let installURL = flowStatus.appInstallUrl ?? authInstallURL - if !didAutoOpenInstallURL, let installURL, let parsedURL = URL(string: installURL) { - didAutoOpenInstallURL = true - NSWorkspace.shared.open(parsedURL) - message = "GitHub authorized. Finish app install/repo selection in browser, then return." - } else { - message = "GitHub authorized. Finish app install/repo selection, then Hack will complete connection." - } - return false - } - let account = flowStatus.accountLogin ?? "GitHub account" - message = "Connected \(account) to profile \(flowStatus.profileId)." - await model.refresh() - await refreshGitHubDiagnostics() - return true - case "error": - message = flowStatus.error ?? "GitHub authentication failed." - return true - case "expired": - message = "Authentication flow expired. Start a new connection." - return true - default: - return false - } - } - - private func requiresGitHubAppInstall(_ status: GitHubOAuthFlowStatusResponse) -> Bool { - let installationId = status.installationId?.trimmingCharacters(in: .whitespacesAndNewlines) - let installationIds = status.installationIds ?? [] - let hasInstallation = (installationId?.isEmpty == false) || !installationIds.isEmpty - let appInstallURL = (status.appInstallUrl ?? authInstallURL)? - .trimmingCharacters(in: .whitespacesAndNewlines) - return !hasInstallation && appInstallURL?.isEmpty == false - } -} - -private struct TailscaleExtensionSettingsView: View { - @Environment(DashboardModel.self) private var model - @State private var isLoadingConfig = false - @State private var isSavingToggle = false - @State private var isSavingAuthKey = false - @State private var isLoadingDiagnostics = false - @State private var suppressEnabledToggleChange = false - @State private var enabled = false - @State private var diagnostics: TailscaleInspectResponse? = nil - @State private var bootstrapAuthKey = "" - @State private var showBootstrapAuthKey = false - @State private var selectedExitNodeId = "" - @State private var showTailnetExitNodes = true - @State private var showTailnetTaggedDevices = true - @State private var showTailnetAllDevices = false - @State private var lastDiagnosticsRefreshAt: Date? = nil - - var body: some View { - ScrollView { - LazyVStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Tailscale", - title: "Tailscale", - subtitle: "Tailnet-based secure access and remote routing for gateway projects" - ) - GlassCard(title: "Extension status", systemImage: "network") { - HStack(alignment: .center, spacing: 8) { - StatusPill(text: enabled ? "Enabled" : "Disabled", tone: enabled ? .good : .neutral) - StatusPill( - text: tailscaleInstalled ? "tailscale installed" : "tailscale missing", - tone: tailscaleInstalled ? .good : .warn - ) - StatusPill( - text: tailscaleConnected ? "Tailnet connected" : "Tailnet disconnected", - tone: tailscaleConnected ? .good : .warn - ) - if let backendState = diagnostics?.backendState, !backendState.isEmpty { - StatusPill( - text: "Backend \(backendState)", - tone: tailscaleConnected ? .good : .neutral - ) - } - StatusPill( - text: selfDeviceOnline ? "Host online" : "Host offline", - tone: selfDeviceOnline ? .good : .warn - ) - Spacer() - Toggle("Enabled", isOn: $enabled) - .labelsHidden() - .toggleStyle(.switch) - .onChange(of: enabled) { _, newValue in - guard !suppressEnabledToggleChange else { return } - Task { - await applyTailscaleEnabledToggle(newValue) - } - } - if isLoadingConfig || isLoadingDiagnostics { - ProgressView() - .controlSize(.small) - } - if isSavingToggle { - ProgressView() - .controlSize(.small) - } - } - Text("tailscale path: \(tailscalePathLabel)") - .font(.mono(.caption2)) - .foregroundStyle(tailscaleInstalled ? .secondary : Color.orange) - .textSelection(.enabled) - if let tailnetLabel { - Text("Tailnet: \(tailnetLabel)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if let selfDevice = diagnostics?.selfDevice { - Text("This device: \(selfDevice.hostname)\(selfDevice.tailscaleIp.map { " (\($0))" } ?? "")") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if let lastDiagnosticsRefreshAt { - Text("Last checked \(lastDiagnosticsRefreshAt.formatted(date: .abbreviated, time: .shortened))") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if let authUrl = diagnostics?.authUrl, !authUrl.isEmpty { - Text("Login required: \(authUrl)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - .textSelection(.enabled) - } - if let error = diagnostics?.error, !error.isEmpty { - Text(error) - .font(.mono(.caption)) - .foregroundStyle(Color.orange) - } else if let detail = tailscaleExposure?.detail, !detail.isEmpty { - Text(detail) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - } - GlassCard(title: "Bootstrap auth key", systemImage: "key.fill") { - VStack(alignment: .leading, spacing: 12) { - Text("Use one reusable Tailscale auth key for private remote-node bootstrap flows (Railway and future providers).") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - - HStack(spacing: 8) { - StatusPill( - text: bootstrapAuthKeyConfigured ? "Auth key configured" : "Auth key missing", - tone: bootstrapAuthKeyConfigured ? .good : .warn - ) - if bootstrapAuthKeyConfigured { - StatusPill(text: "Shared with Railway bootstrap", tone: .neutral) - } - Spacer() - } - - HStack(spacing: 10) { - Group { - if showBootstrapAuthKey { - TextField("tskey-auth-...", text: $bootstrapAuthKey) - } else { - SecureField("TS_AUTHKEY", text: $bootstrapAuthKey) - } - } - .textFieldStyle(.roundedBorder) - .font(.mono(.subheadline)) - - Button { - showBootstrapAuthKey.toggle() - } label: { - Label(showBootstrapAuthKey ? "Hide" : "Show", systemImage: showBootstrapAuthKey ? "eye.slash" : "eye") - } - .adaptiveToolbarButton() - } - - Text( - "Stored in global config at controlPlane.extensions[\"dance.hack.tailscale\"].config.authKey (mirrored to Railway compatibility key)." - ) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - - HStack(spacing: 10) { - Button { - Task { await saveBootstrapAuthKey() } - } label: { - Label("Save auth key", systemImage: "square.and.arrow.down") - } - .adaptiveToolbarButtonProminent() - .disabled(isSavingAuthKey) - - Button { - Task { await clearBootstrapAuthKey() } - } label: { - Label("Clear", systemImage: "trash") - } - .adaptiveToolbarButton() - .disabled(isSavingAuthKey || !bootstrapAuthKeyConfigured) - - Button { - openTailscaleAuthKeysPage() - } label: { - Label("Open auth key admin", systemImage: "link") - } - .adaptiveToolbarButton() - - Spacer() - - if isSavingAuthKey { - ProgressView() - .controlSize(.small) - } - } - } - } - GlassCard(title: "Tailnet controls", systemImage: "slider.horizontal.3") { - VStack(alignment: .leading, spacing: 12) { - Text("The extension controls gateway exposure behavior, while Tailscale runtime state is shown here regardless of extension enablement.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 10) { - Text("Quick actions") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - HStack(spacing: 10) { - Button { - Task { await refreshTailscaleDiagnostics() } - } label: { - Label("Refresh state", systemImage: "arrow.clockwise") - } - .adaptiveToolbarButtonProminent() - - Button { - if tailscaleConnected { - openGlobalCommandInTerminalPanel( - command: "tailscale down", - title: "tailscale down" - ) - } else { - openGlobalCommandInTerminalPanel( - command: "tailscale up", - title: "tailscale up" - ) - } - } label: { - Label( - tailscaleConnected ? "Disconnect tailnet" : "Connect tailnet", - systemImage: tailscaleConnected ? "stop.fill" : "play.fill" - ) - } - .adaptiveToolbarButton() - .disabled(!tailscaleInstalled) - - Button { - openGlobalCommandInTerminalPanel( - command: "tailscale status", - title: "tailscale status" - ) - } label: { - Label("Open CLI status", systemImage: "terminal") - } - .adaptiveToolbarButton() - - Spacer() - } - - HStack(spacing: 10) { - Button { - openGlobalCommandInTerminalPanel( - command: "tailscale status --json", - title: "tailscale status --json" - ) - } label: { - Label("Open status JSON", systemImage: "doc.plaintext") - } - .adaptiveToolbarButton() - Spacer() - } - } - - if !exitNodes.isEmpty { - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 10) { - Text("Exit-node routing") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - - Picker("Exit node", selection: $selectedExitNodeId) { - Text("No exit node").tag("") - ForEach(exitNodes, id: \.id) { peer in - Text(peer.hostname).tag(peer.id) - } - } - .pickerStyle(.menu) - .frame(minWidth: 260, maxWidth: 360, alignment: .leading) - - HStack(spacing: 10) { - Button { - applySelectedExitNode() - } label: { - Label("Use selected node", systemImage: "location.north.line.fill") - } - .adaptiveToolbarButton() - .disabled(!tailscaleInstalled || selectedExitNodeId.isEmpty) - - Button { - clearSelectedExitNode() - } label: { - Label("Clear exit node", systemImage: "location.slash") - } - .adaptiveToolbarButton() - .disabled(!tailscaleInstalled || !hasCurrentExitNode) - Spacer() - } - } - - if let currentExitNodeName = diagnostics?.currentExitNodeName, !currentExitNodeName.isEmpty { - Text("Current exit node: \(currentExitNodeName)") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - } - tailscaleNetworkCard - } - .padding(16) - } - .task { - await loadConfigFromDisk() - } - .onChange(of: model.lastUpdated) { _, _ in - Task { await loadConfigFromDisk() } - } - } - - private var tailscaleExposure: GatewayExposure? { - model.globalStatus?.gateway?.exposures?.first(where: { $0.id == "tailscale" }) - } - - @ViewBuilder - private var tailscaleNetworkCard: some View { - GlassCard(title: "Tailnet state", systemImage: "point.3.connected.trianglepath.dotted") { - if !tailscaleInstalled { - Text("Install Tailscale to populate nodes, tags, and exit-node controls.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - if let diagnostics, let error = diagnostics.error, !error.isEmpty { - Text(error) - .font(.mono(.caption)) - .foregroundStyle(Color.orange) - } - - if let selfDevice = diagnostics?.selfDevice { - VStack(alignment: .leading, spacing: 4) { - Text("This device") - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.secondary) - Text(selfDevice.hostname) - .font(.mono(.subheadline, weight: .semibold)) - if let ip = selfDevice.tailscaleIp { - Text(ip) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if let dnsName = selfDevice.dnsName, !dnsName.isEmpty { - Text(dnsName) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - if !selfDevice.tags.isEmpty { - Text("Tags: \(selfDevice.tags.joined(separator: ", "))") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - if let os = selfDevice.os, !os.isEmpty { - Text("OS: \(os)") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } - } - } - - if !healthMessages.isEmpty { - Divider() - .opacity(0.2) - - VStack(alignment: .leading, spacing: 6) { - Text("Health checks") - .font(.mono(.caption, weight: .semibold)) - .foregroundStyle(.secondary) - ForEach(Array(healthMessages.enumerated()), id: \.offset) { _, message in - Text("• \(message)") - .font(.mono(.caption2)) - .foregroundStyle(Color.orange) - } - } - } - - Divider() - .opacity(0.2) - - DisclosureGroup("Exit nodes", isExpanded: $showTailnetExitNodes) { - VStack(alignment: .leading, spacing: 6) { - if let currentExitNodeName = diagnostics?.currentExitNodeName, !currentExitNodeName.isEmpty { - Text("Current: \(currentExitNodeName)") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - Text("Current: none") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - - let exitNodes = diagnostics?.exitNodes ?? [] - if exitNodes.isEmpty { - Text("No exit-node capable peers detected.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - ForEach(Array(exitNodes.prefix(6)), id: \.id) { peer in - tailscalePeerRow(peer) - } - } - } - .padding(.top, 6) - } - .font(.mono(.caption, weight: .semibold)) - - if !taggedPeers.isEmpty { - Divider() - .opacity(0.2) - - DisclosureGroup( - "Tagged devices (\(taggedPeers.filter(\.online).count)/\(taggedPeers.count) online)", - isExpanded: $showTailnetTaggedDevices - ) { - VStack(alignment: .leading, spacing: 6) { - ForEach(Array(taggedPeers.prefix(12)), id: \.id) { peer in - tailscalePeerRow(peer) - } - } - .padding(.top, 6) - } - .font(.mono(.caption, weight: .semibold)) - } - - Divider() - .opacity(0.2) - - DisclosureGroup( - "Devices (\(diagnostics?.onlinePeerCount ?? 0)/\(diagnostics?.peers.count ?? 0) online)", - isExpanded: $showTailnetAllDevices - ) { - VStack(alignment: .leading, spacing: 6) { - if personalPeers.isEmpty { - Text("No untagged devices reported by tailscale status.") - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - } else { - ForEach(Array(personalPeers.prefix(12)), id: \.id) { peer in - tailscalePeerRow(peer) - } - } - } - .padding(.top, 6) - } - .font(.mono(.caption, weight: .semibold)) - } - } - } - - private func tailscalePeerRow(_ peer: TailscaleInspectPeer) -> some View { - HStack(alignment: .center, spacing: 8) { - Circle() - .fill(peer.online ? Color.green : Color.secondary.opacity(0.7)) - .frame(width: 6, height: 6) - Text(peer.hostname) - .font(.mono(.caption)) - .lineLimit(1) - .truncationMode(.tail) - if peer.isExitNodeOption || peer.isExitNode { - StatusPill(text: "Exit node", tone: .neutral) - } - if !peer.tags.isEmpty { - Text(peer.tags.joined(separator: ", ")) - .font(.mono(.caption2)) - .foregroundStyle(.tertiary) - .lineLimit(1) - .truncationMode(.tail) - } - Spacer(minLength: 0) - if let ip = peer.tailscaleIp { - Text(ip) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - } - } - - private var tailscaleConnected: Bool { - diagnostics?.connected == true - } - - private var selfDeviceOnline: Bool { - diagnostics?.selfDevice?.online == true - } - - private var tailnetLabel: String? { - guard let tailnetName = diagnostics?.tailnetName, !tailnetName.isEmpty else { - return nil - } - guard let suffix = diagnostics?.magicDnsSuffix, !suffix.isEmpty else { - return tailnetName - } - return "\(tailnetName) (\(suffix))" - } - - private var tailscalePath: String? { - resolveExecutablePath(candidates: ["tailscale"]) - } - - private var tailscaleInstalled: Bool { - diagnostics?.installed ?? (tailscalePath != nil) - } - - private var tailscalePathLabel: String { - diagnostics?.binaryPath ?? tailscalePath ?? "Not found in PATH" - } - - private var exitNodes: [TailscaleInspectPeer] { - diagnostics?.exitNodes ?? [] - } - - private var taggedPeers: [TailscaleInspectPeer] { - let peers = diagnostics?.peers ?? [] - return peers.filter { !$0.tags.isEmpty } - } - - private var personalPeers: [TailscaleInspectPeer] { - let peers = diagnostics?.peers ?? [] - return peers.filter(\.tags.isEmpty) - } - - private var healthMessages: [String] { - diagnostics?.health ?? [] - } - - private var hasCurrentExitNode: Bool { - guard let currentExitNodeId = diagnostics?.currentExitNodeId else { return false } - return !currentExitNodeId.isEmpty - } - - private var bootstrapAuthKeyConfigured: Bool { - !bootstrapAuthKey.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } - - private func loadConfigFromDisk() async { - isLoadingConfig = true - let snapshot = GlobalConfigSnapshot.load() - suppressEnabledToggleChange = true - enabled = snapshot.tailscaleExtensionEnabled ?? false - suppressEnabledToggleChange = false - bootstrapAuthKey = snapshot.tailscaleAuthKey ?? snapshot.railwayTailscaleAuthKey ?? "" - isLoadingConfig = false - await refreshTailscaleDiagnostics() - } - - private func refreshTailscaleDiagnostics() async { - isLoadingDiagnostics = true - defer { isLoadingDiagnostics = false } - diagnostics = await model.inspectTailscale() - lastDiagnosticsRefreshAt = Date() - syncExitNodeSelection() - } - - private func applyTailscaleEnabledToggle(_ isEnabled: Bool) async { - isSavingToggle = true - defer { isSavingToggle = false } - - let didUpdate = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.tailscale\"].enabled", - value: isEnabled ? "true" : "false" - ) - guard didUpdate else { - await loadConfigFromDisk() - return - } - await loadConfigFromDisk() - } - - private func saveBootstrapAuthKey() async { - isSavingAuthKey = true - defer { isSavingAuthKey = false } - - let trimmed = bootstrapAuthKey.trimmingCharacters(in: .whitespacesAndNewlines) - let didSaveTailscale = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.tailscale\"].config.authKey", - value: trimmed - ) - guard didSaveTailscale else { - await loadConfigFromDisk() - return - } - - // Keep Railway bootstrap compatibility while routing continues to read this key. - let didSaveRailway = await model.setGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.railway\"].config.tailscaleAuthKey", - value: trimmed - ) - guard didSaveRailway else { - await loadConfigFromDisk() - return - } - - await loadConfigFromDisk() - } - - private func clearBootstrapAuthKey() async { - bootstrapAuthKey = "" - await saveBootstrapAuthKey() - } - - private func syncExitNodeSelection() { - guard let diagnostics else { - selectedExitNodeId = "" - return - } - if diagnostics.exitNodes.contains(where: { $0.id == selectedExitNodeId }) { - return - } - selectedExitNodeId = diagnostics.currentExitNodeId ?? "" - } - - private func applySelectedExitNode() { - guard !selectedExitNodeId.isEmpty else { return } - openGlobalCommandInTerminalPanel( - command: "tailscale set --exit-node=\(shellQuote(selectedExitNodeId))", - title: "tailscale set --exit-node" - ) - } - - private func clearSelectedExitNode() { - openGlobalCommandInTerminalPanel( - command: "tailscale set --exit-node=", - title: "tailscale clear exit node" - ) - } - - private func shellQuote(_ value: String) -> String { - let escaped = value.replacingOccurrences(of: "'", with: "'\\''") - return "'\(escaped)'" - } - - private func openTailscaleAuthKeysPage() { - guard let url = URL(string: "https://login.tailscale.com/admin/settings/keys") else { - return - } - NSWorkspace.shared.open(url) - } -} - -private struct PermissionsSettingsView: View { - @AppStorage("hackDesktop.permissions.terminalAutomationChecked") private var automationChecked = false - @AppStorage("hackDesktop.permissions.terminalAutomationGranted") private var storedAutomationGranted = false - @State private var lastAutomationCheckAt: Date? = nil - - var body: some View { - ScrollView { - VStack(alignment: .leading, spacing: 20) { - SettingsSectionHeader( - breadcrumb: "Settings / Permissions", - title: "Permissions + Access", - subtitle: "Required OS access for terminal automation, networking, and embedded terminal features" - ) - - GlassCard(title: "Permission matrix", systemImage: "hand.raised.fill") { - VStack(alignment: .leading, spacing: 12) { - permissionStatusRow( - title: "Terminal automation", - detail: automationStatusDetail, - granted: automationGranted, - actionLabel: automationGranted == true ? nil : "Request", - action: automationGranted == true - ? nil - : { - let granted = TerminalIntegration.requestTerminalAutomationPermission() - storedAutomationGranted = granted - automationChecked = true - lastAutomationCheckAt = Date() - } - ) - permissionStatusRow( - title: "Embedded terminal", - detail: GhosttyVTRuntime.shared.isAvailable - ? "Ghostty VT runtime is available." - : "Ghostty VT runtime is unavailable on this system.", - granted: GhosttyVTRuntime.shared.isAvailable - ) - permissionStatusRow( - title: "Filesystem access", - detail: "Desktop app is running in developer tooling mode (sandbox disabled).", - granted: true - ) - permissionStatusRow( - title: "Local network", - detail: "Managed by macOS and prompted on first local-network access.", - granted: nil - ) - } - } + Text("Hack Desktop can open and seed commands into Terminal for setup and repair flows. macOS will prompt the first time you grant Automation access.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) - InlineCallout( - tone: automationGranted == true ? .good : .warn, - title: automationGranted == true ? "Permissions look good" : "Terminal automation still missing", - message: automationGranted == true - ? "Automation access is approved. macOS still prompts local-network permission when first needed." - : "Grant Terminal automation so the app can execute setup and maintenance commands without manual copy/paste.", - actions: [ - InlineCalloutAction(label: "Open Automation privacy", systemImage: "gearshape") { - openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") - }, - InlineCalloutAction(label: "Open Local Network privacy", systemImage: "network") { - openSystemSettings("x-apple.systempreferences:com.apple.preference.security?Privacy_LocalNetwork") + HStack(spacing: 10) { + Button { + terminalAutomationGranted = TerminalIntegration.requestTerminalAutomationPermission() + } label: { + Label("Request access", systemImage: "hand.raised.fill") } - ] - ) + .adaptiveToolbarButtonProminent() - GlassCard(title: "What each permission does", systemImage: "questionmark.circle") { - VStack(alignment: .leading, spacing: 8) { - helperLine( - title: "Terminal automation", - body: "Needed for one-click setup flows that open/run commands in Terminal." - ) - helperLine( - title: "Embedded terminal", - body: "Powers inline shells/log tails without leaving the desktop app." - ) - helperLine( - title: "Local network", - body: "Required when exposing gateway to LAN or connecting to local service hostnames." - ) - } - } - - GlassCard(title: "Setup helpers", systemImage: "wand.and.stars") { - VStack(alignment: .leading, spacing: 10) { - Text("Use setup commands when permissions or trust state changes:") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - HStack(spacing: 10) { - Button("Run global install") { - TerminalIntegration.openTerminalWithCommand("hack global install") - } - .adaptiveToolbarButton() - Button("Run global trust") { - TerminalIntegration.openTerminalWithCommand("hack global trust") - } - .adaptiveToolbarButton() - Button("Restart daemon") { - TerminalIntegration.openTerminalWithCommand("hack daemon restart") + Button { + if let url = URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Automation") { + NSWorkspace.shared.open(url) } - .adaptiveToolbarButton() - Spacer() + } label: { + Label("Open Privacy settings", systemImage: "gearshape") } + .adaptiveToolbarButton() } } - } - .padding(16) - } - } - private var automationGranted: Bool? { - automationChecked ? storedAutomationGranted : nil - } + GlassCard(title: "Local network and host trust", systemImage: "checkmark.shield") { + Text("The supported local workflow relies on `hack global install`, `hack global trust`, and macOS prompts for local networking when needed. If routing or HTTPS trust looks wrong, run the doctor flow before manual repair.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) - private var automationStatusDetail: String { - let base = automationChecked - ? (storedAutomationGranted ? "Granted." : "Denied or not granted.") - : "Not checked yet." - if let lastAutomationCheckAt { - return "\(base) Last check \(relativeTimeString(from: lastAutomationCheckAt))." - } - return base - } + HStack(spacing: 10) { + Button { + TerminalIntegration.openTerminalWithCommand("hack doctor") + } label: { + Label("Run `hack doctor`", systemImage: "stethoscope") + } + .adaptiveToolbarButtonProminent() - private func permissionStatusRow( - title: String, - detail: String, - granted: Bool?, - actionLabel: String? = nil, - action: (() -> Void)? = nil - ) -> some View { - HStack(alignment: .top, spacing: 10) { - Image(systemName: permissionIcon(granted: granted)) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(permissionColor(granted: granted)) - .padding(.top, 1) - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.mono(.subheadline, weight: .semibold)) - Text(detail) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - Spacer() - if let actionLabel, let action { - Button(actionLabel) { - action() + Button { + TerminalIntegration.copyToClipboard("hack doctor --fix") + } label: { + Label("Copy repair command", systemImage: "doc.on.doc") + } + .adaptiveToolbarButton() + } } - .adaptiveToolbarButton() - } else { - Text(permissionLabel(granted: granted)) - .font(.mono(.caption2, weight: .semibold)) - .foregroundStyle(permissionColor(granted: granted)) } - } - } - - private func permissionIcon(granted: Bool?) -> String { - switch granted { - case true: - return "checkmark.circle.fill" - case false: - return "xmark.circle.fill" - case .none: - return "questionmark.circle.fill" - } - } - - private func permissionLabel(granted: Bool?) -> String { - switch granted { - case true: - return "Approved" - case false: - return "Missing" - case .none: - return "System-managed" - } - } - - private func permissionColor(granted: Bool?) -> Color { - switch granted { - case true: - return .green - case false: - return .orange - case .none: - return .secondary - } - } - - private func openSystemSettings(_ urlString: String) { - guard let url = URL(string: urlString) else { return } - NSWorkspace.shared.open(url) - } - - private func relativeTimeString(from date: Date) -> String { - RelativeDateTimeFormatter().localizedString(for: date, relativeTo: Date()) - } - - private func helperLine(title: String, body: String) -> some View { - VStack(alignment: .leading, spacing: 4) { - Text(title) - .font(.mono(.subheadline, weight: .semibold)) - Text(body) - .font(.mono(.caption)) - .foregroundStyle(.secondary) + .padding(16) } } } private struct CertificatesSettingsView: View { - @Environment(\.openURL) private var openURL + @Environment(DashboardModel.self) private var model var body: some View { ScrollView { @@ -5583,528 +722,101 @@ private struct CertificatesSettingsView: View { SettingsSectionHeader( breadcrumb: "Settings / Certificates", title: "Certificates", - subtitle: "Inspect local trust assets and run trust tooling" + subtitle: "Trust the local Caddy CA used for HTTPS routes under your Hack projects" ) - GlassCard(title: "Certificate files", systemImage: "checkmark.shield") { - DetailRows(rows: certificateRows, labelWidth: 170) - } - InlineCallout( - tone: .neutral, - title: "Certificate management", - message: "Use `hack global trust` to install or refresh local trust chain. Generated cert files are kept under ~/.hack and mkcert paths.", - actions: [ - InlineCalloutAction(label: "Run trust", systemImage: "terminal") { + + GlassCard(title: "Local trust guidance", systemImage: "checkmark.shield") { + HStack(spacing: 8) { + StatusPill( + text: model.runtimeOverallOk == true ? "Runtime reachable" : "Trust may need repair", + tone: model.runtimeOverallOk == true ? .good : .warn + ) + Spacer() + } + + Text("Hack uses a local internal CA for HTTPS routing. On a fresh Mac, run `hack global trust` once after `hack global install`.") + .font(.mono(.caption)) + .foregroundStyle(.secondary) + + DetailRows( + rows: [ + DetailRowItem(label: "Recommended command", value: "hack global trust"), + DetailRowItem(label: "Fallback repair", value: "hack doctor --fix") + ] + ) + + HStack(spacing: 10) { + Button { TerminalIntegration.openTerminalWithCommand("hack global trust") - }, - InlineCalloutAction(label: "Open cert folder", systemImage: "folder") { - openURL(URL(fileURLWithPath: certDirectoryPath)) - }, - InlineCalloutAction(label: "Copy trust command", systemImage: "doc.on.doc") { + } label: { + Label("Run trust", systemImage: "checkmark.shield") + } + .adaptiveToolbarButtonProminent() + + Button { TerminalIntegration.copyToClipboard("hack global trust") + } label: { + Label("Copy command", systemImage: "doc.on.doc") } - ] - ) + .adaptiveToolbarButton() + } + } } .padding(16) } } - - private var certDirectoryPath: String { - FileManager.default.homeDirectoryForCurrentUser - .appendingPathComponent(".hack/caddy/pki") - .path - } - - private var certificateRows: [DetailRowItem] { - certificateFiles.map { cert in - let exists = FileManager.default.fileExists(atPath: cert.path) ? "Yes" : "No" - return DetailRowItem(label: cert.label, value: "\(cert.path) (\(exists))") - } - } - - private var certificateFiles: [(label: String, path: String)] { - let home = FileManager.default.homeDirectoryForCurrentUser.path - return [ - ("Hack local CA", "\(home)/.hack/caddy/pki/caddy-local-authority.crt"), - ("Hack local CA key", "\(home)/.hack/caddy/pki/caddy-local-authority.key"), - ("mkcert root CA", "\(home)/Library/Application Support/mkcert/rootCA.pem"), - ("mkcert root key", "\(home)/Library/Application Support/mkcert/rootCA-key.pem") - ] - } } private struct LoggingSettingsView: View { @Environment(DashboardModel.self) private var model - @Environment(\.openURL) private var openURL var body: some View { ScrollView { VStack(alignment: .leading, spacing: 20) { SettingsSectionHeader( breadcrumb: "Settings / Logging", - title: "Logging + Global Services", - subtitle: "Grafana + Loki stack, credentials, and Caddy/CoreDNS diagnostics" + title: "Logging", + subtitle: "Fast entry points for daemon and local runtime logs" ) - InlineCallout( - tone: .neutral, - title: "Global logging architecture", - message: "Global logging runs Loki + Alloy + Grafana. Logs from project containers are shipped into Loki and explored in Grafana.", - actions: [] - ) - GlassCard(title: "Grafana + Loki access", systemImage: "waveform.path.ecg") { - DetailRows(rows: grafanaRows) - HStack(spacing: 10) { - if let grafanaURL { - Button("Open Grafana") { - openURL(grafanaURL) - } - .adaptiveToolbarButtonProminent() - } - Button("Open Grafana logs") { - openGlobalCommandInTerminalPanel( - command: "hack global logs grafana --tail 200 --follow", - title: "grafana logs" - ) - } - .adaptiveToolbarButton() - Button("Open Loki logs") { - openGlobalCommandInTerminalPanel( - command: "hack global logs loki --tail 200 --follow", - title: "loki logs" - ) - } - .adaptiveToolbarButton() - Button("Copy LogQL example") { - TerminalIntegration.copyToClipboard("{project=\"event-agent\"}") - } - .adaptiveToolbarButton() - Spacer() - } - Text("Logging is part of global infrastructure and currently has no separate enable/disable toggle.") - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - GlassCard(title: "Grafana credentials", systemImage: "person.crop.circle.badge.key") { - DetailRows(rows: credentialRows, labelWidth: 190) - Text("Default template credentials are `admin` / `admin`. Override by editing the logging compose environment and restarting global services.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - HStack(spacing: 10) { - Button("Reset Grafana admin password") { - openGlobalCommandInTerminalPanel( - command: """ - read -r -s -p "New Grafana admin password: " GRAFANA_PASSWORD; echo - docker compose -f "$HOME/.hack/logging/docker-compose.yml" exec grafana grafana-cli admin reset-admin-password "$GRAFANA_PASSWORD" - """, - title: "reset grafana password" - ) - } - .adaptiveToolbarButton() - Button("Open logging compose") { - openURL(URL(fileURLWithPath: loggingComposePath)) - } - .adaptiveToolbarButton() - Button("Copy logging compose path") { - TerminalIntegration.copyToClipboard(loggingComposePath) - } - .adaptiveToolbarButton() - Spacer() - } - } - GlassCard(title: "Caddy + CoreDNS", systemImage: "network") { - DetailRows(rows: caddyRows) - Text("Advanced Caddy/CoreDNS settings can be edited in ~/.hack/caddy/docker-compose.yml and ~/.hack/caddy/Corefile.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) + + GlassCard(title: "Quick actions", systemImage: "text.alignleft") { HStack(spacing: 10) { - Button("Open Caddy logs") { - openGlobalCommandInTerminalPanel( - command: "hack global logs caddy --tail 200 --follow", - title: "caddy logs" - ) + Button { + TerminalIntegration.openTerminalWithCommand("hack logs --pretty") + } label: { + Label("Project logs", systemImage: "doc.text.magnifyingglass") } - .adaptiveToolbarButton() - Button("Open CoreDNS logs") { - openGlobalCommandInTerminalPanel( - command: "docker compose -f \"$HOME/.hack/caddy/docker-compose.yml\" logs --tail 200 -f coredns", - title: "coredns logs" - ) + .adaptiveToolbarButtonProminent() + + Button { + TerminalIntegration.openTerminalWithCommand("hack global logs caddy --no-follow --tail 200") + } label: { + Label("Caddy logs", systemImage: "globe") } .adaptiveToolbarButton() - Button("Open Caddy compose") { - openURL(URL(fileURLWithPath: caddyComposePath)) + + Button { + TerminalIntegration.openTerminalWithCommand("hack doctor") + } label: { + Label("Doctor", systemImage: "stethoscope") } .adaptiveToolbarButton() - Spacer() } } - GlassCard(title: "Global summary", systemImage: "waveform.path.ecg") { - DetailRows(rows: summaryRows) - } - if let caddy = model.globalStatus?.caddy { - composeGroupCard(title: "Caddy", group: caddy) - } - if let logging = model.globalStatus?.logging { - composeGroupCard(title: "Logging", group: logging) - } - if let daemonLogPath = model.daemonStatus?.logPath, !daemonLogPath.isEmpty { - GlassCard(title: "Daemon log", systemImage: "doc.text") { - DetailRows(rows: [DetailRowItem(label: "Path", value: daemonLogPath)]) - HStack(spacing: 10) { - Button("Open log file") { - openURL(URL(fileURLWithPath: daemonLogPath)) - } - .adaptiveToolbarButton() - Button("Copy path") { - TerminalIntegration.copyToClipboard(daemonLogPath) - } - .adaptiveToolbarButton() - } - } + + GlassCard(title: "Known paths", systemImage: "folder") { + DetailRows(rows: logRows) } } .padding(16) } } - private var homeDirectoryPath: String { - FileManager.default.homeDirectoryForCurrentUser.path - } - - private var loggingComposePath: String { - "\(homeDirectoryPath)/.hack/logging/docker-compose.yml" - } - - private var caddyComposePath: String { - "\(homeDirectoryPath)/.hack/caddy/docker-compose.yml" - } - - private var grafanaURL: URL? { - URL(string: "https://logs.hack") - } - - private var grafanaRows: [DetailRowItem] { - [ - DetailRowItem(label: "Grafana URL", value: "https://logs.hack"), - DetailRowItem(label: "Loki URL", value: "https://loki.hack"), - DetailRowItem(label: "Stack health", value: okUnknown(model.globalStatus?.summary.loggingOk)), - DetailRowItem(label: "Generated", value: model.globalStatus?.generatedAt ?? "—") - ] - } - - private var credentialRows: [DetailRowItem] { + private var logRows: [DetailRowItem] { [ - DetailRowItem(label: "Default username", value: "admin"), - DetailRowItem(label: "Default password", value: "admin"), - DetailRowItem(label: "Compose file", value: loggingComposePath) - ] - } - - private var caddyRows: [DetailRowItem] { - let gatewayBind = model.globalStatus?.gateway?.gatewayBind ?? "127.0.0.1" - let gatewayPort = model.globalStatus?.gateway?.gatewayPort.map(String.init) ?? "7788" - let devNetwork = model.globalStatus?.networks?.networks.first(where: { $0.name == "hack-dev" }) - return [ - DetailRowItem(label: "Gateway bind", value: gatewayBind), - DetailRowItem(label: "Gateway port", value: gatewayPort), - DetailRowItem(label: "Caddy default IP", value: "172.30.0.2"), - DetailRowItem(label: "CoreDNS default IP", value: "172.30.0.53"), - DetailRowItem(label: "Ingress network", value: devNetwork?.name ?? "hack-dev"), - DetailRowItem(label: "Ingress driver", value: devNetwork?.driver ?? "bridge"), - DetailRowItem(label: "Caddy compose", value: caddyComposePath) - ] - } - - private var summaryRows: [DetailRowItem] { - let summary = model.globalStatus?.summary - return [ - DetailRowItem(label: "Overall", value: okUnknown(summary?.ok)), - DetailRowItem(label: "Caddy", value: okUnknown(summary?.caddyOk)), - DetailRowItem(label: "Logging", value: okUnknown(summary?.loggingOk)), - DetailRowItem(label: "Networks", value: okUnknown(summary?.networksOk)), - DetailRowItem(label: "Generated", value: model.globalStatus?.generatedAt ?? "—") - ] - } - - private func composeGroupCard(title: String, group: ComposeStatusGroup) -> some View { - GlassCard(title: title, systemImage: "shippingbox") { - if group.services.isEmpty { - Text("No services reported.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 10) { - ForEach(group.services, id: \.name) { service in - HStack(spacing: 10) { - Text(service.name) - .font(.mono(.subheadline, weight: .medium)) - Spacer() - Text(service.status) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if !service.ports.isEmpty { - Text(service.ports) - .font(.mono(.caption2)) - .foregroundStyle(.secondary) - } - Divider() - .opacity(0.2) - } - } - } - } - } - - private func okUnknown(_ value: Bool?) -> String { - guard let value else { return "Unknown" } - return value ? "Healthy" : "Degraded" - } -} - -private struct GlobalConfigSnapshot { - let daemonLaunchdRunAtLoad: Bool? - let linearExtensionEnabled: Bool? - let githubExtensionEnabled: Bool? - let cloudflareExtensionEnabled: Bool? - let railwayExtensionEnabled: Bool? - let tailscaleExtensionEnabled: Bool? - let linearDefaultProfile: String? - let linearSyncLabels: Bool? - let linearSyncStatuses: Bool? - let linearSyncDependencies: Bool? - let linearSyncProjects: Bool? - let githubDefaultProfile: String? - let railwayProject: String? - let railwayService: String? - let railwayEnvironment: String? - let railwayWorkspace: String? - let railwayNodeName: String? - let railwayLabelsCsv: String? - let railwayEndpoint: String? - let railwayCreateService: Bool? - let railwayImage: String? - let railwayPrivate: Bool? - let railwayTailscaleAuthKey: String? - let railwayTailscaleTagsCsv: String? - let tailscaleAuthKey: String? - let gatewayBind: String? - let cloudflareHostname: String? - let cloudflareSSHHostname: String? - let supervisorEnabled: Bool? - let supervisorMaxConcurrentJobs: Int? - let supervisorLogsMaxBytes: Int? - let preferencesTheme: String? - let preferencesTerminalApp: String? - let preferencesEditorApp: String? - let preferencesCodingAgentApp: String? - let preferencesSessionProvider: String? - let preferencesSessionBinaryPath: String? - let preferencesContainerProvider: String? - let preferencesContainerBinaryPath: String? - let preferencesCodingAgentBinaryPath: String? - - static func load() -> Self { - let home = FileManager.default.homeDirectoryForCurrentUser - let environment = ProcessInfo.processInfo.environment - let overridePath = (environment["HACK_GLOBAL_CONFIG_PATH"] ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let configPath: String - if overridePath.isEmpty { - configPath = home.appendingPathComponent(".hack/hack.config.json").path - } else { - configPath = NSString(string: overridePath).expandingTildeInPath - } - guard - let data = FileManager.default.contents(atPath: configPath), - let object = try? JSONSerialization.jsonObject(with: data), - let root = object as? [String: Any] - else { - return .empty - } - - let controlPlane = dictionary(root, key: "controlPlane") - let daemon = dictionary(controlPlane, key: "daemon") - let launchd = dictionary(daemon, key: "launchd") - let supervisor = dictionary(controlPlane, key: "supervisor") - let gateway = dictionary(controlPlane, key: "gateway") - let preferences = dictionary(controlPlane, key: "preferences") - let appearancePreferences = dictionary(preferences, key: "appearance") - let terminalPreferences = dictionary(preferences, key: "terminal") - let editorPreferences = dictionary(preferences, key: "editor") - let agentPreferences = dictionary(preferences, key: "agents") - let sessionPreferences = dictionary(preferences, key: "sessions") - let containerPreferences = dictionary(preferences, key: "containers") - let extensions = dictionary(controlPlane, key: "extensions") - 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 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 railwayConfig = dictionary(railwayExt, key: "config") - let tailscaleConfig = dictionary(tailscaleExt, key: "config") - - return Self( - daemonLaunchdRunAtLoad: launchd["runAtLoad"] as? Bool, - linearExtensionEnabled: linearExt["enabled"] as? Bool, - githubExtensionEnabled: githubExt["enabled"] as? Bool, - cloudflareExtensionEnabled: cloudflareExt["enabled"] as? Bool, - railwayExtensionEnabled: railwayExt["enabled"] as? Bool, - tailscaleExtensionEnabled: tailscaleExt["enabled"] as? Bool, - linearDefaultProfile: linearConfig["defaultProfile"] as? String, - linearSyncLabels: linearSync["labels"] as? Bool, - linearSyncStatuses: linearSync["statuses"] as? Bool, - linearSyncDependencies: linearSync["dependencies"] as? Bool, - linearSyncProjects: linearSync["projects"] as? Bool, - githubDefaultProfile: githubConfig["defaultProfile"] as? String, - railwayProject: railwayConfig["project"] as? String, - railwayService: railwayConfig["service"] as? String, - railwayEnvironment: railwayConfig["environment"] as? String, - railwayWorkspace: railwayConfig["workspace"] as? String, - railwayNodeName: railwayConfig["nodeName"] as? String, - railwayLabelsCsv: railwayConfig["labelsCsv"] as? String, - railwayEndpoint: railwayConfig["endpoint"] as? String, - railwayCreateService: railwayConfig["createService"] as? Bool, - railwayImage: railwayConfig["image"] as? String, - railwayPrivate: railwayConfig["private"] as? Bool, - railwayTailscaleAuthKey: railwayConfig["tailscaleAuthKey"] as? String, - railwayTailscaleTagsCsv: railwayConfig["tailscaleTagsCsv"] as? String, - tailscaleAuthKey: tailscaleConfig["authKey"] as? String, - gatewayBind: gateway["bind"] as? String, - cloudflareHostname: cloudflareConfig["hostname"] as? String, - cloudflareSSHHostname: cloudflareConfig["sshHostname"] as? String, - supervisorEnabled: supervisor["enabled"] as? Bool, - supervisorMaxConcurrentJobs: supervisor["maxConcurrentJobs"] as? Int, - supervisorLogsMaxBytes: supervisor["logsMaxBytes"] as? Int, - preferencesTheme: appearancePreferences["theme"] as? String, - preferencesTerminalApp: terminalPreferences["defaultApp"] as? String, - preferencesEditorApp: editorPreferences["defaultApp"] as? String, - preferencesCodingAgentApp: agentPreferences["defaultApp"] as? String, - preferencesSessionProvider: sessionPreferences["provider"] as? String, - preferencesSessionBinaryPath: sessionPreferences["binaryPath"] as? String, - preferencesContainerProvider: containerPreferences["provider"] as? String, - preferencesContainerBinaryPath: containerPreferences["binaryPath"] as? String, - preferencesCodingAgentBinaryPath: agentPreferences["binaryPath"] as? String - ) - } - - static var empty: Self { - Self( - daemonLaunchdRunAtLoad: nil, - linearExtensionEnabled: nil, - githubExtensionEnabled: nil, - cloudflareExtensionEnabled: nil, - railwayExtensionEnabled: nil, - tailscaleExtensionEnabled: nil, - linearDefaultProfile: nil, - linearSyncLabels: nil, - linearSyncStatuses: nil, - linearSyncDependencies: nil, - linearSyncProjects: nil, - githubDefaultProfile: nil, - railwayProject: nil, - railwayService: nil, - railwayEnvironment: nil, - railwayWorkspace: nil, - railwayNodeName: nil, - railwayLabelsCsv: nil, - railwayEndpoint: nil, - railwayCreateService: nil, - railwayImage: nil, - railwayPrivate: nil, - railwayTailscaleAuthKey: nil, - railwayTailscaleTagsCsv: nil, - tailscaleAuthKey: nil, - gatewayBind: nil, - cloudflareHostname: nil, - cloudflareSSHHostname: nil, - supervisorEnabled: nil, - supervisorMaxConcurrentJobs: nil, - supervisorLogsMaxBytes: nil, - preferencesTheme: nil, - preferencesTerminalApp: nil, - preferencesEditorApp: nil, - preferencesCodingAgentApp: nil, - preferencesSessionProvider: nil, - preferencesSessionBinaryPath: nil, - preferencesContainerProvider: nil, - preferencesContainerBinaryPath: nil, - preferencesCodingAgentBinaryPath: nil - ) - } - - private static func dictionary(_ source: [String: Any], key: String) -> [String: Any] { - source[key] as? [String: Any] ?? [:] - } -} - -func openGlobalCommandInTerminalPanel(command: String, title: String) { - NotificationCenter.default.post( - name: .hackTerminalOpenRequested, - object: nil, - userInfo: [ - TerminalOpenRequest.projectIdKey: "global-shell", - TerminalOpenRequest.kindKey: TerminalDrawerModel.Kind.shell.rawValue, - TerminalOpenRequest.commandKey: command, - TerminalOpenRequest.titleKey: title + DetailRowItem(label: "Daemon log", value: model.daemonStatus?.logPath ?? "Unknown"), + DetailRowItem(label: "Global logs", value: "hack global logs caddy --no-follow --tail 200"), + DetailRowItem(label: "Project logs", value: "hack logs --pretty") ] - ) -} - -private func resolveExecutablePath(candidates: [String]) -> String? { - let fileManager = FileManager.default - let envPath = ProcessInfo.processInfo.environment["PATH"] ?? "" - for entry in envPath.split(separator: ":") { - for candidate in candidates { - let path = "\(entry)/\(candidate)" - if fileManager.isExecutableFile(atPath: path) { - return path - } - } - } - - let fallbackDirectories = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin", "/bin"] - for directory in fallbackDirectories { - for candidate in candidates { - let path = "\(directory)/\(candidate)" - if fileManager.isExecutableFile(atPath: path) { - return path - } - } - } - - return nil -} - -private func resolveInstalledApplicationPath( - bundleIdentifiers: [String], - fallbackPaths: [String] -) -> String? { - let workspace = NSWorkspace.shared - for bundleIdentifier in bundleIdentifiers { - if let url = workspace.urlForApplication(withBundleIdentifier: bundleIdentifier) { - return url.path - } } - for path in fallbackPaths where FileManager.default.fileExists(atPath: path) { - return path - } - return nil -} - -private func openExecutablePanel() -> String? { - let panel = NSOpenPanel() - panel.canChooseFiles = true - panel.canChooseDirectories = false - panel.allowsMultipleSelection = false - panel.canCreateDirectories = false - panel.prompt = "Select" - panel.title = "Choose executable" - - guard panel.runModal() == .OK else { return nil } - return panel.url?.path } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift index 9e94c88f..da0652c8 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SetupAssistantView.swift @@ -2,45 +2,26 @@ import AppKit import SwiftUI import GhosttyTerminal -import HackDesktopModels -enum SetupAssistantSection: String, CaseIterable, Identifiable { +enum SetupAssistantSection { case runtime - case gateway - - var id: String { rawValue } } struct SetupAssistantView: View { @Environment(DashboardModel.self) private var model @Environment(\.dismiss) private var dismiss - let initialSection: SetupAssistantSection - @State private var section: SetupAssistantSection - @State private var terminalAutomationGranted: Bool? = nil - init(initialSection: SetupAssistantSection) { - self.initialSection = initialSection - _section = State(initialValue: initialSection) - } + init(initialSection _: SetupAssistantSection = .runtime) {} var body: some View { VStack(alignment: .leading, spacing: 14) { header - Picker("Section", selection: $section) { - Text("Runtime").tag(SetupAssistantSection.runtime) - Text("Gateway").tag(SetupAssistantSection.gateway) - } - .pickerStyle(.segmented) ScrollView { VStack(alignment: .leading, spacing: 14) { - if section == .runtime { - runtimeSteps - } else { - gatewaySteps - } + runtimeSteps } .padding(.vertical, 4) } @@ -111,71 +92,6 @@ struct SetupAssistantView: View { ) } - @ViewBuilder - private var gatewaySteps: some View { - permissionsCallout - - SetupAssistantStepCard( - tone: model.globalStatus == nil ? .warn : .neutral, - title: "Install global services", - detail: "Gateway depends on the global runtime. Do this once per machine.", - command: "hack global install", - canRunInApp: false, - onRunInApp: nil, - onRunInEmbeddedTerminal: supportsEmbeddedTerminal ? runInEmbeddedTerminalIfAvailable : nil, - onOpenTerminal: TerminalIntegration.openTerminalWithCommand, - onCopy: TerminalIntegration.copyToClipboard - ) - - SetupAssistantStepCard( - tone: .neutral, - title: "Start the daemon", - detail: "The gateway status UI requires hackd.", - command: "hack daemon start", - canRunInApp: !daemonIsRunning, - onRunInApp: { - Task { await model.startDaemon() } - }, - onRunInEmbeddedTerminal: supportsEmbeddedTerminal ? runInEmbeddedTerminalIfAvailable : nil, - onOpenTerminal: TerminalIntegration.openTerminalWithCommand, - onCopy: TerminalIntegration.copyToClipboard - ) - - SetupAssistantStepCard( - tone: gatewayIsConfigured ? .good : .warn, - title: "Guided gateway setup", - detail: "Enables gateway and helps you generate a token.", - command: "hack gateway setup", - canRunInApp: false, - onRunInApp: nil, - onRunInEmbeddedTerminal: supportsEmbeddedTerminal ? runInEmbeddedTerminalIfAvailable : nil, - onOpenTerminal: TerminalIntegration.openTerminalWithCommand, - onCopy: TerminalIntegration.copyToClipboard - ) - - if shouldShowLanExposureHint { - InlineCallout( - tone: .neutral, - title: "LAN exposure is blocked by loopback bind", - message: "If you want other devices on your LAN to reach the gateway, set the bind to 0.0.0.0 and restart hackd. This is optional and increases your attack surface on local networks.", - actions: [ - InlineCalloutAction(label: "Copy fix", systemImage: "doc.on.doc") { - TerminalIntegration.copyToClipboard(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - }, - InlineCalloutAction(label: "Open Terminal", systemImage: "terminal") { - TerminalIntegration.openTerminalWithCommand(""" - hack config set --global controlPlane.gateway.bind 0.0.0.0 - hack daemon restart - """) - } - ] - ) - } - } - private var daemonIsRunning: Bool { model.daemonStatus?.resolvedLabel == .running } @@ -204,16 +120,6 @@ struct SetupAssistantView: View { return granted ? .good : .warn } - private var gatewayIsConfigured: Bool { - model.globalStatus?.summary.gatewayEnabled == true || model.gatewaySummaryState != nil - } - - private var shouldShowLanExposureHint: Bool { - guard let exposures = model.gatewayExposures as [GatewayExposure]? else { return false } - guard let lan = exposures.first(where: { $0.id == "lan" }) else { return false } - return lan.resolvedState == .blocked && (lan.detail ?? "").lowercased().contains("loopback") - } - private func runInEmbeddedTerminalIfAvailable(_ command: String) { let normalizedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) guard !normalizedCommand.isEmpty else { return } @@ -226,7 +132,7 @@ import HackCLIService #Preview("Setup Assistant") { let model = DashboardModel(client: HackCLIClient()) - return SetupAssistantView(initialSection: .runtime) + return SetupAssistantView() .environment(model) } #endif diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift deleted file mode 100644 index bc07a2ee..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift +++ /dev/null @@ -1,121 +0,0 @@ -import SwiftUI - -import HackDesktopModels - -struct RuntimeRowView: View { - let isHealthy: Bool? - @State private var isHovered = false - - var body: some View { - HStack(spacing: 8) { - Label("Runtime", systemImage: "gauge") - .font(.mono(.subheadline, weight: .medium)) - Spacer() - if let statusColor { - StatusDotView(color: statusColor) - } - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isHovered ? Color.white.opacity(0.06) : .clear) - ) - .contentShape(Rectangle()) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } - - private var statusColor: Color? { - guard let isHealthy else { return nil } - return isHealthy ? .green : .orange - } -} - -struct GatewayRowView: View { - let state: GatewaySummaryState? - @State private var isHovered = false - - var body: some View { - HStack(spacing: 8) { - Label("Gateway", systemImage: "arrow.triangle.branch") - .font(.mono(.subheadline, weight: .medium)) - Spacer() - if let statusColor { - StatusDotView(color: statusColor) - } - } - .padding(.vertical, 4) - .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isHovered ? Color.white.opacity(0.06) : .clear) - ) - .contentShape(Rectangle()) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } - - private var statusColor: Color? { - state?.statusDotColor - } -} - -struct GatewayExposureRowView: View { - let exposure: GatewayExposure - @State private var isHovered = false - - var body: some View { - HStack(spacing: 8) { - Label(exposure.label, systemImage: iconName) - .font(.mono(.subheadline)) - Spacer() - StatusDotView(color: exposure.statusColor) - } - .padding(.vertical, 2) - .padding(.horizontal, 6) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .fill(isHovered ? Color.white.opacity(0.06) : .clear) - ) - .contentShape(Rectangle()) - .onHover { hovering in - isHovered = hovering - } - .animation(.easeInOut(duration: 0.12), value: isHovered) - } - - private var iconName: String { - switch exposure.id { - case "lan": - return "wifi" - case "tailscale": - return "link" - case "cloudflare": - return "cloud" - default: - return "network" - } - } -} - -private struct StatusDotView: View { - let color: Color - - var body: some View { - Circle() - .fill(color.opacity(0.7)) - .frame(width: 8, height: 8) - .overlay( - Circle() - .stroke(color.opacity(0.4), lineWidth: 1) - ) - } -} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift index aafc2526..42c34c66 100644 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalNotifications.swift @@ -10,4 +10,24 @@ enum TerminalOpenRequest { static let branchKey = "branch" static let commandKey = "command" static let titleKey = "title" + static let globalShellProjectId = "global-shell" +} + +func openGlobalCommandInTerminalPanel(command: String, title: String) { + let trimmedCommand = command.trimmingCharacters(in: .whitespacesAndNewlines) + let trimmedTitle = title.trimmingCharacters(in: .whitespacesAndNewlines) + guard !trimmedCommand.isEmpty else { + return + } + + NotificationCenter.default.post( + name: .hackTerminalOpenRequested, + object: nil, + userInfo: [ + TerminalOpenRequest.projectIdKey: TerminalOpenRequest.globalShellProjectId, + TerminalOpenRequest.kindKey: "shell", + TerminalOpenRequest.commandKey: trimmedCommand, + TerminalOpenRequest.titleKey: trimmedTitle.isEmpty ? "Hack" : "Hack - \(trimmedTitle)" + ] + ) } diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift deleted file mode 100644 index d65c2454..00000000 --- a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TicketsView.swift +++ /dev/null @@ -1,3043 +0,0 @@ -import AppKit -import MarkdownUI -import SwiftUI - -import HackDesktopModels - -struct TicketsView: View { - @Environment(DashboardModel.self) private var model - @Environment(\.colorScheme) private var colorScheme - @Environment(\.openURL) private var openURL - - let project: ProjectSummary - - @State private var tickets: [TicketSummary] = [] - @State private var selectedTicketId: String? = nil - @State private var ticketDetail: TicketDetailResponse? = nil - @State private var isLoading = false - @State private var isDetailLoading = false - @State private var errorMessage: String? = nil - @State private var detailErrorMessage: String? = nil - @State private var showCreateSheet = false - @State private var selectedFilter: TicketFilter = .all - @State private var selectedOriginFilter: TicketOriginFilter = .both - @State private var hasLoadedOnce = false - @State private var isPropertiesExpanded = false - @State private var isHistoryExpanded = false - @State private var searchText = "" - @State private var loadNotice: String? = nil - @State private var hoveredTicketId: String? = nil - @State private var linearRouteProfile = "" - @State private var linearRouteProjectId = "" - @State private var linearRouteProjectName = "" - @State private var linearRouteTeamId = "" - @State private var linearAdditionalProjects: [LinearProjectBindingTarget] = [] - @State private var linearAutosyncRouteKeys: Set = [] - @State private var linearSyncFromLinearEnabled = true - @State private var linearCreateHackTicketsEnabled = false - @State private var linearRouteStatus: LinearStatusResponse? = nil - @State private var activeSyncAction: TicketSyncAction? = nil - @State private var resolvingConflictIds: Set = [] - @State private var postingCommentTicketIds: Set = [] - @State private var postingReviewNoteTicketIds: Set = [] - @State private var ticketDetailCache: [String: TicketDetailResponse] = [:] - @State private var reviewComposerDrafts: [String: String] = [:] - @State private var refreshGeneration = 0 - @FocusState private var ticketsListFocused: Bool - - private static let ticketCacheTTL: TimeInterval = 60 * 10 - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - header - - if let errorMessage { - ticketsErrorCard(message: errorMessage) - } else { - content - } - } - .padding(.horizontal, 24) - .padding(.top, 12) - .padding(.bottom, 24) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .task { - loadCachedTickets() - async let routingRefresh: Void = refreshLinearRouting() - await refreshTickets() - _ = await routingRefresh - ticketsListFocused = true - } - .task(id: backgroundSyncTaskKey) { - guard shouldRunBackgroundSyncLoop else { - return - } - await runBackgroundSyncCycle() - while !Task.isCancelled { - try? await Task.sleep(for: .seconds(90)) - await runBackgroundSyncCycle() - } - } - .task(id: selectedTicketId) { - await loadTicketDetail() - } - .onChange(of: selectedFilter) { _, _ in - updateSelectionAfterRefresh() - } - .onChange(of: selectedOriginFilter) { _, _ in - updateSelectionAfterRefresh() - } - .onReceive( - NotificationCenter.default.publisher(for: .hackTicketReviewQueueRequested) - ) { notification in - guard - let userInfo = notification.userInfo, - let requestedProjectId = userInfo[TicketReviewQueueRequest.projectIdKey] as? String, - requestedProjectId == project.id - else { - return - } - activateReviewQueue() - } - .sheet(isPresented: $showCreateSheet) { - TicketCreateSheet( - projectName: project.name, - onCreate: { input in - Task { - await createTicket(input) - } - } - ) - } - } - - private var header: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(alignment: .center, spacing: 12) { - statusFilterMenu - originFilterMenu - searchField - Spacer(minLength: 12) - if let activeSyncAction { - HStack(spacing: 8) { - ProgressView() - .controlSize(.small) - Text(activeSyncAction.progressLabel) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - if reviewQueueCount > 0 { - reviewQueueButton - } - newTicketButton - } - - HStack(alignment: .center, spacing: 10) { - linearSyncStatusRow - Spacer(minLength: 12) - - Menu { - Button("Refresh tickets") { - Task { await refreshTickets() } - } - if canSyncProjectFromLinear { - Divider() - Button("Reconcile from Linear") { - Task { await syncProjectFromLinear() } - } - } - if let selectedTicket { - Divider() - Button(canPushTicketToLinear(selectedTicket) && selectedTicket.externalKey?.isEmpty != false ? "Create selected ticket in Linear" : "Update selected ticket in Linear") { - Task { await syncSelectedTicketToLinear(selectedTicket) } - } - .disabled(!canPushTicketToLinear(selectedTicket) || isAnySyncInFlight) - Button("Refresh selected ticket from Linear") { - Task { await syncSelectedTicketFromLinear(selectedTicket) } - } - .disabled(selectedTicket.externalKey?.isEmpty != false || isAnySyncInFlight) - if let externalURL = selectedTicket.externalUrl, - let url = URL(string: externalURL) { - Button("Open linked Linear issue") { - openURL(url) - } - } - } - Divider() - Button("Open ticket sync settings") { - openProjectRouting() - } - Button("Open Linear settings") { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: ["pane": "linear"] - ) - } - } label: { - Image(systemName: "ellipsis.circle") - .font(.system(size: 15, weight: .semibold)) - .foregroundStyle(.secondary) - } - .buttonStyle(PressableIconButtonStyle()) - } - - loadNoticeCallout - } - } - - private var hasSelection: Bool { - selectedTicketId != nil - } - - private var selectedTicket: TicketSummary? { - guard let selectedTicketId else { - return nil - } - return tickets.first(where: { $0.ticketId == selectedTicketId }) - } - - private var reviewQueueEntries: [TicketReviewQueueEntry] { - tickets.compactMap { ticket in - TicketReviewQueueEntry( - ticket: ticket, - detail: ticketDetailCache[ticket.ticketId], - reviewNoteCount: reviewNotes(for: ticket).count - ) - } - .sorted { lhs, rhs in - if lhs.openConflictCount != rhs.openConflictCount { - return lhs.openConflictCount > rhs.openConflictCount - } - return lhs.updatedAt > rhs.updatedAt - } - } - - private var filteredReviewQueueEntries: [TicketReviewQueueEntry] { - let visibleTicketIds = Set(filteredTickets.map(\.ticketId)) - return reviewQueueEntries.filter { visibleTicketIds.contains($0.ticketId) } - } - - private var reviewQueueCount: Int { - reviewQueueEntries.count - } - - private var reviewQueueLabel: String { - reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews" - } - - private var nextReviewQueueEntry: TicketReviewQueueEntry? { - let remaining = filteredReviewQueueEntries.filter { - $0.ticketId != selectedTicketId - } - return remaining.first - } - - private var canSyncProjectFromLinear: Bool { - linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting && linearSyncFromLinearEnabled - } - - private var canSyncLinkedTicketsToLinear: Bool { - linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting - } - - private var canSyncHackOwnedTicketsToLinear: Bool { - canSyncLinkedTicketsToLinear && linearCreateHackTicketsEnabled - } - - private func canPushTicketToLinear(_ ticket: TicketSummary) -> Bool { - if ticket.externalKey?.isEmpty == false { - return linearRouteStatus?.tokenResolved == true - } - return linearRouteStatus?.tokenResolved == true && linearCreateHackTicketsEnabled - } - - private var isAnySyncInFlight: Bool { - activeSyncAction != nil - } - - @ViewBuilder - private var loadNoticeCallout: some View { - if let loadNotice { - InlineCallout( - tone: loadNoticeTone, - title: loadNoticeTitle, - message: loadNotice, - actions: loadNoticeActions - ) - } - } - - private var hasAnyLinearProjectRouting: Bool { - if !linearRouteProjectId.isEmpty { - return true - } - return !linearAdditionalProjects.isEmpty - } - - private var backgroundSyncTaskKey: String { - [ - project.id, - linearRouteProfile, - linearRouteProjectId, - linearSyncFromLinearEnabled ? "inbound-on" : "inbound-off", - linearCreateHackTicketsEnabled ? "outbound-on" : "outbound-off" - ].joined(separator: "::") - } - - private var shouldRunBackgroundSyncLoop: Bool { - linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting && - (linearSyncFromLinearEnabled || linearCreateHackTicketsEnabled) - } - - private var linearSyncStatusRow: some View { - HStack(spacing: 10) { - Image(systemName: linearRouteStatus?.tokenResolved == true ? "point.3.connected.trianglepath.dotted" : "exclamationmark.triangle.fill") - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(linearRouteStatus?.tokenResolved == true ? Color.secondary : Color.orange) - - Text(linearSyncStatusSummary) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - - Spacer(minLength: 0) - - if reviewQueueCount > 0 { - Text(reviewQueueCount == 1 ? "1 review" : "\(reviewQueueCount) reviews") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.orange) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.04) : Color.black.opacity(0.03)) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke( - linearRouteStatus?.tokenResolved == true && hasAnyLinearProjectRouting - ? Color.primary.opacity(colorScheme == .dark ? 0.08 : 0.06) - : Color.orange.opacity(colorScheme == .dark ? 0.32 : 0.22), - lineWidth: 1 - ) - ) - ) - } - - private var linearSyncStatusSummary: String { - if linearRouteStatus?.tokenResolved != true { - return "Connect Linear to route this repo's tickets." - } - if !hasAnyLinearProjectRouting { - return "Choose a Linear project for this repo." - } - let defaultLabel = linearRouteProjectName.isEmpty ? linearRouteProjectId : linearRouteProjectName - if linearSyncFromLinearEnabled && linearCreateHackTicketsEnabled { - return "\(defaultLabel) · syncing with Linear · create in Linear on" - } - if linearSyncFromLinearEnabled { - return "\(defaultLabel) · syncing with Linear" - } - if linearCreateHackTicketsEnabled { - return "\(defaultLabel) · create in Linear on" - } - return "\(defaultLabel) · local-only outbound" - } - - private var allLinearRouteTargets: [LinearProjectBindingTarget] { - var targets: [LinearProjectBindingTarget] = [] - if !linearRouteProjectId.isEmpty { - targets.append( - LinearProjectBindingTarget( - profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile, - projectId: linearRouteProjectId, - projectName: linearRouteProjectName.isEmpty ? nil : linearRouteProjectName, - teamId: linearRouteTeamId.isEmpty ? nil : linearRouteTeamId - ) - ) - } - targets.append(contentsOf: linearAdditionalProjects.map(normalizedLinearRouteTarget(_:))) - return targets - } - - private func normalizedLinearRouteTarget(_ target: LinearProjectBindingTarget) -> LinearProjectBindingTarget { - if let profileId = target.profileId?.trimmingCharacters(in: .whitespacesAndNewlines), - !profileId.isEmpty { - return target - } - return LinearProjectBindingTarget( - profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile, - projectId: target.projectId, - projectName: target.projectName, - teamId: target.teamId - ) - } - - private func linearRouteKey(for target: LinearProjectBindingTarget) -> String { - let normalized = normalizedLinearRouteTarget(target) - let profileId = normalized.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" - let teamId = normalized.teamId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "*" - return [profileId, normalized.projectId, teamId].joined(separator: "::") - } - - private var loadNoticeTone: StatusTone { - guard let loadNotice else { return .neutral } - let normalized = loadNotice.lowercased() - if normalized.contains("failed") - || normalized.contains("not linked") - || normalized.contains("timed out") - { - return .warn - } - return .good - } - - private var loadNoticeTitle: String { - guard let loadNotice else { return "Ticket status" } - let normalized = loadNotice.lowercased() - if normalized.contains("timed out") { - return "Using cached tickets" - } - if normalized.contains("failed") { - return "Linear sync needs attention" - } - if normalized.contains("not linked") { - return "Local-only ticket" - } - if normalized.hasPrefix("reconciled") { - return "Reconciliation complete" - } - if normalized.hasPrefix("created") || normalized.hasPrefix("updated") { - return "Linear issue updated" - } - return "Ticket status" - } - - private var loadNoticeActions: [InlineCalloutAction] { - guard let loadNotice else { return [] } - let normalized = loadNotice.lowercased() - if normalized.contains("timed out") { - return [ - InlineCalloutAction(label: "Refresh", systemImage: "arrow.clockwise") { - Task { await refreshTickets() } - } - ] - } - if normalized.contains("failed") { - if linearRouteStatus?.tokenResolved != true { - return [ - InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { - openLinearSettings() - } - ] - } - if !hasAnyLinearProjectRouting { - return [ - InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { - openProjectRouting() - } - ] - } - } - if normalized.contains("not linked") && linearRouteStatus?.tokenResolved == true { - return [ - InlineCalloutAction( - label: linearCreateHackTicketsEnabled ? "Create in Linear" : "Ticket sync settings", - systemImage: linearCreateHackTicketsEnabled ? "arrow.up.right" : "slider.horizontal.3" - ) { - if !linearCreateHackTicketsEnabled { - openProjectRouting() - return - } - guard let selectedTicket else { return } - Task { await syncSelectedTicketToLinear(selectedTicket) } - } - ] - } - return [] - } - - private var reviewQueueButton: some View { - Button { - activateReviewQueue() - } label: { - ticketHeaderBadge( - title: reviewQueueLabel, - systemImage: "bubble.left.and.exclamationmark.bubble.right", - tone: selectedFilter == .reviewQueue ? .orange : .secondary - ) - } - .buttonStyle(.plain) - } - - private var statusFilterMenu: some View { - Menu { - ForEach(TicketFilter.allCases, id: \.self) { filter in - Button { - selectedFilter = filter - } label: { - if selectedFilter == filter { - Label("\(filter.label) (\(ticketCount(for: filter)))", systemImage: "checkmark") - } else { - Text("\(filter.label) (\(ticketCount(for: filter)))") - } - } - } - } label: { - HStack(spacing: 8) { - Image(systemName: "line.3.horizontal.decrease.circle") - .font(.system(size: 13, weight: .semibold)) - Text(selectedFilter.label) - .font(.system(size: 13, weight: .semibold)) - Text("\(ticketCount(for: selectedFilter))") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background { - headerControlCapsuleBackground - } - } - .menuStyle(.borderlessButton) - } - - private var originFilterMenu: some View { - Menu { - ForEach(TicketOriginFilter.allCases, id: \.self) { filter in - Button { - selectedOriginFilter = filter - } label: { - if selectedOriginFilter == filter { - Label("\(filter.label) (\(filter.count(in: tickets)))", systemImage: "checkmark") - } else { - Text("\(filter.label) (\(filter.count(in: tickets)))") - } - } - } - } label: { - HStack(spacing: 8) { - Image(systemName: "tray.full") - .font(.system(size: 13, weight: .semibold)) - Text(selectedOriginFilter.label) - .font(.system(size: 13, weight: .semibold)) - Text("\(selectedOriginFilter.count(in: tickets))") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - .background { - headerControlCapsuleBackground - } - } - .menuStyle(.borderlessButton) - } - - private var searchField: some View { - HStack(spacing: 8) { - Image(systemName: "magnifyingglass") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - TextField("Search title or ID", text: $searchText) - .textFieldStyle(.plain) - .font(.system(size: 14, weight: .medium, design: .monospaced)) - .frame(minWidth: 180, maxWidth: 320) - if !searchText.isEmpty { - Button { - searchText = "" - } label: { - Image(systemName: "xmark.circle.fill") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - } - .buttonStyle(.plain) - } - } - .padding(.horizontal, 10) - .padding(.vertical, 8) - .background { - headerControlFieldBackground - } - } - - private var content: some View { - HStack(alignment: .top, spacing: 0) { - ticketsListCard - .frame( - minWidth: 320, - maxWidth: hasSelection ? 520 : .infinity, - maxHeight: .infinity, - alignment: .topLeading - ) - if hasSelection { - ticketDetailCard - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - } - } - .frame(maxHeight: .infinity, alignment: .top) - .onMoveCommand(perform: moveSelection) - .animation(.easeInOut(duration: 0.2), value: hasSelection) - } - - private var ticketsListCard: some View { - VStack(alignment: .leading, spacing: 0) { - if selectedFilter == .reviewQueue { - reviewQueueListHeader - } else { - standardTicketsListHeader - } - Divider() - .opacity(0.12) - ScrollView { - if isLoading && !hasLoadedOnce { - skeletonList - } else if selectedFilter == .reviewQueue && filteredReviewQueueEntries.isEmpty { - reviewQueueEmptyView - } else if filteredTickets.isEmpty { - emptyTicketsView - } else if selectedFilter == .all { - LazyVStack(alignment: .leading, spacing: 0, pinnedViews: [.sectionHeaders]) { - ForEach(TicketStatus.allCases, id: \.self) { status in - ticketSection(status: status) - } - } - } else if selectedFilter == .reviewQueue { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredReviewQueueEntries) { entry in - reviewQueueRow(entry) - } - } - } else { - LazyVStack(alignment: .leading, spacing: 0) { - ForEach(filteredTickets) { ticket in - ticketRow(ticket) - } - } - } - } - .frame(maxHeight: .infinity, alignment: .topLeading) - } - .focusable() - .focused($ticketsListFocused) - .focusEffectDisabled() - } - - private var standardTicketsListHeader: some View { - HStack(spacing: 8) { - Text("\(filteredTickets.count) issue\(filteredTickets.count == 1 ? "" : "s")") - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - Spacer() - if !filteredTickets.isEmpty { - Text("Use ↑ ↓ to navigate") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - - private var reviewQueueListHeader: some View { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text( - "\(filteredReviewQueueEntries.count) review item\(filteredReviewQueueEntries.count == 1 ? "" : "s")" - ) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - Spacer() - Text("Use ↑ ↓ to triage") - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ticketMetaPill( - "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount }) open conflict\(filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount } == 1 ? "" : "s")", - tone: filteredReviewQueueEntries.reduce(0) { $0 + $1.openConflictCount } > 0 ? .orange : .secondary - ) - ticketMetaPill( - "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.commentCount }) comment\(filteredReviewQueueEntries.reduce(0) { $0 + $1.commentCount } == 1 ? "" : "s")", - tone: .secondary - ) - ticketMetaPill( - "\(filteredReviewQueueEntries.reduce(0) { $0 + $1.reviewNoteCount }) review note\(filteredReviewQueueEntries.reduce(0) { $0 + $1.reviewNoteCount } == 1 ? "" : "s")", - tone: .secondary - ) - } - } - HStack(spacing: 8) { - if let nextReviewQueueEntry { - Button { - selectedTicketId = nextReviewQueueEntry.ticketId - ticketsListFocused = true - } label: { - Label("Next item", systemImage: "arrow.right.circle") - } - .adaptiveToolbarButton() - } - Button("All tickets") { - selectedFilter = .all - ticketsListFocused = true - } - .adaptiveToolbarButton() - } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - } - - private var ticketDetailCard: some View { - ticketDetailPanel { - if let detailErrorMessage { - ScrollView { - ticketsErrorCard(message: detailErrorMessage) - .padding(16) - } - } else if isDetailLoading { - ProgressView() - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) - } else if let detail = ticketDetail { - VStack(spacing: 0) { - ScrollView { - ticketDetailView(detail) - .padding(.horizontal, 16) - .padding(.top, 16) - .padding(.bottom, 20) - } - .layoutPriority(1) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - - Divider() - .opacity(0.22) - - ticketDetailFooter(detail) - .padding(.horizontal, 16) - .padding(.vertical, 10) - .background(ticketDetailFooterFillColor) - } - } else { - Text("Select a ticket to see details.") - .font(.mono(.subheadline)) - .foregroundStyle(.secondary) - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .padding(16) - } - } - } - - private var emptyTicketsView: some View { - VStack(alignment: .leading, spacing: 8) { - Text("No tickets found.") - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - Text("Adjust filters, reconcile from Linear, or create a new ticket.") - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - HStack(spacing: 8) { - Button("New ticket") { - showCreateSheet = true - } - .adaptiveToolbarButton() - - Button("Reconcile now") { - Task { await syncProjectFromLinear() } - } - .adaptiveToolbarButtonProminent() - .disabled(!canSyncProjectFromLinear || isAnySyncInFlight) - - Button("Ticket sync") { - openProjectRouting() - } - .adaptiveToolbarButton() - - if linearRouteStatus?.tokenResolved != true { - Button("Linear settings") { - openLinearSettings() - } - .adaptiveToolbarButton() - } - } - } - .padding(.horizontal, 14) - .padding(.top, 8) - } - - private var reviewQueueEmptyView: some View { - VStack(alignment: .leading, spacing: 8) { - Text(reviewQueueCount == 0 ? "No tickets need sync review." : "No review items match the current filters.") - .font(.system(size: 15, weight: .semibold, design: .monospaced)) - Text( - reviewQueueCount == 0 - ? "When conflicts, mergeable-field ambiguity, or follow-up notes land, they will appear here for triage." - : "Clear the search text, widen the origin filter, or switch back to the full ticket board." - ) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - HStack(spacing: 8) { - Button("All tickets") { - selectedFilter = .all - } - .adaptiveToolbarButton() - Button("Refresh list") { - Task { await refreshTickets() } - } - .adaptiveToolbarButton() - } - } - .padding(.horizontal, 14) - .padding(.top, 8) - } - - private func ticketRow(_ ticket: TicketSummary) -> some View { - Button { - if selectedTicketId == ticket.ticketId { - selectedTicketId = nil - ticketDetail = nil - detailErrorMessage = nil - } else { - selectedTicketId = ticket.ticketId - } - ticketsListFocused = true - } label: { - HStack(alignment: .top, spacing: 10) { - Circle() - .fill(ticket.status.color) - .frame(width: 8, height: 8) - .padding(.top, 8) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(ticket.ticketId) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - Spacer() - Text(humanReadableListDate(ticket.updatedAt)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - Text(ticket.title) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .lineLimit(1) - .multilineTextAlignment(.leading) - ticketRowMetadata(ticket) - } - Spacer(minLength: 0) - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(selectionBackground(for: ticket.ticketId)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selectionBorder(for: ticket.ticketId)) - .frame(height: 1) - } - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .onHover { hovering in - hoveredTicketId = hovering ? ticket.ticketId : nil - } - .animation(.easeInOut(duration: 0.12), value: hoveredTicketId) - } - - private func reviewQueueRow(_ entry: TicketReviewQueueEntry) -> some View { - return Button { - if selectedTicketId == entry.ticketId { - selectedTicketId = nil - ticketDetail = nil - detailErrorMessage = nil - } else { - selectedTicketId = entry.ticketId - } - ticketsListFocused = true - } label: { - VStack(alignment: .leading, spacing: 8) { - HStack(alignment: .top, spacing: 8) { - Image(systemName: entry.openConflictCount > 0 ? "exclamationmark.triangle.fill" : "arrow.triangle.branch") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(entry.openConflictCount > 0 ? .orange : .secondary) - .padding(.top, 2) - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - Text(entry.ticketId) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - ticketMetaPill( - entry.badgeLabel, - tone: entry.openConflictCount > 0 ? .orange : .secondary - ) - Spacer(minLength: 8) - Text(humanReadableListDate(entry.updatedAt)) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - Text(entry.title) - .font(.system(size: 14, weight: .semibold, design: .monospaced)) - .multilineTextAlignment(.leading) - .lineLimit(1) - Text(entry.summary) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .multilineTextAlignment(.leading) - .lineLimit(2) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 6) { - ticketMetaPill( - "\(entry.openConflictCount) open", - tone: entry.openConflictCount > 0 ? .orange : .secondary - ) - if entry.reviewNoteCount > 0 { - ticketMetaPill( - "\(entry.reviewNoteCount) review note\(entry.reviewNoteCount == 1 ? "" : "s")", - tone: .secondary - ) - } - if let checkpointSummary = ticketDetailCache[entry.ticketId]?.linearSyncReviewState.checkpointSummary { - ticketMetaPill(checkpointSummary, tone: .secondary) - } - } - } - } - } - } - .padding(.horizontal, 8) - .padding(.vertical, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background(selectionBackground(for: entry.ticketId)) - .overlay(alignment: .bottom) { - Rectangle() - .fill(selectionBorder(for: entry.ticketId)) - .frame(height: 1) - } - } - .buttonStyle(.plain) - .contentShape(Rectangle()) - .onHover { hovering in - hoveredTicketId = hovering ? entry.ticketId : nil - } - .animation(.easeInOut(duration: 0.12), value: hoveredTicketId) - } - - private func ticketRowMetadata(_ ticket: TicketSummary) -> some View { - HStack(spacing: 10) { - if let externalKey = ticket.externalKey, !externalKey.isEmpty { - Text(externalKey) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { - Text(assignee) - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - .lineLimit(1) - } - if ticket.linearSyncUXState.reviewHint != nil { - Text("review") - .font(.system(size: 11, weight: .medium, design: .monospaced)) - .foregroundStyle(.orange) - } - } - .padding(.top, 2) - } - - private func ticketDetailView(_ detail: TicketDetailResponse) -> some View { - VStack(alignment: .leading, spacing: 20) { - HStack(alignment: .top, spacing: 12) { - VStack(alignment: .leading, spacing: 6) { - Text(detail.ticket.title) - .font(.system(size: 24, weight: .semibold, design: .monospaced)) - HStack(spacing: 10) { - Text(detail.ticket.ticketId) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - Text("Updated \(humanReadableDetailDate(detail.ticket.updatedAt))") - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - ticketDetailMetadata(detail.ticket) - } - Spacer() - statusMenu(ticket: detail.ticket) - } - - if let body = detail.ticket.body, !body.isEmpty { - VStack(alignment: .leading, spacing: 6) { - markdownBody(body) - } - } - - ticketSyncGuidanceCallout(detail) - ticketSyncSnapshotStrip(detail) - ticketCommentsSection(detail) - ticketReviewNotesSection(detail) - ticketSyncCheckpointSection(detail) - ticketConflictSection(detail) - } - } - - private func ticketDetailMetadata(_ ticket: TicketSummary) -> some View { - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ticketMetaPill(ticket.status.label, tone: ticket.status.color) - if let assignee = ticket.assignee?.trimmingCharacters(in: .whitespacesAndNewlines), !assignee.isEmpty { - ticketMetaPill(assignee, tone: .secondary) - } - if let externalSystem = ticket.externalSystem, !externalSystem.isEmpty { - ticketMetaPill(externalSystem, tone: .accentColor) - } - if let externalKey = ticket.externalKey, !externalKey.isEmpty { - ticketMetaPill(externalKey, tone: .accentColor) - } - if let projectName = ticket.externalProjectName, !projectName.isEmpty { - ticketMetaPill(projectName, tone: .secondary) - } - if ticket.linearSyncUXState.reviewHint != nil { - ticketMetaPill("Needs review", tone: .orange) - } - } - } - } - - @ViewBuilder - private func ticketSyncGuidanceCallout(_ detail: TicketDetailResponse) -> some View { - let shouldShowGuidance = - detail.linearSyncReviewState.needsReview || - !detail.ticket.linearSyncUXState.isLinkedToLinear - if shouldShowGuidance { - let guidance = ticketSyncGuidance(for: detail) - InlineCallout( - tone: guidance.tone, - title: guidance.title, - message: guidance.message, - actions: ticketSyncGuidanceActions(for: detail) - ) - } - } - - @ViewBuilder - private func ticketSyncSnapshotStrip(_ detail: TicketDetailResponse) -> some View { - let review = detail.linearSyncReviewState - if detail.comments.isEmpty && detail.syncCheckpoints.isEmpty && detail.conflicts.isEmpty { - EmptyView() - } else { - VStack(alignment: .leading, spacing: 8) { - Text("Sync snapshot") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 8) { - ticketMetaPill(review.badgeLabel, tone: reviewToneColor(review.severity)) - ticketMetaPill("\(review.commentCount) comment\(review.commentCount == 1 ? "" : "s")", tone: .secondary) - ticketMetaPill("\(review.openConflictCount) open", tone: review.openConflictCount > 0 ? .orange : .secondary) - ticketMetaPill("\(review.resolvedConflictCount) resolved", tone: review.resolvedConflictCount > 0 ? .green : .secondary) - if reviewNotes(for: detail.ticket).count > 0 { - let noteCount = reviewNotes(for: detail.ticket).count - ticketMetaPill("\(noteCount) review note\(noteCount == 1 ? "" : "s")", tone: .secondary) - } - if let checkpointSummary = review.checkpointSummary { - ticketMetaPill(checkpointSummary, tone: .secondary) - } - } - } - } - } - } - - @ViewBuilder - private func ticketCommentsSection(_ detail: TicketDetailResponse) -> some View { - if !detail.comments.isEmpty { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Activity") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - } - VStack(alignment: .leading, spacing: 10) { - ForEach(detail.comments) { comment in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - ticketMetaPill(comment.source, tone: comment.source == "linear" ? .orange : .secondary) - ticketMetaPill(comment.actor, tone: .secondary) - Text(humanReadableDetailDate(comment.createdAt)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - Spacer() - if let externalUrl = comment.externalUrl, - let url = URL(string: externalUrl) { - Button { - openURL(url) - } label: { - Label("Open", systemImage: "link") - } - .adaptiveToolbarButton() - } - Button { - appendQuotedCommentToReviewDraft(comment, ticket: detail.ticket) - } label: { - Label("Quote", systemImage: "text.quote") - } - .adaptiveToolbarButton() - } - markdownBody(comment.body) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - } - } - } - } - } - - @ViewBuilder - private func ticketReviewNotesSection(_ detail: TicketDetailResponse) -> some View { - let savedNotes = reviewNotes(for: detail.ticket) - let isRelevant = detail.ticket.linearSyncUXState.isLinkedToLinear || detail.linearSyncReviewState.needsReview || !savedNotes.isEmpty - if isRelevant { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Review notes") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - ticketMetaPill("Shared in ticket history", tone: .secondary) - if !savedNotes.isEmpty { - ticketMetaPill("\(savedNotes.count) saved", tone: .secondary) - } - } - - InlineCallout( - tone: detail.linearSyncReviewState.needsReview ? .warn : .neutral, - title: "Compose a review comment", - message: "Draft follow-up from conflicts and comment history here. Save it as a repo-shared review note or append it to the ticket as an immutable Hack comment that sync can push to Linear later.", - actions: [ - InlineCalloutAction(label: "Use review summary", systemImage: "sparkles") { - applyReviewDraftTemplate(for: detail) - }, - InlineCalloutAction(label: "Copy draft", systemImage: "doc.on.doc") { - copyReviewDraft(for: detail.ticket) - }, - ] - ) - - if !savedNotes.isEmpty { - VStack(alignment: .leading, spacing: 8) { - ForEach(savedNotes.sorted(by: { $0.createdAt > $1.createdAt })) { note in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - ticketMetaPill("Review note", tone: .secondary) - ticketMetaPill(note.actor, tone: .secondary) - if let context = note.context, !context.isEmpty { - ticketMetaPill(context, tone: .secondary) - } - Text(humanReadableDetailDate(note.createdAt)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - Spacer() - Button { - copyToPasteboard(note.markdown) - loadNotice = "Copied saved review note." - } label: { - Label("Copy", systemImage: "doc.on.doc") - } - .adaptiveToolbarButton() - } - markdownBody(note.markdown) - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - } - } - } - - VStack(alignment: .leading, spacing: 8) { - Text("Draft") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - ZStack(alignment: .topLeading) { - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - TextEditor(text: reviewDraftBinding(for: detail.ticket)) - .font(.system(size: 13, weight: .medium, design: .monospaced)) - .scrollContentBackground(.hidden) - .padding(.horizontal, 8) - .padding(.vertical, 6) - .frame(minHeight: 128) - if reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - Text("Draft a shared review note, quote an imported comment, or stage a conflict summary.") - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.secondary) - .padding(.horizontal, 14) - .padding(.vertical, 14) - .allowsHitTesting(false) - } - } - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - - HStack(spacing: 8) { - Button { - applyReviewDraftTemplate(for: detail) - } label: { - Label("Insert summary", systemImage: "text.append") - } - .adaptiveToolbarButton() - - if let latestComment = detail.comments.last { - Button { - appendQuotedCommentToReviewDraft(latestComment, ticket: detail.ticket) - } label: { - Label("Quote latest", systemImage: "text.quote") - } - .adaptiveToolbarButton() - } - - Button { - Task { await postReviewDraftAsReviewNote(for: detail.ticket) } - } label: { - Label("Save review note", systemImage: "tray.and.arrow.down") - } - .adaptiveToolbarButtonProminent() - .disabled( - reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - postingReviewNoteTicketIds.contains(detail.ticket.ticketId) - ) - - Button { - Task { await postReviewDraftAsTicketComment(for: detail.ticket) } - } label: { - Label("Post ticket comment", systemImage: "plus.bubble") - } - .adaptiveToolbarButton() - .disabled( - reviewDraft(for: detail.ticket).trimmingCharacters(in: .whitespacesAndNewlines).isEmpty || - postingCommentTicketIds.contains(detail.ticket.ticketId) - ) - - Button("Clear") { - clearReviewDraft(for: detail.ticket) - } - .adaptiveToolbarButton() - .disabled(reviewDraft(for: detail.ticket).isEmpty) - - if postingReviewNoteTicketIds.contains(detail.ticket.ticketId) || - postingCommentTicketIds.contains(detail.ticket.ticketId) - { - ProgressView() - .controlSize(.small) - } - } - } - } - } - } - - @ViewBuilder - private func ticketSyncCheckpointSection(_ detail: TicketDetailResponse) -> some View { - if !detail.syncCheckpoints.isEmpty { - VStack(alignment: .leading, spacing: 10) { - Text("Sync checkpoints") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 8) { - ForEach(detail.syncCheckpoints.sorted(by: { $0.createdAt > $1.createdAt })) { checkpoint in - VStack(alignment: .leading, spacing: 6) { - HStack(spacing: 8) { - ticketMetaPill(checkpoint.provider.capitalized, tone: .accentColor) - if let direction = checkpoint.direction, !direction.isEmpty { - ticketMetaPill(direction, tone: .secondary) - } - if let profileId = checkpoint.profileId, !profileId.isEmpty { - ticketMetaPill("Profile \(profileId)", tone: .secondary) - } - Spacer() - Text(humanReadableDetailDate(checkpoint.createdAt)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - if let remoteCursor = checkpoint.remoteCursor, !remoteCursor.isEmpty { - Text(remoteCursor) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.secondary) - } - if let remoteUpdatedAt = checkpoint.remoteUpdatedAt, !remoteUpdatedAt.isEmpty { - Text("Remote updated \(humanReadableDetailDate(remoteUpdatedAt))") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(colorScheme == .dark ? Color.white.opacity(0.03) : Color.black.opacity(0.025)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(Color.primary.opacity(0.08), lineWidth: 1) - ) - } - } - } - } - } - - @ViewBuilder - private func ticketConflictSection(_ detail: TicketDetailResponse) -> some View { - if !detail.conflicts.isEmpty { - VStack(alignment: .leading, spacing: 10) { - HStack(spacing: 8) { - Text("Conflicts") - .font(.system(size: 12, weight: .semibold)) - .foregroundStyle(.secondary) - if detail.openSyncConflicts.count > 0 { - ticketMetaPill("\(detail.openSyncConflicts.count) open", tone: .orange) - } - if detail.resolvedSyncConflicts.count > 0 { - ticketMetaPill("\(detail.resolvedSyncConflicts.count) resolved", tone: .green) - } - } - VStack(alignment: .leading, spacing: 8) { - ForEach(detail.conflicts.sorted(by: { $0.updatedAt > $1.updatedAt })) { conflict in - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - ticketMetaPill(conflict.field, tone: conflict.status == .open ? .orange : .green) - ticketMetaPill(conflict.status == .open ? "Open" : "Resolved", tone: conflict.status == .open ? .orange : .green) - if let authority = conflict.authority, !authority.isEmpty { - ticketMetaPill(authority, tone: .secondary) - } - Spacer() - Text(humanReadableDetailDate(conflict.updatedAt)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - if let summary = conflict.summary, !summary.isEmpty { - Text(summary) - .font(.system(size: 13, weight: .medium)) - } - VStack(alignment: .leading, spacing: 6) { - ticketConflictValueRow(label: "Local", value: conflict.localValue?.displayText) - ticketConflictValueRow(label: "Remote", value: conflict.remoteValue?.displayText) - if let resolution = conflict.resolution?.rawValue { - ticketConflictValueRow(label: "Resolution", value: resolution.replacingOccurrences(of: "_", with: " ")) - } - if let resolutionSummary = conflict.resolutionSummary, !resolutionSummary.isEmpty { - ticketConflictValueRow(label: "Notes", value: resolutionSummary) - } - } - if conflict.status == .open { - ticketConflictActions(conflict) - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(conflict.status == .open ? Color.orange.opacity(colorScheme == .dark ? 0.08 : 0.06) : Color.green.opacity(colorScheme == .dark ? 0.08 : 0.06)) - ) - .overlay( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .stroke(conflict.status == .open ? Color.orange.opacity(0.18) : Color.green.opacity(0.18), lineWidth: 1) - ) - } - } - } - } - } - - @ViewBuilder - private func ticketConflictActions(_ conflict: TicketSyncConflict) -> some View { - let isResolving = resolvingConflictIds.contains(conflict.conflictId) - HStack(spacing: 8) { - Button { - stageConflictReviewDraft(conflict) - } label: { - Label("Draft note", systemImage: "square.and.pencil") - } - .adaptiveToolbarButton() - .disabled(isResolving) - - Button { - Task { - await resolveTicketConflict( - conflict, - resolution: .acceptLocal - ) - } - } label: { - Label("Keep Hack", systemImage: "arrow.uturn.backward.circle") - } - .adaptiveToolbarButtonProminent() - .disabled(isResolving) - - Button { - Task { - await resolveTicketConflict( - conflict, - resolution: .acceptRemote - ) - } - } label: { - Label("Keep Linear", systemImage: "arrow.uturn.forward.circle") - } - .adaptiveToolbarButton() - .disabled(isResolving) - - Menu { - Button("Mark merged") { - Task { - await resolveTicketConflict( - conflict, - resolution: .merged, - summary: "Marked merged from the macOS review UI." - ) - } - } - Button("Ignore for now") { - Task { - await resolveTicketConflict( - conflict, - resolution: .ignore, - summary: "Ignored from the macOS review UI." - ) - } - } - } label: { - Label("More", systemImage: "ellipsis.circle") - } - .adaptiveToolbarButton() - .disabled(isResolving) - - if isResolving { - ProgressView() - .controlSize(.small) - } - } - } - - private func ticketSyncGuidanceActions(for ticket: TicketSummary) -> [InlineCalloutAction] { - let syncState = ticket.linearSyncUXState - if syncState.reviewHint != nil { - return [ - InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { - openProjectRouting() - } - ] - } - - if !syncState.isLinkedToLinear { - if canPushTicketToLinear(ticket) { - return [ - InlineCalloutAction(label: "Create in Linear", systemImage: "arrow.up.right") { - Task { await syncSelectedTicketToLinear(ticket) } - } - ] - } - return [ - InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { - openLinearSettings() - }, - InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { - openProjectRouting() - }, - ] - } - - switch syncState.authority { - case .linear: - return [] - case .hack: - if !syncState.isLinkedToLinear, canPushTicketToLinear(ticket) { - return [ - InlineCalloutAction(label: "Create in Linear", systemImage: "arrow.up.right") { - Task { await syncSelectedTicketToLinear(ticket) } - } - ] - } - return [ - InlineCalloutAction(label: "Linear settings", systemImage: "link.badge.plus") { - openLinearSettings() - }, - ] - } - } - - private func ticketSyncGuidanceActions(for detail: TicketDetailResponse) -> [InlineCalloutAction] { - if detail.linearSyncReviewState.needsReview { - var actions = [ - InlineCalloutAction(label: "Ticket sync settings", systemImage: "slider.horizontal.3") { - openProjectRouting() - } - ] - if let externalURL = detail.ticket.externalUrl, - let url = URL(string: externalURL) { - actions.append( - InlineCalloutAction(label: "Open linked issue", systemImage: "link") { - openURL(url) - } - ) - } - return actions - } - return ticketSyncGuidanceActions(for: detail.ticket) - } - - private func ticketSyncGuidance(for ticket: TicketSummary) -> TicketSyncGuidance { - let syncState = ticket.linearSyncUXState - if let reviewHint = syncState.reviewHint { - return TicketSyncGuidance( - title: "Needs sync review", - message: "\(syncState.shortGuidance) \(reviewHint)", - tone: .warn - ) - } - - if !syncState.isLinkedToLinear { - if linearRouteStatus?.tokenResolved == true && !linearCreateHackTicketsEnabled { - return TicketSyncGuidance( - title: "Local ticket", - message: "This ticket stays in Hack until Create Hack tickets in Linear is enabled for this repo.", - tone: .neutral - ) - } - return TicketSyncGuidance( - title: "Local ticket", - message: syncState.shortGuidance, - tone: .neutral - ) - } - - switch syncState.authority { - case .hack: - return TicketSyncGuidance( - title: "Linked ticket", - message: "\(syncState.shortGuidance) Linked changes continue syncing in the background.", - tone: .neutral - ) - case .linear: - return TicketSyncGuidance( - title: "Linked ticket", - message: "\(syncState.shortGuidance) Remote updates keep flowing in automatically.", - tone: .neutral - ) - } - } - - private func ticketSyncGuidance(for detail: TicketDetailResponse) -> TicketSyncGuidance { - let review = detail.linearSyncReviewState - return TicketSyncGuidance( - title: review.title, - message: review.message, - tone: review.severity == .conflict || review.severity == .review ? .warn : .neutral - ) - } - - private func ticketDetailFooter(_ detail: TicketDetailResponse) -> some View { - let canPushThisTicket = canPushTicketToLinear(detail.ticket) - let isLinkedToLinear = detail.ticket.externalKey?.isEmpty == false - - return VStack(alignment: .leading, spacing: 12) { - HStack(spacing: 10) { - if let nextReviewQueueEntry { - Button { - selectedFilter = .reviewQueue - selectedTicketId = nextReviewQueueEntry.ticketId - } label: { - Label("Next review", systemImage: "arrow.right.circle") - } - .adaptiveToolbarButton() - } - - if canPushThisTicket && !isLinkedToLinear { - Button { - Task { await syncSelectedTicketToLinear(detail.ticket) } - } label: { - Label("Create in Linear", systemImage: "arrow.up.right") - } - .adaptiveToolbarButtonProminent() - .disabled(!canPushThisTicket || isAnySyncInFlight) - } else if !isLinkedToLinear { - Button { - openProjectRouting() - } label: { - Label("Ticket sync settings", systemImage: "slider.horizontal.3") - } - .adaptiveToolbarButton() - } - - if let externalURL = detail.ticket.externalUrl, - let url = URL(string: externalURL) { - Button { - openURL(url) - } label: { - Label("Open issue", systemImage: "link") - } - .adaptiveToolbarButton() - } - - Spacer() - } - - bottomPinnedDisclosure(title: "History", isExpanded: $isHistoryExpanded) { - if detail.events.isEmpty { - Text("No history events for this ticket.") - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } else { - VStack(alignment: .leading, spacing: 8) { - ForEach(detail.events) { event in - HStack(spacing: 8) { - Text(event.type.replacingOccurrences(of: ".", with: " ")) - .textCase(nil) - .multilineTextAlignment(.leading) - .lineLimit(1) - .truncationMode(.tail) - .font(.system(size: 13, weight: .medium)) - .foregroundStyle(.primary) - Spacer() - Text(humanReadableDetailDate(event.tsIso)) - .font(.system(size: 12, weight: .medium)) - .foregroundStyle(.secondary) - } - } - } - } - } - - Divider() - .opacity(0.25) - - bottomPinnedDisclosure(title: "Properties", isExpanded: $isPropertiesExpanded) { - VStack(alignment: .leading, spacing: 14) { - inspectorGroup(title: "Links", rows: inspectorLinkRows(for: detail.ticket)) - inspectorGroup(title: "Dependencies", rows: inspectorDependencyRows(for: detail.ticket)) - } - } - } - .frame(maxWidth: .infinity, alignment: .leading) - } - - @ViewBuilder - private func bottomPinnedDisclosure( - title: String, - isExpanded: Binding, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 8) { - if isExpanded.wrappedValue { - content() - .transition(.opacity.combined(with: .move(edge: .bottom))) - } - Button { - withAnimation(.easeInOut(duration: 0.16)) { - isExpanded.wrappedValue.toggle() - } - } label: { - HStack(spacing: 6) { - Image(systemName: isExpanded.wrappedValue ? "chevron.down" : "chevron.right") - .font(.system(size: 10, weight: .semibold)) - .foregroundStyle(.secondary) - Text(title) - .font(.system(size: 13, weight: .semibold)) - .foregroundStyle(.secondary) - Spacer(minLength: 0) - } - .contentShape(Rectangle()) - } - .buttonStyle(.plain) - } - } - - private func markdownBody(_ markdown: String) -> some View { - Markdown(markdown) - .markdownTheme(.gitHub) - .markdownTextStyle { - FontFamilyVariant(.monospaced) - FontSize(.em(0.95)) - } - .markdownCodeSyntaxHighlighter(TicketMarkdownCodeSyntaxHighlighter(colorScheme: colorScheme)) - .frame(maxWidth: .infinity, alignment: .leading) - .padding(.vertical, 2) - .textSelection(.enabled) - } - - private func statusMenu(ticket: TicketSummary) -> some View { - Menu { - ForEach(TicketStatus.allCases, id: \.self) { status in - Button(status.label) { - Task { - await updateStatus(ticketId: ticket.ticketId, status: status) - } - } - } - } label: { - HStack(spacing: 6) { - Circle() - .fill(ticket.status.color) - .frame(width: 8, height: 8) - Text(ticket.status.label) - .font(.system(size: 12, weight: .semibold)) - } - .padding(.horizontal, 10) - .padding(.vertical, 6) - .background( - Capsule(style: .continuous) - .fill(ticketBadgeFill(for: ticket.status)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(ticketBadgeStroke(for: ticket.status), lineWidth: 1) - ) - } - .menuStyle(.borderlessButton) - .help("Change status") - } - - private func ticketsErrorCard(message: String) -> some View { - VStack(alignment: .leading, spacing: 8) { - HStack(spacing: 8) { - Image(systemName: "exclamationmark.triangle.fill") - .foregroundStyle(.orange) - Text("Tickets unavailable") - .font(.mono(.subheadline, weight: .medium)) - } - Text(message) - .font(.mono(.caption)) - .foregroundStyle(.secondary) - if message.localizedCaseInsensitiveContains("invalid json") { - Text("Run `hack tickets setup` in this repo, then refresh.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - if message.localizedCaseInsensitiveContains("extension") { - Text("Enable tickets in this project, then refresh.") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - } - HStack(spacing: 10) { - Button("Retry") { - Task { await refreshTickets() } - } - .adaptiveToolbarButton() - - if message.localizedCaseInsensitiveContains("invalid json") - || message.localizedCaseInsensitiveContains("extension") - { - Button("Run setup") { - Task { - _ = await model.setupTickets(for: project) - await refreshTickets() - } - } - .adaptiveToolbarButton() - } - } - } - .padding(12) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - RoundedRectangle(cornerRadius: 12, style: .continuous) - .fill(.thinMaterial) - ) - } - - private var skeletonList: some View { - VStack(alignment: .leading, spacing: 8) { - ForEach(0..<6, id: \.self) { _ in - VStack(alignment: .leading, spacing: 6) { - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.secondary.opacity(0.2)) - .frame(height: 12) - RoundedRectangle(cornerRadius: 6, style: .continuous) - .fill(Color.secondary.opacity(0.2)) - .frame(width: 120, height: 10) - } - .padding(.horizontal, 12) - .padding(.vertical, 8) - } - } - .redacted(reason: .placeholder) - } - - private func selectionBackground(for ticketId: String) -> Color { - if ticketId == selectedTicketId { - return Color.accentColor.opacity(0.2) - } - if ticketId == hoveredTicketId { - return colorScheme == .dark ? Color.white.opacity(0.06) : Color.black.opacity(0.04) - } - return .clear - } - - private func selectionBorder(for ticketId: String) -> Color { - if ticketId == selectedTicketId { - return Color.accentColor.opacity(0.35) - } - if ticketId == hoveredTicketId { - return Color.primary.opacity(0.16) - } - return Color.primary.opacity(0.08) - } - - private func refreshTickets() async { - refreshGeneration += 1 - let generation = refreshGeneration - let previousSelectedTicketId = selectedTicketId - isLoading = true - errorMessage = nil - let result = await loadTicketsWithTimeout(seconds: 12) - guard generation == refreshGeneration else { - return - } - switch result { - case let .success(fetched): - tickets = fetched - updateSelectionAfterRefresh() - if selectedTicketId != nil, - selectedTicketId == previousSelectedTicketId - { - await loadTicketDetail() - } - hasLoadedOnce = true - persistCachedTickets() - case .timedOut: - if hasLoadedOnce { - loadNotice = "Showing cached tickets. Refresh timed out." - } else { - errorMessage = "Timed out loading tickets. Try Sync, or open a shell and run `hack x tickets list --json` (note the `x`)." - } - case let .failure(message): - errorMessage = message - } - isLoading = false - } - - private func loadTicketDetail() async { - guard let selectedTicketId else { - ticketDetail = nil - detailErrorMessage = nil - isDetailLoading = false - return - } - isDetailLoading = true - detailErrorMessage = nil - isHistoryExpanded = false - isPropertiesExpanded = false - do { - let detail = try await model.showTicket(for: project, ticketId: selectedTicketId) - ticketDetail = detail - ticketDetailCache[selectedTicketId] = detail - } catch { - detailErrorMessage = error.localizedDescription - } - isDetailLoading = false - } - - private func updateSelectionAfterRefresh() { - let filtered = filteredTickets - if let selectedTicketId, - !filtered.contains(where: { $0.ticketId == selectedTicketId }) { - self.selectedTicketId = filtered.first?.ticketId - if self.selectedTicketId == nil { - ticketDetail = nil - detailErrorMessage = nil - } - } - } - - private func createTicket(_ input: TicketCreateInput) async { - guard !input.title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty else { return } - let created = await model.createTicket( - for: project, - title: input.title.trimmingCharacters(in: .whitespacesAndNewlines), - body: input.body.trimmingCharacters(in: .whitespacesAndNewlines), - dependsOn: parseTicketRefs(input.dependsOn), - blocks: parseTicketRefs(input.blocks) - ) - if let created { - showCreateSheet = false - await refreshTickets() - selectedTicketId = created.ticketId - } - } - - private func updateStatus(ticketId: String, status: TicketStatus) async { - _ = await model.setTicketStatus(for: project, ticketId: ticketId, status: status) - await refreshTickets() - } - - private func runSyncAction( - _ action: TicketSyncAction, - operation: () async -> Void - ) async { - activeSyncAction = action - defer { activeSyncAction = nil } - await operation() - } - - private func refreshLinearRouting() async { - async let binding = model.inspectLinearProjectBinding(for: project) - async let defaultProfile = model.getGlobalConfig( - key: "controlPlane.extensions[\"dance.hack.linear\"].config.defaultProfile" - ) - async let syncFromLinearConfig = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.syncFromLinear" - ) - async let createHackTicketsConfig = model.getProjectConfig( - for: project, - key: "controlPlane.routing.overrides.linear.createHackTicketsInLinear" - ) - - let resolvedBinding = await binding - let resolvedProjectProfile = (resolvedBinding?.profileId ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - let resolvedDefaultProfile = (await defaultProfile ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - linearSyncFromLinearEnabled = parseProjectBooleanConfig( - await syncFromLinearConfig, - default: true - ) - linearCreateHackTicketsEnabled = parseProjectBooleanConfig( - await createHackTicketsConfig, - default: false - ) - linearRouteProfile = resolvedProjectProfile.isEmpty ? resolvedDefaultProfile : resolvedProjectProfile - linearRouteProjectId = (resolvedBinding?.projectId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - linearRouteProjectName = (resolvedBinding?.projectName ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - linearRouteTeamId = (resolvedBinding?.teamId ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - linearAdditionalProjects = resolvedBinding?.additionalProjects ?? [] - linearRouteStatus = await model.inspectLinearStatus( - profileId: linearRouteProfile.isEmpty ? nil : linearRouteProfile - ) - linearAutosyncRouteKeys = await loadLinearAutosyncRouteKeys(targets: allLinearRouteTargets) - } - - private func parseProjectBooleanConfig(_ value: String?, default defaultValue: Bool) -> Bool { - guard let value else { - return defaultValue - } - switch value.trimmingCharacters(in: .whitespacesAndNewlines).lowercased() { - case "true", "1", "yes", "on": - return true - case "false", "0", "no", "off": - return false - default: - return defaultValue - } - } - - private func loadLinearAutosyncRouteKeys( - targets: [LinearProjectBindingTarget] - ) async -> Set { - var nextKeys: Set = [] - for target in targets { - let normalizedTarget = normalizedLinearRouteTarget(target) - guard let profileId = normalizedTarget.profileId, !profileId.isEmpty else { - continue - } - guard let subscriptions = await model.listLinearAutosyncSubscriptions( - profileId: profileId, - projectId: normalizedTarget.projectId, - teamId: normalizedTarget.teamId - ) else { - continue - } - if subscriptions.subscriptions.contains(where: { - $0.projectId == normalizedTarget.projectId && - $0.teamId == normalizedTarget.teamId && - $0.mode == "auto_apply" && - $0.status == "active" - }) { - nextKeys.insert(linearRouteKey(for: normalizedTarget)) - } - } - return nextKeys - } - - private func syncProjectFromLinear() async { - await runSyncAction(.pullProjectFromLinear) { - loadNotice = nil - let result = await model.syncLinearProject( - for: project, - from: "linear", - ownerMode: nil, - projectId: nil, - teamId: nil, - limit: nil, - syncLabels: nil - ) - guard let result, result.ok else { - loadNotice = model.errorMessage ?? "Linear sync failed." - return - } - let routedProjects = result.projectIds?.count ?? (linearAdditionalProjects.isEmpty ? 1 : linearAdditionalProjects.count + 1) - loadNotice = "Reconciled \(result.processed) ticket\(result.processed == 1 ? "" : "s") against \(routedProjects) Linear project\(routedProjects == 1 ? "" : "s")." - await refreshLinearRouting() - await refreshTickets() - } - } - - private func runBackgroundSyncCycle() async { - guard !isAnySyncInFlight else { - return - } - - if linearSyncFromLinearEnabled, canSyncProjectFromLinear { - let autosyncResult = await model.runLinearAutosync(for: project) - if let autosyncResult, - autosyncResult.ok, - autosyncResult.processedDeliveries > 0 || - autosyncResult.created > 0 || - autosyncResult.updated > 0 - { - await refreshLinearRouting() - await refreshTickets() - } - } - - guard !isAnySyncInFlight else { - return - } - - if canSyncLinkedTicketsToLinear { - let linkedUpdateResult = await model.syncLinearProject( - for: project, - from: "hack", - ownerMode: "linear", - projectId: nil, - teamId: nil, - limit: nil, - syncLabels: nil - ) - if let linkedUpdateResult, - linkedUpdateResult.ok, - linkedUpdateResult.processed > 0 || linkedUpdateResult.updated > 0 - { - await refreshLinearRouting() - await refreshTickets() - } - } - - guard !isAnySyncInFlight else { - return - } - - if linearCreateHackTicketsEnabled, canSyncHackOwnedTicketsToLinear { - let outboundCreateResult = await model.syncLinearProject( - for: project, - from: "hack", - ownerMode: "hack", - projectId: nil, - teamId: nil, - limit: nil, - syncLabels: nil - ) - if let outboundCreateResult, - outboundCreateResult.ok, - outboundCreateResult.processed > 0 || outboundCreateResult.created > 0 - { - await refreshLinearRouting() - await refreshTickets() - } - } - } - - private func syncSelectedTicketToLinear(_ ticket: TicketSummary) async { - await runSyncAction(.pushTicketToLinear(ticket.ticketId)) { - loadNotice = nil - let result = await model.syncLinearIssue( - for: project, - from: "hack", - issueIdentifier: nil, - ticketId: ticket.ticketId, - projectId: nil, - teamId: nil, - syncLabels: nil - ) - guard let result, result.ok else { - loadNotice = model.errorMessage ?? "Ticket sync failed." - return - } - loadNotice = - result.operation == "created" - ? "Created \(result.issueIdentifier) for \(result.ticketId)." - : "Updated \(result.issueIdentifier) from \(result.ticketId)." - await refreshLinearRouting() - await refreshTickets() - } - } - - private func syncSelectedTicketFromLinear(_ ticket: TicketSummary) async { - guard let issueIdentifier = ticket.externalKey, !issueIdentifier.isEmpty else { - loadNotice = "This ticket is not linked to a Linear issue yet." - return - } - await runSyncAction(.pullTicketFromLinear(ticket.ticketId)) { - loadNotice = nil - let result = await model.syncLinearIssue( - for: project, - from: "linear", - issueIdentifier: issueIdentifier, - ticketId: nil, - projectId: nil, - teamId: nil, - syncLabels: nil - ) - guard let result, result.ok else { - loadNotice = model.errorMessage ?? "Ticket refresh failed." - return - } - loadNotice = "Reconciled \(result.ticketId) with \(result.issueIdentifier)." - await refreshLinearRouting() - await refreshTickets() - } - } - - private func resolveTicketConflict( - _ conflict: TicketSyncConflict, - resolution: TicketSyncConflictResolution, - summary: String? = nil - ) async { - guard !resolvingConflictIds.contains(conflict.conflictId) else { - return - } - resolvingConflictIds.insert(conflict.conflictId) - defer { resolvingConflictIds.remove(conflict.conflictId) } - - let result = await model.resolveTicketConflict( - for: project, - ticketId: conflict.ticketId, - conflictId: conflict.conflictId, - resolution: resolution, - summary: summary - ) - guard result?.ok == true else { - loadNotice = model.errorMessage ?? "Failed to resolve ticket conflict." - return - } - - loadNotice = "Resolved \(conflict.field) conflict by \(ticketConflictResolutionLabel(resolution))." - await refreshTickets() - await loadTicketDetail() - } - - private func ticketConflictResolutionLabel( - _ resolution: TicketSyncConflictResolution - ) -> String { - switch resolution { - case .acceptLocal: - return "keeping Hack" - case .acceptRemote: - return "keeping Linear" - case .merged: - return "marking it merged" - case .ignore: - return "ignoring it" - } - } - - private func activateReviewQueue() { - selectedFilter = .reviewQueue - if selectedTicketId == nil || !filteredTickets.contains(where: { $0.ticketId == selectedTicketId }) { - selectedTicketId = reviewQueueEntries.first?.ticketId - } - ticketsListFocused = true - } - - private func reviewNotes(for ticket: TicketSummary) -> [TicketReviewNote] { - if let detail = ticketDetail, detail.ticket.ticketId == ticket.ticketId { - return detail.reviewNotes - } - return ticketDetailCache[ticket.ticketId]?.reviewNotes ?? [] - } - - private func reviewDraft(for ticket: TicketSummary) -> String { - reviewComposerDrafts[ticket.ticketId] ?? "" - } - - private func reviewDraftBinding(for ticket: TicketSummary) -> Binding { - let key = ticket.ticketId - return Binding( - get: { reviewComposerDrafts[key] ?? "" }, - set: { newValue in - reviewComposerDrafts[key] = newValue - } - ) - } - - private func applyReviewDraftTemplate(for detail: TicketDetailResponse) { - reviewComposerDrafts[detail.ticket.ticketId] = TicketReviewComposer.draft(for: detail) - } - - private func appendQuotedCommentToReviewDraft(_ comment: TicketComment, ticket: TicketSummary) { - let quote = TicketReviewComposer.quote(comment: comment) - appendToReviewDraft(quote, ticket: ticket) - } - - private func stageConflictReviewDraft(_ conflict: TicketSyncConflict) { - guard let detail = ticketDetail, detail.ticket.ticketId == conflict.ticketId else { - return - } - let draft = TicketReviewComposer.draft(for: detail, highlightedConflict: conflict) - reviewComposerDrafts[detail.ticket.ticketId] = draft - } - - private func appendToReviewDraft(_ text: String, ticket: TicketSummary) { - let key = ticket.ticketId - let existing = reviewComposerDrafts[key]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return - } - if existing.isEmpty { - reviewComposerDrafts[key] = trimmed - } else { - reviewComposerDrafts[key] = "\(existing)\n\n\(trimmed)" - } - } - - private func postReviewDraftAsReviewNote(for ticket: TicketSummary) async { - let key = ticket.ticketId - let trimmed = (reviewComposerDrafts[key] ?? "").trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - loadNotice = "Draft a review note before saving it." - return - } - guard !postingReviewNoteTicketIds.contains(ticket.ticketId) else { - return - } - - postingReviewNoteTicketIds.insert(ticket.ticketId) - defer { postingReviewNoteTicketIds.remove(ticket.ticketId) } - - let response = await model.appendTicketReviewNote( - for: project, - ticketId: ticket.ticketId, - body: trimmed - ) - guard response != nil else { - loadNotice = model.errorMessage ?? "Failed to save review note." - return - } - - reviewComposerDrafts[key] = "" - loadNotice = "Saved a shared review note to \(ticket.ticketId)." - await refreshTickets() - await loadTicketDetail() - } - - private func postReviewDraftAsTicketComment(for ticket: TicketSummary) async { - let key = ticket.ticketId - let trimmed = (reviewComposerDrafts[key] ?? "") - .trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - loadNotice = "Draft a review comment before posting it." - return - } - guard !postingCommentTicketIds.contains(ticket.ticketId) else { - return - } - - postingCommentTicketIds.insert(ticket.ticketId) - defer { postingCommentTicketIds.remove(ticket.ticketId) } - - let response = await model.appendTicketComment( - for: project, - ticketId: ticket.ticketId, - body: trimmed, - source: "hack" - ) - guard response != nil else { - loadNotice = model.errorMessage ?? "Failed to append ticket comment." - return - } - - reviewComposerDrafts[key] = "" - loadNotice = "Posted an immutable Hack comment to \(ticket.ticketId)." - await refreshTickets() - await loadTicketDetail() - } - - private func clearReviewDraft(for ticket: TicketSummary) { - reviewComposerDrafts[ticket.ticketId] = "" - } - - private func copyReviewDraft(for ticket: TicketSummary) { - let trimmed = reviewDraft(for: ticket).trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - loadNotice = "Draft a review comment before copying it." - return - } - copyToPasteboard(trimmed) - loadNotice = "Copied review draft." - } - - private func copyToPasteboard(_ text: String) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(text, forType: .string) - } - - private func parseTicketRefs(_ text: String) -> [String] { - let parts = text - .split { $0 == "," || $0 == " " || $0 == "\n" || $0 == "\t" } - .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } - .filter { !$0.isEmpty } - return Array(Set(parts)).sorted() - } - - private func ticketCount(for filter: TicketFilter) -> Int { - switch filter { - case .reviewQueue: - return reviewQueueCount - default: - return filter.count(in: tickets) - } - } - - private var filteredTickets: [TicketSummary] { - let originFiltered = tickets.filter { selectedOriginFilter.matches(ticket: $0) } - let base: [TicketSummary] - switch selectedFilter { - case .all: - base = originFiltered - case .open: - base = originFiltered.filter { $0.status == .open } - case .inProgress: - base = originFiltered.filter { $0.status == .inProgress } - case .blocked: - base = originFiltered.filter { $0.status == .blocked } - case .done: - base = originFiltered.filter { $0.status == .done } - case .reviewQueue: - let visibleTicketsById = Dictionary( - uniqueKeysWithValues: originFiltered.map { ($0.ticketId, $0) } - ) - base = reviewQueueEntries.compactMap { visibleTicketsById[$0.ticketId] } - } - - let trimmed = searchText.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { return base } - return base.filter { - $0.title.localizedStandardContains(trimmed) || $0.ticketId.localizedStandardContains(trimmed) - } - } - - @ViewBuilder - private func ticketSection(status: TicketStatus) -> some View { - let items = filteredTickets.filter { $0.status == status } - if !items.isEmpty { - Section(content: { - VStack(alignment: .leading, spacing: 0) { - ForEach(items) { ticket in - ticketRow(ticket) - } - } - }, header: { - HStack(spacing: 8) { - Circle() - .fill(status.color) - .frame(width: 8, height: 8) - Text(status.label) - .font(.system(size: 13, weight: .semibold, design: .monospaced)) - Text("\(items.count)") - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundStyle(.secondary) - } - .padding(.vertical, 6) - .padding(.horizontal, 8) - .frame(maxWidth: .infinity, alignment: .leading) - .background( - Rectangle() - .fill(status.color.opacity(colorScheme == .dark ? 0.14 : 0.07)) - ) - .overlay(alignment: .bottom) { - Rectangle() - .fill(Color.primary.opacity(0.12)) - .frame(height: 1) - } - .zIndex(1) - }) - .textCase(nil) - } - } - - private func inspectorGroup(title: String, rows: [DetailRowItem]) -> some View { - VStack(alignment: .leading, spacing: 6) { - Text(title) - .font(.system(size: 12, weight: .semibold, design: .monospaced)) - .foregroundStyle(.secondary) - DetailRows(rows: rows, labelWidth: 88) - } - } - - private func inspectorDateRows(for ticket: TicketSummary) -> [DetailRowItem] { - [ - DetailRowItem(label: "Created", value: humanReadableDetailDate(ticket.createdAt)), - DetailRowItem(label: "Updated", value: humanReadableDetailDate(ticket.updatedAt)) - ] - } - - private func inspectorLinkRows(for ticket: TicketSummary) -> [DetailRowItem] { - [ - DetailRowItem(label: "ID", value: ticket.ticketId), - DetailRowItem(label: "Status", value: ticket.status.label), - DetailRowItem(label: "Owner", value: ticket.owner), - DetailRowItem(label: "Source", value: ticket.source), - DetailRowItem(label: "Assignee", value: ticket.assignee ?? "—"), - DetailRowItem(label: "Tags", value: ticket.tags.joined(separator: ", ").nilIfEmpty ?? "—"), - DetailRowItem(label: "Remote", value: ticket.externalSystem ?? "—"), - DetailRowItem(label: "Issue key", value: ticket.externalKey ?? "—"), - DetailRowItem(label: "Issue id", value: ticket.externalId ?? "—"), - DetailRowItem(label: "Linear URL", value: ticket.externalUrl ?? "—"), - DetailRowItem(label: "Project", value: ticket.externalProjectName ?? ticket.externalProjectId ?? "—"), - DetailRowItem(label: "Team", value: ticket.externalTeamId ?? "—") - ] - } - - private func inspectorDependencyRows(for ticket: TicketSummary) -> [DetailRowItem] { - [ - DetailRowItem(label: "Depends", value: ticket.dependsOn.joined(separator: ", ").nilIfEmpty ?? "—"), - DetailRowItem(label: "Blocks", value: ticket.blocks.joined(separator: ", ").nilIfEmpty ?? "—") - ] - } - - private func moveSelection(_ direction: MoveCommandDirection) { - let visibleTickets = filteredTickets - guard !visibleTickets.isEmpty else { return } - let nextTicket: TicketSummary? - switch direction { - case .up: - if let selectedTicketId, - let index = visibleTickets.firstIndex(where: { $0.ticketId == selectedTicketId }) { - nextTicket = visibleTickets[max(0, index - 1)] - } else { - nextTicket = visibleTickets.last - } - case .down: - if let selectedTicketId, - let index = visibleTickets.firstIndex(where: { $0.ticketId == selectedTicketId }) { - nextTicket = visibleTickets[min(visibleTickets.count - 1, index + 1)] - } else { - nextTicket = visibleTickets.first - } - default: - return - } - selectedTicketId = nextTicket?.ticketId - } - - private func cacheKey() -> String { - let projectPath = project.repoRoot ?? project.projectDir ?? project.id - return "tickets.cache.\(project.id).\(projectPath)" - } - - private func loadCachedTickets() { - let key = cacheKey() - guard let data = UserDefaults.standard.data(forKey: key) else { return } - let decoder = JSONDecoder() - if let payload = try? decoder.decode(TicketCachePayload.self, from: data) { - guard Date().timeIntervalSince(payload.updatedAt) <= Self.ticketCacheTTL else { - UserDefaults.standard.removeObject(forKey: key) - return - } - tickets = payload.tickets - hasLoadedOnce = true - updateSelectionAfterRefresh() - } - } - - private func persistCachedTickets() { - let payload = TicketCachePayload(updatedAt: Date(), tickets: tickets) - let encoder = JSONEncoder() - guard let data = try? encoder.encode(payload) else { return } - UserDefaults.standard.set(data, forKey: cacheKey()) - } - - private enum TicketLoadResult { - case success([TicketSummary]) - case timedOut - case failure(String) - } - - private func loadTicketsWithTimeout(seconds: Double) async -> TicketLoadResult { - await withTaskGroup(of: TicketLoadResult.self) { group in - group.addTask { - do { - let fetched = try await model.listTickets(for: project) - return .success(fetched) - } catch { - return .failure(error.localizedDescription) - } - } - group.addTask { - try? await Task.sleep(for: .seconds(seconds)) - return .timedOut - } - - guard let result = await group.next() else { - return .failure("No response") - } - group.cancelAll() - return result - } - } - - private func ticketStatusBadge(_ status: TicketStatus) -> some View { - Text(status.label) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(status.color) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(ticketBadgeFill(for: status)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(ticketBadgeStroke(for: status), lineWidth: 1) - ) - } - - private func ticketMetaPill(_ text: String, tone: Color) -> some View { - Text(text) - .font(.system(size: 11, weight: .semibold)) - .padding(.horizontal, 8) - .padding(.vertical, 4) - .background( - Capsule(style: .continuous) - .fill(tone.opacity(colorScheme == .dark ? 0.18 : 0.1)) - ) - .overlay( - Capsule(style: .continuous) - .stroke(tone.opacity(colorScheme == .dark ? 0.34 : 0.2), lineWidth: 1) - ) - .foregroundStyle(tone) - } - - @ViewBuilder - private func ticketConflictValueRow(label: String, value: String?) -> some View { - if let value, !value.isEmpty { - VStack(alignment: .leading, spacing: 4) { - Text(label) - .font(.system(size: 11, weight: .semibold)) - .foregroundStyle(.secondary) - Text(value) - .font(.system(size: 12, weight: .medium, design: .monospaced)) - .foregroundStyle(.primary) - .textSelection(.enabled) - } - } - } - - private func reviewToneColor(_ severity: TicketSyncReviewSeverity) -> Color { - switch severity { - case .clear: - return .green - case .review: - return .orange - case .conflict: - return .orange - } - } - - private func ticketHeaderBadge( - title: String, - systemImage: String, - tone: Color - ) -> some View { - HStack(spacing: 6) { - Image(systemName: systemImage) - .font(.system(size: 11, weight: .semibold)) - Text(title) - .font(.system(size: 12, weight: .semibold)) - .lineLimit(1) - } - .padding(.horizontal, 10) - .padding(.vertical, 7) - .background { - Capsule(style: .continuous) - .fill(tone.opacity(colorScheme == .dark ? 0.16 : 0.08)) - .overlay( - Capsule(style: .continuous) - .stroke(tone.opacity(colorScheme == .dark ? 0.28 : 0.18), lineWidth: 1) - ) - } - .foregroundStyle(tone) - } - - private func openProjectRouting() { - model.selectedItem = .project(project.id) - NotificationCenter.default.post( - name: .hackProjectRoutingRequested, - object: nil, - userInfo: [ - ProjectRoutingRequest.projectIdKey: project.id - ] - ) - } - - private func openLinearSettings() { - NotificationCenter.default.post( - name: .hackSettingsRequested, - object: nil, - userInfo: [SettingsNavigationRequest.paneKey: SettingsSidebarItem.linear.rawValue] - ) - } - - private func ticketPanel( - contentPadding: CGFloat = 14, - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - content() - } - .padding(contentPadding) - .background( - ZStack { - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(panelBaseFillColor) - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(.thinMaterial) - .opacity(colorScheme == .dark ? 0.42 : 0.64) - } - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(panelStrokeColor, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - - private func ticketDetailPanel( - @ViewBuilder content: () -> Content - ) -> some View { - VStack(alignment: .leading, spacing: 0) { - content() - } - .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) - .background( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .fill(ticketDetailPanelFillColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 16, style: .continuous) - .stroke(ticketDetailPanelStrokeColor, lineWidth: 1) - ) - .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) - } - - private var newTicketButton: some View { - Button { - showCreateSheet = true - } label: { - HStack(spacing: 6) { - Image(systemName: "plus") - .font(.system(size: 12, weight: .bold)) - Text("New") - .font(.system(size: 13, weight: .semibold)) - } - .padding(.horizontal, 14) - .padding(.vertical, 7) - .foregroundStyle(.white) - .background( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(newButtonFillColor) - ) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(newButtonStrokeColor, lineWidth: 1) - ) - .shadow(color: .black.opacity(colorScheme == .dark ? 0.35 : 0.14), radius: 6, y: 2) - } - .buttonStyle(.plain) - } - - private var headerControlFillColor: Color { - colorScheme == .dark ? Color.white.opacity(0.08) : Color.white.opacity(0.76) - } - - private var headerControlStrokeColor: Color { - colorScheme == .dark ? Color.white.opacity(0.14) : Color.black.opacity(0.08) - } - - @ViewBuilder - private var headerControlCapsuleBackground: some View { - if colorScheme == .dark { - Capsule(style: .continuous) - .fill(.regularMaterial) - .overlay( - Capsule(style: .continuous) - .stroke(headerControlStrokeColor, lineWidth: 1) - ) - } else { - Capsule(style: .continuous) - .fill(headerControlFillColor) - .overlay( - Capsule(style: .continuous) - .stroke(headerControlStrokeColor, lineWidth: 1) - ) - } - } - - @ViewBuilder - private var headerControlFieldBackground: some View { - if colorScheme == .dark { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(.regularMaterial) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(headerControlStrokeColor, lineWidth: 1) - ) - } else { - RoundedRectangle(cornerRadius: 10, style: .continuous) - .fill(headerControlFillColor) - .overlay( - RoundedRectangle(cornerRadius: 10, style: .continuous) - .stroke(headerControlStrokeColor, lineWidth: 1) - ) - } - } - - private var panelBaseFillColor: Color { - colorScheme == .dark ? Color.black.opacity(0.46) : Color.white.opacity(0.72) - } - - private var panelStrokeColor: Color { - colorScheme == .dark ? Color.white.opacity(0.14) : Color.white.opacity(0.82) - } - - private var ticketDetailPanelFillColor: Color { - colorScheme == .dark ? Color.black.opacity(0.66) : Color.white - } - - private var ticketDetailPanelStrokeColor: Color { - colorScheme == .dark ? Color.white.opacity(0.12) : Color.black.opacity(0.08) - } - - private var ticketDetailFooterFillColor: Color { - colorScheme == .dark ? Color.black.opacity(0.18) : Color.white - } - - private var newButtonFillColor: Color { - colorScheme == .dark ? Color.accentColor.opacity(0.94) : Color.accentColor - } - - private var newButtonStrokeColor: Color { - colorScheme == .dark ? Color.white.opacity(0.16) : Color.black.opacity(0.08) - } - - private func ticketBadgeFill(for status: TicketStatus) -> Color { - if colorScheme == .dark { - return status.color.opacity(0.28) - } - return status.color.opacity(0.14) - } - - private func ticketBadgeStroke(for status: TicketStatus) -> Color { - if colorScheme == .dark { - return status.color.opacity(0.42) - } - return status.color.opacity(0.24) - } - - private func humanReadableListDate(_ isoString: String) -> String { - guard let date = parseDate(isoString) else { - return isoString - } - return TicketDateFormatter.listFormatter.string(from: date) - } - - private func humanReadableDetailDate(_ isoString: String) -> String { - guard let date = parseDate(isoString) else { - return isoString - } - return TicketDateFormatter.detailFormatter.string(from: date) - } - - private func parseDate(_ value: String) -> Date? { - if let date = TicketDateFormatter.isoWithFractional.date(from: value) { - return date - } - return TicketDateFormatter.isoBasic.date(from: value) - } -} - -private struct TicketCachePayload: Codable { - let updatedAt: Date - let tickets: [TicketSummary] -} - -private enum TicketFilter: String, CaseIterable { - case all - case open - case inProgress = "in_progress" - case blocked - case done - case reviewQueue = "review_queue" - - var label: String { - switch self { - case .all: - return "All" - case .open: - return "Open" - case .inProgress: - return "In progress" - case .blocked: - return "Blocked" - case .done: - return "Done" - case .reviewQueue: - return "Review queue" - } - } - - func count(in tickets: [TicketSummary]) -> Int { - switch self { - case .all: - return tickets.count - case .open: - return tickets.filter { $0.status == .open }.count - case .inProgress: - return tickets.filter { $0.status == .inProgress }.count - case .blocked: - return tickets.filter { $0.status == .blocked }.count - case .done: - return tickets.filter { $0.status == .done }.count - case .reviewQueue: - return tickets.filter { $0.linearSyncUXState.reviewHint != nil }.count - } - } -} - -private enum TicketOriginFilter: String, CaseIterable { - case both - case hack - case linear - - var label: String { - switch self { - case .both: - return "All origins" - case .hack: - return "Hack only" - case .linear: - return "Linear only" - } - } - - func matches(ticket: TicketSummary) -> Bool { - let owner = ticket.owner.lowercased() - let source = ticket.source.lowercased() - let externalSystem = ticket.externalSystem?.lowercased() ?? "" - switch self { - case .both: - return true - case .hack: - return owner == "hack" || source == "hack" - case .linear: - return owner == "linear" || source == "linear" || externalSystem == "linear" - } - } - - func count(in tickets: [TicketSummary]) -> Int { - tickets.filter { matches(ticket: $0) }.count - } -} - -private enum TicketSyncAction: Equatable { - case pullProjectFromLinear - case pushTicketToLinear(String) - case pullTicketFromLinear(String) - - var progressLabel: String { - switch self { - case .pullProjectFromLinear: - return "Reconciling Linear…" - case let .pushTicketToLinear(ticketId): - return "Syncing \(ticketId)…" - case let .pullTicketFromLinear(ticketId): - return "Reconciling \(ticketId)…" - } - } -} - -private struct TicketSyncGuidance { - let title: String - let message: String - let tone: StatusTone -} - -private enum TicketDateFormatter { - static let isoWithFractional: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] - return formatter - }() - - static let isoBasic: ISO8601DateFormatter = { - let formatter = ISO8601DateFormatter() - formatter.formatOptions = [.withInternetDateTime] - return formatter - }() - - static let listFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .none - formatter.doesRelativeDateFormatting = true - return formatter - }() - - static let detailFormatter: DateFormatter = { - let formatter = DateFormatter() - formatter.dateStyle = .medium - formatter.timeStyle = .short - formatter.doesRelativeDateFormatting = true - return formatter - }() -} - -private struct TicketCreateInput { - let title: String - let body: String - let dependsOn: String - let blocks: String -} - -private struct TicketCreateSheet: View { - let projectName: String - let onCreate: (TicketCreateInput) -> Void - - @Environment(\.dismiss) private var dismiss - @State private var title = "" - @State private var ticketBody = "" - @State private var dependsOn = "" - @State private var blocks = "" - - var body: some View { - VStack(alignment: .leading, spacing: 16) { - Text("New ticket") - .font(.mono(.title3, weight: .semibold)) - Text(projectName) - .font(.mono(.subheadline)) - .foregroundStyle(.secondary) - VStack(alignment: .leading, spacing: 12) { - TextField("Title", text: $title) - .textFieldStyle(.roundedBorder) - TextField("Depends on (comma-separated)", text: $dependsOn) - .textFieldStyle(.roundedBorder) - TextField("Blocks (comma-separated)", text: $blocks) - .textFieldStyle(.roundedBorder) - VStack(alignment: .leading, spacing: 6) { - Text("Body") - .font(.mono(.caption)) - .foregroundStyle(.secondary) - TextEditor(text: $ticketBody) - .frame(minHeight: 160) - .overlay( - RoundedRectangle(cornerRadius: 8, style: .continuous) - .stroke(Color.secondary.opacity(0.2), lineWidth: 1) - ) - } - } - HStack { - Button("Cancel") { - dismiss() - } - .adaptiveToolbarButton() - Spacer() - Button("Create") { - onCreate( - TicketCreateInput( - title: title, - body: ticketBody, - dependsOn: dependsOn, - blocks: blocks - ) - ) - } - .adaptiveToolbarButtonProminent() - .disabled(title.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - } - } - .padding(24) - .frame(minWidth: 420, idealWidth: 520) - } -} - -private extension TicketStatus { - var label: String { - switch self { - case .open: - return "Open" - case .inProgress: - return "In progress" - case .blocked: - return "Blocked" - case .done: - return "Done" - } - } - - var tone: StatusTone { - switch self { - case .open: - return .neutral - case .inProgress: - return .good - case .blocked: - return .warn - case .done: - return .good - } - } - - var color: Color { - switch self { - case .open: - return .blue - case .inProgress: - return .indigo - case .blocked: - return .orange - case .done: - return .green - } - } -} - -extension TicketStatus: CaseIterable { - public static var allCases: [TicketStatus] { - [.open, .inProgress, .blocked, .done] - } -} - -private extension String { - var nilIfEmpty: String? { - let trimmed = trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } -} diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift index 6ac79cc1..249a6e37 100644 --- a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -22,182 +22,7 @@ public enum HackCLIError: LocalizedError, Equatable { } } -public enum GitSystemIdentityScope: String, Hashable { - case global - case project -} - -public struct GitSystemIdentity: Hashable { - public let scope: GitSystemIdentityScope - public let gitName: String? - public let gitEmail: String? - public let githubLogin: String? - public let githubName: String? - public let githubId: String? - - public init( - scope: GitSystemIdentityScope, - gitName: String?, - gitEmail: String?, - githubLogin: String?, - githubName: String?, - githubId: String? - ) { - self.scope = scope - self.gitName = gitName - self.gitEmail = gitEmail - self.githubLogin = githubLogin - self.githubName = githubName - self.githubId = githubId - } -} - public actor HackCLIClient { - private static let authServerCandidates = [ - "https://auth.hack.broker", - "https://auth.hack.gy", - "https://auth.hack", - "http://127.0.0.1:7790", - ] - - private static let authRequestTimeoutSeconds: TimeInterval = 10 - - private struct BetterAuthStatusEnvelope: Decodable { - let enabled: Bool - let reason: String? - let basePath: String - } - - private struct HackAuthStatusEnvelope: Decodable { - let ok: Bool - let authenticated: Bool? - let tokenStored: Bool? - let validated: Bool? - let brokerBaseUrl: String? - let accessControlMode: String? - let authReason: String? - let error: String? - let user: HackAuthUserEnvelope? - let activeOrganization: HackAuthNamedEntityEnvelope? - let activeTeam: HackAuthNamedEntityEnvelope? - let shellPath: String? - let accountPath: String? - } - - private struct HackAuthUserEnvelope: Decodable { - let id: String? - let email: String? - let name: String? - let emailVerified: Bool? - } - - private struct HackAuthNamedEntityEnvelope: Decodable { - let id: String? - let name: String? - } - - private struct HackAuthLoginEnvelope: Decodable { - let ok: Bool - let authenticated: Bool? - let brokerBaseUrl: String? - let authorizeUrl: String? - let error: String? - let nextStep: String? - } - - private struct HackAuthLogoutEnvelope: Decodable { - let ok: Bool - let loggedOut: Bool? - let hadToken: Bool? - } - - private struct GitHubOAuthStartEnvelope: Decodable { - let ok: Bool - let flow: GitHubOAuthStartEnvelopeFlow - } - - private struct GitHubOAuthStartEnvelopeFlow: Decodable { - let flowId: String - let profileId: String - let setDefault: Bool - let requireInstallation: Bool? - let authorizeUrl: String - let deviceCode: String - let pollUrl: String - let appInstallUrl: String? - let appId: String? - let appSlug: String? - let expiresAt: String - } - - private struct GitHubOAuthStatusEnvelope: Decodable { - let ok: Bool - let status: GitHubOAuthStatusEnvelopeStatus - } - - private struct GitHubOAuthStatusEnvelopeStatus: Decodable { - let id: String - let status: String - let profileId: String - let setDefault: Bool - let createdAt: String - let expiresAt: String - let completedAt: String? - let claimedAt: String? - let accountLogin: String? - let accountName: String? - let accountId: String? - let installationId: String? - let installationIds: [String]? - let appInstallUrl: String? - let appId: String? - let appSlug: String? - let token: String? - let tokenExpiresAt: String? - let error: String? - } - - private struct LinearOAuthStartEnvelope: Decodable { - let ok: Bool - let flow: LinearOAuthStartEnvelopeFlow - } - - private struct LinearOAuthStartEnvelopeFlow: Decodable { - let flowId: String - let profileId: String - let setDefault: Bool - let authorizeUrl: String - let deviceCode: String - let pollUrl: String - let expiresAt: String - } - - private struct LinearOAuthStatusEnvelope: Decodable { - let ok: Bool - let status: LinearOAuthStatusEnvelopeStatus - } - - private struct LinearOAuthStatusEnvelopeStatus: Decodable { - let id: String - let status: String - let profileId: String - let setDefault: Bool - let createdAt: String - let expiresAt: String - let completedAt: String? - let claimedAt: String? - let accountHandle: String? - let accountLogin: String? - let accountName: String? - let accountId: String? - let accountEmail: String? - let token: String? - let tokenExpiresAt: String? - let refreshToken: String? - let refreshTokenExpiresAt: String? - let error: String? - } - public init() {} public func fetchProjects(includeGlobal: Bool) async throws -> ProjectListResponse { @@ -293,1148 +118,43 @@ public actor HackCLIClient { ["config", "get", key, "--global"], allowNonZeroExit: true ) - guard result.exitCode == 0 else { - return nil - } - let value = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - public func setProjectConfig( - key: String, - value: String, - projectPath: String - ) async throws { - _ = try await run(["config", "set", key, value], cwd: projectPath) - } - - public func getProjectConfigValue( - key: String, - projectPath: String - ) async throws -> String? { - let result = try await run( - ["config", "get", key], - allowNonZeroExit: true, - cwd: projectPath - ) - guard result.exitCode == 0 else { - return nil - } - let value = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - return value.isEmpty ? nil : value - } - - public func listGatewayTokens() async throws -> GatewayTokenListResponse { - let result = try await run(["x", "gateway", "token-list", "--json"], allowNonZeroExit: true) - return try decodeJsonOrThrow(GatewayTokenListResponse.self, result: result) - } - - public func createGatewayToken( - scope: GatewayTokenScope, - label: String? - ) async throws -> GatewayTokenCreateResponse { - var args = ["x", "gateway", "token-create", "--scope", scope.rawValue, "--json"] - if let label, !label.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--label", label]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(GatewayTokenCreateResponse.self, result: result) - } - - public func revokeGatewayToken(id: String) async throws -> GatewayTokenRevokeResponse { - let result = try await run(["x", "gateway", "token-revoke", id, "--json"], allowNonZeroExit: true) - return try decodeJsonOrThrow(GatewayTokenRevokeResponse.self, result: result) - } - - public func startCloudflareTunnel() async throws { - _ = try await run(["x", "cloudflare", "tunnel-start"]) - } - - public func stopCloudflareTunnel() async throws { - _ = try await run(["x", "cloudflare", "tunnel-stop"]) - } - - public func inspectTailscale() async throws -> TailscaleInspectResponse { - do { - let result = try await run(["x", "tailscale", "inspect", "--json"], allowNonZeroExit: true) - return try decodeJsonOrThrow(TailscaleInspectResponse.self, result: result) - } catch is CancellationError { - throw CancellationError() - } catch { - // If hack inspect cannot return machine JSON (stale CLI, disabled extension gate, etc), - // fall back to direct `tailscale status --json` so settings still reflect host reality. - return try await inspectTailscaleDirect() - } - } - - public func inspectTailscaleOAuthStatus( - validate: Bool = false - ) async throws -> TailscaleOAuthStatusResponse { - var args = ["x", "tailscale", "oauth-status", "--json"] - if validate { - args.append("--validate") - } - let result = try await run(args, allowNonZeroExit: true) - if let fallback = unsupportedTailscaleOAuthStatusResponse(result: result) { - return fallback - } - return try decodeJsonOrThrow(TailscaleOAuthStatusResponse.self, result: result) - } - - public func connectTailscaleOAuth( - request: TailscaleOAuthConnectRequest - ) async throws -> TailscaleOAuthStatusResponse { - var args = [ - "x", - "tailscale", - "oauth-connect", - "--json", - "--client-id", - request.clientId, - "--client-secret-stdin", - ] - if let value = normalized(request.authRef) { - args.append(contentsOf: ["--auth-ref", value]) - } - if let value = normalized(request.tailnet) { - args.append(contentsOf: ["--tailnet", value]) - } - if let value = request.keyExpirySeconds { - args.append(contentsOf: ["--key-expiry-seconds", String(value)]) - } - - let result = try await run( - args, - allowNonZeroExit: true, - stdin: "\(request.clientSecret)\n" - ) - return try decodeJsonOrThrow(TailscaleOAuthStatusResponse.self, result: result) - } - - public func disconnectTailscaleOAuth( - authRef: String? = nil - ) async throws -> TailscaleOAuthStatusResponse { - var args = ["x", "tailscale", "oauth-disconnect", "--json"] - if let value = normalized(authRef) { - args.append(contentsOf: ["--auth-ref", value]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(TailscaleOAuthStatusResponse.self, result: result) - } - - public func inspectRailway() async throws -> RailwayInspectResponse { - let environment = HackCLILocator.buildEnvironment() - guard let binaryPath = HackCLILocator.resolveExecutable(named: "railway", in: environment) else { - return RailwayInspectResponse( - installed: false, - binaryPath: nil, - version: nil, - authenticated: false, - whoami: nil, - error: "railway not found in PATH" - ) - } - - let versionResult = try await runExecutable( - executablePath: binaryPath, - args: ["--version"], - allowNonZeroExit: true, - cwd: nil - ) - let version = firstNonEmptyLine(versionResult.stdout) - ?? firstNonEmptyLine(versionResult.stderr) - - let whoamiResult = try await runExecutable( - executablePath: binaryPath, - args: ["whoami"], - allowNonZeroExit: true, - cwd: nil - ) - if whoamiResult.exitCode == 0 { - return RailwayInspectResponse( - installed: true, - binaryPath: binaryPath, - version: version, - authenticated: true, - whoami: firstNonEmptyLine(whoamiResult.stdout), - error: nil - ) - } - - let whoamiError = firstNonEmptyLine(whoamiResult.stderr) - ?? firstNonEmptyLine(whoamiResult.stdout) - ?? "railway whoami failed" - return RailwayInspectResponse( - installed: true, - binaryPath: binaryPath, - version: version, - authenticated: false, - whoami: nil, - error: whoamiError - ) - } - - public func inspectGitHubProfiles() async throws -> GitHubProfilesResponse { - let result = try await run( - ["x", "github", "profiles", "--json"], - allowNonZeroExit: true - ) - return try decodeJsonOrThrow(GitHubProfilesResponse.self, result: result) - } - - public func inspectGitHubStatus(profileId: String? = nil) async throws -> GitHubStatusResponse { - var args = ["x", "github", "status", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(GitHubStatusResponse.self, result: result) - } - - public func inspectLinearProfiles() async throws -> LinearProfilesResponse { - let result = try await run( - ["x", "linear", "profiles", "--json"], - allowNonZeroExit: true - ) - return try decodeJsonOrThrow(LinearProfilesResponse.self, result: result) - } - - public func listLinearConnections( - profileId: String? = nil, - organizationId: String? = nil - ) async throws -> LinearConnectionsResponse { - var args = ["x", "linear", "connections", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let organizationId, - !organizationId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - { - args.append(contentsOf: ["--organization-id", organizationId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearConnectionsResponse.self, result: result) - } - - public func seedLinearLocalAccess(profileId: String) async throws -> LinearLocalAccessSeedResponse { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - var args = ["x", "linear", "seed-local-access", "--json"] - if !trimmed.isEmpty { - args.append(contentsOf: ["--profile", trimmed]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearLocalAccessSeedResponse.self, result: result) - } - - public func inspectLinearStatus(profileId: String? = nil) async throws -> LinearStatusResponse { - var args = ["x", "linear", "status", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearStatusResponse.self, result: result) - } - - public func disconnectLinear(profileId: String) async throws { - let trimmed = profileId.trimmingCharacters(in: .whitespacesAndNewlines) - var args = ["x", "linear", "disconnect"] - if !trimmed.isEmpty { - args.append(contentsOf: ["--profile", trimmed]) - } - _ = try await run(args) - } - - public func listLinearProjects(profileId: String? = nil) async throws -> LinearProjectsResponse { - var args = ["x", "linear", "projects", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearProjectsResponse.self, result: result) - } - - public func listLinearAssigneeMappings( - profileId: String? = nil, - teamId: String? = nil - ) async throws -> LinearAssigneeMappingsResponse { - var args = ["x", "linear", "assignee-mappings", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAssigneeMappingsResponse.self, result: result) - } - - public func setLinearAssigneeMapping( - profileId: String? = nil, - teamId: String? = nil, - localAssignee: String, - linearUserId: String? = nil, - linearUserName: String? = nil, - linearUserEmail: String? = nil - ) async throws -> LinearAssigneeMappingMutationResponse { - var args = [ - "x", - "linear", - "set-assignee-mapping", - "--json", - "--local-assignee", - localAssignee, - ] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - if let linearUserId, !linearUserId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--linear-user-id", linearUserId]) - } - if let linearUserName, !linearUserName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--linear-user-name", linearUserName]) - } - if let linearUserEmail, !linearUserEmail.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--linear-user-email", linearUserEmail]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAssigneeMappingMutationResponse.self, result: result) - } - - public func removeLinearAssigneeMapping( - profileId: String? = nil, - teamId: String? = nil, - localAssignee: String - ) async throws -> LinearAssigneeMappingRemovalResponse { - var args = [ - "x", - "linear", - "remove-assignee-mapping", - "--json", - "--local-assignee", - localAssignee, - ] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAssigneeMappingRemovalResponse.self, result: result) - } - - public func listLinearAutosyncSubscriptions( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil - ) async throws -> LinearAutosyncSubscriptionsResponse { - var args = ["x", "linear", "subscriptions", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAutosyncSubscriptionsResponse.self, result: result) - } - - public func setLinearAutosyncSubscription( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - mode: String = "auto_apply", - status: String = "active" - ) async throws -> LinearAutosyncSubscriptionMutationResponse { - var args = ["x", "linear", "set-subscription", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - args.append(contentsOf: ["--mode", mode, "--status", status]) - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAutosyncSubscriptionMutationResponse.self, result: result) - } - - public func removeLinearAutosyncSubscription( - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil - ) async throws -> LinearAutosyncSubscriptionMutationResponse { - var args = ["x", "linear", "remove-subscription", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearAutosyncSubscriptionMutationResponse.self, result: result) - } - - public func bindLinearProject( - path: String, - profileId: String?, - projectId: String?, - projectName: String?, - teamId: String?, - clear: Bool - ) async throws -> LinearProjectBindingResponse { - var args = ["x", "linear", "project-bind", "--json"] - if clear { - args.append("--clear") - } - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let projectName, !projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-name", projectName]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) - } - - public func inspectLinearProjectBinding(path: String) async throws -> LinearProjectBindingResponse { - let result = try await run(["x", "linear", "project-bind", "--json"], allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) - } - - public func linkLinearProject( - path: String, - profileId: String?, - projectId: String, - projectName: String?, - teamId: String? - ) async throws -> LinearProjectBindingResponse { - var args = ["x", "linear", "project-link", "--json", "--project-id", projectId] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectName, !projectName.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-name", projectName]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - let result = try await run(args, allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) - } - - public func unlinkLinearProject( - path: String, - projectId: String - ) async throws -> LinearProjectBindingResponse { - let result = try await run( - ["x", "linear", "project-unlink", "--json", "--project-id", projectId], - allowNonZeroExit: true, - cwd: path - ) - return try decodeJsonOrThrow(LinearProjectBindingResponse.self, result: result) - } - - public func syncLinearProject( - path: String, - from direction: String, - profileId: String? = nil, - ownerMode: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - limit: Int? = nil, - syncLabels: Bool? = nil - ) async throws -> LinearProjectSyncResponse { - var args = ["x", "linear", "sync-project", "--from", direction, "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let ownerMode, !ownerMode.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--owner", ownerMode]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - if let limit { - args.append(contentsOf: ["--limit", String(limit)]) - } - if syncLabels == true { - args.append("--sync-labels") - } - let result = try await run(args, allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearProjectSyncResponse.self, result: result) - } - - public func runLinearAutosync( - path: String, - profileId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - limit: Int? = nil - ) async throws -> LinearAutosyncRunResponse { - var args = ["x", "linear", "run-autosync", "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - if let limit { - args.append(contentsOf: ["--limit", String(limit)]) - } - let result = try await run(args, allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearAutosyncRunResponse.self, result: result) - } - - public func syncLinearIssue( - path: String, - from direction: String, - profileId: String? = nil, - issueIdentifier: String? = nil, - ticketId: String? = nil, - projectId: String? = nil, - teamId: String? = nil, - syncLabels: Bool? = nil - ) async throws -> LinearIssueSyncResponse { - var args = ["x", "linear", "sync-issue", "--from", direction, "--json"] - if let profileId, !profileId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--profile", profileId]) - } - if let issueIdentifier, !issueIdentifier.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--issue", issueIdentifier]) - } - if let ticketId, !ticketId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--ticket", ticketId]) - } - if let projectId, !projectId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--project-id", projectId]) - } - if let teamId, !teamId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--team-id", teamId]) - } - if syncLabels == true { - args.append("--sync-labels") - } - let result = try await run(args, allowNonZeroExit: true, cwd: path) - return try decodeJsonOrThrow(LinearIssueSyncResponse.self, result: result) - } - - /// Resolves the effective system Git identity (global or repo-effective) and optional GitHub CLI account. - /// - /// This is read-only host identity state and is intentionally separate from remote OAuth/App profile routing. - public func inspectSystemGitIdentity(projectPath: String? = nil) async throws -> GitSystemIdentity { - let normalizedPath = normalized(projectPath) - let scope: GitSystemIdentityScope = normalizedPath == nil ? .global : .project - let gitName = try await readGitConfigValue( - key: "user.name", - scope: scope, - projectPath: normalizedPath - ) - let gitEmail = try await readGitConfigValue( - key: "user.email", - scope: scope, - projectPath: normalizedPath - ) - let githubIdentity = try await inspectGitHubCLIIdentity() - return GitSystemIdentity( - scope: scope, - gitName: gitName, - gitEmail: gitEmail, - githubLogin: githubIdentity?.login, - githubName: githubIdentity?.name, - githubId: githubIdentity?.id - ) - } - - /// Reads the desktop-visible Hack account/auth broker state through the public CLI surface. - /// - /// The desktop app intentionally mirrors `hack auth status --json` instead of reaching into - /// broker-only pages so browser login, local secret storage, and provider-linked identity - /// resolve through the same path as the CLI. - public func inspectHackAccountSettingsState() async throws -> HackAccountSettingsState { - let result = try await run(["auth", "status", "--json"], allowNonZeroExit: true) - let payload = try decodeJsonOrThrow(HackAuthStatusEnvelope.self, result: result) - guard payload.ok else { - throw HackCLIError.network( - normalized(payload.error) ?? "Hack auth status returned an unexpected response." - ) - } - - let brokerBaseURL = normalized(payload.brokerBaseUrl) - ?? resolveAuthServerCandidates().first - ?? "https://auth.hack.broker" - let betterAuthStatus = try? await fetchBetterAuthStatus(baseURL: brokerBaseURL) - let shellPath = normalized(payload.shellPath) ?? "/auth" - let accountPath = normalized(payload.accountPath) ?? "/auth/account" - let shellURL = buildAuthURL(base: brokerBaseURL, path: shellPath, queryItems: [])?.absoluteString - let accountURL = - buildAuthURL(base: brokerBaseURL, path: accountPath, queryItems: [])?.absoluteString - let userLabel = normalized(payload.user?.name) - ?? normalized(payload.user?.email) - ?? normalized(payload.user?.id) - let organizationLabel = normalized(payload.activeOrganization?.name) - ?? normalized(payload.activeOrganization?.id) - let teamLabel = normalized(payload.activeTeam?.name) - ?? normalized(payload.activeTeam?.id) - - return HackAccountSettingsState( - brokerBaseURL: brokerBaseURL, - authEnabled: betterAuthStatus?.enabled ?? false, - authReason: - normalized(payload.authReason) - ?? normalized(payload.error) - ?? normalized(betterAuthStatus?.reason), - authBasePath: normalized(betterAuthStatus?.basePath) ?? "/api/auth", - authenticated: payload.authenticated == true, - validated: payload.validated == true, - tokenStored: payload.tokenStored == true, - accessControlMode: normalized(payload.accessControlMode), - shellURL: shellURL, - accountURL: accountURL, - sessionAvailable: payload.authenticated == true, - userDisplayName: userLabel, - userEmail: normalized(payload.user?.email), - organizationName: organizationLabel, - teamName: teamLabel - ) - } - - public func loginHackAccount() async throws { - let redirectURL = desktopAuthCompletionURL() - let result = try await run( - ["auth", "login", "--json", "--redirect", redirectURL], - allowNonZeroExit: true - ) - let payload = try decodeJsonOrThrow(HackAuthLoginEnvelope.self, result: result) - guard payload.ok else { - let message = normalized(payload.error) - ?? normalized(payload.nextStep) - ?? "Hack auth login did not complete." - throw HackCLIError.network(message) - } - } - - public func logoutHackAccount() async throws { - let result = try await run(["auth", "logout", "--json"], allowNonZeroExit: true) - let payload = try decodeJsonOrThrow(HackAuthLogoutEnvelope.self, result: result) - guard payload.ok else { - throw HackCLIError.network("Hack auth logout did not complete.") - } - } - - private func fetchBetterAuthStatus(baseURL: String) async throws -> BetterAuthStatusEnvelope { - guard - let statusURL = buildAuthURL( - base: baseURL, - path: "/v1/auth/better-auth/status", - queryItems: [] - ) - else { - throw HackCLIError.network("Invalid auth broker URL.") - } - let body = try await fetchAuthBody(url: statusURL) - guard let status = tryDecodeLenient(BetterAuthStatusEnvelope.self, from: body) else { - throw HackCLIError.network("Auth broker returned invalid JSON.") - } - return status - } - - /// Starts cloud GitHub OAuth with the dedicated auth broker surface. - /// - /// This flow is intentionally separate from local gateway/daemon bearer-token auth. - public func startGitHubOAuthFlow( - profileId: String, - setDefault: Bool - ) async throws -> GitHubOAuthFlowStartResponse { - let desktopRedirectURL = desktopGitHubOAuthCallbackURL() - var lastError: String? = nil - for candidate in resolveAuthServerCandidates() { - guard - let startURL = buildAuthURL( - base: candidate, - path: "/v1/auth/github/start", - queryItems: [ - URLQueryItem(name: "profile", value: profileId), - URLQueryItem(name: "setDefault", value: setDefault ? "1" : "0"), - URLQueryItem(name: "set_default", value: setDefault ? "1" : "0"), - URLQueryItem(name: "requireInstallation", value: "1"), - URLQueryItem(name: "desktopRedirectUrl", value: desktopRedirectURL), - ] - ) - else { - continue - } - do { - let body = try await fetchAuthBody(url: startURL) - if let direct = tryDecodeLenient(GitHubOAuthFlowStartResponse.self, from: body) { - return direct - } - if let wrapped = tryDecodeLenient(GitHubOAuthStartEnvelope.self, from: body), wrapped.ok { - guard - let statusURL = buildAuthURLWithQuery( - urlString: wrapped.flow.pollUrl, - queryItems: [ - URLQueryItem(name: "deviceCode", value: wrapped.flow.deviceCode), - URLQueryItem(name: "claim", value: "1"), - URLQueryItem(name: "requireInstallation", value: "1"), - ] - )?.absoluteString - else { - throw HackCLIError.network("Auth flow returned an invalid status URL.") - } - return GitHubOAuthFlowStartResponse( - ok: true, - flowId: wrapped.flow.flowId, - profileId: wrapped.flow.profileId, - setDefault: wrapped.flow.setDefault, - authorizeUrl: wrapped.flow.authorizeUrl, - statusUrl: statusURL, - appInstallUrl: wrapped.flow.appInstallUrl, - appId: wrapped.flow.appId, - appSlug: wrapped.flow.appSlug, - expiresAt: wrapped.flow.expiresAt - ) - } - throw HackCLIError.network("Auth server returned invalid JSON.") - } catch { - lastError = error.localizedDescription - } - } - - throw HackCLIError.network( - lastError - ?? "Unable to reach any configured auth broker endpoint. Check network/broker status and retry." - ) - } - - /// Polls cloud OAuth flow state and imports claimed tokens into local keychain-backed profiles. - /// - /// This does not read or mutate gateway token auth used for daemon/gateway transport. - public func fetchGitHubOAuthFlowStatus( - statusURL: String - ) async throws -> GitHubOAuthFlowStatusResponse { - guard let url = URL(string: statusURL) else { - throw HackCLIError.network("Invalid auth flow status URL.") - } - let body = try await fetchAuthBody(url: url) - if let direct = tryDecodeLenient(GitHubOAuthFlowStatusResponse.self, from: body) { - return direct - } - if let wrapped = tryDecodeLenient(GitHubOAuthStatusEnvelope.self, from: body), wrapped.ok { - if let token = normalized(wrapped.status.token) { - let installationId = normalized(wrapped.status.installationId) - ?? wrapped.status.installationIds?.first - do { - try await persistGitHubTokenFromBrokerFlow( - profileId: wrapped.status.profileId, - token: token, - setDefault: wrapped.status.setDefault, - appId: normalized(wrapped.status.appId), - installationId: installationId - ) - } catch { - throw HackCLIError.network( - "GitHub OAuth callback succeeded, but Hack could not save the token locally (\(error.localizedDescription)). Retry Add account and allow keychain access." - ) - } - } else if wrapped.status.status == "claimed" { - let profileId = wrapped.status.profileId - let localStatus = try await inspectGitHubStatus(profileId: profileId) - if !localStatus.tokenResolved { - return brokerClaimedWithoutLocalTokenStatus(wrapped.status) - } - } - return normalizeBrokerFlowStatus(wrapped.status) - } - throw HackCLIError.network("Auth server returned invalid JSON.") - } - - /// Starts cloud Linear OAuth with the dedicated auth broker surface. - public func startLinearOAuthFlow( - profileId: String, - setDefault: Bool - ) async throws -> LinearOAuthFlowStartResponse { - var args = [ - "x", - "linear", - "oauth-connect", - "--json", - "--start-only", - "--profile", - profileId, - "--desktop-redirect-url", - desktopLinearOAuthCallbackURL(), - ] - if setDefault { - args.append("--set-default") - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(LinearOAuthFlowStartResponse.self, result: result) - } - - /// Polls Linear broker OAuth state and imports claimed tokens into local keychain-backed profiles. - public func fetchLinearOAuthFlowStatus( - statusURL: String - ) async throws -> LinearOAuthFlowStatusResponse { - guard let url = URL(string: statusURL) else { - throw HackCLIError.network("Invalid auth flow status URL.") - } - let body = try await fetchAuthBody(url: url) - if let direct = tryDecodeLenient(LinearOAuthFlowStatusResponse.self, from: body) { - return direct - } - if let wrapped = tryDecodeLenient(LinearOAuthStatusEnvelope.self, from: body), wrapped.ok { - if let token = normalized(wrapped.status.token) { - do { - try await persistLinearTokenFromBrokerFlow( - profileId: wrapped.status.profileId, - token: token, - tokenExpiresAt: wrapped.status.tokenExpiresAt, - refreshToken: wrapped.status.refreshToken, - refreshTokenExpiresAt: wrapped.status.refreshTokenExpiresAt, - setDefault: wrapped.status.setDefault - ) - } catch { - throw HackCLIError.network( - "Linear OAuth callback succeeded, but Hack could not save the token locally (\(error.localizedDescription)). Retry Add account and allow keychain access." - ) - } - } else if wrapped.status.status == "claimed" { - let profileId = wrapped.status.profileId - let localStatus = try? await inspectLinearStatus(profileId: profileId) - if localStatus?.tokenResolved != true { - _ = try? await seedLinearLocalAccess(profileId: profileId) - } - let repairedStatus = try? await inspectLinearStatus(profileId: profileId) - if repairedStatus?.tokenResolved != true { - return brokerClaimedWithoutLocalLinearTokenStatus(wrapped.status) - } - } - return normalizeLinearBrokerFlowStatus(wrapped.status) - } - throw HackCLIError.network("Auth server returned invalid JSON.") - } - - public func bootstrapRailwayNode( - request: RailwayBootstrapRequest - ) async throws -> RailwayBootstrapResponse { - var args = [ - "node", - "provider", - "railway", - "bootstrap", - "--json", - ] - - if let value = normalized(request.railwayProject) { - args.append(contentsOf: ["--railway-project", value]) - } - - if let value = normalized(request.railwayService) { - args.append(contentsOf: ["--railway-service", value]) - } - if let value = normalized(request.railwayEnvironment) { - args.append(contentsOf: ["--railway-environment", value]) - } - if let value = normalized(request.railwayWorkspace) { - args.append(contentsOf: ["--railway-workspace", value]) - } - if request.createService { - args.append("--create-service") - } - if let value = normalized(request.railwayImage) { - args.append(contentsOf: ["--railway-image", value]) - } - if let value = normalized(request.railwayBin) { - args.append(contentsOf: ["--railway-bin", value]) - } - if let value = normalized(request.nodeName) { - args.append(contentsOf: ["--name", value]) - } - if let value = normalized(request.endpoint) { - args.append(contentsOf: ["--endpoint", value]) - } - if !request.labels.isEmpty { - args.append(contentsOf: ["--labels", request.labels.joined(separator: ",")]) - } - if request.defaultNode { - args.append("--default") - } - if let value = request.domainPort { - args.append(contentsOf: ["--domain-port", String(value)]) - } - if let value = request.initRetries { - args.append(contentsOf: ["--init-retries", String(value)]) - } - if request.privateNetworking { - args.append("--railway-private") - } - if let value = normalized(request.tailscaleAuthKey) { - args.append(contentsOf: ["--tailscale-auth-key", value]) - } - if let value = normalized(request.tailscaleHostname) { - args.append(contentsOf: ["--tailscale-hostname", value]) - } - if !request.tailscaleTags.isEmpty { - args.append(contentsOf: ["--tailscale-tags", request.tailscaleTags.joined(separator: ",")]) - } - - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(RailwayBootstrapResponse.self, result: result) - } - - public func listNodes() async throws -> NodeRegistryListResponse { - let result = try await run(["node", "list", "--json"], allowNonZeroExit: true) - do { - return try decodeJsonOrThrow(NodeRegistryListResponse.self, result: result) - } catch { - let statusResult = try await run(["node", "status", "--json"], allowNonZeroExit: true) - let status = try decodeJsonOrThrow(NodeStatusResponse.self, result: statusResult) - let inferredNodes = deduplicateNodeRegistryRecords(status.nodes.map(\.input)) - let inferredDefaultNodeId = inferredNodes.first(where: { $0.isDefault == true })?.id - return NodeRegistryListResponse(defaultNodeId: inferredDefaultNodeId, nodes: inferredNodes) - } - } - - public func probeNodes(nodeId: String? = nil) async throws -> NodeStatusResponse { - var args = ["node", "status", "--json"] - if let nodeId, !nodeId.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--node", nodeId]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(NodeStatusResponse.self, result: result) - } - - public func useNode(id: String) async throws -> NodeUseResponse { - let result = try await run(["node", "use", id, "--json"], allowNonZeroExit: true) - return try decodeJsonOrThrow(NodeUseResponse.self, result: result) - } - - public func removeNode(id: String) async throws -> NodeRemoveResponse { - let result = try await run(["node", "remove", id, "--json"], allowNonZeroExit: true) - return try decodeJsonOrThrow(NodeRemoveResponse.self, result: result) - } - - public func cancelNodePairSession(sessionId: String) async throws -> NodePairCancelResponse { - let result = try await run( - ["node", "pair", "cancel", "--session", sessionId, "--json"], - allowNonZeroExit: true - ) - return try decodeJsonOrThrow(NodePairCancelResponse.self, result: result) - } - - public func listNodePairSessions(status: String = "pending") async throws -> NodePairListResponse { - let trimmed = status.trimmingCharacters(in: .whitespacesAndNewlines) - var args = ["node", "pair", "list", "--json"] - if !trimmed.isEmpty { - args.append(contentsOf: ["--status", trimmed]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(NodePairListResponse.self, result: result) - } - - public func fulfillNodePairSession( - sessionId: String, - code: String, - defaultNode: Bool, - sshPort: Int? - ) async throws -> NodePairFulfillResponse { - var args = [ - "node", - "pair", - "fulfill", - "--session", - sessionId, - "--code", - code, - "--json", - ] - if defaultNode { - args.append("--default") - } - if let sshPort { - args.append(contentsOf: ["--ssh-port", String(sshPort)]) - } - let result = try await run(args, allowNonZeroExit: true) - return try decodeJsonOrThrow(NodePairFulfillResponse.self, result: result) - } - - public func listTickets(path: String) async throws -> TicketsListResponse { - let result = try await run(["x", "tickets", "list", "--json"], cwd: path) - return try decodeLenient(TicketsListResponse.self, from: result.stdout) - } - - public func showTicket(path: String, ticketId: String) async throws -> TicketDetailResponse { - let result = try await run(["x", "tickets", "show", ticketId, "--json"], cwd: path) - return try decodeLenient(TicketDetailResponse.self, from: result.stdout) - } - - public func createTicket( - path: String, - title: String, - body: String?, - dependsOn: [String], - blocks: [String] - ) async throws -> TicketCreateResponse { - var args = ["x", "tickets", "create", "--title", title, "--json"] - if let body, !body.isEmpty { - args.append(contentsOf: ["--body", body]) - } - if !dependsOn.isEmpty { - args.append(contentsOf: ["--depends-on", dependsOn.joined(separator: ",")]) - } - if !blocks.isEmpty { - args.append(contentsOf: ["--blocks", blocks.joined(separator: ",")]) - } - let result = try await run(args, cwd: path) - return try decodeLenient(TicketCreateResponse.self, from: result.stdout) - } - - public func updateTicket( - path: String, - ticketId: String, - title: String?, - body: String?, - dependsOn: [String]?, - blocks: [String]?, - clearDependsOn: Bool, - clearBlocks: Bool - ) async throws -> TicketUpdateResponse { - var args = ["x", "tickets", "update", ticketId, "--json"] - if let title { - args.append(contentsOf: ["--title", title]) - } - if let body { - args.append(contentsOf: ["--body", body]) - } - if let dependsOn, !dependsOn.isEmpty { - args.append(contentsOf: ["--depends-on", dependsOn.joined(separator: ",")]) - } - if let blocks, !blocks.isEmpty { - args.append(contentsOf: ["--blocks", blocks.joined(separator: ",")]) - } - if clearDependsOn { - args.append("--clear-depends-on") - } - if clearBlocks { - args.append("--clear-blocks") - } - let result = try await run(args, cwd: path) - return try decodeLenient(TicketUpdateResponse.self, from: result.stdout) - } - - public func setTicketStatus( - path: String, - ticketId: String, - status: TicketStatus - ) async throws -> TicketStatusResponse { - let result = try await run(["x", "tickets", "status", ticketId, status.rawValue, "--json"], cwd: path) - return try decodeLenient(TicketStatusResponse.self, from: result.stdout) - } - - public func appendTicketComment( - path: String, - ticketId: String, - body: String, - source: String? = nil, - actor: String? = nil - ) async throws -> TicketCommentAppendResponse { - var args = [ - "x", - "tickets", - "comment", - ticketId, - "--body", - body, - "--json", - ] - if let source, !source.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--source", source]) - } - if let actor, !actor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--actor", actor]) + guard result.exitCode == 0 else { + return nil } - let result = try await run(args, cwd: path) - return try decodeLenient(TicketCommentAppendResponse.self, from: result.stdout) + let value = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value } - public func appendTicketReviewNote( - path: String, - ticketId: String, - body: String, - actor: String? = nil - ) async throws -> TicketReviewNoteAppendResponse { - var args = [ - "x", - "tickets", - "review-note", - ticketId, - "--body", - body, - "--json", - ] - if let actor, !actor.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty { - args.append(contentsOf: ["--actor", actor]) - } - let result = try await run(args, cwd: path) - return try decodeLenient(TicketReviewNoteAppendResponse.self, from: result.stdout) + public func setProjectConfig( + key: String, + value: String, + projectPath: String + ) async throws { + _ = try await run(["config", "set", key, value], cwd: projectPath) } - public func resolveTicketConflict( - path: String, - ticketId: String, - conflictId: String, - resolution: TicketSyncConflictResolution, - summary: String? = nil - ) async throws -> TicketConflictResolutionResponse { - var args = [ - "x", - "tickets", - "resolve-conflict", - ticketId, - "--conflict-id", - conflictId, - "--resolution", - resolution.rawValue, - "--json", - ] - if let summary, !summary.isEmpty { - args.append(contentsOf: ["--summary", summary]) + public func getProjectConfigValue( + key: String, + projectPath: String + ) async throws -> String? { + let result = try await run( + ["config", "get", key], + allowNonZeroExit: true, + cwd: projectPath + ) + guard result.exitCode == 0 else { + return nil } - let result = try await run(args, cwd: path) - return try decodeLenient(TicketConflictResolutionResponse.self, from: result.stdout) + let value = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) + return value.isEmpty ? nil : value } - public func syncTickets(path: String) async throws -> TicketsSyncResponse { - let result = try await run(["x", "tickets", "sync", "--json"], cwd: path) - return try decodeLenient(TicketsSyncResponse.self, from: result.stdout) + public func startCloudflareTunnel() async throws { + _ = try await run(["x", "cloudflare", "tunnel-start"]) } - public func setupTickets(path: String) async throws { - _ = try await run(["x", "tickets", "setup"], allowNonZeroExit: true, cwd: path) + public func stopCloudflareTunnel() async throws { + _ = try await run(["x", "cloudflare", "tunnel-stop"]) } private func decode(_ type: T.Type, from text: String) throws -> T { @@ -1671,65 +391,6 @@ public actor HackCLIClient { } } - private func buildAuthURL( - base: String, - path: String, - queryItems: [URLQueryItem] - ) -> URL? { - guard var components = URLComponents(string: base) else { - return nil - } - components.path = path - components.queryItems = queryItems - return components.url - } - - private func desktopAuthCompletionURL() -> String { - bundleString(forInfoDictionaryKey: "HackAuthCompletionURL") - ?? "\(desktopURLScheme())://auth/complete" - } - - private func desktopGitHubOAuthCallbackURL() -> String { - bundleString(forInfoDictionaryKey: "HackGitHubOAuthCallbackURL") - ?? "\(desktopURLScheme())://auth/github/callback" - } - - private func desktopLinearOAuthCallbackURL() -> String { - bundleString(forInfoDictionaryKey: "HackLinearOAuthCallbackURL") - ?? "\(desktopURLScheme())://auth/linear/callback" - } - - private func desktopURLScheme() -> String { - if - let urlTypes = Bundle.main.object(forInfoDictionaryKey: "CFBundleURLTypes") - as? [[String: Any]] - { - for urlType in urlTypes { - if - let schemes = urlType["CFBundleURLSchemes"] as? [String], - let scheme = schemes.first?.trimmingCharacters(in: .whitespacesAndNewlines), - !scheme.isEmpty - { - return scheme - } - } - } - return "hack" - } - - private func buildAuthURLWithQuery( - urlString: String, - queryItems: [URLQueryItem] - ) -> URL? { - guard var components = URLComponents(string: urlString) else { - return nil - } - var merged = components.queryItems ?? [] - merged.append(contentsOf: queryItems) - components.queryItems = merged - return components.url - } - private func tryDecodeLenient( _ type: T.Type, from text: String @@ -1737,645 +398,6 @@ public actor HackCLIClient { try? decodeLenient(type, from: text) } - private func normalizeBrokerFlowStatus( - _ wrapped: GitHubOAuthStatusEnvelopeStatus - ) -> GitHubOAuthFlowStatusResponse { - let installationId = normalized(wrapped.installationId) - ?? wrapped.installationIds?.first - let normalizedStatus: String - switch wrapped.status { - case "claimed": - normalizedStatus = "complete" - default: - normalizedStatus = wrapped.status - } - return GitHubOAuthFlowStatusResponse( - id: wrapped.id, - status: normalizedStatus, - profileId: wrapped.profileId, - setDefault: wrapped.setDefault, - createdAt: wrapped.createdAt, - expiresAt: wrapped.expiresAt, - completedAt: wrapped.completedAt ?? wrapped.claimedAt, - accountLogin: wrapped.accountLogin, - accountName: wrapped.accountName, - accountId: wrapped.accountId, - installationId: installationId, - installationIds: wrapped.installationIds, - appInstallUrl: wrapped.appInstallUrl, - appId: wrapped.appId, - appSlug: wrapped.appSlug, - error: wrapped.error - ) - } - - private func brokerClaimedWithoutLocalTokenStatus( - _ wrapped: GitHubOAuthStatusEnvelopeStatus - ) -> GitHubOAuthFlowStatusResponse { - let installationId = normalized(wrapped.installationId) - ?? wrapped.installationIds?.first - return GitHubOAuthFlowStatusResponse( - id: wrapped.id, - status: "error", - profileId: wrapped.profileId, - setDefault: wrapped.setDefault, - createdAt: wrapped.createdAt, - expiresAt: wrapped.expiresAt, - completedAt: wrapped.completedAt ?? wrapped.claimedAt, - accountLogin: wrapped.accountLogin, - accountName: wrapped.accountName, - accountId: wrapped.accountId, - installationId: installationId, - installationIds: wrapped.installationIds, - appInstallUrl: wrapped.appInstallUrl, - appId: wrapped.appId, - appSlug: wrapped.appSlug, - error: - "OAuth token was claimed remotely but is not available in local profile \(wrapped.profileId). Re-run Add account and allow keychain access." - ) - } - - private func normalizeLinearBrokerFlowStatus( - _ wrapped: LinearOAuthStatusEnvelopeStatus - ) -> LinearOAuthFlowStatusResponse { - let normalizedStatus: String - switch wrapped.status { - case "claimed": - normalizedStatus = "complete" - default: - normalizedStatus = wrapped.status - } - return LinearOAuthFlowStatusResponse( - id: wrapped.id, - status: normalizedStatus, - profileId: wrapped.profileId, - setDefault: wrapped.setDefault, - createdAt: wrapped.createdAt, - expiresAt: wrapped.expiresAt, - completedAt: wrapped.completedAt ?? wrapped.claimedAt, - accountHandle: wrapped.accountHandle, - accountLogin: wrapped.accountLogin, - accountName: wrapped.accountName, - accountId: wrapped.accountId, - accountEmail: wrapped.accountEmail, - tokenExpiresAt: wrapped.tokenExpiresAt, - error: wrapped.error - ) - } - - private func brokerClaimedWithoutLocalLinearTokenStatus( - _ wrapped: LinearOAuthStatusEnvelopeStatus - ) -> LinearOAuthFlowStatusResponse { - return LinearOAuthFlowStatusResponse( - id: wrapped.id, - status: "error", - profileId: wrapped.profileId, - setDefault: wrapped.setDefault, - createdAt: wrapped.createdAt, - expiresAt: wrapped.expiresAt, - completedAt: wrapped.completedAt ?? wrapped.claimedAt, - accountHandle: wrapped.accountHandle, - accountLogin: wrapped.accountLogin, - accountName: wrapped.accountName, - accountId: wrapped.accountId, - accountEmail: wrapped.accountEmail, - tokenExpiresAt: wrapped.tokenExpiresAt, - error: - "Hack already connected \(wrapped.profileId) remotely, but this Mac still needs local access. Use Repair access or reconnect the account." - ) - } - - /// Persist a broker-issued GitHub token into local keychain-backed profile storage. - private func persistGitHubTokenFromBrokerFlow( - profileId: String, - token: String, - setDefault: Bool, - appId: String?, - installationId: String? - ) async throws { - if let appId, let installationId { - var appArgs = [ - "x", - "github", - "connect", - "--profile", - profileId, - "--app-id", - appId, - "--installation-id", - installationId, - ] - if setDefault { - appArgs.append("--set-default") - } - do { - _ = try await run(appArgs) - return - } catch { - // Fall back to direct token import when app private-key refresh is not available. - } - } - - var args = ["x", "github", "connect", "--profile", profileId, "--stdin"] - if setDefault { - args.append("--set-default") - } - _ = try await run(args, stdin: "\(token)\n") - } - - /// Persist a broker-issued Linear token into local keychain-backed profile storage. - private func persistLinearTokenFromBrokerFlow( - profileId: String, - token: String, - tokenExpiresAt: String?, - refreshToken: String?, - refreshTokenExpiresAt: String?, - setDefault: Bool - ) async throws { - var args = ["x", "linear", "connect", "--profile", profileId, "--stdin"] - if setDefault { - args.append("--set-default") - } - let envelope = LinearTokenPersistEnvelope( - token: token, - expiresAt: normalized(tokenExpiresAt), - refreshToken: normalized(refreshToken), - refreshTokenExpiresAt: normalized(refreshTokenExpiresAt) - ) - let encoder = JSONEncoder() - let payload = try encoder.encode(envelope) - guard let text = String(data: payload, encoding: .utf8) else { - throw HackCLIError.invalidJson - } - _ = try await run(args, stdin: "\(text)\n") - } - - private struct LinearTokenPersistEnvelope: Encodable { - let token: String - let expiresAt: String? - let refreshToken: String? - let refreshTokenExpiresAt: String? - } - - private func fetchAuthBody(url: URL) async throws -> String { - var request = URLRequest(url: url) - request.timeoutInterval = Self.authRequestTimeoutSeconds - request.httpMethod = "GET" - request.setValue("application/json", forHTTPHeaderField: "Accept") - - let data: Data - let response: URLResponse - do { - (data, response) = try await URLSession.shared.data(for: request) - } catch { - throw HackCLIError.network(error.localizedDescription) - } - - guard let httpResponse = response as? HTTPURLResponse else { - throw HackCLIError.network("Auth server returned an unexpected response.") - } - - let body = String(decoding: data, as: UTF8.self) - if !(200...299).contains(httpResponse.statusCode) { - if let decodedError = try? decode(AuthServerErrorPayload.self, from: body), - let message = normalized(decodedError.error) - { - throw HackCLIError.network(message) - } - let fallback = firstNonEmptyLine(body) ?? HTTPURLResponse.localizedString(forStatusCode: httpResponse.statusCode) - throw HackCLIError.network(fallback) - } - return body - } - - private func unsupportedTailscaleOAuthStatusResponse( - result: CLIResult - ) -> TailscaleOAuthStatusResponse? { - guard result.exitCode != 0 else { - return nil - } - let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - guard !stderr.isEmpty else { - return nil - } - let normalized = stderr.lowercased() - guard - normalized.contains("unknown command"), - normalized.contains("oauth-status"), - normalized.contains("tailscale") - else { - return nil - } - return TailscaleOAuthStatusResponse( - configured: false, - clientId: nil, - authRef: nil, - tailnet: nil, - keyExpirySeconds: nil, - validated: nil, - checkedAt: nil, - tokenExpiresAt: nil, - deleted: nil, - error: - "Installed hack CLI does not support tailscale OAuth commands yet. Run `hack update` (or reinstall from this repo) and reopen the desktop app." - ) - } - - private func inspectTailscaleDirect() async throws -> TailscaleInspectResponse { - let environment = HackCLILocator.buildEnvironment() - guard let binaryPath = HackCLILocator.resolveExecutable(named: "tailscale", in: environment) else { - return TailscaleInspectResponse( - installed: false, - binaryPath: nil, - connected: false, - backendState: nil, - tailnetName: nil, - magicDnsSuffix: nil, - authUrl: nil, - currentExitNodeId: nil, - currentExitNodeName: nil, - selfDevice: nil, - peers: [], - onlinePeerCount: 0, - exitNodes: [], - health: [], - error: "tailscale not found in PATH" - ) - } - - let result = try await runExecutable( - executablePath: binaryPath, - args: ["status", "--json"], - allowNonZeroExit: true, - cwd: nil - ) - if result.exitCode != 0 { - let stderr = result.stderr.trimmingCharacters(in: .whitespacesAndNewlines) - return TailscaleInspectResponse( - installed: true, - binaryPath: binaryPath, - connected: false, - backendState: nil, - tailnetName: nil, - magicDnsSuffix: nil, - authUrl: nil, - currentExitNodeId: nil, - currentExitNodeName: nil, - selfDevice: nil, - peers: [], - onlinePeerCount: 0, - exitNodes: [], - health: [], - error: stderr.isEmpty ? "tailscale status failed" : stderr - ) - } - - let rawStatus = try decode(RawTailscaleStatus.self, from: result.stdout) - let selfDevice = rawStatus.selfPeer.map { - mapRawPeer( - id: $0.id ?? "self", - peer: $0, - treatAsSelf: true - ) - } - - let peers = rawStatus.peers - .map { key, value in - mapRawPeer(id: value.id ?? key, peer: value, treatAsSelf: false) - } - .sorted { lhs, rhs in - if lhs.online != rhs.online { - return lhs.online && !rhs.online - } - return lhs.hostname.localizedCaseInsensitiveCompare(rhs.hostname) == .orderedAscending - } - - let exitNodes = peers.filter { $0.isExitNode || $0.isExitNodeOption } - let currentExitNodeName = rawStatus.currentExitNodeId.flatMap { id in - peers.first(where: { $0.id == id })?.hostname - } - - return TailscaleInspectResponse( - installed: true, - binaryPath: binaryPath, - connected: rawStatus.backendState == "Running", - backendState: rawStatus.backendState, - tailnetName: rawStatus.currentTailnet?.name, - magicDnsSuffix: rawStatus.currentTailnet?.magicDnsSuffix, - authUrl: rawStatus.authUrl, - currentExitNodeId: rawStatus.currentExitNodeId, - currentExitNodeName: currentExitNodeName, - selfDevice: selfDevice.map { - TailscaleInspectSelf( - id: $0.id, - hostname: $0.hostname, - dnsName: $0.dnsName, - tailscaleIp: $0.tailscaleIp, - online: $0.online, - os: $0.os, - tags: $0.tags, - isExitNode: $0.isExitNode - ) - }, - peers: peers, - onlinePeerCount: peers.filter(\.online).count, - exitNodes: exitNodes, - health: rawStatus.health, - error: nil - ) - } - - private func mapRawPeer( - id: String, - peer: RawTailscalePeer, - treatAsSelf: Bool - ) -> TailscaleInspectPeer { - TailscaleInspectPeer( - id: id, - hostname: peer.hostName ?? id, - dnsName: normalizeDNS(peer.dnsName), - tailscaleIp: peer.tailscaleIPs.first, - online: peer.online ?? false, - os: peer.os, - tags: peer.tags ?? [], - isExitNode: peer.exitNode ?? false, - isExitNodeOption: treatAsSelf ? false : (peer.exitNodeOption ?? false) - ) - } - - private func normalizeDNS(_ value: String?) -> String? { - guard let value, !value.isEmpty else { return nil } - if value.hasSuffix(".") { - return String(value.dropLast()) - } - return value - } - - private func firstNonEmptyLine(_ value: String) -> String? { - for line in value.components(separatedBy: .newlines) { - let trimmed = line.trimmingCharacters(in: .whitespacesAndNewlines) - if !trimmed.isEmpty { - return trimmed - } - } - return nil - } - - private func deduplicateNodeRegistryRecords( - _ records: [NodeRegistryRecord] - ) -> [NodeRegistryRecord] { - var seenIds = Set() - var deduplicated: [NodeRegistryRecord] = [] - deduplicated.reserveCapacity(records.count) - for record in records { - if seenIds.contains(record.id) { - continue - } - seenIds.insert(record.id) - deduplicated.append(record) - } - return deduplicated - } - - private func normalized(_ value: String?) -> String? { - guard let value else { return nil } - let trimmed = value.trimmingCharacters(in: .whitespacesAndNewlines) - return trimmed.isEmpty ? nil : trimmed - } - - private func bundleString(forInfoDictionaryKey key: String) -> String? { - normalized(Bundle.main.object(forInfoDictionaryKey: key) as? String) - } - - private func resolveAuthServerCandidates() -> [String] { - var candidates: [String] = [] - var seen = Set() - - let envOverride = normalized(ProcessInfo.processInfo.environment["HACK_AUTH_BROKER_URL"]) - if let envOverride { - let lowercased = envOverride.lowercased() - if seen.insert(lowercased).inserted { - candidates.append(envOverride) - } - } - - for candidate in Self.authServerCandidates { - let lowercased = candidate.lowercased() - if seen.insert(lowercased).inserted { - candidates.append(candidate) - } - } - return candidates - } - - private func readGitConfigValue( - key: String, - scope: GitSystemIdentityScope, - projectPath: String? - ) async throws -> String? { - var args = ["git", "config"] - if scope == .global { - args.append("--global") - } - args.append(contentsOf: ["--get", key]) - let result = try await runExecutable( - executablePath: "/usr/bin/env", - args: args, - allowNonZeroExit: true, - cwd: projectPath - ) - guard result.exitCode == 0 else { - return nil - } - return normalized(firstNonEmptyLine(result.stdout)) - } - - private func inspectGitHubCLIIdentity() async throws -> GitHubCLIIdentity? { - let result = try await runExecutable( - executablePath: "/usr/bin/env", - args: ["gh", "api", "user"], - allowNonZeroExit: true, - cwd: nil - ) - guard result.exitCode == 0 else { - return nil - } - let trimmed = result.stdout.trimmingCharacters(in: .whitespacesAndNewlines) - guard !trimmed.isEmpty else { - return nil - } - let jsonText = extractJsonSnippets(from: trimmed).first ?? trimmed - guard let data = jsonText.data(using: String.Encoding.utf8) else { - return nil - } - let decoded = try? JSONDecoder().decode(GitHubCLIIdentity.self, from: data) - let login = normalized(decoded?.login) - guard let login else { - return nil - } - return GitHubCLIIdentity( - login: login, - name: normalized(decoded?.name), - id: normalized(decoded?.id) - ) - } - - private func runExecutable( - executablePath: String, - args: [String], - allowNonZeroExit: Bool, - cwd: String? - ) async throws -> CLIResult { - try Task.checkCancellation() - - let process = Process() - process.environment = HackCLILocator.buildEnvironment() - if let cwd, !cwd.isEmpty { - process.currentDirectoryURL = URL(fileURLWithPath: cwd) - } - process.executableURL = URL(fileURLWithPath: executablePath) - process.arguments = args - - let stdoutPipe = Pipe() - let stderrPipe = Pipe() - process.standardOutput = stdoutPipe - process.standardError = stderrPipe - - return try await withTaskCancellationHandler(operation: { - do { - try process.run() - } catch { - stdoutPipe.fileHandleForReading.closeFile() - stderrPipe.fileHandleForReading.closeFile() - throw HackCLIError.commandFailed(exitCode: 127, stderr: error.localizedDescription) - } - - async let stdoutData = stdoutPipe.fileHandleForReading.readToEnd() - async let stderrData = stderrPipe.fileHandleForReading.readToEnd() - let exitCode = await Task.detached(priority: nil) { - process.waitUntilExit() - return Int(process.terminationStatus) - }.value - - let stdoutBytes: Data? - let stderrBytes: Data? - - do { - stdoutBytes = try await stdoutData - } catch { - stdoutBytes = nil - } - - do { - stderrBytes = try await stderrData - } catch { - stderrBytes = nil - } - - try Task.checkCancellation() - - let stdout = String(decoding: stdoutBytes ?? Data(), as: UTF8.self) - let stderr = String(decoding: stderrBytes ?? Data(), as: UTF8.self) - if exitCode != 0 && !allowNonZeroExit { - throw HackCLIError.commandFailed( - exitCode: exitCode, - stderr: stderr.trimmingCharacters(in: .whitespacesAndNewlines) - ) - } - - return CLIResult(stdout: stdout, stderr: stderr, exitCode: exitCode) - }, onCancel: { - if process.isRunning { - process.terminate() - } - stdoutPipe.fileHandleForReading.closeFile() - stderrPipe.fileHandleForReading.closeFile() - }) - } -} - -private struct GitHubCLIIdentity: Decodable { - let login: String? - let name: String? - let id: String? -} - -private struct RawTailscaleStatus: Decodable { - let backendState: String? - let currentTailnet: RawTailscaleTailnet? - let authUrl: String? - let currentExitNodeId: String? - let selfPeer: RawTailscalePeer? - let peers: [String: RawTailscalePeer] - let health: [String] - - enum CodingKeys: String, CodingKey { - case backendState = "BackendState" - case currentTailnet = "CurrentTailnet" - case authUrl = "AuthURL" - case currentExitNodeId = "ExitNodeID" - case selfPeer = "Self" - case peers = "Peer" - case health = "Health" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - backendState = try container.decodeIfPresent(String.self, forKey: .backendState) - currentTailnet = try container.decodeIfPresent(RawTailscaleTailnet.self, forKey: .currentTailnet) - authUrl = try container.decodeIfPresent(String.self, forKey: .authUrl) - currentExitNodeId = try container.decodeIfPresent(String.self, forKey: .currentExitNodeId) - selfPeer = try container.decodeIfPresent(RawTailscalePeer.self, forKey: .selfPeer) - peers = try container.decodeIfPresent([String: RawTailscalePeer].self, forKey: .peers) ?? [:] - health = try container.decodeIfPresent([String].self, forKey: .health) ?? [] - } -} - -private struct RawTailscaleTailnet: Decodable { - let name: String? - let magicDnsSuffix: String? - - enum CodingKeys: String, CodingKey { - case name = "Name" - case magicDnsSuffix = "MagicDNSSuffix" - } -} - -private struct RawTailscalePeer: Decodable { - let id: String? - let hostName: String? - let dnsName: String? - let tailscaleIPs: [String] - let online: Bool? - let os: String? - let tags: [String]? - let exitNode: Bool? - let exitNodeOption: Bool? - - enum CodingKeys: String, CodingKey { - case id = "ID" - case hostName = "HostName" - case dnsName = "DNSName" - case tailscaleIPs = "TailscaleIPs" - case online = "Online" - case os = "OS" - case tags = "Tags" - case exitNode = "ExitNode" - case exitNodeOption = "ExitNodeOption" - } - - init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decodeIfPresent(String.self, forKey: .id) - hostName = try container.decodeIfPresent(String.self, forKey: .hostName) - dnsName = try container.decodeIfPresent(String.self, forKey: .dnsName) - tailscaleIPs = try container.decodeIfPresent([String].self, forKey: .tailscaleIPs) ?? [] - online = try container.decodeIfPresent(Bool.self, forKey: .online) - os = try container.decodeIfPresent(String.self, forKey: .os) - tags = try container.decodeIfPresent([String].self, forKey: .tags) - exitNode = try container.decodeIfPresent(Bool.self, forKey: .exitNode) - exitNodeOption = try container.decodeIfPresent(Bool.self, forKey: .exitNodeOption) - } } private struct CLIResult { @@ -2383,7 +405,3 @@ private struct CLIResult { let stderr: String let exitCode: Int } - -private struct AuthServerErrorPayload: Decodable { - let error: String? -} diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift index 81033232..b2749caa 100644 --- a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -610,1639 +610,6 @@ public struct GatewayTokenRevokeResponse: Decodable { } } -public enum NodeRegistryStatus: String, Decodable { - case healthy - case stale - case offline - case unknown -} - -public struct NodeRegistryRecord: Decodable, Identifiable, Hashable { - public let id: String - public let name: String - public let labels: [String] - public let capabilities: [String] - public let endpoint: String - public let authRef: String - public let lastSeenAt: String? - public let status: NodeRegistryStatus? - public let version: String? - public let platform: String? - public let arch: String? - public let createdAt: String? - public let updatedAt: String? - public let isDefault: Bool? - - public init( - id: String, - name: String, - labels: [String], - capabilities: [String], - endpoint: String, - authRef: String, - lastSeenAt: String?, - status: NodeRegistryStatus?, - version: String?, - platform: String?, - arch: String?, - createdAt: String?, - updatedAt: String?, - isDefault: Bool? - ) { - self.id = id - self.name = name - self.labels = labels - self.capabilities = capabilities - self.endpoint = endpoint - self.authRef = authRef - self.lastSeenAt = lastSeenAt - self.status = status - self.version = version - self.platform = platform - self.arch = arch - self.createdAt = createdAt - self.updatedAt = updatedAt - self.isDefault = isDefault - } -} - -public struct NodeRegistryListResponse: Decodable { - public let defaultNodeId: String? - public let nodes: [NodeRegistryRecord] - - public init(defaultNodeId: String?, nodes: [NodeRegistryRecord]) { - self.defaultNodeId = defaultNodeId - self.nodes = nodes - } -} - -public struct NodeRuntimeGatewayProject: Decodable, Identifiable, Hashable { - public let projectId: String - public let projectName: String - - public var id: String { projectId } - - public init(projectId: String, projectName: String) { - self.projectId = projectId - self.projectName = projectName - } -} - -public struct NodeRuntimeGateway: Decodable, Hashable { - public let enabled: Bool? - public let bind: String? - public let port: Int? - public let allowWrites: Bool? - public let projects: [NodeRuntimeGatewayProject]? - - public init( - enabled: Bool?, - bind: String?, - port: Int?, - allowWrites: Bool?, - projects: [NodeRuntimeGatewayProject]? - ) { - self.enabled = enabled - self.bind = bind - self.port = port - self.allowWrites = allowWrites - self.projects = projects - } -} - -public struct NodeRuntimeNodeInfo: Decodable, Hashable { - public let name: String? - public let platform: String? - public let arch: String? - public let bun: String? - - public init(name: String?, platform: String?, arch: String?, bun: String?) { - self.name = name - self.platform = platform - self.arch = arch - self.bun = bun - } -} - -public struct NodeRuntimeSupervisor: Decodable, Hashable { - public let enabled: Bool? - public let maxConcurrentJobs: Int? - - public init(enabled: Bool?, maxConcurrentJobs: Int?) { - self.enabled = enabled - self.maxConcurrentJobs = maxConcurrentJobs - } -} - -public struct NodeRuntimeDevcontainers: Decodable, Hashable { - public let running: Int? - - public init(running: Int?) { - self.running = running - } -} - -public struct NodeRuntimeStatusPayload: Decodable, Hashable { - public let status: String? - public let version: String? - public let pid: Int? - public let startedAt: String? - public let uptimeMs: Int? - public let node: NodeRuntimeNodeInfo? - public let gateway: NodeRuntimeGateway? - public let supervisor: NodeRuntimeSupervisor? - public let devcontainers: NodeRuntimeDevcontainers? - - public init( - status: String?, - version: String?, - pid: Int?, - startedAt: String?, - uptimeMs: Int?, - node: NodeRuntimeNodeInfo?, - gateway: NodeRuntimeGateway?, - supervisor: NodeRuntimeSupervisor?, - devcontainers: NodeRuntimeDevcontainers? - ) { - self.status = status - self.version = version - self.pid = pid - self.startedAt = startedAt - self.uptimeMs = uptimeMs - self.node = node - self.gateway = gateway - self.supervisor = supervisor - self.devcontainers = devcontainers - } -} - -public struct NodeStatusProbe: Decodable, Identifiable, Hashable { - public let ok: Bool - public let input: NodeRegistryRecord - public let status: NodeRegistryStatus? - public let node: NodeRegistryRecord? - public let payload: NodeRuntimeStatusPayload? - public let error: String? - - public var id: String { input.id } - - public init( - ok: Bool, - input: NodeRegistryRecord, - status: NodeRegistryStatus?, - node: NodeRegistryRecord?, - payload: NodeRuntimeStatusPayload?, - error: String? - ) { - self.ok = ok - self.input = input - self.status = status - self.node = node - self.payload = payload - self.error = error - } -} - -public struct NodeStatusResponse: Decodable { - public let nodes: [NodeStatusProbe] - - public init(nodes: [NodeStatusProbe]) { - self.nodes = nodes - } -} - -public struct NodeUseResponse: Decodable { - public let defaultNodeId: String? - - public init(defaultNodeId: String?) { - self.defaultNodeId = defaultNodeId - } -} - -public struct NodeRemoveResponse: Decodable { - public let removed: Bool - public let nodeId: String - - public init(removed: Bool, nodeId: String) { - self.removed = removed - self.nodeId = nodeId - } -} - -public struct NodePairingSession: Decodable, Hashable { - public let id: String - public let source: String - public let endpoint: String - public let codeHash: String - public let createdAt: String - public let expiresAt: String - public let status: String - public let updatedAt: String - public let approvedAt: String? - public let consumedAt: String? - - public init( - id: String, - source: String, - endpoint: String, - codeHash: String, - createdAt: String, - expiresAt: String, - status: String, - updatedAt: String, - approvedAt: String?, - consumedAt: String? - ) { - self.id = id - self.source = source - self.endpoint = endpoint - self.codeHash = codeHash - self.createdAt = createdAt - self.expiresAt = expiresAt - self.status = status - self.updatedAt = updatedAt - self.approvedAt = approvedAt - self.consumedAt = consumedAt - } -} - -public struct NodePairCancelResponse: Decodable, Hashable { - public let cancelled: Bool - public let sessionId: String - - public init(cancelled: Bool, sessionId: String) { - self.cancelled = cancelled - self.sessionId = sessionId - } -} - -public struct NodePairListResponse: Decodable, Hashable { - public let sessions: [NodePairingSession] - public let status: String? - - public init(sessions: [NodePairingSession], status: String?) { - self.sessions = sessions - self.status = status - } -} - -public struct NodePairFulfillPairing: Decodable, Hashable { - public let sessionId: String - public let consumedAt: String? - - public init(sessionId: String, consumedAt: String?) { - self.sessionId = sessionId - self.consumedAt = consumedAt - } -} - -public struct NodePairFulfillProbe: 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 NodePairFulfillResponse: Decodable, Hashable { - public let node: NodeRegistryRecord - public let created: Bool - public let pairing: NodePairFulfillPairing - public let probe: NodePairFulfillProbe - - public init( - node: NodeRegistryRecord, - created: Bool, - pairing: NodePairFulfillPairing, - probe: NodePairFulfillProbe - ) { - self.node = node - self.created = created - self.pairing = pairing - self.probe = probe - } -} - -public struct RailwayInspectResponse: Decodable, Hashable { - public let installed: Bool - public let binaryPath: String? - public let version: String? - public let authenticated: Bool - public let whoami: String? - public let error: String? - - public init( - installed: Bool, - binaryPath: String?, - version: String?, - authenticated: Bool, - whoami: String?, - error: String? - ) { - self.installed = installed - self.binaryPath = binaryPath - self.version = version - self.authenticated = authenticated - self.whoami = whoami - self.error = error - } -} - -public struct HackAccountSettingsState: Decodable, Hashable { - public let brokerBaseURL: String - public let authEnabled: Bool - public let authReason: String? - public let authBasePath: String - public let authenticated: Bool - public let validated: Bool - public let tokenStored: Bool - public let accessControlMode: String? - public let shellURL: String? - public let accountURL: String? - public let sessionAvailable: Bool - public let userDisplayName: String? - public let userEmail: String? - public let organizationName: String? - public let teamName: String? - - public init( - brokerBaseURL: String, - authEnabled: Bool, - authReason: String?, - authBasePath: String, - authenticated: Bool, - validated: Bool, - tokenStored: Bool, - accessControlMode: String?, - shellURL: String?, - accountURL: String?, - sessionAvailable: Bool, - userDisplayName: String?, - userEmail: String?, - organizationName: String?, - teamName: String? - ) { - self.brokerBaseURL = brokerBaseURL - self.authEnabled = authEnabled - self.authReason = authReason - self.authBasePath = authBasePath - self.authenticated = authenticated - self.validated = validated - self.tokenStored = tokenStored - self.accessControlMode = accessControlMode - self.shellURL = shellURL - self.accountURL = accountURL - self.sessionAvailable = sessionAvailable - self.userDisplayName = userDisplayName - self.userEmail = userEmail - self.organizationName = organizationName - self.teamName = teamName - } - - public var manageAccountAvailable: Bool { - guard let accountURL else { - return false - } - return !accountURL.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty - } -} - -public struct GitHubProfileSummary: Decodable, Hashable { - public let id: String - public let isDefault: Bool - public let mode: String - public let authRef: String - public let service: String - public let appId: String? - public let installationId: String? - public let accountLogin: String? - public let accountName: String? - public let accountId: String? - - public init( - id: String, - isDefault: Bool, - mode: String, - authRef: String, - service: String, - appId: String?, - installationId: String?, - accountLogin: String?, - accountName: String?, - accountId: String? - ) { - self.id = id - self.isDefault = isDefault - self.mode = mode - self.authRef = authRef - self.service = service - self.appId = appId - self.installationId = installationId - self.accountLogin = accountLogin - self.accountName = accountName - self.accountId = accountId - } -} - -public struct GitHubProfilesResponse: Decodable, Hashable { - public let selectedProfile: String - public let selectedSource: String - public let defaultProfile: String - public let projectOverride: String? - public let selectedMissing: Bool - public let profiles: [GitHubProfileSummary] - - public init( - selectedProfile: String, - selectedSource: String, - defaultProfile: String, - projectOverride: String?, - selectedMissing: Bool, - profiles: [GitHubProfileSummary] - ) { - self.selectedProfile = selectedProfile - self.selectedSource = selectedSource - self.defaultProfile = defaultProfile - self.projectOverride = projectOverride - self.selectedMissing = selectedMissing - self.profiles = profiles - } -} - -public struct GitHubStatusResponse: Decodable, Hashable { - public let extensionId: String - public let selectedProfile: String - public let selectedSource: String - public let defaultProfile: String - public let authRef: String - public let service: String - public let tokenEnvFallback: String - public let mode: String - public let appId: String? - public let installationId: String? - public let privateKeyEnv: String - public let privateKeyAuthRef: String? - public let apiBaseUrl: String - public let accountLogin: String? - public let accountName: String? - public let accountId: String? - public let tokenResolved: Bool - public let tokenSource: String? - public let tokenExpiresAt: String? - public let profileError: String? - public let error: String? - - public init( - extensionId: String, - selectedProfile: String, - selectedSource: String, - defaultProfile: String, - authRef: String, - service: String, - tokenEnvFallback: String, - mode: String, - appId: String?, - installationId: String?, - privateKeyEnv: String, - privateKeyAuthRef: String?, - apiBaseUrl: String, - accountLogin: String?, - accountName: String?, - accountId: String?, - tokenResolved: Bool, - tokenSource: String?, - tokenExpiresAt: String?, - profileError: String?, - error: String? - ) { - self.extensionId = extensionId - self.selectedProfile = selectedProfile - self.selectedSource = selectedSource - self.defaultProfile = defaultProfile - self.authRef = authRef - self.service = service - self.tokenEnvFallback = tokenEnvFallback - self.mode = mode - self.appId = appId - self.installationId = installationId - self.privateKeyEnv = privateKeyEnv - self.privateKeyAuthRef = privateKeyAuthRef - self.apiBaseUrl = apiBaseUrl - self.accountLogin = accountLogin - self.accountName = accountName - self.accountId = accountId - self.tokenResolved = tokenResolved - self.tokenSource = tokenSource - self.tokenExpiresAt = tokenExpiresAt - self.profileError = profileError - self.error = error - } -} - -public struct GitHubOAuthFlowStartResponse: Decodable, Hashable { - public let ok: Bool - public let flowId: String - public let profileId: String - public let setDefault: Bool - public let authorizeUrl: String - public let statusUrl: String - public let appInstallUrl: String? - public let appId: String? - public let appSlug: String? - public let expiresAt: String - - public init( - ok: Bool, - flowId: String, - profileId: String, - setDefault: Bool, - authorizeUrl: String, - statusUrl: String, - appInstallUrl: String?, - appId: String?, - appSlug: String?, - expiresAt: String - ) { - self.ok = ok - self.flowId = flowId - self.profileId = profileId - self.setDefault = setDefault - self.authorizeUrl = authorizeUrl - self.statusUrl = statusUrl - self.appInstallUrl = appInstallUrl - self.appId = appId - self.appSlug = appSlug - self.expiresAt = expiresAt - } -} - -public struct GitHubOAuthFlowStatusResponse: Decodable, Hashable { - public let id: String - public let status: String - public let profileId: String - public let setDefault: Bool - public let createdAt: String - public let expiresAt: String - public let completedAt: String? - public let accountLogin: String? - public let accountName: String? - public let accountId: String? - public let installationId: String? - public let installationIds: [String]? - public let appInstallUrl: String? - public let appId: String? - public let appSlug: String? - public let error: String? - - public init( - id: String, - status: String, - profileId: String, - setDefault: Bool, - createdAt: String, - expiresAt: String, - completedAt: String?, - accountLogin: String?, - accountName: String?, - accountId: String?, - installationId: String?, - installationIds: [String]?, - appInstallUrl: String?, - appId: String?, - appSlug: String?, - error: String? - ) { - self.id = id - self.status = status - self.profileId = profileId - self.setDefault = setDefault - self.createdAt = createdAt - self.expiresAt = expiresAt - self.completedAt = completedAt - self.accountLogin = accountLogin - self.accountName = accountName - self.accountId = accountId - self.installationId = installationId - self.installationIds = installationIds - self.appInstallUrl = appInstallUrl - self.appId = appId - self.appSlug = appSlug - self.error = error - } -} - -public struct LinearProfileSummary: Decodable, Hashable { - public let id: String - public let isDefault: Bool - public let authRef: String - public let service: String - public let tokenEnv: String - public let apiUrl: String - public let accountId: String? - public let accountName: String? - public let accountEmail: String? - - public init( - id: String, - isDefault: Bool, - authRef: String, - service: String, - tokenEnv: String, - apiUrl: String, - accountId: String?, - accountName: String?, - accountEmail: String? - ) { - self.id = id - self.isDefault = isDefault - self.authRef = authRef - self.service = service - self.tokenEnv = tokenEnv - self.apiUrl = apiUrl - self.accountId = accountId - self.accountName = accountName - self.accountEmail = accountEmail - } -} - -public struct LinearProfilesResponse: Decodable, Hashable { - public let selectedProfile: String - public let selectedSource: String - public let defaultProfile: String - public let projectOverride: String? - public let selectedMissing: Bool - public let profiles: [LinearProfileSummary] - - public init( - selectedProfile: String, - selectedSource: String, - defaultProfile: String, - projectOverride: String?, - selectedMissing: Bool, - profiles: [LinearProfileSummary] - ) { - self.selectedProfile = selectedProfile - self.selectedSource = selectedSource - self.defaultProfile = defaultProfile - self.projectOverride = projectOverride - self.selectedMissing = selectedMissing - self.profiles = profiles - } -} - -public struct LinearRemoteConnection: Decodable, Hashable, Identifiable { - public let id: String - public let profileId: String? - public let accountId: String? - public let accountName: String? - public let accountEmail: String? - public let authRef: String? - public let betterAuthUserId: String? - public let betterAuthOrganizationId: String? - public let betterAuthTeamId: String? - public let organizationId: String? - public let teamId: String? - public let localAccessAvailable: Bool - public let createdAt: String - public let updatedAt: String - - public init( - id: String, - profileId: String?, - accountId: String?, - accountName: String?, - accountEmail: String?, - authRef: String?, - betterAuthUserId: String?, - betterAuthOrganizationId: String?, - betterAuthTeamId: String?, - organizationId: String?, - teamId: String?, - localAccessAvailable: Bool, - createdAt: String, - updatedAt: String - ) { - self.id = id - self.profileId = profileId - self.accountId = accountId - self.accountName = accountName - self.accountEmail = accountEmail - self.authRef = authRef - self.betterAuthUserId = betterAuthUserId - self.betterAuthOrganizationId = betterAuthOrganizationId - self.betterAuthTeamId = betterAuthTeamId - self.organizationId = organizationId - self.teamId = teamId - self.localAccessAvailable = localAccessAvailable - self.createdAt = createdAt - self.updatedAt = updatedAt - } - - enum CodingKeys: String, CodingKey { - case id - case profileId - case accountId - case accountName - case accountEmail - case authRef - case betterAuthUserId - case betterAuthOrganizationId - case betterAuthTeamId - case organizationId - case teamId - case localAccessAvailable - case createdAt - case updatedAt - } - - public init(from decoder: any Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - id = try container.decode(String.self, forKey: .id) - profileId = try container.decodeIfPresent(String.self, forKey: .profileId) - accountId = try container.decodeIfPresent(String.self, forKey: .accountId) - accountName = try container.decodeIfPresent(String.self, forKey: .accountName) - accountEmail = try container.decodeIfPresent(String.self, forKey: .accountEmail) - authRef = try container.decodeIfPresent(String.self, forKey: .authRef) - betterAuthUserId = try container.decodeIfPresent(String.self, forKey: .betterAuthUserId) - betterAuthOrganizationId = try container.decodeIfPresent( - String.self, - forKey: .betterAuthOrganizationId - ) - betterAuthTeamId = try container.decodeIfPresent(String.self, forKey: .betterAuthTeamId) - organizationId = try container.decodeIfPresent(String.self, forKey: .organizationId) - teamId = try container.decodeIfPresent(String.self, forKey: .teamId) - localAccessAvailable = try container.decodeIfPresent(Bool.self, forKey: .localAccessAvailable) ?? false - createdAt = try container.decode(String.self, forKey: .createdAt) - updatedAt = try container.decode(String.self, forKey: .updatedAt) - } -} - -public struct LinearConnectionsResponse: Decodable, Hashable { - public let accessControlMode: String? - public let connections: [LinearRemoteConnection] - - public init( - accessControlMode: String?, - connections: [LinearRemoteConnection] - ) { - self.accessControlMode = accessControlMode - self.connections = connections - } -} - -public struct LinearLocalAccessSeedResponse: Decodable, Hashable { - public let profileId: String - public let accountName: String? - public let accountEmail: String? - public let refreshed: Bool - public let setDefault: Bool - - public init( - profileId: String, - accountName: String?, - accountEmail: String?, - refreshed: Bool, - setDefault: Bool - ) { - self.profileId = profileId - self.accountName = accountName - self.accountEmail = accountEmail - self.refreshed = refreshed - self.setDefault = setDefault - } -} - -public enum LinearConnectionPresentationState: String, Hashable { - case connected - case connecting - case needsAttention - case notConnected - - public var label: String { - switch self { - case .connected: - "Connected" - case .connecting: - "Connecting" - case .needsAttention: - "Needs attention" - case .notConnected: - "Not connected" - } - } -} - -public enum LinearConnectionStateResolver { - public static func presentationState( - profileId: String?, - localProfilePresent: Bool, - localTokenResolved: Bool?, - remoteLocalAccessAvailable: Bool - ) -> LinearConnectionPresentationState { - let trimmedProfileId = profileId?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" - guard !trimmedProfileId.isEmpty else { - return .needsAttention - } - - let localAccessReady = localProfilePresent && (localTokenResolved ?? true) - return (localAccessReady || remoteLocalAccessAvailable) ? .connected : .needsAttention - } -} - -public struct LinearStatusResponse: Decodable, Hashable { - public let extensionId: String - public let selectedProfile: String - public let selectedSource: String - public let defaultProfile: String - public let selectedMissing: Bool - public let authRef: String - public let service: String - public let tokenEnvFallback: String - public let apiUrl: String - public let accountId: String? - public let accountName: String? - public let accountEmail: String? - public let tokenResolved: Bool - public let tokenSource: String? - public let tokenExpiresAt: String? - public let error: String? - public let profileError: String? - - public init( - extensionId: String, - selectedProfile: String, - selectedSource: String, - defaultProfile: String, - selectedMissing: Bool, - authRef: String, - service: String, - tokenEnvFallback: String, - apiUrl: String, - accountId: String?, - accountName: String?, - accountEmail: String?, - tokenResolved: Bool, - tokenSource: String?, - tokenExpiresAt: String?, - error: String?, - profileError: String? - ) { - self.extensionId = extensionId - self.selectedProfile = selectedProfile - self.selectedSource = selectedSource - self.defaultProfile = defaultProfile - self.selectedMissing = selectedMissing - self.authRef = authRef - self.service = service - self.tokenEnvFallback = tokenEnvFallback - self.apiUrl = apiUrl - self.accountId = accountId - self.accountName = accountName - self.accountEmail = accountEmail - self.tokenResolved = tokenResolved - self.tokenSource = tokenSource - self.tokenExpiresAt = tokenExpiresAt - self.error = error - self.profileError = profileError - } -} - -public struct LinearOAuthFlowStartResponse: Decodable, Hashable { - public let ok: Bool - public let flowId: String - public let profileId: String - public let setDefault: Bool - public let authorizeUrl: String - public let statusUrl: String - public let expiresAt: String - - public init( - ok: Bool, - flowId: String, - profileId: String, - setDefault: Bool, - authorizeUrl: String, - statusUrl: String, - expiresAt: String - ) { - self.ok = ok - self.flowId = flowId - self.profileId = profileId - self.setDefault = setDefault - self.authorizeUrl = authorizeUrl - self.statusUrl = statusUrl - self.expiresAt = expiresAt - } -} - -public struct LinearOAuthFlowStatusResponse: Decodable, Hashable { - public let id: String - public let status: String - public let profileId: String - public let setDefault: Bool - public let createdAt: String - public let expiresAt: String - public let completedAt: String? - public let accountHandle: String? - public let accountLogin: String? - public let accountName: String? - public let accountId: String? - public let accountEmail: String? - public let tokenExpiresAt: String? - public let error: String? - - public init( - id: String, - status: String, - profileId: String, - setDefault: Bool, - createdAt: String, - expiresAt: String, - completedAt: String?, - accountHandle: String?, - accountLogin: String?, - accountName: String?, - accountId: String?, - accountEmail: String?, - tokenExpiresAt: String?, - error: String? - ) { - self.id = id - self.status = status - self.profileId = profileId - self.setDefault = setDefault - self.createdAt = createdAt - self.expiresAt = expiresAt - self.completedAt = completedAt - self.accountHandle = accountHandle - self.accountLogin = accountLogin - self.accountName = accountName - self.accountId = accountId - self.accountEmail = accountEmail - self.tokenExpiresAt = tokenExpiresAt - self.error = error - } -} - -public struct LinearProjectSummary: Decodable, Hashable, Identifiable { - public let id: String - public let name: String - public let teamId: String - public let teamKey: String? - public let teamName: String? - - public init( - id: String, - name: String, - teamId: String, - teamKey: String?, - teamName: String? - ) { - self.id = id - self.name = name - self.teamId = teamId - self.teamKey = teamKey - self.teamName = teamName - } -} - -public struct LinearProjectsResponse: Decodable, Hashable { - public let profile: String - public let projects: [LinearProjectSummary] - - public init(profile: String, projects: [LinearProjectSummary]) { - self.profile = profile - self.projects = projects - } -} - -public struct LinearAssigneeMapping: Decodable, Hashable, Identifiable { - public let profileId: String - public let teamId: String? - public let localAssignee: String - public let linearUserId: String? - public let linearUserName: String? - public let linearUserEmail: String? - - public var id: String { - [profileId, teamId ?? "*", localAssignee].joined(separator: "::") - } - - public init( - profileId: String, - teamId: String?, - localAssignee: String, - linearUserId: String?, - linearUserName: String?, - linearUserEmail: String? - ) { - self.profileId = profileId - self.teamId = teamId - self.localAssignee = localAssignee - self.linearUserId = linearUserId - self.linearUserName = linearUserName - self.linearUserEmail = linearUserEmail - } -} - -public struct LinearAssigneeMappingsResponse: Decodable, Hashable { - public let profileId: String - public let teamId: String? - public let mappings: [LinearAssigneeMapping] - - public init( - profileId: String, - teamId: String?, - mappings: [LinearAssigneeMapping] - ) { - self.profileId = profileId - self.teamId = teamId - self.mappings = mappings - } -} - -public struct LinearAssigneeMappingMutationResponse: Decodable, Hashable { - public let upserted: Bool - public let replacedExisting: Bool - public let mapping: LinearAssigneeMapping - - public init( - upserted: Bool, - replacedExisting: Bool, - mapping: LinearAssigneeMapping - ) { - self.upserted = upserted - self.replacedExisting = replacedExisting - self.mapping = mapping - } -} - -public struct LinearAssigneeMappingRemovalResponse: Decodable, Hashable { - public let removed: Bool - public let profileId: String - public let teamId: String? - public let localAssignee: String - - public init( - removed: Bool, - profileId: String, - teamId: String?, - localAssignee: String - ) { - self.removed = removed - self.profileId = profileId - self.teamId = teamId - self.localAssignee = localAssignee - } -} - -public struct LinearAutosyncSubscription: Decodable, Hashable, Identifiable { - public let id: String - public let profileId: String - public let projectId: String? - public let teamId: String? - public let mode: String - public let status: String - public let updatedAt: String? - - public init( - id: String, - profileId: String, - projectId: String?, - teamId: String?, - mode: String, - status: String, - updatedAt: String? - ) { - self.id = id - self.profileId = profileId - self.projectId = projectId - self.teamId = teamId - self.mode = mode - self.status = status - self.updatedAt = updatedAt - } -} - -public struct LinearAutosyncSubscriptionsResponse: Decodable, Hashable { - public let profileId: String - public let subscriptions: [LinearAutosyncSubscription] - - public init( - profileId: String, - subscriptions: [LinearAutosyncSubscription] - ) { - self.profileId = profileId - self.subscriptions = subscriptions - } -} - -public struct LinearAutosyncSubscriptionMutationResponse: Decodable, Hashable { - public let profileId: String - public let subscription: LinearAutosyncSubscription - - public init( - profileId: String, - subscription: LinearAutosyncSubscription - ) { - self.profileId = profileId - self.subscription = subscription - } -} - -public struct LinearProjectBindingResponse: Decodable, Hashable { - public let ok: Bool - public let cleared: Bool? - public let profileId: String? - public let projectId: String? - public let projectName: String? - public let teamId: String? - public let additionalProjects: [LinearProjectBindingTarget] - public let additionalProjectChanged: Bool? - public let removedProjectId: String? - - public init( - ok: Bool, - cleared: Bool?, - profileId: String?, - projectId: String?, - projectName: String?, - teamId: String?, - additionalProjects: [LinearProjectBindingTarget] = [], - additionalProjectChanged: Bool? = nil, - removedProjectId: String? = nil - ) { - self.ok = ok - self.cleared = cleared - self.profileId = profileId - self.projectId = projectId - self.projectName = projectName - self.teamId = teamId - self.additionalProjects = additionalProjects - self.additionalProjectChanged = additionalProjectChanged - self.removedProjectId = removedProjectId - } - - public var defaultProject: LinearProjectBindingTarget? { - guard let projectId else { - return nil - } - return LinearProjectBindingTarget( - profileId: profileId, - projectId: projectId, - projectName: projectName, - teamId: teamId - ) - } - - public var allProjects: [LinearProjectBindingTarget] { - var projects: [LinearProjectBindingTarget] = [] - if let defaultProject { - projects.append(defaultProject) - } - projects.append(contentsOf: additionalProjects) - return projects - } -} - -public struct LinearProjectBindingTarget: Decodable, Hashable, Identifiable { - public let profileId: String? - public let projectId: String - public let projectName: String? - public let teamId: String? - - public var id: String { - let profile = profileId ?? "" - return "\(profile)::\(projectId)" - } - - public init( - profileId: String?, - projectId: String, - projectName: String?, - teamId: String? - ) { - self.profileId = profileId - self.projectId = projectId - self.projectName = projectName - self.teamId = teamId - } -} - -public struct LinearIssueSyncResponse: Decodable, Hashable { - public let ok: Bool - public let operation: String - public let ticketId: String - public let issueIdentifier: String - public let issueId: String? - - public init( - ok: Bool, - operation: String, - ticketId: String, - issueIdentifier: String, - issueId: String? - ) { - self.ok = ok - self.operation = operation - self.ticketId = ticketId - self.issueIdentifier = issueIdentifier - self.issueId = issueId - } -} - -public struct LinearProjectSyncResponse: Decodable, Hashable { - public let ok: Bool - public let projectIds: [String]? - public let processed: Int - public let created: Int - public let updated: Int - - public init( - ok: Bool, - projectIds: [String]? = nil, - processed: Int, - created: Int, - updated: Int - ) { - self.ok = ok - self.projectIds = projectIds - self.processed = processed - self.created = created - self.updated = updated - } -} - -public struct LinearAutosyncRunResponse: Decodable, Hashable { - public let ok: Bool - public let subscribedRoutes: Int - public let processedDeliveries: Int - public let appliedDeliveries: Int - public let skippedDeliveries: Int - public let failedDeliveries: Int - public let created: Int - public let updated: Int - public let commentsPulled: Int - public let conflictsRecorded: Int - public let checkpointsRecorded: Int - public let projectIds: [String]? - - public init( - ok: Bool, - subscribedRoutes: Int, - processedDeliveries: Int, - appliedDeliveries: Int, - skippedDeliveries: Int, - failedDeliveries: Int, - created: Int, - updated: Int, - commentsPulled: Int, - conflictsRecorded: Int, - checkpointsRecorded: Int, - projectIds: [String]? = nil - ) { - self.ok = ok - self.subscribedRoutes = subscribedRoutes - self.processedDeliveries = processedDeliveries - self.appliedDeliveries = appliedDeliveries - self.skippedDeliveries = skippedDeliveries - self.failedDeliveries = failedDeliveries - self.created = created - self.updated = updated - self.commentsPulled = commentsPulled - self.conflictsRecorded = conflictsRecorded - self.checkpointsRecorded = checkpointsRecorded - self.projectIds = projectIds - } -} - -public struct RailwayBootstrapRequest: Hashable { - public let railwayProject: String? - public let railwayService: String? - public let railwayEnvironment: String? - public let railwayWorkspace: String? - public let createService: Bool - public let railwayImage: String? - public let railwayBin: String? - public let nodeName: String? - public let endpoint: String? - public let labels: [String] - public let defaultNode: Bool - public let domainPort: Int? - public let initRetries: Int? - public let privateNetworking: Bool - public let tailscaleAuthKey: String? - public let tailscaleHostname: String? - public let tailscaleTags: [String] - - public init( - railwayProject: String?, - railwayService: String?, - railwayEnvironment: String?, - railwayWorkspace: String?, - createService: Bool, - railwayImage: String?, - railwayBin: String?, - nodeName: String?, - endpoint: String?, - labels: [String], - defaultNode: Bool, - domainPort: Int?, - initRetries: Int?, - privateNetworking: Bool, - tailscaleAuthKey: String?, - tailscaleHostname: String?, - tailscaleTags: [String] - ) { - self.railwayProject = railwayProject - self.railwayService = railwayService - self.railwayEnvironment = railwayEnvironment - self.railwayWorkspace = railwayWorkspace - self.createService = createService - self.railwayImage = railwayImage - self.railwayBin = railwayBin - self.nodeName = nodeName - self.endpoint = endpoint - self.labels = labels - self.defaultNode = defaultNode - self.domainPort = domainPort - self.initRetries = initRetries - self.privateNetworking = privateNetworking - self.tailscaleAuthKey = tailscaleAuthKey - self.tailscaleHostname = tailscaleHostname - self.tailscaleTags = tailscaleTags - } -} - -public struct RailwayBootstrapProviderMetadata: Decodable, Hashable { - public let project: String? - public let service: String? - public let environment: String? - public let network: String? - public let tailscaleAuth: String? - public let workspace: String? - public let createService: Bool? - public let domainPort: Int? - public let initAttempts: Int? - - public init( - project: String?, - service: String?, - environment: String?, - network: String?, - tailscaleAuth: String?, - workspace: String?, - createService: Bool?, - domainPort: Int?, - initAttempts: Int? - ) { - self.project = project - self.service = service - self.environment = environment - self.network = network - self.tailscaleAuth = tailscaleAuth - self.workspace = workspace - self.createService = createService - self.domainPort = domainPort - self.initAttempts = initAttempts - } -} - -public struct RailwayBootstrapProbe: 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 RailwayBootstrapResponse: Decodable, Hashable { - public let provider: String - public let railway: RailwayBootstrapProviderMetadata - public let node: NodeRegistryRecord - public let endpoint: String - public let created: Bool - public let probe: RailwayBootstrapProbe - - public init( - provider: String, - railway: RailwayBootstrapProviderMetadata, - node: NodeRegistryRecord, - endpoint: String, - created: Bool, - probe: RailwayBootstrapProbe - ) { - self.provider = provider - self.railway = railway - self.node = node - self.endpoint = endpoint - self.created = created - self.probe = probe - } -} - -public struct TailscaleInspectResponse: Decodable { - public let installed: Bool - public let binaryPath: String? - public let connected: Bool - public let backendState: String? - public let tailnetName: String? - public let magicDnsSuffix: String? - public let authUrl: String? - public let currentExitNodeId: String? - public let currentExitNodeName: String? - public let selfDevice: TailscaleInspectSelf? - public let peers: [TailscaleInspectPeer] - public let onlinePeerCount: Int - public let exitNodes: [TailscaleInspectPeer] - public let health: [String] - public let error: String? - - public init( - installed: Bool, - binaryPath: String?, - connected: Bool, - backendState: String?, - tailnetName: String?, - magicDnsSuffix: String?, - authUrl: String?, - currentExitNodeId: String?, - currentExitNodeName: String?, - selfDevice: TailscaleInspectSelf?, - peers: [TailscaleInspectPeer], - onlinePeerCount: Int, - exitNodes: [TailscaleInspectPeer], - health: [String], - error: String? - ) { - self.installed = installed - self.binaryPath = binaryPath - self.connected = connected - self.backendState = backendState - self.tailnetName = tailnetName - self.magicDnsSuffix = magicDnsSuffix - self.authUrl = authUrl - self.currentExitNodeId = currentExitNodeId - self.currentExitNodeName = currentExitNodeName - self.selfDevice = selfDevice - self.peers = peers - self.onlinePeerCount = onlinePeerCount - self.exitNodes = exitNodes - self.health = health - self.error = error - } - - private enum CodingKeys: String, CodingKey { - case installed - case binaryPath - case connected - case backendState - case tailnetName - case magicDnsSuffix - case authUrl - case currentExitNodeId - case currentExitNodeName - case selfDevice = "self" - case peers - case onlinePeerCount - case exitNodes - case health - case error - } -} - -public struct TailscaleInspectSelf: Decodable, Hashable, Identifiable { - public let id: String - public let hostname: String - public let dnsName: String? - public let tailscaleIp: String? - public let online: Bool - public let os: String? - public let tags: [String] - public let isExitNode: Bool - - public init( - id: String, - hostname: String, - dnsName: String?, - tailscaleIp: String?, - online: Bool, - os: String?, - tags: [String], - isExitNode: Bool - ) { - self.id = id - self.hostname = hostname - self.dnsName = dnsName - self.tailscaleIp = tailscaleIp - self.online = online - self.os = os - self.tags = tags - self.isExitNode = isExitNode - } -} - -public struct TailscaleInspectPeer: Decodable, Hashable, Identifiable { - public let id: String - public let hostname: String - public let dnsName: String? - public let tailscaleIp: String? - public let online: Bool - public let os: String? - public let tags: [String] - public let isExitNode: Bool - public let isExitNodeOption: Bool - - public init( - id: String, - hostname: String, - dnsName: String?, - tailscaleIp: String?, - online: Bool, - os: String?, - tags: [String], - isExitNode: Bool, - isExitNodeOption: Bool - ) { - self.id = id - self.hostname = hostname - self.dnsName = dnsName - self.tailscaleIp = tailscaleIp - self.online = online - self.os = os - self.tags = tags - self.isExitNode = isExitNode - self.isExitNodeOption = isExitNodeOption - } -} - -public struct TailscaleOAuthStatusResponse: Decodable, Hashable { - public let configured: Bool - public let clientId: String? - public let authRef: String? - public let tailnet: String? - public let keyExpirySeconds: Int? - public let validated: Bool? - public let checkedAt: String? - public let tokenExpiresAt: String? - public let deleted: Bool? - public let error: String? - - public init( - configured: Bool, - clientId: String?, - authRef: String?, - tailnet: String?, - keyExpirySeconds: Int?, - validated: Bool?, - checkedAt: String?, - tokenExpiresAt: String?, - deleted: Bool?, - error: String? - ) { - self.configured = configured - self.clientId = clientId - self.authRef = authRef - self.tailnet = tailnet - self.keyExpirySeconds = keyExpirySeconds - self.validated = validated - self.checkedAt = checkedAt - self.tokenExpiresAt = tokenExpiresAt - self.deleted = deleted - self.error = error - } -} - -public struct TailscaleOAuthConnectRequest: Hashable { - public let clientId: String - public let clientSecret: String - public let authRef: String? - public let tailnet: String? - public let keyExpirySeconds: Int? - - public init( - clientId: String, - clientSecret: String, - authRef: String?, - tailnet: String?, - keyExpirySeconds: Int? - ) { - self.clientId = clientId - self.clientSecret = clientSecret - self.authRef = authRef - self.tailnet = tailnet - self.keyExpirySeconds = keyExpirySeconds - } -} - public struct GatewayExposure: Decodable, Identifiable, Hashable { public enum State: String, Decodable { case disabled @@ -2276,975 +643,3 @@ public struct GatewayExposure: Decodable, Identifiable, Hashable { self.url = url } } - -public enum TicketStatus: String, Decodable { - case open - case inProgress = "in_progress" - case blocked - case done -} -extension TicketStatus: Encodable {} - -public struct TicketSummary: Decodable, Encodable, Identifiable, Hashable { - public let ticketId: String - public let title: String - public let body: String? - public let status: TicketStatus - public let createdAt: String - public let updatedAt: String - public let dependsOn: [String] - public let blocks: [String] - public let owner: String - public let source: String - public let assignee: String? - public let tags: [String] - public let externalSystem: String? - public let externalId: String? - public let externalKey: String? - public let externalUrl: String? - public let externalProjectId: String? - public let externalProjectName: String? - public let externalTeamId: String? - public let projectId: String? - public let projectName: String? - - public var id: String { ticketId } - - public init( - ticketId: String, - title: String, - body: String?, - status: TicketStatus, - createdAt: String, - updatedAt: String, - dependsOn: [String], - blocks: [String], - owner: String, - source: String, - assignee: String?, - tags: [String], - externalSystem: String?, - externalId: String?, - externalKey: String?, - externalUrl: String?, - externalProjectId: String?, - externalProjectName: String?, - externalTeamId: String?, - projectId: String?, - projectName: String? - ) { - self.ticketId = ticketId - self.title = title - self.body = body - self.status = status - self.createdAt = createdAt - self.updatedAt = updatedAt - self.dependsOn = dependsOn - self.blocks = blocks - self.owner = owner - self.source = source - self.assignee = assignee - self.tags = tags - self.externalSystem = externalSystem - self.externalId = externalId - self.externalKey = externalKey - self.externalUrl = externalUrl - self.externalProjectId = externalProjectId - self.externalProjectName = externalProjectName - self.externalTeamId = externalTeamId - self.projectId = projectId - self.projectName = projectName - } - - private enum CodingKeys: String, CodingKey { - case ticketId - case title - case body - case status - case createdAt - case updatedAt - case dependsOn - case blocks - case owner - case source - case assignee - case tags - case externalSystem - case externalId - case externalKey - case externalUrl - case externalProjectId - case externalProjectName - case externalTeamId - case projectId - case projectName - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - ticketId = try container.decode(String.self, forKey: .ticketId) - title = try container.decode(String.self, forKey: .title) - body = try container.decodeIfPresent(String.self, forKey: .body) - status = try container.decode(TicketStatus.self, forKey: .status) - createdAt = try container.decode(String.self, forKey: .createdAt) - updatedAt = try container.decode(String.self, forKey: .updatedAt) - dependsOn = try container.decodeIfPresent([String].self, forKey: .dependsOn) ?? [] - blocks = try container.decodeIfPresent([String].self, forKey: .blocks) ?? [] - owner = try container.decodeIfPresent(String.self, forKey: .owner) ?? "hack" - source = try container.decodeIfPresent(String.self, forKey: .source) ?? "hack" - assignee = try container.decodeIfPresent(String.self, forKey: .assignee) - tags = try container.decodeIfPresent([String].self, forKey: .tags) ?? [] - externalSystem = try container.decodeIfPresent(String.self, forKey: .externalSystem) - externalId = try container.decodeIfPresent(String.self, forKey: .externalId) - externalKey = try container.decodeIfPresent(String.self, forKey: .externalKey) - externalUrl = try container.decodeIfPresent(String.self, forKey: .externalUrl) - externalProjectId = try container.decodeIfPresent(String.self, forKey: .externalProjectId) - externalProjectName = try container.decodeIfPresent(String.self, forKey: .externalProjectName) - externalTeamId = try container.decodeIfPresent(String.self, forKey: .externalTeamId) - projectId = try container.decodeIfPresent(String.self, forKey: .projectId) - projectName = try container.decodeIfPresent(String.self, forKey: .projectName) - } -} - -/// Describes which system currently owns authoritative Linear-sync fields for a ticket. -public enum LinearSyncAuthority: String, Hashable { - case hack - case linear - - public var label: String { - switch self { - case .hack: - "Hack" - case .linear: - "Linear" - } - } -} - -/// Pure, metadata-derived sync hints for UI surfaces that need to explain Linear ticket behavior. -public struct LinearSyncUXState: Hashable { - public let authority: LinearSyncAuthority - public let isLinkedToLinear: Bool - public let shortGuidance: String - public let reviewHint: String? - - public init(ticket: TicketSummary) { - let authority = Self.resolveAuthority(source: ticket.source) - let isLinkedToLinear = Self.isLinkedToLinear(ticket: ticket) - self.authority = authority - self.isLinkedToLinear = isLinkedToLinear - shortGuidance = Self.makeShortGuidance( - authority: authority, - isLinkedToLinear: isLinkedToLinear - ) - reviewHint = Self.makeReviewHint( - source: ticket.source, - owner: ticket.owner, - isLinkedToLinear: isLinkedToLinear - ) - } - - private static func resolveAuthority(source: String) -> LinearSyncAuthority { - switch LinearSyncActor(rawValue: source.normalizedSyncActor) ?? .unknown { - case .linear: - .linear - case .hack, .unknown: - .hack - } - } - - private static func isLinkedToLinear(ticket: TicketSummary) -> Bool { - if ticket.source.normalizedSyncActor == LinearSyncActor.linear.rawValue { - return true - } - if ticket.owner.normalizedSyncActor == LinearSyncActor.linear.rawValue { - return true - } - if ticket.externalSystem?.normalizedSyncActor == LinearSyncActor.linear.rawValue { - return true - } - if ticket.externalId?.isEmpty == false { - return true - } - if ticket.externalKey?.isEmpty == false { - return true - } - return false - } - - private static func makeShortGuidance( - authority: LinearSyncAuthority, - isLinkedToLinear: Bool - ) -> String { - guard isLinkedToLinear else { - return "Local only. Connect to Linear to sync this ticket." - } - return "\(authority.label) controls title, body, and status. Linear linkage metadata refreshes from Linear, while Hack project placement stays local. Comments append only. Assignee, labels, and dependencies sync best effort." - } - - private static func makeReviewHint( - source: String, - owner: String, - isLinkedToLinear: Bool - ) -> String? { - guard isLinkedToLinear else { - return nil - } - let normalizedSource = LinearSyncActor(rawValue: source.normalizedSyncActor) ?? .unknown - let normalizedOwner = LinearSyncActor(rawValue: owner.normalizedSyncActor) ?? .unknown - guard normalizedSource == .unknown || normalizedOwner == .unknown || normalizedSource != normalizedOwner - else { - return nil - } - return "Review assignee, labels, and dependencies before the next sync." - } -} - -public extension TicketSummary { - var linearSyncAuthority: LinearSyncAuthority { - linearSyncUXState.authority - } - - var linearSyncUXState: LinearSyncUXState { - LinearSyncUXState(ticket: self) - } -} - -public indirect enum TicketMetadataValue: Decodable, Hashable { - case string(String) - case integer(Int) - case double(Double) - case boolean(Bool) - case array([TicketMetadataValue]) - case object([String: TicketMetadataValue]) - case null - - public init(from decoder: Decoder) throws { - let container = try decoder.singleValueContainer() - if container.decodeNil() { - self = .null - return - } - if let string = try? container.decode(String.self) { - self = .string(string) - return - } - if let integer = try? container.decode(Int.self) { - self = .integer(integer) - return - } - if let double = try? container.decode(Double.self) { - self = .double(double) - return - } - if let boolean = try? container.decode(Bool.self) { - self = .boolean(boolean) - return - } - if let array = try? container.decode([TicketMetadataValue].self) { - self = .array(array) - return - } - if let object = try? container.decode([String: TicketMetadataValue].self) { - self = .object(object) - return - } - throw DecodingError.dataCorruptedError( - in: container, - debugDescription: "Unsupported ticket metadata value." - ) - } - - public func hash(into hasher: inout Hasher) { - switch self { - case let .string(value): - hasher.combine(0) - hasher.combine(value) - case let .integer(value): - hasher.combine(1) - hasher.combine(value) - case let .double(value): - hasher.combine(2) - hasher.combine(value) - case let .boolean(value): - hasher.combine(3) - hasher.combine(value) - case let .array(values): - hasher.combine(4) - for value in values { - hasher.combine(value) - } - case let .object(values): - hasher.combine(5) - for key in values.keys.sorted() { - hasher.combine(key) - hasher.combine(values[key]) - } - case .null: - hasher.combine(6) - } - } - - public var displayText: String { - switch self { - case let .string(value): - return value - case let .integer(value): - return String(value) - case let .double(value): - return value.formatted(.number) - case let .boolean(value): - return value ? "true" : "false" - case let .array(values): - return "[\(values.map(\.displayText).joined(separator: ", "))]" - case let .object(values): - let entries = values.keys.sorted().compactMap { key -> String? in - guard let value = values[key] else { - return nil - } - return #""\#(key)":\#(value.quotedDisplayText)"# - } - return "{\(entries.joined(separator: ","))}" - case .null: - return "null" - } - } - - private var quotedDisplayText: String { - switch self { - case .string: - return #""\#(escapedDisplayText)""# - default: - return displayText - } - } - - private var escapedDisplayText: String { - displayText - .replacingOccurrences(of: "\\", with: "\\\\") - .replacingOccurrences(of: "\"", with: "\\\"") - } -} - -public struct TicketComment: Decodable, Identifiable, Hashable { - public let commentId: String - public let ticketId: String - public let body: String - public let source: String - public let actor: String - public let createdAt: String - public let externalId: String? - public let externalUrl: String? - - public var id: String { commentId } - - public init( - commentId: String, - ticketId: String, - body: String, - source: String, - actor: String, - createdAt: String, - externalId: String?, - externalUrl: String? - ) { - self.commentId = commentId - self.ticketId = ticketId - self.body = body - self.source = source - self.actor = actor - self.createdAt = createdAt - self.externalId = externalId - self.externalUrl = externalUrl - } -} - -public struct TicketSyncCheckpoint: Decodable, Identifiable, Hashable { - public let checkpointId: String - public let ticketId: String - public let provider: String - public let profileId: String? - public let direction: String? - public let remoteCursor: String? - public let remoteUpdatedAt: String? - public let localUpdatedAt: String? - public let actor: String - public let createdAt: String - - public var id: String { checkpointId } - - public init( - checkpointId: String, - ticketId: String, - provider: String, - profileId: String?, - direction: String?, - remoteCursor: String?, - remoteUpdatedAt: String?, - localUpdatedAt: String?, - actor: String, - createdAt: String - ) { - self.checkpointId = checkpointId - self.ticketId = ticketId - self.provider = provider - self.profileId = profileId - self.direction = direction - self.remoteCursor = remoteCursor - self.remoteUpdatedAt = remoteUpdatedAt - self.localUpdatedAt = localUpdatedAt - self.actor = actor - self.createdAt = createdAt - } -} - -public enum TicketSyncConflictStatus: String, Decodable, Hashable { - case open - case resolved -} - -public enum TicketSyncConflictResolution: String, Decodable, Hashable { - case acceptLocal = "accept_local" - case acceptRemote = "accept_remote" - case merged - case ignore -} - -public struct TicketSyncConflict: Decodable, Identifiable, Hashable { - public let conflictId: String - public let ticketId: String - public let provider: String - public let field: String - public let status: TicketSyncConflictStatus - public let authority: String? - public let summary: String? - public let localValue: TicketMetadataValue? - public let remoteValue: TicketMetadataValue? - public let createdAt: String - public let updatedAt: String - public let resolution: TicketSyncConflictResolution? - public let resolutionSummary: String? - public let resolvedAt: String? - public let resolvedBy: String? - - public var id: String { conflictId } - - public init( - conflictId: String, - ticketId: String, - provider: String, - field: String, - status: TicketSyncConflictStatus, - authority: String?, - summary: String?, - localValue: TicketMetadataValue?, - remoteValue: TicketMetadataValue?, - createdAt: String, - updatedAt: String, - resolution: TicketSyncConflictResolution?, - resolutionSummary: String?, - resolvedAt: String?, - resolvedBy: String? - ) { - self.conflictId = conflictId - self.ticketId = ticketId - self.provider = provider - self.field = field - self.status = status - self.authority = authority - self.summary = summary - self.localValue = localValue - self.remoteValue = remoteValue - self.createdAt = createdAt - self.updatedAt = updatedAt - self.resolution = resolution - self.resolutionSummary = resolutionSummary - self.resolvedAt = resolvedAt - self.resolvedBy = resolvedBy - } -} - -public enum TicketSyncReviewSeverity: String, Hashable { - case clear - case review - case conflict -} - -public struct TicketSyncReviewState: Hashable { - public let severity: TicketSyncReviewSeverity - public let needsReview: Bool - public let badgeLabel: String - public let title: String - public let message: String - public let commentCount: Int - public let openConflictCount: Int - public let resolvedConflictCount: Int - public let checkpointSummary: String? - public let highlightedFields: [String] - - public init(detail: TicketDetailResponse) { - let checkpointSummary = Self.makeCheckpointSummary(detail.latestSyncCheckpoint) - let openConflicts = detail.openSyncConflicts - let resolvedConflicts = detail.resolvedSyncConflicts - let highlightedFields = Self.makeHighlightedFields( - openConflicts: openConflicts, - reviewHint: detail.ticket.linearSyncUXState.reviewHint - ) - let guidance = detail.ticket.linearSyncUXState.shortGuidance - - let severity: TicketSyncReviewSeverity - let needsReview: Bool - let badgeLabel: String - let title: String - let message: String - - if !openConflicts.isEmpty { - severity = .conflict - needsReview = true - badgeLabel = openConflicts.count == 1 ? "1 open conflict" : "\(openConflicts.count) open conflicts" - title = "Resolve sync conflicts" - let summaries = openConflicts.compactMap(\.summary).joined(separator: " ") - let fieldSummary = highlightedFields.joined(separator: ", ") - let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" - message = "Review \(fieldSummary) before the next sync. Comments stay append-only. \(guidance) \(summaries)\(checkpointClause)" - .replacingOccurrences(of: " ", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - } else if let reviewHint = detail.ticket.linearSyncUXState.reviewHint { - severity = .review - needsReview = true - badgeLabel = "Needs review" - title = "Review mergeable fields" - let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" - message = "\(reviewHint) \(guidance)\(checkpointClause)" - .replacingOccurrences(of: " ", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - } else { - severity = .clear - needsReview = false - badgeLabel = "Ready" - title = detail.ticket.linearSyncUXState.isLinkedToLinear ? "Sync ready" : "Local only" - let checkpointClause = checkpointSummary.map { " \($0)" } ?? "" - message = "\(guidance)\(checkpointClause)" - .replacingOccurrences(of: " ", with: " ") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - self.severity = severity - self.needsReview = needsReview - self.badgeLabel = badgeLabel - self.title = title - self.message = message - commentCount = detail.comments.count - openConflictCount = openConflicts.count - resolvedConflictCount = resolvedConflicts.count - self.checkpointSummary = checkpointSummary - self.highlightedFields = highlightedFields - } - - private static func makeCheckpointSummary(_ checkpoint: TicketSyncCheckpoint?) -> String? { - guard let checkpoint else { - return nil - } - let direction = checkpoint.direction?.trimmingCharacters(in: .whitespacesAndNewlines) - let profile = checkpoint.profileId?.trimmingCharacters(in: .whitespacesAndNewlines) - let provider = checkpoint.provider.capitalized - var segments: [String] = [] - if let direction, !direction.isEmpty { - segments.append(direction) - } - segments.append("via \(provider)") - var summary = "Last sync: \(segments.joined(separator: " "))" - if let profile, !profile.isEmpty { - summary += " (profile \(profile))" - } - summary += "." - return summary - } - - private static func makeHighlightedFields( - openConflicts: [TicketSyncConflict], - reviewHint: String? - ) -> [String] { - let fields = openConflicts.map(\.field) - if !fields.isEmpty { - return fields - } - guard reviewHint != nil else { - return [] - } - return ["assignee", "labels", "dependencies"] - } -} - -public struct TicketReviewQueueEntry: Hashable, Identifiable { - public let ticketId: String - public let title: String - public let badgeLabel: String - public let summary: String - public let commentCount: Int - public let openConflictCount: Int - public let reviewNoteCount: Int - public let updatedAt: String - - public var id: String { ticketId } - - public init?( - ticket: TicketSummary, - detail: TicketDetailResponse? = nil, - reviewNoteCount: Int = 0 - ) { - let normalizedReviewNoteCount = max(reviewNoteCount, 0) - let summary: String - let badgeLabel: String - let commentCount: Int - let openConflictCount: Int - - if let detail, detail.linearSyncReviewState.needsReview { - let review = detail.linearSyncReviewState - summary = review.message - badgeLabel = review.badgeLabel - commentCount = review.commentCount - openConflictCount = review.openConflictCount - } else if let reviewHint = ticket.linearSyncUXState.reviewHint { - summary = reviewHint - badgeLabel = "Needs review" - commentCount = detail?.comments.count ?? 0 - openConflictCount = detail?.openSyncConflicts.count ?? 0 - } else { - return nil - } - - ticketId = ticket.ticketId - title = ticket.title - self.badgeLabel = badgeLabel - self.summary = summary - self.commentCount = commentCount - self.openConflictCount = openConflictCount - self.reviewNoteCount = normalizedReviewNoteCount - updatedAt = detail?.ticket.updatedAt ?? ticket.updatedAt - } -} - -public struct TicketReviewNote: Decodable, Hashable, Identifiable { - public let noteId: String - public let ticketId: String - public let markdown: String - public let actor: String - public let createdAt: String - public let context: String? - - public var id: String { noteId } - - public init( - noteId: String, - ticketId: String, - markdown: String, - actor: String, - createdAt: String, - context: String? - ) { - self.noteId = noteId - self.ticketId = ticketId - self.markdown = markdown - self.actor = actor - self.createdAt = createdAt - self.context = context - } - - private enum CodingKeys: String, CodingKey { - case noteId - case ticketId - case markdown - case body - case actor - case createdAt - case context - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - noteId = try container.decode(String.self, forKey: .noteId) - ticketId = try container.decode(String.self, forKey: .ticketId) - markdown = - try container.decodeIfPresent(String.self, forKey: .markdown) - ?? container.decode(String.self, forKey: .body) - actor = try container.decode(String.self, forKey: .actor) - createdAt = try container.decode(String.self, forKey: .createdAt) - context = try container.decodeIfPresent(String.self, forKey: .context) - } -} - -public enum TicketReviewComposer { - public static func draft( - for detail: TicketDetailResponse, - highlightedConflict: TicketSyncConflict? = nil - ) -> String { - var lines = ["Review follow-up", ""] - - if let highlightedConflict { - lines.append("Conflict to resolve:") - lines.append("- Field: \(highlightedConflict.field)") - if let summary = highlightedConflict.summary, !summary.isEmpty { - lines.append("- Summary: \(summary)") - } - if let localValue = highlightedConflict.localValue?.displayText, !localValue.isEmpty { - lines.append("- Hack: \(localValue)") - } - if let remoteValue = highlightedConflict.remoteValue?.displayText, !remoteValue.isEmpty { - lines.append("- Linear: \(remoteValue)") - } - } else if !detail.openSyncConflicts.isEmpty { - lines.append("Open conflicts:") - for conflict in detail.openSyncConflicts.prefix(3) { - let summary = conflict.summary?.trimmingCharacters(in: .whitespacesAndNewlines) - let summarySuffix = (summary?.isEmpty == false) ? ": \(summary!)" : "" - lines.append("- \(conflict.field)\(summarySuffix)") - } - } else { - lines.append(detail.linearSyncReviewState.message) - } - - if let latestComment = detail.comments.last { - lines.append("") - lines.append("Latest comment context:") - lines.append(quote(comment: latestComment)) - } - - return lines.joined(separator: "\n") - .replacingOccurrences(of: "\n\n\n", with: "\n\n") - .trimmingCharacters(in: .whitespacesAndNewlines) - } - - public static func quote(comment: TicketComment) -> String { - let header = "> \(comment.actor) • \(comment.source) • \(comment.createdAt)" - let bodyLines = comment.body - .split(separator: "\n", omittingEmptySubsequences: false) - .map { "> \($0)" } - return ([header] + bodyLines).joined(separator: "\n") - } -} - -private enum LinearSyncActor: String { - case hack - case linear - case unknown -} - -private extension String { - var normalizedSyncActor: String { - trimmingCharacters(in: .whitespacesAndNewlines).lowercased() - } -} - -public struct TicketEvent: Decodable, Identifiable, Hashable { - public let eventId: String - public let ts: Int - public let tsIso: String - public let actor: String - public let projectId: String? - public let projectName: String? - public let ticketId: String - public let type: String - - public var id: String { eventId } - - public init( - eventId: String, - ts: Int, - tsIso: String, - actor: String, - projectId: String?, - projectName: String?, - ticketId: String, - type: String - ) { - self.eventId = eventId - self.ts = ts - self.tsIso = tsIso - self.actor = actor - self.projectId = projectId - self.projectName = projectName - self.ticketId = ticketId - self.type = type - } -} - -public struct TicketsListResponse: Decodable { - public let tickets: [TicketSummary] - - public init(tickets: [TicketSummary]) { - self.tickets = tickets - } -} - -public struct TicketDetailResponse: Decodable { - public let ticket: TicketSummary - public let comments: [TicketComment] - public let reviewNotes: [TicketReviewNote] - public let syncCheckpoints: [TicketSyncCheckpoint] - public let conflicts: [TicketSyncConflict] - public let events: [TicketEvent] - - public init(ticket: TicketSummary, events: [TicketEvent]) { - self.ticket = ticket - comments = [] - reviewNotes = [] - syncCheckpoints = [] - conflicts = [] - self.events = events - } - - public init( - ticket: TicketSummary, - comments: [TicketComment], - reviewNotes: [TicketReviewNote], - syncCheckpoints: [TicketSyncCheckpoint], - conflicts: [TicketSyncConflict], - events: [TicketEvent] - ) { - self.ticket = ticket - self.comments = comments - self.reviewNotes = reviewNotes - self.syncCheckpoints = syncCheckpoints - self.conflicts = conflicts - self.events = events - } - - private enum CodingKeys: String, CodingKey { - case ticket - case comments - case reviewNotes - case syncCheckpoints - case conflicts - case events - } - - public init(from decoder: Decoder) throws { - let container = try decoder.container(keyedBy: CodingKeys.self) - ticket = try container.decode(TicketSummary.self, forKey: .ticket) - comments = try container.decodeIfPresent([TicketComment].self, forKey: .comments) ?? [] - reviewNotes = - try container.decodeIfPresent([TicketReviewNote].self, forKey: .reviewNotes) ?? [] - syncCheckpoints = try container.decodeIfPresent([TicketSyncCheckpoint].self, forKey: .syncCheckpoints) ?? [] - conflicts = try container.decodeIfPresent([TicketSyncConflict].self, forKey: .conflicts) ?? [] - events = try container.decodeIfPresent([TicketEvent].self, forKey: .events) ?? [] - } -} - -public extension TicketDetailResponse { - var latestSyncCheckpoint: TicketSyncCheckpoint? { - syncCheckpoints.max(by: { lhs, rhs in - lhs.createdAt.localizedCompare(rhs.createdAt) == .orderedAscending - }) - } - - var openSyncConflicts: [TicketSyncConflict] { - conflicts.filter { $0.status == .open } - } - - var resolvedSyncConflicts: [TicketSyncConflict] { - conflicts.filter { $0.status == .resolved } - } - - var linearSyncReviewState: TicketSyncReviewState { - TicketSyncReviewState(detail: self) - } -} - -public struct TicketCreateResponse: Decodable { - public let ticket: TicketSummary - - public init(ticket: TicketSummary) { - self.ticket = ticket - } -} - -public struct TicketUpdateResponse: Decodable { - public let ok: Bool - public let ticketId: String - - public init(ok: Bool, ticketId: String) { - self.ok = ok - self.ticketId = ticketId - } -} - -public struct TicketStatusResponse: Decodable { - public let ok: Bool - public let ticketId: String - public let status: TicketStatus - - public init(ok: Bool, ticketId: String, status: TicketStatus) { - self.ok = ok - self.ticketId = ticketId - self.status = status - } -} - -public struct TicketConflictResolutionResponse: Decodable { - public let ok: Bool - public let ticketId: String - public let conflictId: String - public let resolution: TicketSyncConflictResolution - - public init( - ok: Bool, - ticketId: String, - conflictId: String, - resolution: TicketSyncConflictResolution - ) { - self.ok = ok - self.ticketId = ticketId - self.conflictId = conflictId - self.resolution = resolution - } -} - -public struct TicketCommentAppendResponse: Decodable { - public let comment: TicketComment - - public init(comment: TicketComment) { - self.comment = comment - } -} - -public struct TicketReviewNoteAppendResponse: Decodable { - public let reviewNote: TicketReviewNote - - public init(reviewNote: TicketReviewNote) { - self.reviewNote = reviewNote - } -} - -public struct TicketsSyncResponse: Decodable { - public let sync: TicketsSyncResult - - public init(sync: TicketsSyncResult) { - self.sync = sync - } -} - -public struct TicketsSyncResult: Decodable { - public let ok: Bool - public let branch: String? - public let remote: String? - public let didCommit: Bool? - public let didPush: Bool? - public let error: String? - - public init( - ok: Bool, - branch: String?, - remote: String?, - didCommit: Bool?, - didPush: Bool?, - error: String? - ) { - self.ok = ok - self.branch = branch - self.remote = remote - self.didCommit = didCommit - self.didPush = didPush - self.error = error - } -} diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift deleted file mode 100644 index a8faa216..00000000 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/LinearSyncUXModelTests.swift +++ /dev/null @@ -1,128 +0,0 @@ -import XCTest - -@testable import HackDesktopModels - -final class LinearSyncUXModelTests: XCTestCase { - func testRemoteLinearConnectionWithBrokerSeededLocalAccessRendersConnected() { - let state = LinearConnectionStateResolver.presentationState( - profileId: "default", - localProfilePresent: false, - localTokenResolved: nil, - remoteLocalAccessAvailable: true - ) - - XCTAssertEqual(state, .connected) - } - - func testRemoteLinearConnectionWithoutLocalOrBrokerAccessNeedsAttention() { - let state = LinearConnectionStateResolver.presentationState( - profileId: "default", - localProfilePresent: false, - localTokenResolved: nil, - remoteLocalAccessAvailable: false - ) - - XCTAssertEqual(state, .needsAttention) - } - - func testHackOriginLinkedTicketIsHackAuthoritative() { - let ticket = makeTicketSummary( - owner: "hack", - source: "hack", - externalSystem: "linear", - externalId: "lin_issue_1", - externalKey: "ENG-101" - ) - - XCTAssertEqual(ticket.linearSyncAuthority, .hack) - XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) - XCTAssertNil(ticket.linearSyncUXState.reviewHint) - XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Hack controls title, body, and status.")) - XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Hack project placement stays local.")) - } - - func testLinearOriginTicketIsLinearAuthoritative() { - let ticket = makeTicketSummary( - owner: "linear", - source: "linear", - externalSystem: "linear", - externalId: "lin_issue_2", - externalKey: "ENG-102" - ) - - XCTAssertEqual(ticket.linearSyncAuthority, .linear) - XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) - XCTAssertNil(ticket.linearSyncUXState.reviewHint) - XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Linear controls title, body, and status.")) - XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Hack project placement stays local.")) - } - - func testLinkedLinearTicketWithMismatchedOwnershipNeedsReviewHint() { - let ticket = makeTicketSummary( - owner: "linear", - source: "hack", - externalSystem: "linear", - externalId: "lin_issue_3", - externalKey: "ENG-103" - ) - - XCTAssertEqual(ticket.linearSyncAuthority, .hack) - XCTAssertTrue(ticket.linearSyncUXState.isLinkedToLinear) - XCTAssertEqual( - ticket.linearSyncUXState.reviewHint, - "Review assignee, labels, and dependencies before the next sync." - ) - XCTAssertTrue(ticket.linearSyncUXState.shortGuidance.contains("Comments append only.")) - } - - func testUnlinkedLocalTicketUsesLocalOnlyGuidance() { - let ticket = makeTicketSummary( - owner: "hack", - source: "hack", - externalSystem: nil, - externalId: nil, - externalKey: nil - ) - - XCTAssertEqual(ticket.linearSyncAuthority, .hack) - XCTAssertFalse(ticket.linearSyncUXState.isLinkedToLinear) - XCTAssertNil(ticket.linearSyncUXState.reviewHint) - XCTAssertEqual( - ticket.linearSyncUXState.shortGuidance, - "Local only. Connect to Linear to sync this ticket." - ) - } - - private func makeTicketSummary( - owner: String, - source: String, - externalSystem: String?, - externalId: String?, - externalKey: String?, - assignee: String? = nil - ) -> TicketSummary { - TicketSummary( - ticketId: "T-00001", - title: "Sync issue", - body: "Sync issue body", - status: .open, - createdAt: "2026-03-05T12:00:00Z", - updatedAt: "2026-03-05T12:00:00Z", - dependsOn: [], - blocks: [], - owner: owner, - source: source, - assignee: assignee, - tags: ["linear"], - externalSystem: externalSystem, - externalId: externalId, - externalKey: externalKey, - externalUrl: "https://linear.app/hack/issue/\(externalKey ?? "local")", - externalProjectId: "proj-1", - externalProjectName: "Alpha", - externalTeamId: "team-1", - projectId: "local-proj-1", - projectName: "Hack CLI" - ) - } -} diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/RailwayBootstrapRequestTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/RailwayBootstrapRequestTests.swift deleted file mode 100644 index 210e1152..00000000 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/RailwayBootstrapRequestTests.swift +++ /dev/null @@ -1,53 +0,0 @@ -import XCTest - -@testable import HackDesktopModels - -final class RailwayBootstrapRequestTests: XCTestCase { - func testRailwayProjectCanBeNil() { - let request = RailwayBootstrapRequest( - railwayProject: nil, - railwayService: nil, - railwayEnvironment: "production", - railwayWorkspace: nil, - createService: true, - railwayImage: "hackdance/hack:latest", - railwayBin: nil, - nodeName: "node-a", - endpoint: nil, - labels: ["railway", "linux"], - defaultNode: false, - domainPort: nil, - initRetries: nil, - privateNetworking: true, - tailscaleAuthKey: nil, - tailscaleHostname: nil, - tailscaleTags: [] - ) - - XCTAssertNil(request.railwayProject) - } - - func testRailwayProjectValueIsRetainedWhenProvided() { - let request = RailwayBootstrapRequest( - railwayProject: "my-project", - railwayService: "svc-a", - railwayEnvironment: "production", - railwayWorkspace: "ws-a", - createService: false, - railwayImage: nil, - railwayBin: nil, - nodeName: nil, - endpoint: nil, - labels: [], - defaultNode: true, - domainPort: 443, - initRetries: 2, - privateNetworking: false, - tailscaleAuthKey: nil, - tailscaleHostname: nil, - tailscaleTags: [] - ) - - XCTAssertEqual(request.railwayProject, "my-project") - } -} diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift deleted file mode 100644 index 451376c1..00000000 --- a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/TicketDetailReviewModelTests.swift +++ /dev/null @@ -1,416 +0,0 @@ -import XCTest - -@testable import HackDesktopModels - -final class TicketDetailReviewModelTests: XCTestCase { - func testTicketDetailDecodesRicherSyncPayload() throws { - let data = Data( - #""" - { - "ticket": { - "ticketId": "T-00042", - "title": "Investigate sync drift", - "body": "Imported from Linear.", - "status": "in_progress", - "createdAt": "2026-03-05T16:00:00Z", - "updatedAt": "2026-03-05T17:00:00Z", - "dependsOn": ["T-00001"], - "blocks": ["T-00099"], - "owner": "hack", - "source": "hack", - "assignee": "alice@hack", - "tags": ["linear", "review"], - "externalSystem": "linear", - "externalId": "lin_issue_42", - "externalKey": "ENG-42", - "externalUrl": "https://linear.app/hack/issue/ENG-42", - "externalProjectId": "proj-42", - "externalProjectName": "Control Plane", - "externalTeamId": "eng", - "projectId": "local-project", - "projectName": "Hack CLI" - }, - "comments": [ - { - "commentId": "c-1", - "ticketId": "T-00042", - "body": "Imported from Linear.", - "source": "linear", - "actor": "linear@app", - "createdAt": "2026-03-05T16:10:00Z", - "externalId": "linear-comment-1", - "externalUrl": "https://linear.app/comment/1" - }, - { - "commentId": "c-2", - "ticketId": "T-00042", - "body": "Local follow-up.", - "source": "hack", - "actor": "alice@hack", - "createdAt": "2026-03-05T16:20:00Z" - } - ], - "reviewNotes": [ - { - "noteId": "note-1", - "ticketId": "T-00042", - "markdown": "Follow up after assignee review.", - "actor": "alice@hack", - "createdAt": "2026-03-05T16:25:00Z", - "context": "conflict_review" - } - ], - "syncCheckpoints": [ - { - "checkpointId": "cp-1", - "ticketId": "T-00042", - "provider": "linear", - "profileId": "default", - "direction": "pull", - "remoteCursor": "issue/ENG-42#v2", - "remoteUpdatedAt": "2026-03-05T16:59:00Z", - "localUpdatedAt": "2026-03-05T17:00:00Z", - "actor": "sync@app", - "createdAt": "2026-03-05T17:00:01Z" - } - ], - "conflicts": [ - { - "conflictId": "conflict-1", - "ticketId": "T-00042", - "provider": "linear", - "field": "assignee", - "status": "open", - "authority": "origin", - "summary": "Assignee diverged during pull.", - "localValue": "alice@hack", - "remoteValue": "bob@linear", - "createdAt": "2026-03-05T17:00:02Z", - "updatedAt": "2026-03-05T17:00:02Z" - }, - { - "conflictId": "conflict-2", - "ticketId": "T-00042", - "provider": "linear", - "field": "labels", - "status": "resolved", - "authority": "mergeable", - "summary": "Label translation reviewed.", - "localValue": ["infra", "review"], - "remoteValue": {"team": "Platform", "name": "infra"}, - "createdAt": "2026-03-05T16:40:00Z", - "updatedAt": "2026-03-05T16:50:00Z", - "resolution": "merged", - "resolutionSummary": "Kept both labels.", - "resolvedAt": "2026-03-05T16:50:00Z", - "resolvedBy": "alice@hack" - } - ], - "events": [ - { - "eventId": "event-1", - "ts": 1741190400, - "tsIso": "2026-03-05T16:00:00Z", - "actor": "creator@hack", - "ticketId": "T-00042", - "type": "ticket.created" - } - ] - } - """#.utf8 - ) - - let decoder = JSONDecoder() - let detail = try decoder.decode(TicketDetailResponse.self, from: data) - - XCTAssertEqual(detail.ticket.assignee, "alice@hack") - XCTAssertEqual(detail.comments.count, 2) - XCTAssertEqual(detail.reviewNotes.count, 1) - XCTAssertEqual(detail.reviewNotes.first?.markdown, "Follow up after assignee review.") - XCTAssertEqual(detail.syncCheckpoints.count, 1) - XCTAssertEqual(detail.conflicts.count, 2) - XCTAssertEqual(detail.latestSyncCheckpoint?.remoteCursor, "issue/ENG-42#v2") - XCTAssertEqual(detail.openSyncConflicts.count, 1) - XCTAssertEqual(detail.resolvedSyncConflicts.count, 1) - XCTAssertEqual(detail.conflicts[0].localValue?.displayText, "alice@hack") - XCTAssertEqual(detail.conflicts[1].remoteValue?.displayText, #"{"name":"infra","team":"Platform"}"#) - } - - func testReviewStatePrioritizesOpenConflictsOverOwnershipHint() { - let detail = makeDetail( - ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), - comments: [ - TicketComment( - commentId: "c-1", - ticketId: "T-00042", - body: "Imported from Linear.", - source: "linear", - actor: "linear@app", - createdAt: "2026-03-05T16:10:00Z", - externalId: "comment-1", - externalUrl: nil - ) - ], - syncCheckpoints: [ - TicketSyncCheckpoint( - checkpointId: "cp-1", - ticketId: "T-00042", - provider: "linear", - profileId: "default", - direction: "pull", - remoteCursor: "issue/ENG-42#v2", - remoteUpdatedAt: nil, - localUpdatedAt: nil, - actor: "sync@app", - createdAt: "2026-03-05T17:00:01Z" - ) - ], - conflicts: [ - TicketSyncConflict( - conflictId: "conflict-1", - ticketId: "T-00042", - provider: "linear", - field: "assignee", - status: .open, - authority: "origin", - summary: "Assignee diverged during pull.", - localValue: .string("alice@hack"), - remoteValue: .string("bob@linear"), - createdAt: "2026-03-05T17:00:02Z", - updatedAt: "2026-03-05T17:00:02Z", - resolution: nil, - resolutionSummary: nil, - resolvedAt: nil, - resolvedBy: nil - ) - ] - ) - - let review = detail.linearSyncReviewState - - XCTAssertEqual(review.severity, .conflict) - XCTAssertTrue(review.needsReview) - XCTAssertEqual(review.badgeLabel, "1 open conflict") - XCTAssertEqual(review.highlightedFields, ["assignee"]) - XCTAssertTrue(review.message.contains("Comments stay append-only")) - XCTAssertTrue(review.message.contains("Last sync: pull via Linear")) - } - - func testReviewStateUsesOwnershipHintWhenConflictLedgerIsEmpty() { - let detail = makeDetail( - ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: nil), - comments: [], - syncCheckpoints: [], - conflicts: [] - ) - - let review = detail.linearSyncReviewState - - XCTAssertEqual(review.severity, .review) - XCTAssertTrue(review.needsReview) - XCTAssertEqual(review.badgeLabel, "Needs review") - XCTAssertEqual(review.highlightedFields, ["assignee", "labels", "dependencies"]) - XCTAssertTrue(review.message.contains("Review assignee, labels, and dependencies before the next sync.")) - } - - func testReviewStateReportsCleanLinkedTicketWithCheckpointSummary() { - let detail = makeDetail( - ticket: makeTicketSummary(owner: "hack", source: "hack", assignee: "alice@hack"), - comments: [ - TicketComment( - commentId: "c-1", - ticketId: "T-00042", - body: "Local follow-up.", - source: "hack", - actor: "alice@hack", - createdAt: "2026-03-05T16:20:00Z", - externalId: nil, - externalUrl: nil - ) - ], - syncCheckpoints: [ - TicketSyncCheckpoint( - checkpointId: "cp-1", - ticketId: "T-00042", - provider: "linear", - profileId: "default", - direction: "push", - remoteCursor: "issue/ENG-42#v3", - remoteUpdatedAt: nil, - localUpdatedAt: nil, - actor: "sync@app", - createdAt: "2026-03-05T17:00:01Z" - ) - ], - conflicts: [] - ) - - let review = detail.linearSyncReviewState - - XCTAssertEqual(review.severity, .clear) - XCTAssertFalse(review.needsReview) - XCTAssertEqual(review.badgeLabel, "Ready") - XCTAssertEqual(review.commentCount, 1) - XCTAssertEqual(review.openConflictCount, 0) - XCTAssertEqual(review.resolvedConflictCount, 0) - XCTAssertEqual(review.checkpointSummary, "Last sync: push via Linear (profile default).") - } - - func testReviewQueueEntryUsesDetailedReviewStateWhenAvailable() { - let detail = makeDetail( - ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), - comments: [ - TicketComment( - commentId: "c-1", - ticketId: "T-00042", - body: "Imported from Linear.", - source: "linear", - actor: "linear@app", - createdAt: "2026-03-05T16:10:00Z", - externalId: "comment-1", - externalUrl: nil - ) - ], - syncCheckpoints: [], - conflicts: [ - TicketSyncConflict( - conflictId: "conflict-1", - ticketId: "T-00042", - provider: "linear", - field: "assignee", - status: .open, - authority: "origin", - summary: "Assignee diverged during pull.", - localValue: .string("alice@hack"), - remoteValue: .string("bob@linear"), - createdAt: "2026-03-05T17:00:02Z", - updatedAt: "2026-03-05T17:00:02Z", - resolution: nil, - resolutionSummary: nil, - resolvedAt: nil, - resolvedBy: nil - ) - ] - ) - - let entry = TicketReviewQueueEntry(ticket: detail.ticket, detail: detail, reviewNoteCount: 2) - - XCTAssertEqual(entry?.ticketId, detail.ticket.ticketId) - XCTAssertEqual(entry?.badgeLabel, "1 open conflict") - XCTAssertEqual(entry?.commentCount, 1) - XCTAssertEqual(entry?.openConflictCount, 1) - XCTAssertEqual(entry?.reviewNoteCount, 2) - } - - func testReviewComposerDraftIncludesConflictAndLatestComment() { - let detail = makeDetail( - ticket: makeTicketSummary(owner: "linear", source: "hack", assignee: "alice@hack"), - comments: [ - TicketComment( - commentId: "c-1", - ticketId: "T-00042", - body: "Imported from Linear.", - source: "linear", - actor: "linear@app", - createdAt: "2026-03-05T16:10:00Z", - externalId: "comment-1", - externalUrl: nil - ) - ], - syncCheckpoints: [], - conflicts: [ - TicketSyncConflict( - conflictId: "conflict-1", - ticketId: "T-00042", - provider: "linear", - field: "assignee", - status: .open, - authority: "origin", - summary: "Assignee diverged during pull.", - localValue: .string("alice@hack"), - remoteValue: .string("bob@linear"), - createdAt: "2026-03-05T17:00:02Z", - updatedAt: "2026-03-05T17:00:02Z", - resolution: nil, - resolutionSummary: nil, - resolvedAt: nil, - resolvedBy: nil - ) - ] - ) - - let draft = TicketReviewComposer.draft( - for: detail, - highlightedConflict: detail.openSyncConflicts.first - ) - - XCTAssertTrue(draft.contains("Conflict to resolve:")) - XCTAssertTrue(draft.contains("Field: assignee")) - XCTAssertTrue(draft.contains("Hack: alice@hack")) - XCTAssertTrue(draft.contains("Linear: bob@linear")) - XCTAssertTrue(draft.contains("> linear@app")) - XCTAssertTrue(draft.contains("> Imported from Linear.")) - } - - func testReviewNoteDecodesLegacyBodyField() throws { - let data = Data( - #""" - { - "noteId": "note-legacy", - "ticketId": "T-00042", - "body": "Legacy review note body.", - "actor": "alice@hack", - "createdAt": "2026-03-05T16:25:00Z" - } - """#.utf8 - ) - - let note = try JSONDecoder().decode(TicketReviewNote.self, from: data) - - XCTAssertEqual(note.noteId, "note-legacy") - XCTAssertEqual(note.markdown, "Legacy review note body.") - } - - private func makeDetail( - ticket: TicketSummary, - comments: [TicketComment], - reviewNotes: [TicketReviewNote] = [], - syncCheckpoints: [TicketSyncCheckpoint], - conflicts: [TicketSyncConflict] - ) -> TicketDetailResponse { - TicketDetailResponse( - ticket: ticket, - comments: comments, - reviewNotes: reviewNotes, - syncCheckpoints: syncCheckpoints, - conflicts: conflicts, - events: [] - ) - } - - private func makeTicketSummary(owner: String, source: String, assignee: String?) -> TicketSummary { - TicketSummary( - ticketId: "T-00042", - title: "Investigate sync drift", - body: "Imported from Linear.", - status: .inProgress, - createdAt: "2026-03-05T16:00:00Z", - updatedAt: "2026-03-05T17:00:00Z", - dependsOn: ["T-00001"], - blocks: ["T-00099"], - owner: owner, - source: source, - assignee: assignee, - tags: ["linear", "review"], - externalSystem: "linear", - externalId: "lin_issue_42", - externalKey: "ENG-42", - externalUrl: "https://linear.app/hack/issue/ENG-42", - externalProjectId: "proj-42", - externalProjectName: "Control Plane", - externalTeamId: "eng", - projectId: "local-project", - projectName: "Hack CLI" - ) - } -} diff --git a/apps/macos/README.md b/apps/macos/README.md index a9b3ec9e..d8346697 100644 --- a/apps/macos/README.md +++ b/apps/macos/README.md @@ -1,8 +1,8 @@ -# Hack Desktop for macOS (Beta) +# Hack Desktop for macOS -Native macOS app for managing local hack projects and daemon status. +Native macOS app for managing local hack projects, runtime status, and quick actions. -> Beta: the macOS app is useful, but it is still outside Hack's core getting-started path. +Hack v3 keeps the app intentionally small and local-first. ## Requirements @@ -13,20 +13,18 @@ Native macOS app for managing local hack projects and daemon status. ## What it does - Lists local hack projects with status and details. -- Actions: start/stop project, open URL, open logs (experimental). -- Shows hackd status with start/stop controls. -- Includes a fullscreen Settings overlay with sidebar navigation for runtime, gateway, Railway, Tailscale, and node topology. -- GitHub account connect now runs browser OAuth through the auth-broker endpoint surface and imports tokens into local profile keychain storage. -- GitHub connect now supports authorize+install flow hints from the broker and can keep polling in "install required" state until a GitHub App installation is selected. -- Browser callback pages can deep-link back to the app (`hack://auth/github/callback?...`) to immediately refocus and finalize active connect flows. -- Hack account browser sign-in can deep-link back to the app (`hack://auth/complete`) to refocus the account/settings UI after Better Auth finishes in the browser. -- Cloud OAuth remains separate from local gateway/daemon bearer-token auth (transport auth boundary). -- Node topology pane includes a controller-host mode toggle, interactive network graph canvas (node selection, typed edge labels, draggable node layout persisted per controller profile, auto-tidy reflow, expandable full map), primary/default node view, authorized node management, connectivity probes, and pairing request inbox approval actions. -- Tailscale settings now include a dedicated bootstrap auth-key panel; saving there writes the shared key used by private Railway bootstrap (`controlPlane.extensions["dance.hack.tailscale"].config.authKey`, mirrored to Railway compatibility key) with optional `HACK_TAILSCALE_AUTH_KEY` fallback. -- Project detail and topology screens use lazy stacked section rendering plus cancellable async refresh tasks to reduce unnecessary recomputation while navigating quickly. -- Project detail now includes an "Execution target" section for per-project routing: inherited default, fixed node, or provider profile. +- Actions: start/stop project, open URL, open logs, open shell. +- Shows `hackd` and global runtime status with start/stop controls. +- Includes a fullscreen Settings overlay focused on runtime, global status, logging, certificates, updates, and trust guidance. +- Keeps the Ghostty-backed bottom terminal panel for local logs and shell workflows. - Menu bar item for quick status + actions. +## What it does not do + +- It does not manage hosted account/auth flows. +- It does not expose built-in GitHub or Linear integration. +- It does not expose topology maps, gateway admin, or provider-specific remote setup as part of the supported app surface. + ## Quick commands (repo root) ```bash @@ -64,18 +62,6 @@ If `hack` is not in PATH for GUI apps, ensure it lives in `~/.hack/bin`, `/opt/homebrew/bin`, or `/usr/local/bin` (all are added to PATH by the app). You can also set `HACK_CLI_PATH` to an absolute path for the binary. -For GitHub browser OAuth, the app defaults to: - -1. `https://auth.hack.broker` -2. `https://auth.hack.gy` -3. `https://auth.hack` -4. `http://127.0.0.1:7790` - -Set `HACK_AUTH_BROKER_URL` to override and pin a custom broker endpoint. - -Release builds register URL scheme `hack`. Debug/Xcode builds register `hack-dev` -so browser auth returns target the running debug app instead of any installed release copy. - ## Data source The app shells out to `hack` for JSON data (projects + daemon status). It does not diff --git a/apps/web/components.json b/apps/web/components.json deleted file mode 100644 index 9dd42c44..00000000 --- a/apps/web/components.json +++ /dev/null @@ -1,27 +0,0 @@ -{ - "$schema": "https://ui.shadcn.com/schema.json", - "style": "radix-luma", - "rsc": true, - "tsx": true, - "tailwind": { - "config": "", - "css": "src/styles/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" - }, - "iconLibrary": "hugeicons", - "rtl": false, - "aliases": { - "components": "@/components", - "utils": "@/lib/utils", - "ui": "@/components/ui", - "lib": "@/lib", - "hooks": "@/hooks" - }, - "menuColor": "inverted-translucent", - "menuAccent": "subtle", - "registries": { - "@efferd": "https://efferd.com/r/{style}/{name}.json" - } -} diff --git a/apps/web/next-env.d.ts b/apps/web/next-env.d.ts deleted file mode 100644 index c4b7818f..00000000 --- a/apps/web/next-env.d.ts +++ /dev/null @@ -1,6 +0,0 @@ -/// -/// -import "./.next/dev/types/routes.d.ts"; - -// NOTE: This file should not be edited -// see https://nextjs.org/docs/app/api-reference/config/typescript for more information. diff --git a/apps/web/next.config.ts b/apps/web/next.config.ts deleted file mode 100644 index dca21212..00000000 --- a/apps/web/next.config.ts +++ /dev/null @@ -1,18 +0,0 @@ -import { dirname, resolve } from "node:path"; -import { fileURLToPath } from "node:url"; - -import type { NextConfig } from "next"; - -import { resolveAllowedDevOrigins } from "./src/lib/next-dev-origins"; - -const rootDir = dirname(fileURLToPath(import.meta.url)); -const workspaceRoot = resolve(rootDir, "../.."); - -const nextConfig: NextConfig = { - allowedDevOrigins: resolveAllowedDevOrigins(), - turbopack: { - root: workspaceRoot, - }, -}; - -export default nextConfig; diff --git a/apps/web/package.json b/apps/web/package.json deleted file mode 100644 index d773ae88..00000000 --- a/apps/web/package.json +++ /dev/null @@ -1,42 +0,0 @@ -{ - "name": "@hack/web", - "version": "0.1.0", - "private": true, - "type": "module", - "scripts": { - "dev": "next dev", - "build": "next build", - "start": "next start", - "typecheck": "bunx tsc -p tsconfig.json --noEmit", - "test": "bun test", - "check": "cd ../.. && bunx ultracite check apps/web/app apps/web/src apps/web/tests" - }, - "dependencies": { - "@hack/auth-contract": "workspace:*", - "@hugeicons/core-free-icons": "^4.1.1", - "@hugeicons/react": "^1.1.6", - "@radix-ui/react-slot": "^1.2.4", - "class-variance-authority": "^0.7.1", - "clsx": "^2.1.1", - "lucide-react": "^1.7.0", - "motion": "^12.23.24", - "next": "16.2.1", - "next-themes": "^0.4.6", - "radix-ui": "^1.4.3", - "react": "19.2.4", - "react-dom": "19.2.4", - "tailwind-merge": "^3.5.0", - "tw-animate-css": "^1.4.0" - }, - "devDependencies": { - "@tailwindcss/postcss": "^4.1.18", - "@types/bun": "latest", - "@types/node": "^20", - "@types/react": "^19", - "@types/react-dom": "^19", - "linkedom": "^0.18.12", - "shadcn": "^4.1.2", - "tailwindcss": "^4.1.18", - "typescript": "^5" - } -} diff --git a/apps/web/postcss.config.mjs b/apps/web/postcss.config.mjs deleted file mode 100644 index c2ddf748..00000000 --- a/apps/web/postcss.config.mjs +++ /dev/null @@ -1,5 +0,0 @@ -export default { - plugins: { - "@tailwindcss/postcss": {}, - }, -}; diff --git a/apps/web/src/app/account/integrations/page.tsx b/apps/web/src/app/account/integrations/page.tsx deleted file mode 100644 index a99360b5..00000000 --- a/apps/web/src/app/account/integrations/page.tsx +++ /dev/null @@ -1,154 +0,0 @@ -import { - AccountEmptyState, - AccountPageFrame, - AccountSectionCard, - AccountStatsGrid, -} from "@/components/account-page-frame"; -import { getAccountShellContext } from "@/lib/account-shell"; -import { resolveBrowserSharedProjectScope } from "@/lib/browser-shared-project-scope"; -import { loadGitHubManagementState } from "@/lib/github-management"; -import { loadLinearManagementState } from "@/lib/linear-management"; - -type SearchParams = Promise>; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default async function AccountIntegrationsPage(input: { - readonly searchParams?: SearchParams; -}) { - const searchParams = (await input.searchParams) ?? {}; - const account = await getAccountShellContext({ - selectedOrganizationKey: readSearchParam(searchParams.org), - selectedTeamKey: readSearchParam(searchParams.team), - selectedProjectKey: readSearchParam(searchParams.project), - }); - - if (!account.authenticated) { - return null; - } - - const browserSharedProjectScope = resolveBrowserSharedProjectScope({ - account, - }); - const [githubManagement, linearManagement] = await Promise.all([ - loadGitHubManagementState({ - browserSharedProjectScope, - }), - loadLinearManagementState({ - browserSharedProjectScope, - }), - ]); - - return ( - - - -
- -
- - - - -
-
- - -
- - - - -
-
-
- - {githubManagement.readiness.ready || - linearManagement.summary.connected ? null : ( - - )} -
- ); -} - -function IntegrationValue(input: { - readonly label: string; - readonly value: string; -}) { - return ( -
-
- {input.label} -
-
{input.value}
-
- ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/account/layout.tsx b/apps/web/src/app/account/layout.tsx deleted file mode 100644 index 193a7799..00000000 --- a/apps/web/src/app/account/layout.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import { AppShell } from "@/components/app-shell"; -import { - buildAccountShellSignInHref, - getAccountShellContext, -} from "@/lib/account-shell"; -import { redirect } from "@/lib/server-navigation"; - -export default async function AccountLayout({ - children, -}: { - readonly children: React.ReactNode; -}) { - const account = await getAccountShellContext(); - if (!account.authenticated) { - redirect( - buildAccountShellSignInHref({ - returnToPath: "/account", - }) - ); - } - - return {children}; -} diff --git a/apps/web/src/app/account/page.tsx b/apps/web/src/app/account/page.tsx deleted file mode 100644 index 78fbdae7..00000000 --- a/apps/web/src/app/account/page.tsx +++ /dev/null @@ -1,186 +0,0 @@ -import Link from "next/link"; - -import { - AccountEmptyState, - AccountPageFrame, - AccountSectionCard, - AccountStatsGrid, -} from "@/components/account-page-frame"; -import { Button } from "@/components/ui/button"; -import { - buildAccountControlPlanePath, - resolveAccountControlPlaneFeedback, -} from "@/lib/account-control-plane"; -import { getAccountShellContext } from "@/lib/account-shell"; - -type SearchParams = Promise>; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default async function AccountPage(input: { - readonly searchParams?: SearchParams; -}) { - const searchParams = (await input.searchParams) ?? {}; - const requestedOrganizationKey = readSearchParam(searchParams.org); - const requestedTeamKey = readSearchParam(searchParams.team); - const requestedProjectKey = readSearchParam(searchParams.project); - const account = await getAccountShellContext({ - selectedOrganizationKey: requestedOrganizationKey, - selectedTeamKey: requestedTeamKey, - selectedProjectKey: requestedProjectKey, - }); - - if (!account.authenticated) { - return null; - } - - const feedback = resolveAccountControlPlaneFeedback({ - notice: readSearchParam(searchParams.notice), - error: readSearchParam(searchParams.error), - requestedOrganizationKey, - requestedTeamKey, - requestedProjectKey, - selectedOrganizationVisible: account.selectedOrganizationVisible, - selectedTeamVisible: account.selectedTeamVisible, - selectedProjectVisible: account.selectedProjectVisible, - }); - - return ( - - {feedback ? ( -
-

{feedback.title}

-

{feedback.body}

-
- ) : null} - - - -
- - Open projects - - } - description="Use this as the main org switcher and current workspace anchor for the rest of the shell." - title="Available organizations" - > - {account.organizations.length > 0 ? ( -
- {account.organizations.map((organization) => { - const isSelected = - account.selectedOrganization?.slug === organization.slug; - const href = buildAccountControlPlanePath({ - redirectTo: "/account", - org: organization.slug, - }); - return ( - - - - {organization.name} - - - {organization.slug} - - - - {isSelected ? "Active" : "Switch"} - - - ); - })} -
- ) : ( - - )} -
- - -
-
-
- User -
-
- {account.user.name ?? account.user.email ?? "Signed in"} -
-
-
-
- Organization -
-
- {account.selectedOrganization?.name ?? - account.activeOrganization?.name ?? - "No active organization"} -
-
-
-
- Team -
-
- {account.selectedTeam?.name ?? - account.activeTeam?.name ?? - "No active team"} -
-
-
-
- Project -
-
- {account.selectedProject?.name ?? "No project selected"} -
-
-
-
-
-
- ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/account/projects/page.tsx b/apps/web/src/app/account/projects/page.tsx deleted file mode 100644 index 2857ea64..00000000 --- a/apps/web/src/app/account/projects/page.tsx +++ /dev/null @@ -1,171 +0,0 @@ -import Link from "next/link"; - -import { - AccountEmptyState, - AccountPageFrame, - AccountSectionCard, - AccountStatsGrid, -} from "@/components/account-page-frame"; -import { buildAccountControlPlanePath } from "@/lib/account-control-plane"; -import { getAccountShellContext } from "@/lib/account-shell"; - -type SearchParams = Promise>; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default async function AccountProjectsPage(input: { - readonly searchParams?: SearchParams; -}) { - const searchParams = (await input.searchParams) ?? {}; - const account = await getAccountShellContext({ - selectedOrganizationKey: readSearchParam(searchParams.org), - selectedTeamKey: readSearchParam(searchParams.team), - selectedProjectKey: readSearchParam(searchParams.project), - }); - - if (!account.authenticated) { - return null; - } - - return ( - - - -
- - {account.projects.length > 0 ? ( -
- {account.projects.map((project) => ( - - - - {project.name} - - - {project.slug} - - - - {project.currentAccessRole} - - - ))} -
- ) : ( - - )} -
- - - {account.selectedProject ? ( -
-
-
-
- Ownership -
-
- {account.selectedProject.ownership.ownerName ?? - account.selectedProject.ownership.ownerType} -
-
-
-
- Managed by -
-
- {account.selectedProject.ownership.managedBy} -
-
-
- {account.selectedProjectAccess.length > 0 ? ( -
-

- Access grants -

-
- {account.selectedProjectAccess.map((grant) => ( -
- - - {grant.subjectName} - - - {grant.scope} - - - - {grant.role} - -
- ))} -
-
- ) : ( - - )} -
- ) : ( - - )} -
-
-
- ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/account/secrets/page.tsx b/apps/web/src/app/account/secrets/page.tsx deleted file mode 100644 index 375c860b..00000000 --- a/apps/web/src/app/account/secrets/page.tsx +++ /dev/null @@ -1,104 +0,0 @@ -import { - AccountPageFrame, - AccountSectionCard, - AccountStatsGrid, -} from "@/components/account-page-frame"; -import { loadEnvManagementState } from "@/lib/env-management"; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default async function AccountSecretsPage() { - const envManagement = await loadEnvManagementState(); - - return ( - - - -
- -
- - - - -
-
- - -
- - - - -
-
-
-
- ); -} - -function SecretValue(input: { - readonly label: string; - readonly value: string; -}) { - return ( -
-
- {input.label} -
-
{input.value}
-
- ); -} diff --git a/apps/web/src/app/account/teams/page.tsx b/apps/web/src/app/account/teams/page.tsx deleted file mode 100644 index 89897038..00000000 --- a/apps/web/src/app/account/teams/page.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import Link from "next/link"; - -import { - AccountEmptyState, - AccountPageFrame, - AccountSectionCard, - AccountStatsGrid, -} from "@/components/account-page-frame"; -import { buildAccountControlPlanePath } from "@/lib/account-control-plane"; -import { getAccountShellContext } from "@/lib/account-shell"; - -type SearchParams = Promise>; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default async function AccountTeamsPage(input: { - readonly searchParams?: SearchParams; -}) { - const searchParams = (await input.searchParams) ?? {}; - const account = await getAccountShellContext({ - selectedOrganizationKey: readSearchParam(searchParams.org), - selectedTeamKey: readSearchParam(searchParams.team), - selectedProjectKey: readSearchParam(searchParams.project), - }); - - if (!account.authenticated) { - return null; - } - - return ( - - - -
- - {account.teams.length > 0 ? ( -
- {account.teams.map((team) => ( - - - - {team.name} - - - {team.slug} - - - - {account.selectedTeam?.slug === team.slug - ? "Active" - : "Open"} - - - ))} -
- ) : ( - - )} -
- - - {account.selectedTeamMemberships.length > 0 ? ( -
- {account.selectedTeamMemberships.map((membership) => ( -
- - - {membership.email ?? membership.target} - - - {membership.scope} - - - - {membership.state} - -
- ))} -
- ) : ( - - )} -
-
-
- ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/account/tickets/page.tsx b/apps/web/src/app/account/tickets/page.tsx deleted file mode 100644 index 0080af0b..00000000 --- a/apps/web/src/app/account/tickets/page.tsx +++ /dev/null @@ -1,49 +0,0 @@ -import Link from "next/link"; - -import { - AccountEmptyState, - AccountPageFrame, - AccountSectionCard, -} from "@/components/account-page-frame"; -import { Button } from "@/components/ui/button"; - -export const dynamic = "force-dynamic"; -export const fetchCache = "force-no-store"; - -export default function AccountTicketsPage() { - return ( - - - - Open repo - - - } - description="This page keeps the information architecture honest without pretending the tickets UX is already built." - title="Coming next" - > - -
- - -
-
-
- ); -} diff --git a/apps/web/src/app/api/auth/flows/[flowId]/route.ts b/apps/web/src/app/api/auth/flows/[flowId]/route.ts deleted file mode 100644 index 2cd41624..00000000 --- a/apps/web/src/app/api/auth/flows/[flowId]/route.ts +++ /dev/null @@ -1,51 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { buildAuthBrokerProxyUrl, getWebAuthConfig } from "@/lib/auth-config"; - -export async function GET( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly flowId: string; - }>; - } -) { - const { flowId } = await context.params; - const deviceCode = request.nextUrl.searchParams.get("deviceCode")?.trim(); - if (!deviceCode) { - return NextResponse.json( - { - error: "device_code_required", - message: "This browser handoff is missing its device code.", - }, - { status: 400 } - ); - } - - const config = getWebAuthConfig(); - const brokerUrl = new URL( - buildAuthBrokerProxyUrl({ - authBrokerProxyBaseUrl: config.authBrokerProxyBaseUrl, - path: `/v1/auth/session/flows/${encodeURIComponent(flowId)}`, - }) - ); - brokerUrl.searchParams.set("deviceCode", deviceCode); - - const response = await fetch(brokerUrl, { - headers: { - accept: "application/json", - }, - cache: "no-store", - }); - const rawText = await response.text(); - - return new NextResponse(rawText, { - status: response.status, - headers: { - "cache-control": "no-store", - "content-type": - response.headers.get("content-type") ?? - "application/json; charset=utf-8", - }, - }); -} diff --git a/apps/web/src/app/api/auth/providers/route.ts b/apps/web/src/app/api/auth/providers/route.ts deleted file mode 100644 index c139b860..00000000 --- a/apps/web/src/app/api/auth/providers/route.ts +++ /dev/null @@ -1,30 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { buildAuthBrokerProxyUrl, getWebAuthConfig } from "@/lib/auth-config"; - -export async function GET(_request: NextRequest) { - const config = getWebAuthConfig(); - const response = await fetch( - buildAuthBrokerProxyUrl({ - authBrokerProxyBaseUrl: config.authBrokerProxyBaseUrl, - path: "/v1/auth/providers", - }), - { - headers: { - accept: "application/json", - }, - cache: "no-store", - } - ); - const rawText = await response.text(); - - return new NextResponse(rawText, { - status: response.status, - headers: { - "cache-control": "no-store", - "content-type": - response.headers.get("content-type") ?? - "application/json; charset=utf-8", - }, - }); -} diff --git a/apps/web/src/app/api/auth/sign-out/route.ts b/apps/web/src/app/api/auth/sign-out/route.ts deleted file mode 100644 index be4eb19d..00000000 --- a/apps/web/src/app/api/auth/sign-out/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { HACK_WEB_BROKER_SESSION_COOKIE_NAME } from "@hack/auth-contract"; -import { type NextRequest, NextResponse } from "next/server"; - -const IP_ADDRESS_PATTERN = - /^(?:\d{1,3}(?:\.\d{1,3}){3}|(?:[a-f0-9]*:){2,}[a-f0-9]*)$/i; - -export function GET(request: NextRequest) { - const redirectTarget = normalizeRedirectTarget({ - value: request.nextUrl.searchParams.get("redirect"), - }); - const response = NextResponse.redirect(new URL(redirectTarget, request.url)); - const cookieTarget = resolveBrokerSessionCookieTarget({ - requestUrl: request.url, - }); - response.cookies.set({ - name: HACK_WEB_BROKER_SESSION_COOKIE_NAME, - value: "", - expires: new Date(0), - httpOnly: true, - domain: cookieTarget.domain ?? undefined, - path: "/", - sameSite: "lax", - secure: cookieTarget.secure, - }); - return response; -} - -function normalizeRedirectTarget(input: { - readonly value: string | null; -}): string { - if (input.value?.startsWith("/") && !input.value.startsWith("//")) { - return input.value; - } - - return "/auth"; -} - -function resolveBrokerSessionCookieTarget(input: { - readonly requestUrl: string; -}): { - readonly domain: string | null; - readonly secure: boolean; -} { - const requestUrl = new URL(input.requestUrl); - return { - domain: resolveCookieDomain({ host: requestUrl.hostname }), - secure: requestUrl.protocol === "https:", - }; -} - -function resolveCookieDomain(input: { readonly host: string }): string | null { - const host = input.host.trim(); - if (!host || host === "localhost" || isIpAddress({ value: host })) { - return null; - } - return host; -} - -function isIpAddress(input: { readonly value: string }): boolean { - return IP_ADDRESS_PATTERN.test(input.value); -} diff --git a/apps/web/src/app/api/auth/social/route.ts b/apps/web/src/app/api/auth/social/route.ts deleted file mode 100644 index a8940b0c..00000000 --- a/apps/web/src/app/api/auth/social/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; -import { - buildBrokerBrowserStartUrl, - normalizeAppReturnUrl, -} from "@/lib/auth-handoff"; - -export async function POST(request: NextRequest) { - const body = (await readJsonBody(request)) as { - readonly provider?: string; - readonly flowId?: string; - readonly deviceCode?: string; - readonly redirect?: string; - } | null; - - const provider = normalizeString(body?.provider); - if (!provider) { - return NextResponse.json( - { - error: "provider_required", - message: "Choose a provider before starting Hack auth.", - }, - { status: 400 } - ); - } - - const config = await getAuthoritativeWebAuthConfig(); - if (config.betterAuthSource === "fail_closed") { - return NextResponse.json( - { - error: "auth_metadata_unavailable", - message: - "Hack could not confirm the broker auth metadata for this sign-in flow.", - }, - { status: 503 } - ); - } - if ( - !config.betterAuth.socialProviders.some( - (socialProvider) => socialProvider.id === provider - ) - ) { - return NextResponse.json( - { - error: "provider_unavailable", - message: - "This sign-in provider is currently unavailable in the broker metadata.", - }, - { status: 400 } - ); - } - const finalReturnUrl = normalizeAppReturnUrl({ - value: body?.redirect, - appBaseUrl: config.appBaseUrl, - trustedOrigins: config.betterAuth.trustedOrigins, - }); - const callbackUrl = buildBrokerBrowserStartUrl({ - authBrokerBaseUrl: config.authBrokerBaseUrl, - appBaseUrl: config.appBaseUrl, - flowId: normalizeString(body?.flowId), - deviceCode: normalizeString(body?.deviceCode), - finalReturnUrl, - providerId: provider, - }); - - return NextResponse.json( - { - ok: true, - url: callbackUrl, - }, - { - headers: { - "cache-control": "no-store", - }, - } - ); -} - -async function readJsonBody(request: NextRequest): Promise { - try { - return (await request.json()) as unknown; - } catch { - return null; - } -} - -function normalizeString(value: unknown): string | null { - return typeof value === "string" && value.trim().length > 0 - ? value.trim() - : null; -} diff --git a/apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts b/apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts deleted file mode 100644 index bcf7c276..00000000 --- a/apps/web/src/app/api/control-plane/invitations/[inviteId]/accept/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly inviteId: string; - }>; - } -) { - const { inviteId } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/invitations/${encodeURIComponent(inviteId)}/accept`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - error: "invite_accept_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - notice: "invite_accepted", - }) - ); -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts b/apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts deleted file mode 100644 index a1555be0..00000000 --- a/apps/web/src/app/api/control-plane/invitations/[inviteId]/decline/route.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly inviteId: string; - }>; - } -) { - const { inviteId } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/invitations/${encodeURIComponent(inviteId)}/decline`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - error: "invite_decline_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - notice: "invite_declined", - }) - ); -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts b/apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts deleted file mode 100644 index 96d7e7b6..00000000 --- a/apps/web/src/app/api/control-plane/orgs/[org]/members/invite/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly org: string; - }>; - } -) { - const { org } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const target = readAccountControlPlaneFormValue({ formData, key: "target" }); - if (!target) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "org_member_invite_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/orgs/${encodeURIComponent(org)}/members/invite`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - target, - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "org_member_invite_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - notice: "org_member_invited", - }) - ); -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts b/apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts deleted file mode 100644 index 43f91122..00000000 --- a/apps/web/src/app/api/control-plane/orgs/[org]/members/remove/route.ts +++ /dev/null @@ -1,91 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly org: string; - }>; - } -) { - const { org } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const target = readAccountControlPlaneFormValue({ formData, key: "target" }); - if (!target) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "org_member_remove_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/orgs/${encodeURIComponent(org)}/members/remove`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - target, - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "org_member_remove_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - notice: "org_member_removed", - }) - ); -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/orgs/route.ts b/apps/web/src/app/api/control-plane/orgs/route.ts deleted file mode 100644 index ae3f585c..00000000 --- a/apps/web/src/app/api/control-plane/orgs/route.ts +++ /dev/null @@ -1,100 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type CreateOrganizationResponse = { - readonly organization?: { - readonly slug?: string | null; - } | null; -}; - -export async function POST(request: NextRequest) { - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const slug = readAccountControlPlaneFormValue({ formData, key: "slug" }); - const name = readAccountControlPlaneFormValue({ formData, key: "name" }); - if (!slug) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - error: "org_create_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/orgs`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - slug, - ...(name ? { name } : {}), - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - error: "org_create_failed", - }) - ); - } - - const payload = (await safeReadJson( - response - )) as CreateOrganizationResponse | null; - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - notice: "org_created", - org: payload?.organization?.slug ?? slug, - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts b/apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts deleted file mode 100644 index 730593dc..00000000 --- a/apps/web/src/app/api/control-plane/projects/[project]/access/grant/route.ts +++ /dev/null @@ -1,142 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type ProjectAccessResponse = { - readonly error?: string | null; -}; - -export async function POST( - request: NextRequest, - context: { readonly params: Promise<{ readonly project: string }> } -) { - const { project } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const redirectSelection = readRedirectSelection(redirectTo); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const scope = readAccountControlPlaneFormValue({ formData, key: "scope" }); - const role = readAccountControlPlaneFormValue({ formData, key: "role" }); - const org = readAccountControlPlaneFormValue({ formData, key: "org" }); - const team = readAccountControlPlaneFormValue({ formData, key: "team" }); - if (!(isAccessScope(scope) && isGrantRole(role))) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - project, - error: "project_access_grant_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/projects/${encodeURIComponent(project)}/access/grant`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - scope, - role, - ...(org ? { org } : {}), - ...(team ? { team } : {}), - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const payload = (await safeReadJson( - response - )) as ProjectAccessResponse | null; - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - project, - error: normalizeBrokerProjectError( - payload?.error, - "project_access_grant_failed" - ), - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - project, - notice: "project_access_granted", - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function isAccessScope(value: string | null): value is "organization" | "team" { - return value === "organization" || value === "team"; -} - -function isGrantRole(value: string | null): value is "viewer" | "admin" { - return value === "viewer" || value === "admin"; -} - -function normalizeBrokerProjectError( - error: string | null | undefined, - fallback: string -) { - return error === "project_scope_forbidden" ? error : fallback; -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} - -function readRedirectSelection(path: string) { - const url = new URL(path, "https://hack-control-plane.local"); - return { - org: url.searchParams.get("org"), - team: url.searchParams.get("team"), - }; -} diff --git a/apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts b/apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts deleted file mode 100644 index b3af02ff..00000000 --- a/apps/web/src/app/api/control-plane/projects/[project]/access/revoke/route.ts +++ /dev/null @@ -1,124 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type ProjectAccessResponse = { - readonly error?: string | null; -}; - -export async function POST( - request: NextRequest, - context: { readonly params: Promise<{ readonly project: string }> } -) { - const { project } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const redirectSelection = readRedirectSelection(redirectTo); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const grantId = readAccountControlPlaneFormValue({ - formData, - key: "grantId", - }); - if (!grantId) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: redirectSelection.org, - team: redirectSelection.team, - project, - error: "project_access_revoke_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/projects/${encodeURIComponent(project)}/access/revoke`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - grantId, - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const payload = (await safeReadJson( - response - )) as ProjectAccessResponse | null; - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: redirectSelection.org, - team: redirectSelection.team, - project, - error: - typeof payload?.error === "string" - ? payload.error - : "project_access_revoke_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: redirectSelection.org, - team: redirectSelection.team, - project, - notice: "project_access_revoked", - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} - -function readRedirectSelection(path: string) { - const url = new URL(path, "https://hack-control-plane.local"); - return { - org: url.searchParams.get("org"), - team: url.searchParams.get("team"), - }; -} diff --git a/apps/web/src/app/api/control-plane/projects/route.ts b/apps/web/src/app/api/control-plane/projects/route.ts deleted file mode 100644 index 1d760a8c..00000000 --- a/apps/web/src/app/api/control-plane/projects/route.ts +++ /dev/null @@ -1,139 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type RegisterProjectResponse = { - readonly project?: { - readonly slug?: string | null; - } | null; - readonly error?: string | null; -}; - -export async function POST(request: NextRequest) { - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const redirectSelection = readRedirectSelection(redirectTo); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const slug = readAccountControlPlaneFormValue({ formData, key: "slug" }); - const name = readAccountControlPlaneFormValue({ formData, key: "name" }); - const mode = readAccountControlPlaneFormValue({ formData, key: "mode" }); - const org = readAccountControlPlaneFormValue({ formData, key: "org" }); - const team = readAccountControlPlaneFormValue({ formData, key: "team" }); - if (!(slug && isProjectMode(mode))) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - error: "project_register_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/projects`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - slug, - ...(name ? { name } : {}), - mode, - ...(org ? { org } : {}), - ...(team ? { team } : {}), - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const payload = (await safeReadJson( - response - )) as RegisterProjectResponse | null; - if (!response.ok) { - let error: - | "project_registration_conflict" - | "project_scope_forbidden" - | "project_register_failed" = "project_register_failed"; - if (payload?.error === "project_registration_conflict") { - error = "project_registration_conflict"; - } else if (payload?.error === "project_scope_forbidden") { - error = "project_scope_forbidden"; - } - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - project: slug, - error, - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org: org ?? redirectSelection.org, - team: team ?? redirectSelection.team, - project: payload?.project?.slug ?? slug, - notice: "project_registered", - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function isProjectMode( - value: string | null -): value is "local" | "organization" | "team" { - return value === "local" || value === "organization" || value === "team"; -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} - -function readRedirectSelection(path: string) { - const url = new URL(path, "https://hack-control-plane.local"); - return { - org: url.searchParams.get("org"), - team: url.searchParams.get("team"), - }; -} diff --git a/apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts b/apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts deleted file mode 100644 index 3e60e236..00000000 --- a/apps/web/src/app/api/control-plane/teams/[team]/members/invite/route.ts +++ /dev/null @@ -1,113 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type ErrorResponse = { - readonly error?: string | null; -}; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly team: string; - }>; - } -) { - const { team } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const org = readAccountControlPlaneFormValue({ formData, key: "org" }); - const target = readAccountControlPlaneFormValue({ formData, key: "target" }); - if (!(org && target)) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - error: "team_member_invite_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/teams/${encodeURIComponent(team)}/members/invite`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - org, - target, - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - const payload = (await safeReadJson(response)) as ErrorResponse | null; - const error = - payload?.error === "team_member_requires_active_org_membership" - ? "team_member_requires_active_org_membership" - : "team_member_invite_failed"; - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - error, - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - notice: "team_member_invited", - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts b/apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts deleted file mode 100644 index 4767b8f3..00000000 --- a/apps/web/src/app/api/control-plane/teams/[team]/members/remove/route.ts +++ /dev/null @@ -1,96 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -export async function POST( - request: NextRequest, - context: { - readonly params: Promise<{ - readonly team: string; - }>; - } -) { - const { team } = await context.params; - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const org = readAccountControlPlaneFormValue({ formData, key: "org" }); - const target = readAccountControlPlaneFormValue({ formData, key: "target" }); - if (!(org && target)) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - error: "team_member_remove_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/teams/${encodeURIComponent(team)}/members/remove`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - org, - target, - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - error: "team_member_remove_failed", - }) - ); - } - - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team, - notice: "team_member_removed", - }) - ); -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/api/control-plane/teams/route.ts b/apps/web/src/app/api/control-plane/teams/route.ts deleted file mode 100644 index edb2bdf8..00000000 --- a/apps/web/src/app/api/control-plane/teams/route.ts +++ /dev/null @@ -1,103 +0,0 @@ -import { type NextRequest, NextResponse } from "next/server"; - -import { - buildAccountControlPlanePath, - createBrokerJsonRequestHeaders, - normalizeAccountRedirectPath, - readAccountControlPlaneFormValue, - readBrokerSessionTokenFromRequest, -} from "@/lib/account-control-plane"; -import { buildAccountShellSignInHref } from "@/lib/account-shell"; -import { getWebAuthConfig } from "@/lib/auth-config"; - -type CreateTeamResponse = { - readonly team?: { - readonly slug?: string | null; - } | null; -}; - -export async function POST(request: NextRequest) { - const formData = await request.formData(); - const redirectTo = normalizeAccountRedirectPath({ - value: readAccountControlPlaneFormValue({ formData, key: "redirectTo" }), - }); - const token = readBrokerSessionTokenFromRequest({ request }); - if (!token) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - - const org = readAccountControlPlaneFormValue({ formData, key: "org" }); - const slug = readAccountControlPlaneFormValue({ formData, key: "slug" }); - const name = readAccountControlPlaneFormValue({ formData, key: "name" }); - if (!(org && slug)) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "team_create_failed", - }) - ); - } - - const config = getWebAuthConfig(); - const response = await fetch( - `${config.authBrokerProxyBaseUrl}/v1/auth/teams`, - { - method: "POST", - headers: createBrokerJsonRequestHeaders({ token }), - body: JSON.stringify({ - org, - slug, - ...(name ? { name } : {}), - }), - cache: "no-store", - } - ); - if (response.status === 401) { - return redirectToPath( - buildAccountShellSignInHref({ - returnToPath: redirectTo, - }) - ); - } - if (!response.ok) { - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - error: "team_create_failed", - }) - ); - } - - const payload = (await safeReadJson(response)) as CreateTeamResponse | null; - return redirectToPath( - buildAccountControlPlanePath({ - redirectTo, - org, - team: payload?.team?.slug ?? slug, - notice: "team_created", - }) - ); -} - -async function safeReadJson(response: Response): Promise { - try { - return (await response.json()) as unknown; - } catch { - return null; - } -} - -function redirectToPath(path: string) { - return new NextResponse(null, { - status: 303, - headers: { - location: path, - }, - }); -} diff --git a/apps/web/src/app/auth/account/page.tsx b/apps/web/src/app/auth/account/page.tsx deleted file mode 100644 index c5ab9a71..00000000 --- a/apps/web/src/app/auth/account/page.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { AuthEntrypoint } from "@/components/auth-entrypoint"; -import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; -import { hasAuthenticatedBrowserSession } from "@/lib/browser-auth-session"; -import { redirect as navigate } from "@/lib/server-navigation"; - -type SearchParams = Promise>; - -export default async function AuthAccountPage({ - searchParams, -}: { - readonly searchParams?: SearchParams; -}) { - const params = (await searchParams) ?? {}; - const config = await getAuthoritativeWebAuthConfig(); - const flowId = readSearchParam(params.flowId); - const deviceCode = readSearchParam(params.deviceCode); - const redirect = readSearchParam(params.redirect); - const browserSessionAuthenticated = - flowId && deviceCode ? false : await hasAuthenticatedBrowserSession(); - if (browserSessionAuthenticated && !(flowId && deviceCode) && !redirect) { - navigate("/account"); - } - - return ( - - ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/auth/page.tsx b/apps/web/src/app/auth/page.tsx deleted file mode 100644 index ab856327..00000000 --- a/apps/web/src/app/auth/page.tsx +++ /dev/null @@ -1,34 +0,0 @@ -import { AuthEntrypoint } from "@/components/auth-entrypoint"; -import { getAuthoritativeWebAuthConfig } from "@/lib/auth-config"; - -type SearchParams = Promise>; - -export default async function AuthPage({ - searchParams, -}: { - readonly searchParams?: SearchParams; -}) { - const params = (await searchParams) ?? {}; - const config = await getAuthoritativeWebAuthConfig(); - - return ( - - ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/app/auth/sign-up/page.tsx b/apps/web/src/app/auth/sign-up/page.tsx deleted file mode 100644 index 6a37621f..00000000 --- a/apps/web/src/app/auth/sign-up/page.tsx +++ /dev/null @@ -1,35 +0,0 @@ -import { redirect } from "next/navigation"; - -type SearchParams = Promise>; - -/** - * Sign-up is the same OAuth path as sign-in; keep the route for old links. - */ -export default async function AuthSignUpPage({ - searchParams, -}: { - readonly searchParams?: SearchParams; -}) { - const params = (await searchParams) ?? {}; - const query = buildQueryString(params); - redirect(query.length > 0 ? `/auth?${query}` : "/auth"); -} - -function buildQueryString( - record: Record -): string { - const searchParams = new URLSearchParams(); - for (const [key, value] of Object.entries(record)) { - if (value === undefined) { - continue; - } - if (Array.isArray(value)) { - for (const entry of value) { - searchParams.append(key, entry); - } - } else { - searchParams.set(key, value); - } - } - return searchParams.toString(); -} diff --git a/apps/web/src/app/layout.tsx b/apps/web/src/app/layout.tsx deleted file mode 100644 index 552ef904..00000000 --- a/apps/web/src/app/layout.tsx +++ /dev/null @@ -1,57 +0,0 @@ -import type { ReactNode } from "react"; - -import "@/styles/globals.css"; - -import { Geist } from "next/font/google"; -import Script from "next/script"; - -import { Providers } from "@/app/providers"; -import { appMetadata } from "@/lib/control-plane-shell"; -import { cn } from "@/lib/utils"; - -const geist = Geist({ subsets: ["latin"], variable: "--font-sans" }); - -const themeBootScript = ` - try { - const storageKey = "hack-theme"; - const storedTheme = window.localStorage.getItem(storageKey); - const theme = - storedTheme === "light" || - storedTheme === "dark" || - storedTheme === "system" - ? storedTheme - : "system"; - const resolvedTheme = - theme === "system" - ? window.matchMedia("(prefers-color-scheme: dark)").matches - ? "dark" - : "light" - : theme; - const root = document.documentElement; - root.classList.toggle("dark", resolvedTheme === "dark"); - root.style.colorScheme = resolvedTheme; - } catch {} -`; - -export const metadata = appMetadata; - -export default function RootLayout({ - children, -}: { - readonly children: ReactNode; -}) { - return ( - - - - {children} - - - ); -} diff --git a/apps/web/src/app/page.tsx b/apps/web/src/app/page.tsx deleted file mode 100644 index b8a63720..00000000 --- a/apps/web/src/app/page.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import { BigLogo } from "@/components/big-logo"; -import { MarketingChrome } from "@/components/marketing-chrome"; - -export default function HomePage() { - return ( -
- -
- -
-
- ); -} diff --git a/apps/web/src/app/providers.tsx b/apps/web/src/app/providers.tsx deleted file mode 100644 index 0f359d8e..00000000 --- a/apps/web/src/app/providers.tsx +++ /dev/null @@ -1,14 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; - -import { ThemeProvider } from "@/components/theme-provider"; -import { TooltipProvider } from "@/components/ui/tooltip"; - -export function Providers({ children }: { readonly children: ReactNode }) { - return ( - - {children} - - ); -} diff --git a/apps/web/src/components/account-control-plane-sections.tsx b/apps/web/src/components/account-control-plane-sections.tsx deleted file mode 100644 index 03835f4b..00000000 --- a/apps/web/src/components/account-control-plane-sections.tsx +++ /dev/null @@ -1,2002 +0,0 @@ -import type { ReactNode } from "react"; -import LinearManagementSection from "@/components/linear-management-section"; -import type { AccountControlPlaneFeedback } from "@/lib/account-control-plane"; -import { buildAccountControlPlanePath } from "@/lib/account-control-plane"; -import type { AccountShellContext } from "@/lib/account-shell"; -import type { EnvManagementState } from "@/lib/env-management"; -import type { GitHubManagementState } from "@/lib/github-management"; -import type { LinearManagementState } from "@/lib/linear-management"; -import { cn } from "@/lib/utils"; - -const sectionSurfaceClassName = cn( - "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", - "transition duration-200 motion-safe:hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const actionClassName = cn( - "inline-flex min-h-10 items-center justify-center rounded-full border border-sky-300/30 bg-sky-300 px-4 py-2 font-medium text-slate-950 text-sm", - "transition duration-200 hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const secondaryActionClassName = cn( - "inline-flex min-h-10 items-center justify-center rounded-full border border-white/15 bg-white/5 px-4 py-2 font-medium text-sm text-white/85", - "transition duration-200 hover:bg-white/10 motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const fieldClassName = cn( - "w-full rounded-2xl border border-white/10 bg-slate-950/60 px-4 py-3 text-sm text-white placeholder:text-white/35", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -type AccountControlPlaneSectionsProps = { - readonly account: AccountShellContext; - readonly envManagement: EnvManagementState; - readonly githubManagement: GitHubManagementState; - readonly linearManagement: LinearManagementState; - readonly feedback?: AccountControlPlaneFeedback | null; - readonly returnToPath: string; -}; - -export default function AccountControlPlaneSections({ - account, - envManagement, - githubManagement, - linearManagement, - feedback = null, - returnToPath, -}: AccountControlPlaneSectionsProps) { - const integrationScopeFeedback = resolveSharedIntegrationScopeFeedback({ - account, - }); - const selectedOrganizationKey = account.authenticated - ? (account.selectedOrganization?.slug ?? account.requestedOrganizationKey) - : null; - const selectedTeamKey = account.authenticated - ? (account.selectedTeam?.slug ?? account.requestedTeamKey) - : null; - const selectedProjectKey = account.authenticated - ? (account.selectedProject?.slug ?? account.requestedProjectKey) - : null; - const scopedReturnPath = buildAccountControlPlanePath({ - redirectTo: returnToPath, - org: selectedOrganizationKey, - team: selectedTeamKey, - project: selectedProjectKey, - }); - const baseReturnPath = buildAccountControlPlanePath({ - redirectTo: returnToPath, - }); - - return ( - <> - {feedback ? ( -
-

{feedback.title}

-

- {feedback.body} -

-
- ) : null} - -
-
-

Organizations

-

- Create shared organizations, keep list and detail views scoped to - the current caller, and manage org invites without changing the - broker-backed lifecycle semantics. -

-
- -
- - -
-
- - - - - - - - - - - -
-
-

Invitations

-

- Only the intended recipient sees accept and decline actions. Pending - invites remain non-active until this account responds. -

-
- - -
- - ); -} - -function EnvSection(input: { readonly envManagement: EnvManagementState }) { - const envManagement = input.envManagement; - const sharedStateLabel = formatEnvClassificationLabel({ - value: envManagement.status.sharedState, - }); - const missingRequiredCount = envManagement.missingRequired.length; - - return ( -
-
-

Env

-

- Local env status stays explicit about trust model, custody, and - portability so plaintext-compatible or local-only values are not - mistaken for broker-managed shared env state. -

-
- -
-
-
-
-

Effective env

-

- {envManagement.status.summary} -

-

- {envManagement.status.detail} -

-
- - {sharedStateLabel} - -
- -
- - {envManagement.envSelectionLabel} - - - {envManagement.status.trustModel} - - - {envManagement.status.custody} - - - {envManagement.status.portability} - - - {envManagement.status.sharedState} - - - {missingRequiredCount > 0 - ? `${missingRequiredCount} key${missingRequiredCount === 1 ? "" : "s"}` - : "None"} - -
- -
-

- Repo-bound status commands -

- - {envManagement.statusCommand} - - - {envManagement.backendCommand} - -

- Compare these repo-bound CLI status payloads with the browser view - before treating local env as portable or shared. -

-
- - {envManagement.missingRequired.length > 0 ? ( -
-

- Missing required env -

-

- {envManagement.missingRequired.join(", ")} -

-
- ) : null} -
- -
-
-

Storage surfaces

-

- Each storage surface keeps its own machine-readable custody and - portability state so local compatibility never looks like shared - broker custody. -

-
- -
- - {envManagement.backend.name} - - - {envManagement.backend.status.storageMode} - - - {envManagement.backend.classification.sharedState} - - - {envManagement.localPlaintext.path} - - - {envManagement.localPlaintext.classification.custody} - - - {`${envManagement.localSecrets.backend} (${envManagement.localSecrets.mode})`} - - - {envManagement.localSecrets.classification.custody} - - - {envManagement.portableState.status} - - - {envManagement.portableState.classification.sharedState} - -
- -
-

Compatibility output

-

- {envManagement.compatibilityMode.summary} -

-
- - {envManagement.compatibilityMode.plaintextTarget} - - - {envManagement.compatibilityMode.secretBackend} - -
-
-
-
- -
-
-

Key-level status

-

- Each repo-bound env key keeps its declared source, resolved source, - storage kind/backend, and classification so the browser can - distinguish local plaintext, secret-backed, and portable/shared - values without exposing raw secrets. -

-
- - {envManagement.variables.length > 0 ? ( -
    - {envManagement.variables.map((variable) => ( -
  • -
    -
    -

    {variable.key}

    -

    - {formatEnvVariableStorageLabel({ - variable, - })} -

    -
    - - {variable.required ? "required" : "optional"} - -
    - -
    - - {variable.source} - - - {variable.resolvedSource ?? "unresolved"} - - - {formatEnvVariableStorageLabel({ - variable, - })} - - - {variable.storage.trustModel} - - - {variable.storage.classification.custody} - - - {variable.storage.classification.sharedState} - -
    -
  • - ))} -
- ) : ( -

- {envManagement.status.sharedState === "unavailable" - ? "Repo-bound env key status is unavailable until Hack can run the env status commands again." - : "No repo-bound env keys were returned for this project."} -

- )} -
-
- ); -} - -function GitHubSection(input: { - readonly githubManagement: GitHubManagementState; - readonly scopeFeedback: AccountControlPlaneFeedback | null; -}) { - const githubManagement = input.githubManagement; - const selectedProfile = githubManagement.selectedProfile; - const selectedAccount = - githubManagement.accountName ?? - githubManagement.accountLogin ?? - "Not resolved"; - const installationSummary = describeGitHubInstallation({ - githubManagement, - }); - - return ( -
-
-

GitHub

-

- Repo-bound GitHub status stays honest about routing, profile - selection, installation context, and repair steps instead of marking - partial configuration as healthy. -

-
- - {input.scopeFeedback ? ( - - ) : null} - -
-
-
-
-

- {githubManagement.readiness.ready ? "Ready" : "Needs repair"} -

-

- {githubManagement.readiness.summary} -

-

- {githubManagement.readiness.detail} -

-
- - {githubManagement.readiness.state.replaceAll("_", " ")} - -
- -
- - {selectedProfile} - - - {githubManagement.selectedSource} - - - {githubManagement.mode} - - - {installationSummary} - - - {selectedAccount} - - - {githubManagement.tokenResolved - ? (githubManagement.tokenSource ?? "resolved") - : "not resolved"} - -
- -
-

- Repo-bound status command -

- - {githubManagement.statusCommand} - -

- Compare this UI with the same repo-bound status payload the CLI - uses for GitHub routing checks. -

-
- - {githubManagement.readiness.repairGuidance.length > 0 ? ( -
-

- Repair guidance -

-
    - {githubManagement.readiness.repairGuidance.map((guidance) => ( -
  • -

    {guidance.title}

    -

    - {guidance.action} -

    -
  • - ))} -
-
- ) : null} -
- -
-
-

- Available profiles -

-

- The routed project profile, default profile, and saved GitHub - account metadata all stay visible so repair work can target the - correct configuration quickly. -

-
- -
- - {githubManagement.defaultProfile} - - - {githubManagement.projectOverride ?? "No project override"} - - - {githubManagement.extensionEnabled ? "yes" : "no"} - -
- - {githubManagement.profiles.length > 0 ? ( -
    - {githubManagement.profiles.map((profile) => { - const selected = - profile.id === githubManagement.selectedProfile; - let profileStateLabel = "saved"; - if (selected) { - profileStateLabel = "active"; - } else if (profile.isDefault) { - profileStateLabel = "default"; - } - return ( -
  • -
    -
    -

    {profile.id}

    -

    - {profile.mode} •{" "} - {profile.accountLogin ?? "No account snapshot"} -

    -
    - - {profileStateLabel} - -
    -
    - - {profile.authRef} - - - {profile.installationId ?? "Not selected"} - -
    -
  • - ); - })} -
- ) : ( -

- No GitHub profiles are configured for this repo yet. -

- )} -
-
-
- ); -} - -function CreateOrganizationCard(input: { - readonly authenticated: boolean; - readonly returnToPath: string; -}) { - return ( -
-
-

Create organization

-

- New organizations immediately make the creator the first active member - visible in the shared admin surface. -

-
- - {input.authenticated ? ( -
- - - - - - - -
- ) : ( -

- Sign in first to create shared organizations from the browser control - plane. -

- )} -
- ); -} - -function OrganizationsCard(input: { - readonly account: AccountShellContext; - readonly returnToPath: string; - readonly scopedReturnPath: string; -}) { - if (!input.account.authenticated) { - return ( -
-

- Visible organizations -

-

- Sign in to view the organizations and shared members available to the - current caller. -

-
- ); - } - - const selectedOrganization = input.account.selectedOrganization; - const selectedOrganizationId = selectedOrganization?.id ?? null; - const shouldShowVisibilityMessage = Boolean( - input.account.requestedOrganizationKey && - !input.account.selectedOrganizationVisible && - !selectedOrganization - ); - - return ( -
-
-
-

- Visible organizations -

-

- The broker returns only organizations visible to this account, and - the detail panel below stays scoped to that same set. -

-
- - {input.account.organizations.length} visible - -
- - {input.account.organizations.length > 0 ? ( - - ) : ( -

- No shared organizations are visible yet. Create one above or accept an - invitation from another admin. -

- )} - - {selectedOrganization ? ( - - ) : null} - - {shouldShowVisibilityMessage ? ( -
-

- Requested organization not visible -

-

- This account cannot load the requested organization detail because - it is not part of the caller-scoped org list. -

-
- ) : null} -
- ); -} - -function SelectedOrganizationDetail(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; - readonly returnToPath: string; -}) { - const selectedOrganization = input.account.selectedOrganization; - if (!selectedOrganization) { - return null; - } - - return ( -
-
-

Organization detail

-

- {selectedOrganization.name} -

-

- Pending access stays pending until the intended principal accepts or - declines it. Admin-side revoke uses the same broker route for pending - and active org membership, while team-specific revoke stays scoped to - the selected team below. -

-
- -
-
-
Invite member
-
- - - - - -
-
- -
-
-
- Members and invites -
-

- Active members can manage the org. Pending recipients must accept - or decline before access becomes active. -

-
- - {input.account.selectedOrganizationMemberships.length > 0 ? ( -
    - {input.account.selectedOrganizationMemberships.map( - (membership) => { - const isCurrentUser = - membership.userId === input.account.user.id || - (membership.email && - membership.email === input.account.user.email); - const targetLabel = membership.email ?? membership.target; - return ( -
  • -
    -
    -

    - {targetLabel} -

    -

    - {describeMembershipState({ - state: membership.state, - })} -

    -
    - - {membership.state} - -
    - - {!isCurrentUser && membership.state !== "removed" ? ( -
    - - - -
    - ) : null} -
  • - ); - } - )} -
- ) : ( -

- No actionable memberships are visible for this organization yet. -

- )} -
-
-
- ); -} - -function TeamsSection(input: { - readonly account: AccountShellContext; - readonly returnToPath: string; -}) { - if (!input.account.authenticated) { - return ( -
-
-

Teams

-

- Sign in to manage explicit parent-org team scope from the browser - control plane. -

-
- -
-

- Team creation and membership changes stay hidden until this account - has a signed-in broker session. -

-
-
- ); - } - - const selectedOrganization = input.account.selectedOrganization; - if (!selectedOrganization) { - return ( -
-
-

Teams

-

- Team creation and membership changes always require an explicit - parent organization scope. -

-
- -
-

- Select a visible organization first. Hack only shows teams and - team-scoped resources when the current account belongs to them - directly. -

-
-
- ); - } - - const selectedTeam = input.account.selectedTeam; - const selectedTeamId = selectedTeam?.id ?? null; - const scopedReturnPath = buildAccountControlPlanePath({ - redirectTo: input.returnToPath, - org: selectedOrganization.slug, - team: selectedTeam?.slug ?? input.account.requestedTeamKey, - }); - const shouldShowVisibilityMessage = Boolean( - input.account.requestedTeamKey && - !input.account.selectedTeamVisible && - selectedOrganization - ); - - return ( -
-
-

Teams

-

- Team creation and membership changes stay anchored to the explicit - parent organization{" "} - - {selectedOrganization.slug} - - . Org-only members cannot administer or load team-scoped resources - until they join the team directly. -

-
- -
- - -
-
-
-

Visible teams

-

- Hack only lists teams that the current account can use inside - the selected organization. -

-
- - {input.account.teams.length} visible - -
- - {input.account.teams.length > 0 ? ( - - ) : ( -

- No teams are visible for this organization yet. Create one above - or add this account to an existing team first. -

- )} - - {selectedTeam ? ( - - ) : null} - - {shouldShowVisibilityMessage ? ( -
-

- Requested team not visible -

-

- This account cannot load the requested team because Hack only - exposes team-scoped resources to direct team members. -

-
- ) : null} -
-
-
- ); -} - -function CreateTeamCard(input: { - readonly organizationSlug: string; - readonly returnToPath: string; -}) { - return ( -
-
-

Create team

-

- Every team stays nested under one explicit parent organization so the - scope never becomes ambiguous. -

-
- -
- - - - - - - - -
-
- ); -} - -function SelectedTeamDetail(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; - readonly returnToPath: string; -}) { - const selectedOrganization = input.account.selectedOrganization; - const selectedTeam = input.account.selectedTeam; - if (!(selectedOrganization && selectedTeam)) { - return null; - } - - return ( -
-
-

Team detail

-

- {selectedTeam.name} -

-

- Members keep their parent organization access when a team-specific - revoke happens. Team invites only succeed when the recipient already - has active access to{" "} - - {selectedOrganization.slug} - - . -

-
- -
-
-
Invite member
-
- - - - - - -
-
- -
-
-
- Members and revokes -
-

- Team revoke stays team-scoped. Removing one of these entries does - not remove the member from the parent organization. -

-
- - {input.account.selectedTeamMemberships.length > 0 ? ( -
    - {input.account.selectedTeamMemberships.map((membership) => { - const isCurrentUser = - membership.userId === input.account.user.id || - (membership.email && - membership.email === input.account.user.email); - const targetLabel = membership.email ?? membership.target; - return ( -
  • -
    -
    -

    {targetLabel}

    -

    - {describeTeamMembershipState({ - state: membership.state, - })} -

    -
    - - {membership.state} - -
    - - {!isCurrentUser && membership.state !== "removed" ? ( -
    - - - - -
    - ) : null} -
  • - ); - })} -
- ) : ( -

- No direct team memberships are visible for this team yet. -

- )} -
-
-
- ); -} - -function ProjectsSection(input: { - readonly account: AccountShellContext; - readonly returnToPath: string; -}) { - if (!input.account.authenticated) { - return ( -
-
-

Projects

-

- Sign in to register shared projects and review caller-scoped access - grants from the browser control plane. -

-
- -
-

- Shared project ownership and access controls stay hidden until this - account has a signed-in broker session. -

-
-
- ); - } - - const account: Extract< - AccountShellContext, - { readonly authenticated: true } - > = input.account; - const selectedProject = account.selectedProject; - const scopedReturnPath = buildAccountControlPlanePath({ - redirectTo: input.returnToPath, - org: account.selectedOrganization?.slug ?? account.requestedOrganizationKey, - team: account.selectedTeam?.slug ?? account.requestedTeamKey, - project: selectedProject?.slug ?? account.requestedProjectKey, - }); - const shouldShowVisibilityMessage = Boolean( - account.requestedProjectKey && - !account.selectedProjectVisible && - !selectedProject - ); - - return ( -
-
-

Projects

-

- Register projects with explicit local or shared ownership and keep - durable access grants visible from the same caller-scoped browser - surface. -

-
- -
- - -
-
-
-

- Visible projects -

-

- Hack only lists projects the current account can see through - durable ownership or explicit project access grants. -

-
- - {account.projects.length} visible - -
- - {account.projects.length > 0 ? ( - - ) : ( -

- No projects are visible yet. Register one with explicit local or - shared ownership, or wait for an admin to grant this account - access. -

- )} - - {selectedProject ? ( - - ) : null} - - {shouldShowVisibilityMessage ? ( -
-

- Requested project not visible -

-

- This account cannot load the requested project because Hack only - exposes shared projects through durable ownership or explicit - access grants. -

-
- ) : null} -
-
-
- ); -} - -function RegisterProjectCard(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; - readonly returnToPath: string; -}) { - const scopedOrganizations = filterOrganizationsByActiveScope({ - account: input.account, - }); - const scopedTeams = filterTeamsByActiveScope({ - account: input.account, - }); - const selectedOrganizationSlug = - scopedOrganizations[0]?.slug ?? - input.account.selectedOrganization?.slug ?? - ""; - const selectedTeamSlug = - scopedTeams[0]?.slug ?? input.account.selectedTeam?.slug ?? ""; - const defaultMode = resolveDefaultProjectOwnershipMode({ - organizations: scopedOrganizations, - teams: scopedTeams, - }); - - return ( -
-
-

Register project

-

- Shared registrations stay durable in the broker, while local mode - remains explicit so CLI and web can report the same ownership state. -

-

- Shared ownership choices follow the active Hack scope:{" "} - {describeSharedProjectScope({ account: input.account })}. -

-
- -
- - - - - - - - - - - - - -
-
- ); -} - -function SelectedProjectDetail(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; - readonly returnToPath: string; -}) { - const selectedProject = input.account.selectedProject; - if (!selectedProject) { - return null; - } - const scopedOrganizations = filterOrganizationsByActiveScope({ - account: input.account, - }); - const scopedTeams = filterTeamsByActiveScope({ - account: input.account, - }); - const organizationGrantDisabled = scopedOrganizations.length === 0; - const teamGrantDisabled = - scopedOrganizations.length === 0 || scopedTeams.length === 0; - - return ( -
-
-

Project detail

-

- {selectedProject.name} -

-

- Ownership remains explicit:{" "} - - {selectedProject.ownership.mode} - {" "} - managed by{" "} - - {selectedProject.ownership.ownerName ?? - selectedProject.ownership.ownerSlug ?? - selectedProject.ownership.ownerType} - - . -

-
- -
- - {selectedProject.ownership.mode} - - - {selectedProject.ownership.ownerName ?? - selectedProject.ownership.ownerSlug ?? - selectedProject.ownership.ownerType} - - - {selectedProject.currentAccessRole} - -
- -
-
-
Explicit access
-

- Shared grant targets follow the active Hack scope:{" "} - {describeSharedProjectScope({ account: input.account })}. -

-
- - - - - - -
- -
- - - - - - -
-
- -
-
-
Access grants
-

- Shared project access remains explicit and removable per grant. -

-
- - {input.account.selectedProjectAccess.length > 0 ? ( -
    - {input.account.selectedProjectAccess.map((grant) => ( -
  • -
    -
    -

    - {grant.subjectName} -

    -

    - {grant.scope} • {grant.role} -

    -
    - - {grant.subjectSlug} - -
    - -
    - - - -
    -
  • - ))} -
- ) : ( -

- No explicit grants are visible for this project yet. -

- )} -
-
-
- ); -} - -function ProjectDetailCard(input: { - readonly label: string; - readonly children: ReactNode; -}) { - return ( -
-
{input.label}
-
{input.children}
-
- ); -} - -function formatEnvClassificationLabel(input: { readonly value: string }) { - return input.value.replaceAll("_", " "); -} - -function formatEnvVariableStorageLabel(input: { - readonly variable: EnvManagementState["variables"][number]; -}) { - return `${input.variable.storage.kind} • ${input.variable.storage.backend}`; -} - -function describeGitHubInstallation(input: { - readonly githubManagement: GitHubManagementState; -}) { - if (input.githubManagement.readiness.installation.state === "configured") { - return input.githubManagement.installationId ?? "Configured"; - } - if (input.githubManagement.readiness.installation.state === "missing") { - return "Missing installation"; - } - return "Not required in token mode"; -} - -function InvitationsCard(input: { - readonly account: AccountShellContext; - readonly returnToPath: string; -}) { - if (!input.account.authenticated) { - return ( -
-

- Sign in to review invitations that are specifically pending for the - current account email address. -

-
- ); - } - - const incomingInvitations = input.account.incomingInvitations; - - return ( -
- {incomingInvitations.length > 0 ? ( -
    - {incomingInvitations.map((invitation) => ( -
  • -
    -

    {invitation.email}

    -

    - Pending {invitation.scope} invite for organization{" "} - - {invitation.organizationId} - -

    -
    - -
    -
    - - -
    -
    - - -
    -
    -
  • - ))} -
- ) : ( -

- No invitations are pending for this account right now. -

- )} -
- ); -} - -function IntegrationScopeCard(input: { - readonly feedback: AccountControlPlaneFeedback; -}) { - return ( -
-

{input.feedback.title}

-

- {input.feedback.body} -

-
- ); -} - -function resolveSharedIntegrationScopeFeedback(input: { - readonly account: AccountShellContext; -}): AccountControlPlaneFeedback | null { - if (!input.account.authenticated) { - return null; - } - - if ( - input.account.requestedProjectKey && - input.account.selectedProjectVisible === false && - !input.account.selectedProject - ) { - return { - tone: "danger", - title: "Shared project scope denied", - body: "The current org/team context does not expose the requested shared project. Switch back to a visible shared scope before treating GitHub or Linear state as broker-managed for this repo.", - }; - } - - const selectedProject = input.account.selectedProject; - if (!selectedProject) { - return { - tone: "info", - title: "No visible project scope", - body: "Select or register a visible project before comparing shared GitHub and Linear scope with the active org/team context.", - }; - } - - if (selectedProject.ownership.mode === "local") { - return { - tone: "info", - title: "Local project scope", - body: "This repo currently uses local project ownership, so GitHub and Linear readiness here reflects repo-local state instead of shared org/team broker scope.", - }; - } - - if (selectedProject.currentAccessRole === "viewer") { - return { - tone: "info", - title: "Read-only shared project scope", - body: "The active org/team can inspect shared integration state for this project, but broker-managed mutations stay blocked while the current access role remains viewer.", - }; - } - - return { - tone: "success", - title: "Shared project scope active", - body: "The active org/team can inspect and manage shared GitHub and Linear state for the selected project without crossing tenant boundaries.", - }; -} - -function filterOrganizationsByActiveScope(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; -}) { - const activeOrganizationId = input.account.activeOrganization?.id ?? null; - if (!activeOrganizationId) { - return input.account.organizations; - } - return input.account.organizations.filter((organization) => { - return organization.id === activeOrganizationId; - }); -} - -function filterTeamsByActiveScope(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; -}) { - const activeTeamId = input.account.activeTeam?.id ?? null; - if (activeTeamId) { - return input.account.teams.filter((team) => team.id === activeTeamId); - } - return []; -} - -function resolveDefaultProjectOwnershipMode(input: { - readonly organizations: readonly { readonly id: string }[]; - readonly teams: readonly { readonly id: string }[]; -}): "local" | "organization" | "team" { - if (input.teams.length > 0) { - return "team"; - } - if (input.organizations.length > 0) { - return "organization"; - } - return "local"; -} - -function describeSharedProjectScope(input: { - readonly account: Extract< - AccountShellContext, - { readonly authenticated: true } - >; -}) { - if (input.account.activeTeam?.name) { - return `${input.account.activeTeam.name} team inside ${input.account.activeOrganization?.name ?? "the active organization"}`; - } - if (input.account.activeOrganization?.name) { - return `${input.account.activeOrganization.name} organization`; - } - return "local user context"; -} - -function describeMembershipState(input: { - readonly state: "pending" | "active" | "removed"; -}) { - if (input.state === "pending") { - return "Pending recipient action"; - } - if (input.state === "active") { - return "Active org access"; - } - return "Removed access"; -} - -function describeTeamMembershipState(input: { - readonly state: "pending" | "active" | "removed"; -}) { - if (input.state === "pending") { - return "Pending team invite"; - } - if (input.state === "active") { - return "Active team access"; - } - return "Removed team access"; -} - -function membershipTargetValue(input: { - readonly membership: { - readonly userId: string | null; - readonly email: string | null; - readonly target: string; - }; -}) { - return ( - input.membership.userId ?? input.membership.email ?? input.membership.target - ); -} diff --git a/apps/web/src/components/account-page-frame.tsx b/apps/web/src/components/account-page-frame.tsx deleted file mode 100644 index 589dfb4f..00000000 --- a/apps/web/src/components/account-page-frame.tsx +++ /dev/null @@ -1,91 +0,0 @@ -import type { ReactNode } from "react"; - -import { - Card, - CardContent, - CardDescription, - CardHeader, - CardTitle, -} from "@/components/ui/card"; - -export function AccountPageFrame(input: { - readonly title: string; - readonly description: string; - readonly children: ReactNode; -}) { - return ( -
-
-

- {input.title} -

-

- {input.description} -

-
- {input.children} -
- ); -} - -export function AccountSectionCard(input: { - readonly title: string; - readonly description?: string; - readonly action?: ReactNode; - readonly children: ReactNode; -}) { - return ( - - -
-
- {input.title} - {input.description ? ( - {input.description} - ) : null} -
- {input.action ?
{input.action}
: null} -
-
- {input.children} -
- ); -} - -export function AccountEmptyState(input: { - readonly title: string; - readonly body: string; -}) { - return ( -
-

{input.title}

-

{input.body}

-
- ); -} - -export function AccountStatsGrid(input: { - readonly items: readonly { - readonly label: string; - readonly value: string; - readonly hint?: string; - }[]; -}) { - return ( -
- {input.items.map((item) => ( -
-
- {item.label} -
-
- {item.value} -
- {item.hint ? ( -

{item.hint}

- ) : null} -
- ))} -
- ); -} diff --git a/apps/web/src/components/account-shell-loading.tsx b/apps/web/src/components/account-shell-loading.tsx deleted file mode 100644 index 91152d4b..00000000 --- a/apps/web/src/components/account-shell-loading.tsx +++ /dev/null @@ -1,100 +0,0 @@ -import { Compass } from "lucide-react"; - -import { shellSummary, shellTitle } from "@/lib/control-plane-shell"; -import { cn } from "@/lib/utils"; - -const loadingSurfaceClassName = cn( - "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const loadingPlaceholderClassName = - "rounded-full bg-white/10 animate-pulse motion-reduce:animate-none"; - -export default function AccountShellLoading() { - return ( -
- - Skip to main content - - -
-
-
-
- - -
-

- {shellTitle} -

-

- {shellSummary} -

-
-
- -
- -
- - -
-
-

- Loading account context -

-
-

- Resolving your Hack account shell… -

-

- Hack is reconciling the browser sign-in handoff, repo env - status, and integration state before rendering the full account - shell. -

-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/account-shell-page.tsx b/apps/web/src/components/account-shell-page.tsx deleted file mode 100644 index 2b224619..00000000 --- a/apps/web/src/components/account-shell-page.tsx +++ /dev/null @@ -1,92 +0,0 @@ -import ControlPlaneShell from "@/components/control-plane-shell"; -import { - buildAccountControlPlanePath, - resolveAccountControlPlaneFeedback, -} from "@/lib/account-control-plane"; -import { - buildAccountShellSignInHref, - getAccountShellContext, -} from "@/lib/account-shell"; -import { resolveBrowserSharedProjectScope } from "@/lib/browser-shared-project-scope"; -import { loadEnvManagementState } from "@/lib/env-management"; -import { loadGitHubManagementState } from "@/lib/github-management"; -import { loadLinearManagementState } from "@/lib/linear-management"; - -export default async function AccountShellPage(input: { - readonly returnToPath: string; - readonly searchParams?: Promise< - Record - >; -}) { - const searchParams = (await input.searchParams) ?? {}; - const requestedOrganizationKey = readSearchParam(searchParams.org); - const requestedTeamKey = readSearchParam(searchParams.team); - const requestedProjectKey = readSearchParam(searchParams.project); - const account = await getAccountShellContext({ - selectedOrganizationKey: requestedOrganizationKey, - selectedTeamKey: requestedTeamKey, - selectedProjectKey: requestedProjectKey, - }); - const browserSharedProjectScope = resolveBrowserSharedProjectScope({ - account, - }); - const [envManagement, githubManagement, linearManagement] = await Promise.all( - [ - loadEnvManagementState(), - loadGitHubManagementState({ - browserSharedProjectScope, - }), - loadLinearManagementState({ - browserSharedProjectScope, - }), - ] - ); - const feedback = resolveAccountControlPlaneFeedback({ - notice: readSearchParam(searchParams.notice), - error: readSearchParam(searchParams.error), - requestedOrganizationKey, - requestedTeamKey, - requestedProjectKey, - selectedOrganizationVisible: account.authenticated - ? account.selectedOrganizationVisible - : true, - selectedTeamVisible: account.authenticated - ? account.selectedTeamVisible - : true, - selectedProjectVisible: account.authenticated - ? account.selectedProjectVisible - : true, - }); - const returnToPath = buildAccountControlPlanePath({ - redirectTo: input.returnToPath, - org: - requestedOrganizationKey ?? - (account.authenticated ? account.selectedOrganization?.slug : null), - team: - requestedTeamKey ?? - (account.authenticated ? account.selectedTeam?.slug : null), - project: - requestedProjectKey ?? - (account.authenticated ? account.selectedProject?.slug : null), - }); - - return ( - - ); -} - -function readSearchParam( - value: string | string[] | undefined -): string | undefined { - return Array.isArray(value) ? value[0] : value; -} diff --git a/apps/web/src/components/app-navbar.tsx b/apps/web/src/components/app-navbar.tsx deleted file mode 100644 index c36f551a..00000000 --- a/apps/web/src/components/app-navbar.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; - -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -import { CustomSidebarTrigger } from "@/components/custom-sidebar-trigger"; -import { NavUser } from "@/components/nav-user"; -import { - Breadcrumb, - BreadcrumbItem, - BreadcrumbLink, - BreadcrumbList, - BreadcrumbPage, - BreadcrumbSeparator, -} from "@/components/ui/breadcrumb"; -import { Separator } from "@/components/ui/separator"; -import { resolveAccountPageTitle } from "@/lib/account-navigation"; -import type { AccountShellContext } from "@/lib/account-shell"; -import { cn } from "@/lib/utils"; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -export function AppNavbar(input: { readonly account: AuthenticatedAccount }) { - const pathname = usePathname(); - const pageTitle = resolveAccountPageTitle({ - pathname, - }); - const isOverview = pathname === "/account"; - - return ( -
-
- - - - - - {isOverview ? ( - Account - ) : ( - - Account - - )} - - {isOverview ? null : ( - <> - - - {pageTitle} - - - )} - - -
-
- -
-
- ); -} diff --git a/apps/web/src/components/app-shell.tsx b/apps/web/src/components/app-shell.tsx deleted file mode 100644 index 99292570..00000000 --- a/apps/web/src/components/app-shell.tsx +++ /dev/null @@ -1,26 +0,0 @@ -import { AppNavbar } from "@/components/app-navbar"; -import { AppSidebar } from "@/components/app-sidebar"; -import { SidebarInset, SidebarProvider } from "@/components/ui/sidebar"; -import type { AccountShellContext } from "@/lib/account-shell"; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -export function AppShell(input: { - readonly account: AuthenticatedAccount; - readonly children: React.ReactNode; -}) { - return ( - - - - -
- {input.children} -
-
-
- ); -} diff --git a/apps/web/src/components/app-sidebar.tsx b/apps/web/src/components/app-sidebar.tsx deleted file mode 100644 index 6bb52047..00000000 --- a/apps/web/src/components/app-sidebar.tsx +++ /dev/null @@ -1,152 +0,0 @@ -"use client"; - -import { ArrowUpRightIcon } from "lucide-react"; -import Link from "next/link"; -import { usePathname } from "next/navigation"; - -import { Logo } from "@/components/logo"; -import { OrganizationSwitcher } from "@/components/organization-switcher"; -import { - Sidebar, - SidebarContent, - SidebarFooter, - SidebarGroup, - SidebarGroupLabel, - SidebarHeader, - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - SidebarRail, -} from "@/components/ui/sidebar"; -import { accountNavigationItems } from "@/lib/account-navigation"; -import type { AccountShellContext } from "@/lib/account-shell"; -import { cn } from "@/lib/utils"; - -export type SidebarNavItem = { - title: string; - url: string; - icon: React.ReactNode; - isActive?: boolean; -}; - -type SidebarSection = { - label: string; - items: SidebarNavItem[]; -}; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -const footerNavLinks: SidebarNavItem[] = [ - { - title: "Documentation", - url: "https://github.com/hack-dance/hack", - icon: , - }, - { - title: "GitHub", - url: "https://github.com/hack-dance/hack", - icon: , - }, -]; - -export function AppSidebar(input: { readonly account: AuthenticatedAccount }) { - const pathname = usePathname(); - const navSections = buildNavSections({ - pathname, - }); - - return ( - span]:text-foreground/75" - )} - collapsible="icon" - variant="sidebar" - > - - - - - Hack - - - - - {navSections.map((section) => ( - - - {section.label} - - - {section.items.map((item) => ( - - - - {item.icon} - {item.title} - - - - ))} - - - ))} - - -
- -
- - {footerNavLinks.map((item) => ( - - - - {item.icon} - {item.title} - - - - ))} - -
-

- © {new Date().getFullYear()} Hack -

-
-
- -
- ); -} - -function buildNavSections(input: { - readonly pathname: string; -}): readonly SidebarSection[] { - return [ - { - label: "Workspace", - items: accountNavigationItems.map((item) => ({ - title: item.title, - url: item.href, - icon: , - isActive: - item.href === "/account" - ? input.pathname === "/account" - : input.pathname.startsWith(item.href), - })), - }, - ]; -} diff --git a/apps/web/src/components/auth-entrypoint.tsx b/apps/web/src/components/auth-entrypoint.tsx deleted file mode 100644 index a31c12fd..00000000 --- a/apps/web/src/components/auth-entrypoint.tsx +++ /dev/null @@ -1,627 +0,0 @@ -"use client"; - -import type { - BetterAuthProviderMetadata, - BetterAuthSocialProvider, -} from "@hack/auth-contract"; -import type { ReactNode } from "react"; -import { useEffect, useMemo, useState } from "react"; - -import { GitHubIcon } from "@/components/github-icon"; -import { Button } from "@/components/ui/button"; -import { CardDescription, CardFooter, CardTitle } from "@/components/ui/card"; -import { cn } from "@/lib/utils"; - -import { - normalizeAppReturnUrl, - resolveInitialAuthFlowKind, - shouldAutoNavigateToReturnUrl, -} from "../lib/auth-handoff"; - -type AuthEntrypointProps = { - readonly mode: "sign-in" | "account"; - readonly providers: readonly BetterAuthSocialProvider[]; - readonly appBaseUrl: string; - readonly authBrokerBaseUrl: string; - readonly trustedOrigins: readonly string[]; - readonly betterAuthSource: "broker" | "fail_closed"; - readonly betterAuthEnabled: boolean; - readonly flowId?: string; - readonly deviceCode?: string; - readonly redirect?: string; - readonly browserSessionAuthenticated?: boolean; -}; - -type ActionState = - | { readonly kind: "idle" } - | { readonly kind: "loading"; readonly providerId: string } - | { readonly kind: "error"; readonly message: string }; - -type FlowState = - | { readonly kind: "idle" } - | { readonly kind: "polling" } - | { readonly kind: "ready" } - | { readonly kind: "claimed" } - | { readonly kind: "error"; readonly message: string }; - -type FlowStatusPayload = { - readonly ok?: boolean; - readonly status?: { - readonly status?: string; - readonly error?: string; - }; - readonly error?: string; - readonly message?: string; -}; - -type SocialStartPayload = { - readonly url?: string; - readonly error?: string; - readonly message?: string; -}; - -function renderGithubAuthBlock(input: { - readonly actionState: ActionState; - readonly githubOnlyGate: { - readonly body: string; - readonly title: string; - } | null; - readonly githubProvider: BetterAuthSocialProvider | undefined; - readonly onGithubClick: () => void; - readonly providerGate: { - readonly body: string; - readonly title: string; - } | null; -}): ReactNode { - if (input.providerGate) { - return ( -
-

- {input.providerGate.title} -

-

- {input.providerGate.body} -

-
- ); - } - if (input.githubOnlyGate) { - return ( -
-

- {input.githubOnlyGate.title} -

-

- {input.githubOnlyGate.body} -

-
- ); - } - if (!input.githubProvider) { - return null; - } - const githubLoading = - input.actionState.kind === "loading" && - input.actionState.providerId === "github"; - return ( - - ); -} - -export function AuthEntrypoint({ - mode, - providers, - appBaseUrl, - authBrokerBaseUrl: _authBrokerBaseUrl, - trustedOrigins, - betterAuthSource, - betterAuthEnabled, - flowId, - deviceCode, - redirect, - browserSessionAuthenticated = false, -}: AuthEntrypointProps) { - const [resolvedProviders, setResolvedProviders] = useState(providers); - const [resolvedTrustedOrigins, setResolvedTrustedOrigins] = - useState(trustedOrigins); - const normalizedRedirect = useMemo( - () => - normalizeAppReturnUrl({ - value: redirect, - appBaseUrl, - trustedOrigins: resolvedTrustedOrigins, - }), - [appBaseUrl, redirect, resolvedTrustedOrigins] - ); - const [actionState, setActionState] = useState({ kind: "idle" }); - const [flowState, setFlowState] = useState( - (() => { - const initialFlowKind = resolveInitialAuthFlowKind({ - mode, - flowId, - deviceCode, - redirect: normalizedRedirect, - browserSessionAuthenticated, - }); - if (initialFlowKind === "polling") { - return { kind: "polling" }; - } - if (initialFlowKind === "ready") { - return { kind: "ready" }; - } - return { kind: "idle" }; - })() - ); - - useEffect(() => { - let active = true; - - const loadProviders = async () => { - try { - const response = await fetch("/api/auth/providers", { - cache: "no-store", - }); - if (!response.ok) { - return; - } - const payload = (await response.json()) as { - readonly providers?: readonly BetterAuthProviderMetadata[]; - }; - const betterAuthProvider = payload.providers?.find( - (provider) => provider.id === "better-auth" - ); - if (!(active && betterAuthProvider)) { - return; - } - setResolvedProviders( - betterAuthProvider.enabled ? betterAuthProvider.socialProviders : [] - ); - setResolvedTrustedOrigins(betterAuthProvider.trustedOrigins); - } catch { - // Keep the boot-time provider contract when the broker metadata endpoint is unavailable. - } - }; - - void loadProviders(); - - return () => { - active = false; - }; - }, []); - - useEffect(() => { - if (!(mode === "account" && flowId && deviceCode)) { - return; - } - let active = true; - let nextPollHandle: number | undefined; - - const poll = async () => { - try { - const response = await fetch( - buildFlowStatusUrl({ - deviceCode, - flowId, - }), - { - cache: "no-store", - } - ); - const payload = (await response.json()) as FlowStatusPayload; - if (!active) { - return; - } - const nextState = resolvePolledFlowState({ - payload, - responseOk: response.ok, - }); - if (nextState.kind === "polling") { - setFlowState({ kind: "polling" }); - nextPollHandle = window.setTimeout(() => { - void poll(); - }, 1000); - return; - } - setFlowState(nextState); - } catch (error) { - if (!active) { - return; - } - setFlowState( - createFlowErrorState({ - fallbackMessage: "Hack could not confirm this browser handoff.", - message: error instanceof Error ? error.message : undefined, - }) - ); - } - }; - - void poll(); - - return () => { - active = false; - if (typeof nextPollHandle === "number") { - window.clearTimeout(nextPollHandle); - } - }; - }, [deviceCode, flowId, mode]); - - useEffect(() => { - if ( - !( - (flowState.kind === "ready" || flowState.kind === "claimed") && - normalizedRedirect && - shouldAutoNavigateToReturnUrl({ value: normalizedRedirect }) - ) - ) { - return; - } - const handle = window.setTimeout(() => { - window.location.assign(normalizedRedirect); - }, 180); - return () => { - window.clearTimeout(handle); - }; - }, [flowState.kind, normalizedRedirect]); - - const hasFlowContext = Boolean(flowId && deviceCode); - const summary = resolveSummary({ - mode, - hasFlowContext, - }); - const flowStatus = resolveFlowStatus({ - flowState, - hasFlowContext, - normalizedRedirect, - }); - const signInHref = buildAuthPageHref({ - flowId, - deviceCode, - redirect: normalizedRedirect, - }); - - const handleProviderClick = async (providerId: string) => { - setActionState({ kind: "loading", providerId }); - try { - const response = await fetch("/api/auth/social", { - method: "POST", - headers: { - "content-type": "application/json", - }, - body: JSON.stringify({ - provider: providerId, - ...(flowId ? { flowId } : {}), - ...(deviceCode ? { deviceCode } : {}), - ...(normalizedRedirect ? { redirect: normalizedRedirect } : {}), - }), - }); - const payload = (await response.json()) as SocialStartPayload; - if ( - !( - response.ok && - typeof payload.url === "string" && - payload.url.length > 0 - ) - ) { - throw new Error( - payload.message ?? - payload.error ?? - "Hack could not start the selected sign-in provider." - ); - } - window.location.assign(payload.url); - } catch (error) { - setActionState({ - kind: "error", - message: - error instanceof Error - ? error.message - : "Hack could not start the selected sign-in provider.", - }); - } - }; - - const providerGate = resolveProviderGate({ - betterAuthSource, - betterAuthEnabled, - providerCount: resolvedProviders.length, - }); - const githubProvider = resolvedProviders.find( - (provider) => provider.id === "github" - ); - const githubOnlyGate = - !providerGate && resolvedProviders.length > 0 && !githubProvider - ? { - title: "GitHub sign-in only", - body: "This app uses GitHub OAuth. Enable GitHub in the broker’s social providers list.", - } - : null; - const hideGithubAuthBlock = - mode === "account" && - (flowState.kind === "ready" || flowState.kind === "claimed"); - - return ( -
-
-
- - {summary.title} - - - {summary.body} - -
-
- {hasFlowContext ? ( -
-

- Linked browser handoff -

-

- This tab is linked to a Hack client flow. Complete sign-in here - so the broker can finish the session for your CLI or deep link. -

-
- ) : null} - - {mode === "account" ? ( -
-

- {flowStatus.title} -

-

- {flowStatus.body} -

- {flowStatus.href ? ( - - ) : null} -
- ) : null} - - {hideGithubAuthBlock - ? null - : renderGithubAuthBlock({ - actionState, - githubOnlyGate, - githubProvider, - onGithubClick: () => void handleProviderClick("github"), - providerGate, - })} - -

- {actionState.kind === "error" - ? actionState.message - : flowStatus.statusText} -

-
- {mode === "account" ? ( - - - - ) : null} -
-
- ); -} - -function resolveProviderGate(input: { - readonly betterAuthSource: "broker" | "fail_closed"; - readonly betterAuthEnabled: boolean; - readonly providerCount: number; -}): { readonly title: string; readonly body: string } | null { - if (input.betterAuthSource === "fail_closed") { - return { - title: "Cannot reach the auth broker", - body: "Start your stack with Hack (for example `hack up`), then open this site using your Hack dev hostname (for example https://hack-cli.hack), not raw localhost, so the app can reach the broker.", - }; - } - if (!input.betterAuthEnabled) { - return { - title: "Better Auth is not active", - body: "The broker is reachable but Better Auth is off. Ensure DATABASE_URL and BETTER_AUTH_SECRET are set for the auth-broker service, then restart it.", - }; - } - if (input.providerCount === 0) { - return { - title: "No OAuth providers", - body: "GitHub client credentials are not configured on the broker. Set GITHUB_CLIENT_ID and GITHUB_CLIENT_SECRET (or BETTER_AUTH_GITHUB_*), redeploy the broker, and add your callback URL to the GitHub OAuth app.", - }; - } - return null; -} - -function authPanelClassName( - tone: "neutral" | "info" | "success" | "danger" | "muted" -): string { - return cn( - "grid gap-2 rounded-xl border p-4", - tone === "neutral" && "border-border bg-muted/40", - tone === "info" && - "border-sky-500/30 bg-sky-500/10 dark:border-sky-400/25 dark:bg-sky-400/10", - tone === "success" && - "border-emerald-500/35 bg-emerald-500/10 dark:border-emerald-400/30 dark:bg-emerald-400/10", - tone === "danger" && "border-destructive/40 bg-destructive/10", - tone === "muted" && "border-border bg-muted/25" - ); -} - -function resolveSummary(input: { - readonly mode: "sign-in" | "account"; - readonly hasFlowContext: boolean; -}): { readonly title: string; readonly body: string } { - if (input.mode === "account") { - return { - title: "Finish your Hack browser handoff", - body: input.hasFlowContext - ? "Hack will poll the broker-backed session flow here while the browser completes sign-in." - : "Use this route to complete browser sign-in and manage your Hack session.", - }; - } - return { - title: "", - body: input.hasFlowContext - ? "This browser tab is linked to a Hack client flow. Authorize GitHub to finish the handoff." - : "", - }; -} - -function buildAuthPageHref(input: { - readonly flowId?: string; - readonly deviceCode?: string; - readonly redirect: string | null; -}): string { - const searchParams = new URLSearchParams(); - if (input.flowId) { - searchParams.set("flowId", input.flowId); - } - if (input.deviceCode) { - searchParams.set("deviceCode", input.deviceCode); - } - if (input.redirect) { - searchParams.set("redirect", input.redirect); - } - const query = searchParams.toString(); - return query.length > 0 ? `/auth?${query}` : "/auth"; -} - -function buildFlowStatusUrl(input: { - readonly deviceCode: string; - readonly flowId: string; -}): string { - return `/api/auth/flows/${encodeURIComponent(input.flowId)}?deviceCode=${encodeURIComponent(input.deviceCode)}`; -} - -function resolvePolledFlowState(input: { - readonly payload: FlowStatusPayload; - readonly responseOk: boolean; -}): FlowState { - if (!input.responseOk || input.payload.ok !== true) { - return createFlowErrorState({ - fallbackMessage: "Hack could not confirm this browser handoff.", - message: input.payload.message ?? input.payload.error, - }); - } - - switch (input.payload.status?.status) { - case "complete": - return { kind: "ready" }; - case "claimed": - return { kind: "claimed" }; - case "error": - return createFlowErrorState({ - fallbackMessage: - "Hack reported an unrecoverable browser handoff error.", - message: input.payload.status.error, - }); - default: - return { kind: "polling" }; - } -} - -function createFlowErrorState(input: { - readonly fallbackMessage: string; - readonly message?: string; -}): FlowState { - return { - kind: "error", - message: input.message ?? input.fallbackMessage, - }; -} - -function resolveFlowStatus(input: { - readonly flowState: FlowState; - readonly hasFlowContext: boolean; - readonly normalizedRedirect: string | null; -}): { - readonly title: string; - readonly body: string; - readonly tone: "neutral" | "info" | "success" | "danger" | "muted"; - readonly statusText: string; - readonly href?: string; - readonly label?: string; -} { - if (input.flowState.kind === "polling") { - return { - title: "Waiting for the broker session", - body: "Complete sign-in in the provider window. This page will update as soon as the broker marks the flow ready.", - tone: "info", - statusText: "Waiting for the broker-backed handoff to complete…", - }; - } - if (input.flowState.kind === "ready" || input.flowState.kind === "claimed") { - const title = - input.hasFlowContext || input.normalizedRedirect - ? "Browser handoff confirmed" - : "Signed in to Hack"; - let body = - "Your browser session is active. You can close this tab or start another sign-in."; - if (input.normalizedRedirect) { - body = - "The broker established the session. Return to Hack when you are ready."; - } else if (input.hasFlowContext) { - body = - "The broker established the session. You can close this tab when you are done."; - } - return { - title, - body, - tone: "success", - statusText: - input.normalizedRedirect && - shouldAutoNavigateToReturnUrl({ value: input.normalizedRedirect }) - ? "Returning to Hack…" - : title, - ...(input.normalizedRedirect - ? { - href: input.normalizedRedirect, - label: "Return to Hack", - } - : {}), - }; - } - if (input.flowState.kind === "error") { - return { - title: "Browser handoff needs attention", - body: input.flowState.message, - tone: "danger", - statusText: input.flowState.message, - }; - } - return { - title: "No linked handoff is active", - body: "Use the sign-in route to start a browser session, or open a broker-managed account flow and then return here.", - tone: "muted", - statusText: "", - }; -} diff --git a/apps/web/src/components/big-logo.tsx b/apps/web/src/components/big-logo.tsx deleted file mode 100644 index df2c281f..00000000 --- a/apps/web/src/components/big-logo.tsx +++ /dev/null @@ -1,145 +0,0 @@ -export function BigLogo({ className }: { className?: string }) { - return ( - - Hack logo - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - ); -} diff --git a/apps/web/src/components/control-plane-shell.tsx b/apps/web/src/components/control-plane-shell.tsx deleted file mode 100644 index a34e7ac7..00000000 --- a/apps/web/src/components/control-plane-shell.tsx +++ /dev/null @@ -1,396 +0,0 @@ -import { ArrowRight, Compass, Keyboard, ShieldCheck } from "lucide-react"; -import AccountControlPlaneSections from "@/components/account-control-plane-sections"; -import type { AccountControlPlaneFeedback } from "@/lib/account-control-plane"; -import type { AccountShellContext } from "@/lib/account-shell"; -import { - shellGuardrails, - shellHighlights, - shellNavigationItems, - shellPrinciples, - shellSummary, - shellTitle, -} from "@/lib/control-plane-shell"; -import type { EnvManagementState } from "@/lib/env-management"; -import type { GitHubManagementState } from "@/lib/github-management"; -import type { LinearManagementState } from "@/lib/linear-management"; -import { cn } from "@/lib/utils"; - -const interactiveSurfaceClassName = cn( - "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", - "transition duration-200 motion-safe:hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const focusLinkClassName = cn( - "rounded-full px-4 py-2 text-sm text-white/80", - "transition duration-200 hover:bg-white/8 hover:text-white motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -type ControlPlaneShellProps = { - readonly account?: AccountShellContext; - readonly envManagement?: EnvManagementState; - readonly githubManagement: GitHubManagementState; - readonly linearManagement: LinearManagementState; - readonly feedback?: AccountControlPlaneFeedback | null; - readonly returnToPath: string; - readonly signInHref?: string; -}; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -const fallbackAccountContext = { - authenticated: false, -} as const satisfies AccountShellContext; - -const fallbackEnvManagement = { - ready: false, - envSelectionLabel: "Unavailable", - missingRequired: [], - status: { - trustModel: "unavailable", - custody: "unavailable", - portability: "unavailable", - sharedState: "unavailable", - summary: "Env status unavailable", - detail: - "Hack could not resolve the repo-bound env status for this browser shell.", - }, - backend: { - name: "unknown", - classification: { - trustModel: "unavailable", - custody: "unavailable", - portability: "unavailable", - sharedState: "unavailable", - }, - status: { - storageMode: "Unavailable", - trustModel: "Unavailable", - portability: "Unavailable", - plaintextCompatibility: "Unavailable", - }, - }, - localPlaintext: { - path: ".hack/.env", - exists: false, - classification: { - trustModel: "unavailable", - custody: "unavailable", - portability: "unavailable", - sharedState: "unavailable", - }, - }, - localSecrets: { - backend: "unknown", - location: "Unavailable", - mode: "unknown", - provider: null, - classification: { - trustModel: "unavailable", - custody: "unavailable", - portability: "unavailable", - sharedState: "unavailable", - }, - }, - portableState: { - status: "unknown", - message: "Portable env status is unavailable.", - classification: { - trustModel: "unavailable", - custody: "unavailable", - portability: "unavailable", - sharedState: "unavailable", - }, - }, - compatibilityMode: { - plaintextTarget: ".hack/.env", - secretBackend: "unknown", - plaintextMirroredToBackend: false, - summary: "Env compatibility status is unavailable.", - }, - variables: [], - statusCommand: "./dist/hack env list --json", - backendCommand: "./dist/hack env backend status --json", -} as const satisfies EnvManagementState; - -export default function ControlPlaneShell({ - account = fallbackAccountContext, - envManagement = fallbackEnvManagement, - githubManagement, - linearManagement, - feedback = null, - returnToPath, - signInHref = "/auth?redirect=%2F", -}: ControlPlaneShellProps) { - const identityLabel = account.authenticated - ? formatIdentityLabel({ account }) - : null; - - return ( -
- - Skip to main content - - -
-
-
-
- - -
-

- {shellTitle} -

-

- {shellSummary} -

-
-
- -
    - {shellPrinciples.map(({ title }) => ( -
  • - - {title} - -
  • - ))} -
-
- -
-
-
-
-

- Use the skip link, tab through the shell navigation, and land in - a semantic main region without crossing unfinished auth or admin - flows. -

-
- -
-
-
-

- The routed shell is ready for later slices while staying - explicit about what does not belong to this commit. -

-
-
-
-
- -
- - -
-
- {account.authenticated ? ( -
-
-

- Signed in context -

-
-

- {identityLabel} -

-

- This account shell mirrors the broker current-user payload - and the same org/team context that{" "} - - hack auth status --json - {" "} - resolves locally. -

-
-
- -
- - - - -
- - -
- ) : ( -
-

- Sign in to load your Hack account context -

-

- Open the browser-owned sign-in entrypoint and Hack will return - to this shell with the same identity, org, and team context - that the broker and CLI expose. -

- - Continue to sign in - -
- )} -
- - - -
-
-

Foundations

-

- The shell introduces reusable accessibility and layout patterns - for later browser-owned slices. -

-
- -
- {shellHighlights.map(({ description, title }) => ( -
-

{title}

-

- {description} -

-
- ))} -
-
- -
-

Guardrails

-
    - {shellGuardrails.map((guardrail) => ( -
  • -
  • - ))} -
-
-
-
-
- ); -} - -function ContextCard(input: { - readonly label: string; - readonly value: string; -}) { - return ( -
-
{input.label}
-
{input.value}
-
- ); -} - -function formatIdentityLabel(input: { - readonly account: AuthenticatedAccount; -}): string { - return ( - input.account.user.name ?? input.account.user.email ?? input.account.user.id - ); -} - -function formatNamedEntity(input: { - readonly entity: - | AuthenticatedAccount["activeOrganization"] - | AuthenticatedAccount["activeTeam"]; - readonly emptyLabel: string; -}): string { - if (!input.entity) { - return input.emptyLabel; - } - - return input.entity.name ?? input.entity.id; -} diff --git a/apps/web/src/components/custom-sidebar-trigger.tsx b/apps/web/src/components/custom-sidebar-trigger.tsx deleted file mode 100644 index 7d1d288b..00000000 --- a/apps/web/src/components/custom-sidebar-trigger.tsx +++ /dev/null @@ -1,24 +0,0 @@ -import { Kbd, KbdGroup } from "@/components/ui/kbd"; -import { SidebarTrigger } from "@/components/ui/sidebar"; -import { - Tooltip, - TooltipContent, - TooltipTrigger, -} from "@/components/ui/tooltip"; - -export function CustomSidebarTrigger() { - return ( - - - - - - Toggle Sidebar{" "} - - - b - - - - ); -} diff --git a/apps/web/src/components/dashboard-skeleton.tsx b/apps/web/src/components/dashboard-skeleton.tsx deleted file mode 100644 index 1bd20cec..00000000 --- a/apps/web/src/components/dashboard-skeleton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -import { cn } from "@/lib/utils"; - -export function DashboardSkeleton() { - return ( -
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/src/components/github-icon.tsx b/apps/web/src/components/github-icon.tsx deleted file mode 100644 index ed56660a..00000000 --- a/apps/web/src/components/github-icon.tsx +++ /dev/null @@ -1,13 +0,0 @@ -import type { SVGProps } from "react"; - -/** - * GitHub mark for OAuth buttons (currentColor fill). - */ -export function GitHubIcon(props: SVGProps) { - return ( - - GitHub - - - ); -} diff --git a/apps/web/src/components/leatest-change.tsx b/apps/web/src/components/leatest-change.tsx deleted file mode 100644 index 3e597db5..00000000 --- a/apps/web/src/components/leatest-change.tsx +++ /dev/null @@ -1,56 +0,0 @@ -"use client"; - -import { X } from "lucide-react"; -import { useState } from "react"; - -import { Button } from "@/components/ui/button"; -import { cn } from "@/lib/utils"; - -const latestChange = { - badge: "UPDATE", - title: "Product update", - description: "Performance boosts and UI polish.", // TIP: Use a single line of text for the description. (max 5 words) - readMore: { href: "#", label: "Learn more" }, -} as const; - -export function LatestChange() { - const [isOpen, setIsOpen] = useState(true); - - if (!isOpen) { - return null; - } - - return ( -
- - {latestChange.badge} - -

{latestChange.title}

- - {latestChange.description} - - - -
- ); -} diff --git a/apps/web/src/components/linear-management-section.tsx b/apps/web/src/components/linear-management-section.tsx deleted file mode 100644 index bb202c6d..00000000 --- a/apps/web/src/components/linear-management-section.tsx +++ /dev/null @@ -1,549 +0,0 @@ -import type { AccountControlPlaneFeedback } from "@/lib/account-control-plane"; -import type { LinearManagementState } from "@/lib/linear-management"; -import { cn } from "@/lib/utils"; - -const sectionSurfaceClassName = cn( - "rounded-3xl border border-white/10 bg-white/[0.04] shadow-[0_24px_80px_rgba(15,23,42,0.24)]", - "transition duration-200 motion-safe:hover:-translate-y-0.5 motion-reduce:transform-none motion-reduce:transition-none", - "focus-visible:outline focus-visible:outline-2 focus-visible:outline-sky-300 focus-visible:outline-offset-2" -); - -const codeClassName = - "rounded-2xl bg-slate-950/70 px-4 py-3 text-sm text-white/85"; - -export default function LinearManagementSection(input: { - readonly linearManagement: LinearManagementState; - readonly scopeFeedback?: AccountControlPlaneFeedback | null; -}) { - const { linearManagement } = input; - const localAccessLabel = linearManagement.localAccess.ready - ? `ready${linearManagement.tokenSource ? ` (${linearManagement.tokenSource})` : ""}` - : "needs repair"; - let hackConnectionLabel = "sign in to inspect"; - if (linearManagement.hackConnection.inspectable) { - hackConnectionLabel = linearManagement.hackConnection.connected - ? "connected" - : "not connected"; - } - - return ( -
-
-

Linear

-

- Compare Hack-owned connection state with locally usable Linear access, - inspect the repo-bound default and linked projects, and repair the - active failure mode without hiding the current routing context. -

-
- - {input.scopeFeedback ? ( -
-

- {input.scopeFeedback.title} -

-

- {input.scopeFeedback.body} -

-
- ) : null} - -
- - -
-
- ); -} - -function LinearOverviewSection(input: { - readonly linearManagement: LinearManagementState; - readonly localAccessLabel: string; - readonly hackConnectionLabel: string; -}) { - const readinessLabel = - input.linearManagement.hackConnection.connected && - input.linearManagement.localAccess.ready - ? "Ready" - : "Needs attention"; - const localStatusLabel = input.linearManagement.summary.connected - ? "local ready" - : "local repair"; - - return ( -
-
-
-

{readinessLabel}

-

- {input.linearManagement.hackConnection.summary} -

-

- {input.linearManagement.hackConnection.detail} -

-
- - {localStatusLabel} - -
- -
- - {input.linearManagement.selectedProfile} - - - {input.linearManagement.selectedSource} - - - {input.hackConnectionLabel} - - {input.localAccessLabel} - - {input.linearManagement.hackConnection.ownerLabel ?? "No Hack owner"} - - - {input.linearManagement.hackConnection.accountLabel} - -
- -
-

- Repo-bound status commands -

- - {input.linearManagement.statusCommand} - - - {input.linearManagement.connectionsCommand} - -

- Compare the browser view with the same repo-bound status and - connection payloads the CLI exposes for this machine. -

-
- - {input.linearManagement.summary.capabilities.length > 0 ? ( -
-

Available now

-
    - {input.linearManagement.summary.capabilities.map((capability) => ( -
  • {capability}
  • - ))} -
-
- ) : null} - - {input.linearManagement.repair ? ( -
-

- {input.linearManagement.repair.title} -

-

- {input.linearManagement.repair.reason} -

- - {input.linearManagement.repair.command} - -
- ) : null} -
- ); -} - -function LinearBindingSection(input: { - readonly linearManagement: LinearManagementState; -}) { - return ( -
-
-

Binding visibility

-

- The default route and additional linked projects stay visible without - duplication so the current repo routing context remains explicit. -

-
- -
- - {input.linearManagement.projectBinding.profileId ?? - "No repo profile override"} - - - {input.linearManagement.projectBinding.defaultProject?.label ?? - "No default Linear route"} - - - {String( - input.linearManagement.projectBinding.additionalProjects.length - )} - -
- - {input.linearManagement.projectBinding.additionalProjects.length > 0 ? ( -
    - {input.linearManagement.projectBinding.additionalProjects.map( - (project) => ( -
  • -

    {project.label}

    -
  • - ) - )} -
- ) : ( -

- No additional linked projects are in scope for this repo right now. -

- )} - -
-
-

Available profiles

-

- Keep the default profile, project override, and saved profile - metadata visible so repairs target the right route quickly. -

-
- -
- - {input.linearManagement.defaultProfile} - - - {input.linearManagement.projectOverride ?? "No project override"} - - - {input.linearManagement.extensionEnabled ? "yes" : "no"} - -
- - -
- - -
- ); -} - -function LinearProfilesList(input: { - readonly linearManagement: LinearManagementState; -}) { - if (input.linearManagement.profiles.length === 0) { - return ( -

- No Linear profiles are configured for this repo yet. -

- ); - } - - return ( -
    - {input.linearManagement.profiles.map((profile) => { - const selected = profile.id === input.linearManagement.selectedProfile; - let profileStateLabel = "saved"; - if (selected) { - profileStateLabel = "active"; - } else if (profile.isDefault) { - profileStateLabel = "default"; - } - return ( -
  • -
    -
    -

    {profile.id}

    -

    - {profile.accountName ?? - profile.accountEmail ?? - profile.accountId ?? - "No account snapshot"} -

    -
    - - {profileStateLabel} - -
    -
    - {profile.authRef} - {profile.tokenEnv} -
    -
  • - ); - })} -
- ); -} - -function RepoAuditSection(input: { - readonly audit: LinearManagementState["audit"]; -}) { - const latestPublished = input.audit?.statusUpdates.latestPublished ?? null; - const deliveryAudit = input.audit?.delivery ?? null; - const deliveryCorruption = input.audit?.deliveryCorruption ?? null; - const closeout = input.audit?.closeout ?? null; - const draftCount = input.audit?.statusUpdates.draftCount ?? 0; - const draftLabel = `${draftCount} draft${draftCount === 1 ? "" : "s"} still waiting to publish`; - - return ( -
-
-

Repo audit trail

-

- Keep publish metadata and the latest delivery reconciliation visible - from the same repo-bound state the CLI reports. -

-
- -
- - - -
-
- ); -} - -function PublishedStatusUpdateAuditCard(input: { - readonly draftLabel: string; - readonly latestPublished: - | NonNullable< - LinearManagementState["audit"] - >["statusUpdates"]["latestPublished"] - | null; -}) { - return ( -
-

- Latest published status update -

- {input.latestPublished ? ( - <> -

- {input.latestPublished.title} -

-
- - {input.latestPublished.linearId ?? "Pending remote identity"} - - - {input.latestPublished.publishedAt ?? - input.latestPublished.updatedAt ?? - input.latestPublished.path} - -
-

- {input.latestPublished.path} -

- - ) : ( -

- No published repo-bound status updates are recorded yet. -

- )} -

{input.draftLabel}

-
- ); -} - -function DeliveryAuditCard(input: { - readonly deliveryAudit: NonNullable< - LinearManagementState["audit"] - >["delivery"]; - readonly deliveryCorruption: NonNullable< - LinearManagementState["audit"] - >["deliveryCorruption"]; -}) { - if (input.deliveryCorruption) { - return ( -
-

- Latest delivery reconciliation -

-

Delivery audit is corrupt

-

- {input.deliveryCorruption.message} -

- {input.deliveryCorruption.path} -

- {input.deliveryCorruption.recovery} -

-
- ); - } - - if (!input.deliveryAudit) { - return ( -
-

- Latest delivery reconciliation -

-

- No durable delivery audit is recorded yet. Run the repo-bound autosync - flow to capture processed, applied, and failed counts. -

-
- ); - } - - return ( -
-

- Latest delivery reconciliation -

-
- - {`processed ${input.deliveryAudit.processedDeliveries}`} - - - {`applied ${input.deliveryAudit.appliedDeliveries}`} - - - {`failed ${input.deliveryAudit.failedDeliveries}`} - - {input.deliveryAudit.updatedAt} -
- {input.deliveryAudit.deliveries.length > 0 ? ( -
    - {input.deliveryAudit.deliveries.map((delivery) => ( -
  • -

    - {delivery.deliveryId} -

    -

    - {delivery.mode} · {delivery.status} - {delivery.issueIdentifier - ? ` · ${delivery.issueIdentifier}` - : ""} -

    - {delivery.reason ? ( -

    - {delivery.reason} -

    - ) : null} -
  • - ))} -
- ) : null} - {input.deliveryAudit.path} -
- ); -} - -function CloseoutAuditCard(input: { - readonly closeout: NonNullable["closeout"]; -}) { - if (!input.closeout) { - return ( -
-

Mission closeout

-

- No repo-bound closeout scope is recorded yet for this Linear project. -

-
- ); - } - - const unresolvedEntries = input.closeout.entries.filter( - (entry) => entry.status !== "done" - ); - - return ( -
-

Mission closeout

-

- Track the frozen mission scope against repo-bound synced ticket status - so the browser and CLI report the same unresolved count. -

-
- - {`${input.closeout.resolvedCount}/${input.closeout.totalItems}`} - - - {String(input.closeout.unresolvedCount)} - -
- {unresolvedEntries.length > 0 ? ( -
    - {unresolvedEntries.map((entry) => ( -
  • -

    - {entry.externalKey ?? entry.ticketId} -

    -

    {entry.title}

    -

    - Current status: {entry.status} -

    -
  • - ))} -
- ) : ( -

- All frozen mission-scoped Linear tickets now resolve to done from the - repo-bound synced ticket store. -

- )} - {input.closeout.path} -

- Published closeout evidence:{" "} - {input.closeout.latestPublishedTitle ?? "Unavailable"} -

- {input.closeout.latestPublishedPath ? ( - - {input.closeout.latestPublishedPath} - - ) : null} -

- Delivery audit state: {input.closeout.deliveryAuditState} -

-
- ); -} - -function DetailCard(input: { - readonly label: string; - readonly children: string; -}) { - return ( -
-
{input.label}
-
{input.children}
-
- ); -} diff --git a/apps/web/src/components/logo.tsx b/apps/web/src/components/logo.tsx deleted file mode 100644 index 63340aab..00000000 --- a/apps/web/src/components/logo.tsx +++ /dev/null @@ -1,31 +0,0 @@ -export function Logo({ - className, - decorative = false, -}: { - readonly className?: string; - readonly decorative?: boolean; -}) { - return ( - - - - - - ); -} diff --git a/apps/web/src/components/marketing-chrome.tsx b/apps/web/src/components/marketing-chrome.tsx deleted file mode 100644 index 3ba0d9d1..00000000 --- a/apps/web/src/components/marketing-chrome.tsx +++ /dev/null @@ -1,17 +0,0 @@ -"use client"; - -import Link from "next/link"; - -import { ModeToggle } from "@/components/mode-toggle"; -import { Button } from "@/components/ui/button"; - -export function MarketingChrome() { - return ( -
- - -
- ); -} diff --git a/apps/web/src/components/mode-toggle.tsx b/apps/web/src/components/mode-toggle.tsx deleted file mode 100644 index 3185d876..00000000 --- a/apps/web/src/components/mode-toggle.tsx +++ /dev/null @@ -1,63 +0,0 @@ -"use client"; - -import { MoonIcon, SunIcon } from "lucide-react"; -import { useEffect, useState } from "react"; - -import { useTheme } from "@/components/theme-provider"; -import { Button } from "@/components/ui/button"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; - -export function ModeToggle() { - const { setTheme } = useTheme(); - const [mounted, setMounted] = useState(false); - - useEffect(() => { - setMounted(true); - }, []); - - if (!mounted) { - return ( - - ); - } - - return ( - - - - - - setTheme("light")}> - Light - - setTheme("dark")}> - Dark - - setTheme("system")}> - System - - - - ); -} diff --git a/apps/web/src/components/nav-user.tsx b/apps/web/src/components/nav-user.tsx deleted file mode 100644 index 121f78dc..00000000 --- a/apps/web/src/components/nav-user.tsx +++ /dev/null @@ -1,108 +0,0 @@ -"use client"; - -import { Building2Icon, LogOutIcon } from "lucide-react"; -import Link from "next/link"; - -import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuGroup, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { AccountShellContext } from "@/lib/account-shell"; - -const WHITESPACE_PATTERN = /\s+/; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -export function NavUser(input: { readonly account: AuthenticatedAccount }) { - const userName = - input.account.user.name ?? input.account.user.email ?? "Hack user"; - const userEmail = input.account.user.email ?? "Signed in"; - const organizationName = - input.account.activeOrganization?.name ?? "No organization selected"; - const avatarFallback = resolveAvatarFallback({ - email: input.account.user.email, - name: input.account.user.name, - }); - - return ( - - - - - - - - - - {avatarFallback} - -
- - {userName} - -
- {userEmail} -
-
-
-
- - - - - - {organizationName} - - - - - - - - - Sign out - - - -
-
- ); -} - -function resolveAvatarFallback(input: { - readonly name: string | null; - readonly email: string | null; -}): string { - const name = input.name?.trim(); - if (name) { - const parts = name.split(WHITESPACE_PATTERN).slice(0, 2); - return parts.map((part) => part.charAt(0).toUpperCase()).join(""); - } - - return input.email?.charAt(0).toUpperCase() ?? "H"; -} diff --git a/apps/web/src/components/organization-switcher.tsx b/apps/web/src/components/organization-switcher.tsx deleted file mode 100644 index 93a78889..00000000 --- a/apps/web/src/components/organization-switcher.tsx +++ /dev/null @@ -1,125 +0,0 @@ -"use client"; - -import { Building2Icon, CheckIcon, ChevronDownIcon } from "lucide-react"; -import Link from "next/link"; -import { usePathname, useSearchParams } from "next/navigation"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import type { AccountShellContext } from "@/lib/account-shell"; - -type AuthenticatedAccount = Extract< - AccountShellContext, - { readonly authenticated: true } ->; - -export function OrganizationSwitcher(input: { - readonly account: AuthenticatedAccount; -}) { - const pathname = usePathname(); - const searchParams = useSearchParams(); - const requestedOrganization = searchParams.get("org"); - const selectedOrganization = - input.account.organizations.find( - (organization) => organization.slug === requestedOrganization - ) ?? input.account.activeOrganization; - const selectedOrganizationName = - selectedOrganization?.name ?? - selectedOrganization?.id ?? - "Personal workspace"; - - return ( - - - - - - Switch organization - {input.account.organizations.length > 0 ? ( - <> - {input.account.organizations.map((organization) => { - const href = buildScopedHref({ - organizationSlug: organization.slug, - pathname, - searchParams, - }); - const isActive = organization.slug === requestedOrganization; - return ( - - - - - {organization.name} - - - {organization.slug} - - - {isActive ? ( - - ) : null} - - - ); - })} - - - ) : null} - - - Clear organization scope - - - - - ); -} - -function buildScopedHref(input: { - readonly pathname: string; - readonly searchParams: URLSearchParams; - readonly organizationSlug?: string; -}): string { - const nextSearchParams = new URLSearchParams(input.searchParams.toString()); - nextSearchParams.delete("team"); - nextSearchParams.delete("project"); - if (input.organizationSlug) { - nextSearchParams.set("org", input.organizationSlug); - } else { - nextSearchParams.delete("org"); - } - - const query = nextSearchParams.toString(); - return query.length > 0 ? `${input.pathname}?${query}` : input.pathname; -} diff --git a/apps/web/src/components/theme-provider.tsx b/apps/web/src/components/theme-provider.tsx deleted file mode 100644 index 2bba574d..00000000 --- a/apps/web/src/components/theme-provider.tsx +++ /dev/null @@ -1,94 +0,0 @@ -"use client"; - -import type { ReactNode } from "react"; -import { createContext, useContext, useEffect, useMemo, useState } from "react"; - -const THEME_STORAGE_KEY = "hack-theme"; -const SYSTEM_THEME_QUERY = "(prefers-color-scheme: dark)"; - -type Theme = "light" | "dark" | "system"; -type ResolvedTheme = "light" | "dark"; - -type ThemeContextValue = { - readonly resolvedTheme: ResolvedTheme; - readonly setTheme: (theme: Theme) => void; - readonly theme: Theme; -}; - -const ThemeContext = createContext(null); - -export function ThemeProvider({ children }: { readonly children: ReactNode }) { - const [theme, setThemeState] = useState("system"); - const [resolvedTheme, setResolvedTheme] = useState("light"); - - useEffect(() => { - const nextTheme = readStoredTheme(); - setThemeState(nextTheme); - }, []); - - useEffect(() => { - const mediaQuery = window.matchMedia(SYSTEM_THEME_QUERY); - - const syncTheme = () => { - const nextResolvedTheme = resolveTheme({ theme }); - applyTheme({ resolvedTheme: nextResolvedTheme }); - setResolvedTheme(nextResolvedTheme); - }; - - syncTheme(); - mediaQuery.addEventListener("change", syncTheme); - - return () => { - mediaQuery.removeEventListener("change", syncTheme); - }; - }, [theme]); - - const value = useMemo( - () => ({ - resolvedTheme, - setTheme: (nextTheme) => { - setThemeState(nextTheme); - window.localStorage.setItem(THEME_STORAGE_KEY, nextTheme); - }, - theme, - }), - [resolvedTheme, theme] - ); - - return ( - {children} - ); -} - -export function useTheme() { - const value = useContext(ThemeContext); - if (value === null) { - throw new Error("useTheme must be used inside ThemeProvider"); - } - return value; -} - -function readStoredTheme(): Theme { - const storedTheme = window.localStorage.getItem(THEME_STORAGE_KEY); - if ( - storedTheme === "light" || - storedTheme === "dark" || - storedTheme === "system" - ) { - return storedTheme; - } - return "system"; -} - -function resolveTheme(input: { readonly theme: Theme }): ResolvedTheme { - if (input.theme === "system") { - return window.matchMedia(SYSTEM_THEME_QUERY).matches ? "dark" : "light"; - } - return input.theme; -} - -function applyTheme(input: { readonly resolvedTheme: ResolvedTheme }) { - const root = document.documentElement; - root.classList.toggle("dark", input.resolvedTheme === "dark"); - root.style.colorScheme = input.resolvedTheme; -} diff --git a/apps/web/src/components/ui/avatar.tsx b/apps/web/src/components/ui/avatar.tsx deleted file mode 100644 index 031e499b..00000000 --- a/apps/web/src/components/ui/avatar.tsx +++ /dev/null @@ -1,109 +0,0 @@ -"use client"; - -import { Avatar as AvatarPrimitive } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Avatar({ - className, - size = "default", - ...props -}: React.ComponentProps & { - size?: "default" | "sm" | "lg"; -}) { - return ( - - ); -} - -function AvatarImage({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AvatarFallback({ - className, - ...props -}: React.ComponentProps) { - return ( - - ); -} - -function AvatarBadge({ className, ...props }: React.ComponentProps<"span">) { - return ( - svg]:hidden", - "group-data-[size=default]/avatar:size-2.5 group-data-[size=default]/avatar:[&>svg]:size-2", - "group-data-[size=lg]/avatar:size-3 group-data-[size=lg]/avatar:[&>svg]:size-2", - className - )} - data-slot="avatar-badge" - {...props} - /> - ); -} - -function AvatarGroup({ className, ...props }: React.ComponentProps<"div">) { - return ( -
- ); -} - -function AvatarGroupCount({ - className, - ...props -}: React.ComponentProps<"div">) { - return ( -
svg]:size-4 group-has-data-[size=lg]/avatar-group:[&>svg]:size-5 group-has-data-[size=sm]/avatar-group:[&>svg]:size-3", - className - )} - data-slot="avatar-group-count" - {...props} - /> - ); -} - -export { - Avatar, - AvatarImage, - AvatarFallback, - AvatarBadge, - AvatarGroup, - AvatarGroupCount, -}; diff --git a/apps/web/src/components/ui/breadcrumb.tsx b/apps/web/src/components/ui/breadcrumb.tsx deleted file mode 100644 index cbd86821..00000000 --- a/apps/web/src/components/ui/breadcrumb.tsx +++ /dev/null @@ -1,107 +0,0 @@ -import { ChevronRight, MoreHorizontal } from "lucide-react"; -import { Slot } from "radix-ui"; -import type * as React from "react"; - -import { cn } from "@/lib/utils"; - -function Breadcrumb({ ...props }: React.ComponentProps<"nav">) { - return