diff --git a/.codex/skills/hack-cli/SKILL.md b/.codex/skills/hack-cli/SKILL.md index faa834c8..57071c7f 100644 --- a/.codex/skills/hack-cli/SKILL.md +++ b/.codex/skills/hack-cli/SKILL.md @@ -8,7 +8,13 @@ description: > # hack CLI -Use `hack` as the primary interface for local development. +Use `hack` as the primary interface for local-first development. + +## Product Boundary + +- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, daemon, and optional local tickets. +- Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. +- Remote/gateway/node/dispatch code is unsupported experimental. Do not put it on the critical path for local dev unless explicitly requested. ## Operating Rules @@ -44,6 +50,7 @@ Use `hack` as the primary interface for local development. ## Managed Files - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. +- Worktree-local override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (gitignored; machine-specific state). - Generated by hack: `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` using `hack internal extra-hosts ...` commands. @@ -77,7 +84,9 @@ Use `hack` as the primary interface for local development. - Start/stop/status: `hack global up`, `hack global down`, `hack global status` - Global logs: `hack global logs --no-follow --tail 200` -## Remote Nodes + Dispatch +## Unsupported Experimental Remote + +These commands are source-available but outside the supported v3 product contract: - Pair/register a node: `hack node pair ...`, then verify via `hack node list` and `hack node status --watch`. - Repair SSH access for remote Git/mutagen: `hack node ssh setup --node `. @@ -89,6 +98,8 @@ Use `hack` as the primary interface for local development. - Put host setup in `.hack/hack.config.json` under `startup` / `lifecycle`. - Use lifecycle processes for long-running host tasks, not ad-hoc terminals. +- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. +- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - Inspect via `hack projects --details` and `hack logs `. ## Verification Loops @@ -96,10 +107,11 @@ Use `hack` as the primary interface for local development. - For `hack run` / `hack exec` / env-resolution changes, verify the effective env transition matrix in `tests/project-run-command.test.ts`. - Cover omitted env, explicit overlay, explicit `base`, default-overlay resolution, cached runtime-state env, - and target-service running/not-running. -- For lifecycle or startup-process changes, verify `tests/project-lifecycle-processes.test.ts`. -- Preserve `sh -c` semantics, process-group cleanup, stale pane-metadata reconciliation, and interactive stdin - behavior. + target-service running/not-running, worktree-local overrides, and host-vs-compose target mode. +- For lifecycle or startup-process changes, verify `tests/project-lifecycle-processes.test.ts` and + `tests/project-lifecycle-singleton.test.ts`. +- Preserve `sh -c` semantics, process-group cleanup, stale pane-metadata reconciliation, singleton listener + behavior, and interactive stdin behavior. - When semantics change, update `docs/env.md` or `docs/lifecycle.md` in the same patch so future agent work starts from the current contract. @@ -124,8 +136,11 @@ Use branch instances to run parallel environments: ## Host-side env helpers -- One-off host command with injected env: `hack env exec --env qa --service api -- bun db:migrate` -- Interactive host shell with injected env: `hack env shell --env qa --service api` +- One-off host command with injected env: `hack host exec --env qa --scope api -- bun db:migrate` +- Host commands default to a host-local env view; use `--target compose` when you explicitly want container-oriented addresses. +- `--scope` selects which env scope to inject; it does not move execution into that service container. +- Interactive host shell with injected env: `hack host shell --env qa --scope api` +- Run inside an already-running service container: `hack exec api -- bun test` ## Tickets diff --git a/.factory/library/account-shell-hydration.md b/.factory/library/account-shell-hydration.md deleted file mode 100644 index 55081998..00000000 --- a/.factory/library/account-shell-hydration.md +++ /dev/null @@ -1,7 +0,0 @@ -# Account shell cold-bootstrap hydration note - -- The `/account` route can still throw a React hydration mismatch on the first authenticated bridge redirect even when `ControlPlaneShell` server markup is structurally correct. -- The reliable fix in local Next 16/Turbopack dev was to keep the route dynamic (`dynamic = "force-dynamic"`, `fetchCache = "force-no-store"`) **and** wrap `AccountShellPage` in an explicit `Suspense` fallback so the first server/client render agrees during the broker-to-web bootstrap. -- The fallback lives in `apps/web/src/components/account-shell-loading.tsx` and preserves the skip link plus `#main-content` focus target so keyboard/reduced-motion checks stay valid while the async account loaders settle. -- Browser proof path: create a disposable Better Auth account, set `__Secure-better-auth.session_token` in `agent-browser`, open `https://auth.hack-cli.hack/auth/account?bridge=1&redirect=https%3A%2F%2Fhack-cli.hack%2Faccount`, and inspect `agent-browser errors` for hydration failures. -- Running the routed web dev server can rewrite `apps/web/next-env.d.ts` and `apps/web/tsconfig.json`; restore those incidental changes before final validators/commit unless the feature explicitly owns them. diff --git a/.factory/library/architecture.md b/.factory/library/architecture.md index 280d2793..25372a41 100644 --- a/.factory/library/architecture.md +++ b/.factory/library/architecture.md @@ -1,41 +1,38 @@ # Architecture -Architectural decisions and patterns discovered during mission planning. - -**What belongs here:** durable architecture rules, canonical paths, ownership boundaries, and shared design constraints. - ---- +Durable architecture rules for current Hack work. ## Core Product Boundary -- Hack remains CLI-first and local-first. -- `apps/web` is optional and must never become the only path for critical local workflows. -- Shared administration, integration management, and env/secret-sharing can prefer the web app, but CLI parity and local usability must remain intact. +- Hack v3 is CLI-first, local-first, and self-contained. +- Supported product surface: project init, local runtime orchestration, routing/TLS, env and secrets, lifecycle, sessions, diagnostics, MCP/agent setup, the slim macOS companion, and optional local tickets. +- Retired product surfaces: hosted auth, account/org/team admin, web dashboard, built-in GitHub workflows, and built-in Linear sync. +- Remote/gateway/node/dispatch code may remain source-available, but it is unsupported experimental and must stay out of first-run docs, release gates, and default agent paths. -## Auth Ownership +## Runtime Ownership -- Keep one coherent Better Auth model. -- The browser app owns the interactive web auth UX. -- `services/auth-broker` remains the auth/session and control-plane API backend. -- Do not introduce a second independent auth authority. +- `hack` is the source of truth for project start/stop/open/logs/session flows. +- `.hack/.internal/**` and `.hack/.branch/**` are generated runtime state and should not be hand-edited. +- Branch/worktree instances must clean up only their own runtime, lifecycle, and generated state. +- Doctor output should classify recovery as restartable, repairable, or configuration drift and give one concrete next command. -## Persistence Rules +## Env Ownership -- Shared org/team/project/integration state must be durable by default. -- In-memory-only admin state is mission work to remove or explicitly surface as dev-only if temporarily retained during transition. -- Use existing Neon + Drizzle foundations where practical instead of inventing a separate persistence layer. +- Canonical shared env files are `.hack/hack.env.default.yaml` and optional `.hack/hack.env..yaml`. +- Worktree-local overrides are `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. +- `.hack/.env` and `.hack/.env.state.json` are derived compatibility artifacts, not runtime source of truth. +- Secret-key lookup order is checkout-local `.hack.secret.key`, git-common-dir shared key for linked worktrees, then `HACK_ENV_SECRET_KEY`. -## Linear Canonical State +## Lifecycle Ownership -- Repo-bound Linear artifacts belong under `.hack/linear/projects//...`. -- `.hack/.hack/linear/**` is a legacy bug surface and must not remain authoritative. -- Mission closeout scope is the frozen set of Hack-project Linear work open at mission start plus mission-created optional-web-control-plane work. +- Long-running host helpers belong in `lifecycle.processes` or `startup` entries with `persistent: true`. +- Fixed-port helpers such as AWS SSM tunnels should declare `singleton.ports`. +- Use `onConflict: "adopt"` only when a complete existing listener set is equivalent and should be reused. +- `singleton` adoption is listener-level reuse, not ownership transfer; Hack must leave adopted external processes running on `hack down`. +- Stale mux state should be recovered through lifecycle metadata carefully enough to avoid orphaning Hack-owned processes while not broadening cleanup to unrelated process groups. -## Durable Persistence Targets +## Tickets Ownership -- Better Auth-owned tables and broker-specific auth persistence currently live under `services/auth-broker/src/db/schema.ts`. -- Org/team admin state now persists in broker-owned `org_admin_*` tables under `services/auth-broker/src/db/schema.ts`, and the default auth-broker wiring uses the DB-backed store whenever `DATABASE_URL` is available. -- Auth-broker startup now reports the selected org/team store mode. When `DATABASE_URL` is absent, the broker runs in an explicitly surfaced development-only in-memory mode, and durable initialization failures must throw instead of silently falling back to memory. -- Broker-backed project registration and project-access visibility are now derived from the same org/team membership store (`services/auth-broker/src/modules/projects/service.ts` uses org/team visibility to decide which shared projects and grants are visible), so project-admin parity checks must account for active org/team scope rather than treating project ownership as independent state. -- Shared control-plane tables currently live under `packages/db/src/schema/core.ts`, with migrations/verification through `bun run db:generate`, `bun run db:migrate`, and `bun run db:push`. -- Workers may extract shared durable contracts, but they must keep one concrete Neon + Drizzle-backed persistence path and document any migration boundary changes in code/tests. +- Tickets are optional local helpers, not a headline hosted workflow. +- Durable ticket state is the git-backed JSONL journal under `refs/hack/tickets` or the configured branch ref. +- Local projection and checkout state under `.hack/tickets/` is rebuildable. diff --git a/.factory/library/environment.md b/.factory/library/environment.md index ea8437b7..4ec35a4b 100644 --- a/.factory/library/environment.md +++ b/.factory/library/environment.md @@ -1,80 +1,38 @@ # Environment -Environment variables, external dependencies, and setup notes. +Environment variables, external dependencies, and setup notes for current Hack work. -**What belongs here:** required env vars, external services, credential assumptions, platform/runtime quirks. -**What does NOT belong here:** service ports or lifecycle commands (use `.factory/services.yaml`). - ---- - -## Credential Assumptions - -- Existing local/remote credentials for Hack, GitHub, Linear, Railway, and Neon are assumed to be the source of truth for this mission. -- New env wiring may be added for `apps/web`, but live Vercel deployment is out of scope; the app only needs to be local + deploy-ready. -- Gateway writes are disabled by default and may be temporarily enabled only for explicit validation steps that require them. - -## Auth-Broker Runtime Inputs - -The broker already depends on environment such as: -- `DATABASE_URL` -- `BETTER_AUTH_SECRET` -- `GITHUB_CLIENT_ID` -- `GITHUB_CLIENT_SECRET` -- `HACK_PROVIDER_TOKEN_ENCRYPTION_KEY` -- any trusted-origin configuration needed for local Hack hosts and deploy-ready web origins - -Workers should extend existing env handling rather than introducing parallel secret/config channels. - -Repo-bound broker-auth verification can also use: -- `HACK_AUTH_SESSION_TOKEN` -- `HACK_AUTH_SESSION_EXPIRES_AT` - -These let repo-bound CLI flows prove an authenticated broker session without reading stored local secret or keychain-backed auth state first. Use them when validating keychainless broker-seeded Linear flows. - -## Web Auth Runtime Inputs - -`apps/web/src/lib/auth-config.ts` currently derives the browser auth contract from: -- app base URL: `NEXT_PUBLIC_HACK_WEB_APP_BASE_URL`, `HACK_WEB_APP_BASE_URL`, `NEXT_PUBLIC_APP_BASE_URL`, `APP_BASE_URL` -- broker public base URL: `NEXT_PUBLIC_HACK_AUTH_BROKER_URL`, `HACK_AUTH_BROKER_URL`, `AUTH_BROKER_PUBLIC_BASE_URL` -- broker internal/proxy base URL: `HACK_AUTH_BROKER_INTERNAL_URL`, `AUTH_BROKER_INTERNAL_URL` -- trusted origins: `BETTER_AUTH_TRUSTED_ORIGINS` -- local routed-host inference override: `HACK_LOCAL_DEV_HOST`, `NEXT_PUBLIC_HACK_LOCAL_DEV_HOST` - -When verifying provider parity or browser handoff behavior, prefer these variables over introducing app-specific aliases outside the shared auth-config path. - -## Tooling Notes +## Local-first Assumptions - Bun is the canonical runtime and validator path for this repo. -- The local machine currently has Bun available, but the installed version may lag the version declared in `package.json`; prefer repo commands and keep validation evidence concrete. -- Use `./dist/hack` for repo-bound CLI behavior after build; use global `hack` only for runtime orchestration. -- Repo-external Bun smoke scripts are a poor fit for monorepo validation here: if a smoke needs workspace imports such as `@hack/auth-contract`, keep the script under the repo root or use an existing repo-resident entrypoint instead of generating it under `/tmp`. -- Bun/WHATWG URL parsing normalizes dot segments before most handlers inspect `req.url` or `URL.pathname`; security-sensitive route validation that needs to reject raw traversal attempts cannot rely on normalized pathname checks alone. - -For outage-mode proofs of repo-local CLI fallback behavior, point broker traffic at a dead local address with `HACK_AUTH_BROKER_URL=http://127.0.0.1:9` and set `HACK_SETUP_SYNC_MODE=off` so setup-sync noise does not mask the intended offline signal. - -## Auth-Broker Test Isolation +- Use repo-local commands and `./dist/hack` for current-branch CLI behavior after build. +- Use global `hack` only when validating installed runtime orchestration intentionally. +- Do not add new required hosted services, auth brokers, web dashboards, GitHub apps, or Linear credentials to the supported path. -- Bun loads repo-root `.env` / `.env.local`, so auth-broker tests should clear broker-related env before asserting defaults or failure guidance. -- `services/auth-broker/tests/test-env.ts` provides `installAuthBrokerEnvIsolation()` for suites and `withIsolatedAuthBrokerEnv()` for per-test overrides. -- Those helpers set `HACK_AUTH_BROKER_DISABLE_ROOT_ENV_FALLBACK=true`, which disables `services/auth-broker/src/config.ts` fallback reads from repo-root `.env.local` and `.env` so config tests stay hermetic. -- Use `withAuthBrokerRootEnvFallback()` plus `configureRootEnvFallbackForTests()` when a regression needs deterministic fake repo-root dotenv contents without depending on ambient checkout state. +## Env and Secrets -## Env Status Taxonomy +- Canonical shared env files: `.hack/hack.env.default.yaml` and `.hack/hack.env..yaml`. +- Worktree-local override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. +- Derived compatibility files: `.hack/.env` and `.hack/.env.state.json`. +- Local checkout key: `.hack.secret.key`. +- Linked-worktree shared key: stored under the git common dir so sibling worktrees can decrypt committed secrets. +- Portable/CI/container key: `HACK_ENV_SECRET_KEY`. -- `trust_model` answers whether env state is local-only, plaintext-compatible, or broker/shared. -- `custody` answers who currently holds the sensitive material (for example local secret backend vs broker-managed). -- `portability` answers whether the current representation can move safely across machines. -- `shared_state` is the cross-surface summary used by CLI/API/web status views; treat unknown or command-error states explicitly instead of relabeling them as local-only. +## Managed Containers -## Secret Storage Notes +- Use `hackdance/hack:slim` for repo-local managed-agent containers when Docker Hub is available. +- Inject `HACK_ENV_SECRET_KEY` from the runtime or secret manager; never bake `.hack.secret.key` into an image. +- Slim/codex mode should use repo-local commands such as `hack env list`, `hack host exec`, `hack host shell`, and `hack tickets`. +- Machine-wide surfaces such as `hack global install`, Caddy/CoreDNS/Loki/Grafana, and local CA bootstrap are not expected in slim mode. -- `HACK_SECRETS_DISABLE_KEYCHAIN_FALLBACK=true` disables encrypted-file fallback to keychain-backed material; use it when recovery tests must prove there is no silent downgrade to local keychain access. +## Runtime and Lifecycle -## Trusted-Origin Inventory +- Prefer `hack doctor` and `hack doctor --fix` before manual runtime/network repair. +- For lifecycle changes, verify `sh -c` semantics, stdin behavior, process-group cleanup, stale mux metadata recovery, and singleton listener handling. +- For daemon/gateway request-target hardening, use an isolated temp-HOME repo-built daemon (`bun index.ts daemon start --foreground`) when live proof would otherwise mutate shared user daemon state. -Use one explicit allowlist model for auth/session flows: -- local routed hosts: `https://hack-cli.hack`, `https://*.hack-cli.hack`, `https://hack-cli.hack.gy`, `https://*.hack-cli.hack.gy` -- local broker smoke URL only where direct HTTP validation is required: `http://127.0.0.1:8080` -- deploy-ready web origins from environment for Vercel preview/production (for example `https://` and `https://`); do not hardcode a second deploy domain path outside env/config +## Outage and Drift Proofs -Trusted-origin tests should exercise one allowed routed host, one allowed deploy-ready env-supplied origin, and one rejected untrusted origin. +- For stale env compatibility output, use `hack doctor` and `hack env materialize`. +- For stale lifecycle state, use `hack doctor`, then `hack down`, then rerun `hack doctor`. +- For tickets remote auth failures, prefer explicit SSH guidance and bounded timeouts over interactive prompts. diff --git a/.factory/library/linear.md b/.factory/library/linear.md deleted file mode 100644 index ae886931..00000000 --- a/.factory/library/linear.md +++ /dev/null @@ -1,33 +0,0 @@ -# Linear - -Repo-bound Linear project and artifact guidance for this mission. - -**What belongs here:** project binding, artifact roots, issue-scope rules, and worker expectations for Linear sync/status work. - ---- - -## Bound Project - -- Project: `Hack` -- Project ID: `7a3c8adf-ede5-4d3a-8779-9c32695c76bf` -- Team ID: `e0aedec9-5273-446f-b975-aa4cd1525900` -- Active profile: `default` - -## Canonical Artifact Root - -- Use `.hack/linear/projects//...` as the only authoritative repo path. -- Treat `.hack/.hack/linear/**` as legacy bug fallout that must be neutralized by mission work. -- In command code, resolve that artifact tree from the repo root (`project.projectRoot`), not from `project.projectDir` (`.hack`), or audit/status reads will drift back into the wrong path. - -## Tracking Expectations - -- Keep repo-bound Linear project state current while working, especially for features that touch project sync, status updates, or closeout. -- Prefer repo-bound `./dist/hack linear ...` commands over manual remote edits when verifying project/status behavior. -- Broker-backed inspection commands such as `./dist/hack linear connections --json` or `./dist/hack linear subscriptions --json` can still require a fresh `./dist/hack auth login` even when `./dist/hack linear status --json` already works from local token-backed access. -- Preserve an auditable frozen closeout scope: all Hack-project Linear issues open at mission start plus mission-created optional-web-control-plane work. - -## Frozen Closeout Inventory - -- Canonical mission snapshot file: `missionDir/linear-closeout-scope.json` -- `linear-tracking-foundation-and-freeze-set` must populate `openedAtStart` before status-changing mission work proceeds and append any mission-created optional-web-control-plane issue IDs under `missionCreated`. -- Final closeout work must reconcile exactly this inventory to zero unresolved items. diff --git a/.factory/library/project-admin-scope-verification.md b/.factory/library/project-admin-scope-verification.md deleted file mode 100644 index 4ca673ba..00000000 --- a/.factory/library/project-admin-scope-verification.md +++ /dev/null @@ -1,6 +0,0 @@ -## Shared project admin scope verification - -- Repo-bound CLI parity checks against the local Hack runtime need `HACK_AUTH_BROKER_URL=https://auth.hack-cli.hack`; otherwise `project owner show` falls back to the default remote broker URL. -- For local browser verification, a scoped management token can be minted from `services/auth-broker/src/modules/better-auth/management-token.ts` via Bun on the repo root because Bun loads the local auth secret from `.env`. -- Next.js may rewrite `apps/web/next-env.d.ts` and `apps/web/tsconfig.json` during routed web verification; restore those incidental changes before commit. -- Durable `DbProjectStore` parity regressions should use a real backend helper like the temp-directory `drizzle-orm/pglite` setup in `services/auth-broker/tests/project-admin.test.ts`, not an in-memory shim, so schema creation, query semantics, and store-recreation persistence are actually exercised. diff --git a/.factory/library/user-testing.md b/.factory/library/user-testing.md index 04246dae..70030f97 100644 --- a/.factory/library/user-testing.md +++ b/.factory/library/user-testing.md @@ -1,85 +1,52 @@ # User Testing -Testing surface findings, required tools, and validation concurrency for this mission. +Testing surfaces, tools, and validation concurrency for current Hack work. -**What belongs here:** user-facing validation surfaces, tooling, setup notes, concurrency limits, and validation gotchas. +## Validation Surface ---- +### CLI and runtime -## Validation Surface +- Primary tools: `./dist/hack`, repo-local Bun commands, and global `hack` only for installed-runtime orchestration checks. +- Use for: runtime lifecycle, env, tickets, sessions, doctor, daemon, project config, and agent setup. +- Prefer `--json` when validating machine-readable behavior. +- If validating current-branch command behavior, build first and run `./dist/hack` or `bun index.ts` from the repo root. -### CLI surface -- Primary tools: `./dist/hack`, repo-local Bun commands, global `hack` for runtime orchestration only. -- Use for: auth status, Linear sync/status, tickets, env, runtime/session flows, project ownership/admin parity. -- Notes: prefer repo-bound CLI validation (`./dist/hack`) after a build so command behavior matches the current branch. -- Notes: repo-bound GitHub CLI validation can use the project config directly because `.hack/hack.config.json` now enables `dance.hack.github` alongside the Linear and tickets extensions. -- Notes: for isolated outage-mode CLI validation, if `HACK_GLOBAL_CONFIG_PATH` points at a temp config file, place the matching `projects.json` beside that config file (not under `HOME/.hack`) so `status --project` and session/project commands resolve the temp registry correctly. -- Notes: for isolated `hack session` validation inside another tmux session, unset `TMUX` and set `TMUX_TMPDIR` to a temp directory so the validator does not accidentally target the parent shell's shared tmux server. - -### Broker / HTTP surface -- Primary tools: `curl`, declared local service commands from `.factory/services.yaml`, and later hack-managed routed hosts. -- Use for: broker health, auth/session APIs, org/team/project admin APIs, integration-management APIs, env/gateway endpoints. -- Notes: early mission work should harden env-sensitive auth-broker tests so these checks are trustworthy. - -- Notes: for daemon/gateway request-target hardening, do not treat the shared listener on `127.0.0.1:7788` as authoritative for current-branch validation. Use an isolated temp-HOME repo-built daemon (`bun /index.ts daemon start --foreground`) with its own temp global config and gateway port or unix socket so the probe hits the current branch instead of any stale installed `hackd`. -### Web surface -- Primary tools: `agent-browser` against the routed host returned by `hack open --json` once `apps/web` exists. -- Use for: sign-in/account UX, org/team/project admin, GitHub/Linear management, env/status views, outage-mode optionality checks. -- Notes: no shell-installed browser runner was found during dry run; browser user testing must rely on `agent-browser`. -- Notes: `hack up -d` currently exposes the routed web host at `https://hack-cli.hack` and the broker host at `https://auth.hack-cli.hack`. -- Notes: the live broker enables Better Auth email/password routes, so validator sessions can create authenticated broker cookies through `POST https://auth.hack-cli.hack/api/auth/sign-up/email` without depending on an external GitHub login. -- Notes: for `agent-browser` web validation, a reliable local auth bootstrap is: - 1. create a disposable account with `curl -c -X POST https://auth.hack-cli.hack/api/auth/sign-up/email ...` - 2. parse `__Secure-better-auth.session_token` from the cookie jar - 3. in the browser session, open `https://auth.hack-cli.hack/health` - 4. run `agent-browser --session cookies set __Secure-better-auth.session_token ` - 5. open `https://auth.hack-cli.hack/auth/account?bridge=1&redirect=https%3A%2F%2Fhack-cli.hack%2Faccount` - This mints the shared `hack_web_broker_session` cookie for `https://hack-cli.hack/account` without relying on GitHub OAuth. -- Notes: for protected routed-page verification that does not need to prove browser-owned sign-in continuity, an alternate bootstrap is to mint a scoped broker management token, set `hack_web_broker_session` directly in the browser session, and then open the protected `https://hack-cli.hack/...` route. Use this only for state inspection or scoped management-page checks; it is not evidence for browser-owned auth return flows. -- Notes: that bridge-cookie bootstrap is not enough to validate browser-owned auth return flows by itself. When a feature claims post-login deep-link continuity, also start from a real browser-owned entry such as `https://hack-cli.hack/auth?redirect=https%3A%2F%2Fhack-cli.hack%2Faccount` and confirm the user is returned to the requested trusted destination after sign-in. +### macOS companion -## Validation Concurrency +- Use Xcode/local app commands for retained desktop flows only: project list/detail, daemon/runtime status, up/down/restart/open, logs entrypoints, doctor/trust guidance, menu bar quick actions, and the Ghostty-backed bottom panel. +- Do not reintroduce tickets UI, hosted auth settings, GitHub/Linear settings, topology/network maps, gateway panes, or org/team/admin surfaces. -### CLI validators -- Max concurrent validators: `2` -- Rationale: machine headroom is ample, but some CLI flows share project state, runtime state, and repo-bound artifacts. Two concurrent validators is conservative without creating noisy artifact races. +### Managed containers -### Broker / local HTTP validators -- Max concurrent validators: `1` per service -- Rationale: local HTTP surfaces currently depend on fixed ports and shared local state. Serialize broker/runtime validation per declared service. +- Use `hackdance/hack:slim` or the release install script for Codex/CI-style environments. +- Inject `HACK_ENV_SECRET_KEY` and verify `hack env list --json` plus `hack host exec -- printenv KEY`. +- Do not rely on `hack global install`, local CA trust, Caddy/CoreDNS, or Loki/Grafana in slim mode. -### Web / browser validators -- Max concurrent validators: `1` -- Rationale: browser validation depends on a single local runtime plus `agent-browser`, and the highest-signal checks involve auth/session and routed-host state that should not overlap. +### Unsupported experimental remote -## Known Validation Gaps To Fix Early +- Remote/gateway/node/dispatch tests are not core release gates. +- If a change touches these surfaces, isolate tests from local-first gates and verify they do not depend on removed hosted auth, web, GitHub, or Linear systems. + +## Validation Concurrency + +### CLI validators -- `services/auth-broker` config/auth verification has env-sensitive paths that can produce weak signals until hardened. -- The repo does not yet have a real `apps/web` runtime or hack-managed local web host; milestone 2 must establish that path before meaningful browser validation can pass. -- Detached-worktree `./dist/hack linear sync-project --from linear` runs can hit hidden-ref `refs/hack/tickets` push rejections; for isolated validation, prefer repo-bound `documents|milestones|status-updates pull` plus focused Linear sync regressions unless you intentionally want to exercise remote ticket-ref writes. +- Max concurrent validators: `2`. +- Rationale: project state, tickets state, runtime metadata, and branch/worktree artifacts can race. -## Flow Validator Guidance: CLI surface +### Runtime/lifecycle validators -- Use the current repo root (for example ``) and prefer `./dist/hack` after a local `bun run build`. -- Stay repo-bound: do not use the globally installed `hack` binary for product behavior except runtime orchestration commands explicitly assigned by the coordinator. -- Treat `.hack/linear/**` as managed output. Do not delete unmanaged scratch files and do not create or validate against `.hack/.hack/linear/**`. -- For env-only auth assertions, set explicit process env in the command invocation rather than mutating shell startup files. -- When committing validation evidence, replace local absolute paths with placeholders such as ``, ``, ``, and ``. -- For mission-artifact sanitization that touches temp-home or daemon socket paths, the approved placeholder forms are `` and `` rather than embedding a home-like suffix under ``. -- Because CLI assertions in this milestone share repo-local Linear artifacts and profile state, validators for this surface must run serialized unless given a separate working copy. +- Max concurrent validators: `1` per project checkout. +- Rationale: lifecycle sessions, fixed ports, and process groups are intentionally stateful. -## Flow Validator Guidance: Broker / local HTTP surface +### macOS app validators -- Use `http://127.0.0.1:8080` only when you intentionally start the standalone `auth-broker-local` service from `.factory/services.yaml`. -- When the hack-managed stack is already up, prefer the routed broker host `https://auth.hack-cli.hack` for broker/API evidence and browser auth bridging. -- For routed-host shell checks, use `curl -k` and set `NODE_TLS_REJECT_UNAUTHORIZED=0` for isolated CLI/curl probes unless local trust has already been configured outside the validator session. -- Treat a failed health check on both `http://127.0.0.1:8080/health` and `https://auth.hack-cli.hack/health` as a blocker before continuing with broker-dependent assertions. -- Do not bind additional fixed ports or start a second broker instance unless the coordinator gives a separate port and evidence directory. -- For daemon/gateway raw-path validation, prefer an isolated repo-built daemon with a temp HOME and temp global config. If you need live gateway proof, allocate a non-shared temp port and probe that daemon rather than the shared machine-global listener on `127.0.0.1:7788`. +- Max concurrent validators: `1`. +- Rationale: Xcode/app launch state and menu bar app instances should not overlap. -## Flow Validator Guidance: Web surface +## Required Proof Patterns -- Use a single `agent-browser` session against the routed host `https://hack-cli.hack`; re-use the same session across `/`, `/auth`, and `/auth/account` instead of opening parallel browsers. -- Re-snapshot after every navigation and always capture annotated screenshots for the shell, sign-in page, account page, and any redirect from `https://auth.hack-cli.hack/auth*`. -- Keep browser validation read-mostly: avoid mutating shared org/team state from the browser because this milestone only ships the shell and auth entrypoints, while durable org/team mutations are better exercised through isolated broker API cookies. -- When an authenticated broker session is required, create it through the real Better Auth email/password API on `https://auth.hack-cli.hack/api/auth/sign-up/email`, store cookies in an isolated temp directory, and treat those cookies as the isolation boundary for the corresponding API checks. +- Env changes: cover overlay order, worktree-local overrides, linked-worktree key lookup, host-vs-compose target mode, and materialization drift. +- Lifecycle changes: cover shell semantics, process groups, stale metadata, singleton full/partial listener conflicts, and doctor recovery guidance. +- Tickets changes: cover offline/stale-local fallback only for transient connectivity; hard remote misconfiguration should surface clearly. +- Agent setup changes: update source renderers and checked-in generated examples, then run setup/MCP tests. diff --git a/.factory/library/web-control-plane.md b/.factory/library/web-control-plane.md deleted file mode 100644 index daf79a07..00000000 --- a/.factory/library/web-control-plane.md +++ /dev/null @@ -1,44 +0,0 @@ -# Web Control Plane - -Guidance specific to the optional `apps/web` control plane. - -**What belongs here:** web stack, UX rules, deployment boundary, and UI-system expectations. - ---- - -## Delivery Target - -- Build `apps/web` as a Next.js App Router application that is ready for Vercel deployment. -- The mission does not need a live Vercel deployment; local validation plus deploy-ready wiring is sufficient. - -## UI System - -- Tailwind CSS v4 is the styling baseline. -- Use `shadcn/ui` as the primitive layer. -- Use Kibo UI selectively for richer admin patterns where it genuinely helps. -- Use Motion only for subtle, accessible transitions; always respect reduced-motion preferences. - -## UX Constraints - -- The app should feel like a calm, durable control plane rather than a demo dashboard. -- Preserve semantic structure, labels, focus states, and keyboard navigation. -- Do not hide critical state transitions behind animation. - -## Current Shell Contract - -The first shipped shell in `apps/web/src/components/control-plane-shell.tsx` establishes concrete foundation rules for later slices: -- keep a skip link that targets the main region -- use a labeled section nav (`aria-label="Control plane sections"`) -- keep the primary content in a focusable `main` region with explicit section ids -- preserve visible `focus-visible` outlines and `motion-reduce` fallbacks on interactive surfaces -- treat auth, admin, and integration flows as future slices instead of implying they already landed - -## Testing Quirk - -- `bunfig.toml` pins Bun tests to `./tests`, so workspace-targeted coverage may need a root shim file (for example `tests/apps/web.test.ts`) that imports package-local tests when workers need `bun test apps/web` to execute package assertions from the repo root. -- The routed `/account` proof from `.factory/validation/env-hardening-closeout/user-testing/flows/web-env-linear-state.json` includes populated Linear delivery and closeout audit cards, so account-shell regressions that touch the Linear section should not rely only on `audit: null` fixtures. - -## Runtime Constraint - -- Local web validation should run through hack-managed routing once the web runtime is declared. -- Browser verification must use `agent-browser` against the routed local host. diff --git a/.factory/services.yaml b/.factory/services.yaml index 3775e9e1..364c18fd 100644 --- a/.factory/services.yaml +++ b/.factory/services.yaml @@ -4,39 +4,19 @@ commands: typecheck: bun run typecheck check: bun run check test: bun run test - auth_test: bun run auth:test - auth_targeted_test: bun test ./services/auth-broker/tests/config.test.ts ./services/auth-broker/tests/index.test.ts - auth_wildcard_handoff_targeted_test: bun test ./services/auth-broker/tests/index.test.ts ./services/auth-broker/tests/session-auth.test.ts - auth_org_team_store_mode_targeted_test: bun test ./services/auth-broker/tests/index.test.ts ./services/auth-broker/tests/org-team-membership.test.ts db_test: bun run db:test - auth_broker_smoke: curl -sf http://127.0.0.1:8080/health runtime_up: hack up -d runtime_ps: hack ps --json runtime_open: hack open --json - runtime_open_auth: hack open auth --json - runtime_web_smoke: curl -ksSf https://hack-cli.hack - runtime_auth_routed_smoke: curl -ksSf https://auth.hack-cli.hack/health - runtime_stack_routed_healthcheck: hack ps --json && curl -ksSf https://hack-cli.hack && curl -ksSf https://auth.hack-cli.hack/health - runtime_auth_provider_metadata_smoke: curl -ksSf https://auth.hack-cli.hack/v1/auth/providers - runtime_auth_legacy_shell_headers: curl -ksSI https://auth.hack-cli.hack/auth - github_status: ./dist/hack x github status --json - repo_github_smoke: bun run build && ./dist/hack x github status --json - linear_status: ./dist/hack linear status --json - linear_broker_env_status: 'HACK_AUTH_SESSION_TOKEN="${HACK_AUTH_SESSION_TOKEN:?set HACK_AUTH_SESSION_TOKEN}" HACK_AUTH_SESSION_EXPIRES_AT="${HACK_AUTH_SESSION_EXPIRES_AT:-2099-01-01T00:00:00.000Z}" ./dist/hack linear status --json' - repo_linear_smoke: bun run build && ./dist/hack linear status --json + runtime_doctor: hack doctor + runtime_down: hack down + lifecycle_tests: bun test tests/project-lifecycle-processes.test.ts tests/project-lifecycle-singleton.test.ts tests/project-lifecycle-hygiene.test.ts env_list_json: ./dist/hack env list --json - env_backend_status_json: ./dist/hack env backend status --json - env_status_smoke: ./dist/hack env list --json && ./dist/hack env backend status --json - project_owner_routed_scope_status: HACK_AUTH_BROKER_URL=https://auth.hack-cli.hack bun ./index.ts project owner show --json + env_tests: bun test tests/project-env-config.test.ts tests/env-command.test.ts tests/project-run-command.test.ts + tickets_tests: bun test tests/tickets-git-channel.test.ts tests/tickets-store.test.ts + setup_docs_tests: bun test tests/setup.test.ts tests/mcp.test.ts services: - auth-broker-local: - start: PORT=8080 HOST=127.0.0.1 AUTH_BROKER_PUBLIC_BASE_URL=http://127.0.0.1:8080 bun run auth:start - stop: lsof -ti tcp:8080 | xargs kill - healthcheck: curl -sf http://127.0.0.1:8080/health - port: 8080 - depends_on: [] - hack-stack: start: hack up -d stop: hack down diff --git a/.factory/skills/control-plane-worker/SKILL.md b/.factory/skills/control-plane-worker/SKILL.md index 12afd6ea..abeb199a 100644 --- a/.factory/skills/control-plane-worker/SKILL.md +++ b/.factory/skills/control-plane-worker/SKILL.md @@ -1,6 +1,6 @@ --- name: control-plane-worker -description: Implements CLI, broker, database, runtime, integration, and closeout features for the Hack control plane. +description: Implements local-first CLI, runtime, env, lifecycle, tickets, and macOS companion features for Hack. --- # Control Plane Worker @@ -11,95 +11,76 @@ NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the W Use this skill for features that primarily touch: - `src/**` CLI and control-plane code -- `services/auth-broker/**` -- `packages/db/**` - `.hack/docker-compose.yml`, `.hack/hack.config.json`, or other source-of-truth Hack runtime files -- Linear/GitHub auth resolution, project sync, status updates, env/runtime hardening, or mission closeout/audit work +- local runtime orchestration, env/runtime hardening, lifecycle processes, tickets, sessions, MCP/agent setup, docs, or the slim macOS companion + +Do not use this skill for retired v3 surfaces: +- hosted auth/account/org/team management +- web dashboard work +- built-in GitHub workflows +- built-in Linear sync/project-artifact flows ## Required Skills -- `agent-browser` — invoke when a control-plane feature changes visible web surfaces or when mission/user-testing guidance requires browser proof for user-facing behavior owned by this worker type. -- `hack-cli` — invoke when the feature touches `.hack/**`, runtime orchestration, gateway/session flows, tickets, or any `hack up/ps/open/down` verification. -- `linear` — invoke when the feature changes repo-bound Linear project sync, status-update publishing, artifact layout, or mission closeout behavior. +- `hack-cli` — invoke when the feature touches `.hack/**`, runtime orchestration, lifecycle/session flows, tickets, env, or any `hack up/ps/open/down` verification. ## Work Procedure 1. Read the assigned feature, `mission.md`, mission `AGENTS.md`, `.factory/services.yaml`, and relevant `.factory/library/*.md` files. Restate the exact assertions or outcomes the feature must complete. -2. Investigate existing code paths and add the failing test or regression harness first. Prefer the narrowest relevant suites (`tests/*.test.ts`, `services/auth-broker/tests/*.test.ts`, `packages/db/tests/*.test.ts`). If the feature has no `fulfills` claims, still add characterization or regression coverage for the changed behavior. -3. Implement the smallest coherent change set in CLI, broker, database, or Hack runtime config. Never hand-edit `.hack/.internal/**` or `.hack/.branch/**`; only change source-of-truth files. - - If the feature claims durable-store or database-backed parity coverage, prove it against a real durable backend path or explicitly narrow the claim in the handoff; do not substitute an in-memory fake or query-AST shim and still call it durable verification. -4. Run focused validators first, then the smallest meaningful `typecheck`/`check` commands for the touched surfaces. For repo-bound CLI behavior, build and validate with `./dist/hack` or repo-local Bun entrypoints. For HTTP features, use declared service commands and `curl` health/API checks. When invoking `bun test` from the repo root against files outside `./tests`, use absolute paths or explicit `./`-prefixed paths that Bun actually honors in this repo so targeted commands do not silently skip files. +2. Investigate existing code paths and add the failing test or regression harness first. Prefer the narrowest relevant suites under `tests/*.test.ts`. If the feature has no `fulfills` claims, still add characterization or regression coverage for the changed behavior. +3. Implement the smallest coherent change set in CLI, runtime config, tickets, env, lifecycle, macOS, or agent setup. Never hand-edit `.hack/.internal/**` or `.hack/.branch/**`; only change source-of-truth files. +4. Run focused validators first, then the smallest meaningful `typecheck`/`check` commands for the touched surfaces. For repo-bound CLI behavior, build and validate with `./dist/hack` or repo-local Bun entrypoints. When invoking `bun test` from the repo root against files outside `./tests`, use absolute paths or explicit `./`-prefixed paths that Bun actually honors in this repo so targeted commands do not silently skip files. - If the assigned feature is explicitly about fixing a known red baseline, capture the failing baseline evidence once, then continue the repair work and rerun the gate before handoff. - If repo-bound GitHub CLI routes cannot reach the changed auth code because `dance.hack.github` is not enabled in project config yet, use a direct resolver or similarly narrow deterministic smoke and record why the repo-bound path was unavailable. - If no safe repo-bound hook exists to force a failure mode (for example local-sync failure injection), deterministic regression tests are acceptable proof as long as you explain why a live manual repro would mutate real project state. - For daemon/gateway request-target hardening, raw-socket regression coverage against the proxy transport is preferred. If you also need live proof without mutating shared user daemon state, an isolated temp-HOME `bun index.ts daemon start --foreground` smoke is an acceptable validation pattern; record the isolation setup in the handoff. -5. If the feature touches Linear project/state behavior, use repo-bound `./dist/hack linear ...` flows to verify the effect and record the exact commands/results. Do not rely on manual remote edits as the primary proof. For keychainless or broker-seeded auth work, `tokenSource: "broker"` alone is not sufficient proof: capture a stronger negative signal showing the broker path avoided local secret/keychain reads, or fail closed with explicit guidance instead of claiming success. -6. If the feature changes user-facing web behavior even though the primary code lives outside `apps/web`, run `agent-browser` against the routed host unless the change is strictly non-visual. If the visible behavior cannot be exercised in-browser, record a justified deviation and pair it with the strongest available CLI/API proof. -7. Capture any blockers, discovered issues, or scope mismatches immediately. If a feature needs new credentials, unavailable infrastructure, or a change that would violate CLI optionality/auth ownership, return to the orchestrator instead of guessing. -8. Stop any processes you started and produce a detailed handoff with exact commands, observations, tests added, and remaining issues. + - For lifecycle changes, verify shell semantics, process-group cleanup, stale pane/process metadata reconciliation, singleton listener behavior, and doctor recovery guidance. + - For env changes, verify overlay order, worktree-local override behavior, linked-worktree secret-key lookup, host-vs-compose target behavior, and materialization drift detection. +5. Capture any blockers, discovered issues, or scope mismatches immediately. If a feature needs credentials, unavailable infrastructure, or a change that would reintroduce hosted/web/integration dependencies, return to the orchestrator instead of guessing. +6. Stop any processes you started and produce a detailed handoff with exact commands, observations, tests added, and remaining issues. 9. If `bun run check` succeeds and only re-surfaces the known warning-only complexity diagnostics already documented in mission `AGENTS.md`, do not return to the orchestrator for that reason and do not record them as new discovered issues unless your feature directly worsened the warned files. ## Example Handoff ```json { - "salientSummary": "Hardened Linear unattended auth and artifact-path behavior so env-only mode fails closed without keychain fallback and repo-bound artifact commands no longer treat .hack/.hack/linear as authoritative. Added regression tests and verified the current branch CLI with ./dist/hack.", - "whatWasImplemented": "Updated the Linear auth resolver and command guidance paths, added canonical-path normalization/rejection in project artifact helpers, and extended regression coverage for env-only failure, refresh persistence, and legacy artifact-root handling. The repo-bound CLI now reports the correct repair guidance for broker-vs-local failures.", - "whatWasLeftUndone": "Did not touch the future apps/web runtime wiring; that remains for later milestones.", + "salientSummary": "Hardened lifecycle singleton behavior for local tunnel helpers so Hack adopts a healthy existing listener set, fails partial conflicts, and avoids duplicate host process churn.", + "whatWasImplemented": "Updated lifecycle config parsing, runtime startup decisions, docs, and regression coverage for singleton ports and adopt/fail behavior.", + "whatWasLeftUndone": "Remote/gateway/node/dispatch were intentionally left untouched because they are unsupported experimental in v3.", "verification": { "commandsRun": [ { - "command": "bun test tests/linear-auth.test.ts tests/linear-project-artifacts.test.ts tests/linear-commands.test.ts tests/prerequisites-matrix.test.ts", + "command": "bun test tests/project-lifecycle-singleton.test.ts tests/project-config.test.ts tests/project-config-schema.test.ts", "exitCode": 0, - "observation": "Targeted Linear/auth regression suite passed after adding the new fail-closed and canonical-path cases." + "observation": "Targeted lifecycle singleton/config regression suite passed." }, { - "command": "bun run build", + "command": "bun run typecheck", "exitCode": 0, - "observation": "Rebuilt ./dist/hack for repo-bound CLI verification." - }, - { - "command": "./dist/hack linear status --json", - "exitCode": 0, - "observation": "Repo-bound Linear status still resolves the bound Hack project/profile after the auth-path changes." + "observation": "Workspace typecheck passed." } ], "interactiveChecks": [ { - "action": "Ran a manual repo-bound artifact pull/apply smoke and inspected the working tree.", - "observed": "Only .hack/linear/projects//... changed; the legacy .hack/.hack/linear tree was not used as the active artifact root." + "action": "Ran hack doctor before and after hack down in a repo with stale lifecycle state.", + "observed": "Doctor classified stale lifecycle metadata and pointed to hack down; cleanup removed only the matching lifecycle state." } ] }, "tests": { "added": [ { - "file": "tests/linear-auth.test.ts", - "cases": [ - { - "name": "env-only mode fails closed when the env token is missing", - "verifies": "No keychain fallback or silent success occurs when HACK_LINEAR_PREFER_ENV_TOKEN_ONLY=true without a configured token." - } - ] - }, - { - "file": "tests/linear-project-artifacts.test.ts", + "file": "tests/project-lifecycle-singleton.test.ts", "cases": [ { - "name": "legacy .hack/.hack/linear paths are rejected or ignored", - "verifies": "Artifact commands only treat the canonical .hack/linear tree as authoritative." + "name": "adopts a complete singleton listener set", + "verifies": "Hack does not start duplicate fixed-port tunnel helpers when all configured ports are already listening and onConflict is adopt." } ] } ] }, - "discoveredIssues": [ - { - "severity": "medium", - "description": "The broader auth-broker config harness is still environment-sensitive when repo-root env files leak into tests outside the focused Linear suites.", - "suggestedFix": "Keep the planned validation-harness-hardening feature near the top of the mission queue." - } - ] + "discoveredIssues": [] } ``` @@ -107,5 +88,5 @@ Use this skill for features that primarily touch: - The feature needs credentials, accounts, or third-party setup that are not already present. - Hack global/runtime infrastructure is unavailable and cannot be restored within the mission boundaries. -- The change would introduce a second auth authority or make a critical local-first workflow web-only. +- The change would reintroduce hosted auth, built-in GitHub/Linear, web dashboard, or remote dependencies into the supported v3 product. - The feature requires a larger decomposition because the claimed assertions cannot be completed coherently in one session. diff --git a/.factory/skills/web-control-plane-worker/SKILL.md b/.factory/skills/web-control-plane-worker/SKILL.md deleted file mode 100644 index bee673e4..00000000 --- a/.factory/skills/web-control-plane-worker/SKILL.md +++ /dev/null @@ -1,107 +0,0 @@ ---- -name: web-control-plane-worker -description: Implements browser-facing Next.js control-plane slices and verifies them end-to-end with agent-browser. ---- - -# Web Control Plane Worker - -NOTE: Startup and cleanup are handled by `worker-base`. This skill defines the WORK PROCEDURE. - -## When to Use This Skill - -Use this skill for features that primarily touch: -- `apps/web/**` -- shared auth/UI modules consumed by the web app -- browser-facing admin, integration-management, or env/status surfaces -- vertical slices that require visible browser verification against the local routed host - -## Required Skills - -- `agent-browser` — mandatory for every feature that changes visible UI or user flows in `apps/web`. -- `hack-cli` — invoke when starting or inspecting the local runtime (`hack up`, `hack ps`, `hack open`) or when the feature touches `.hack/**` source-of-truth files. If this skill is unavailable in the worker session, use the equivalent shell commands directly and record the justified deviation. -- `linear` — invoke when the feature changes repo-bound Linear project/status surfaces and you need to verify synced project state or status-update behavior. If this skill is unavailable in the worker session, use the equivalent repo-bound CLI commands directly and record the justified deviation. - -## Work Procedure - -1. Read the assigned feature, `mission.md`, mission `AGENTS.md`, `.factory/services.yaml`, and relevant `.factory/library/*.md` files. Identify the exact user-visible flows and assertions this feature completes. -2. If the feature starts from a pre-existing mixed-scope dirty tree or stash snapshot, inventory the candidate files first. Recover or keep dirty only the feature-owned allowlist, keep later-scope files quarantined, and never stage generated `apps/web/.next/**`, `.turbo/**`, `node_modules/**`, or `tsconfig.tsbuildinfo`. -3. Write the failing tests first. Cover the smallest meaningful mix of component, route, integration, or contract tests needed for the slice before editing implementation. -4. Implement the smallest coherent vertical slice across `apps/web` and any supporting broker/shared modules it truly needs. Preserve browser-owned auth UX, durable shared state, and CLI optionality. -5. Start the local runtime with Hack-managed commands or the declared services manifest, then use `hack open --json` to determine the routed host. Prefer detached cycles such as `hack down && hack up -d` over attached restarts for web slices, and if Next rewrites tracked files like `apps/web/next-env.d.ts` or `apps/web/tsconfig.json` during verification, restore those incidental changes before commit unless the feature explicitly owns them. Use `agent-browser` to exercise each changed user flow end-to-end whenever the change is visible in the browser. -6. Verify not only the happy path but also loading/error/repair states. For navigation and shell work, include keyboard navigation and reduced-motion checks. For auth-entrypoint or auth/account handoff features, explicitly cover disabled-provider and trusted-origin negative cases so broker metadata remains authoritative, and verify both a browser-originated `/auth?redirect=...` flow and any bridge-cookie bootstrap flow when return continuity is part of the contract. For integration/admin flows, verify the visible state against the underlying broker or CLI output when the feature requires parity. Use the authenticated routed-browser bootstrap documented in `.factory/library/user-testing.md` for protected integration pages; if the routed local runtime intentionally lacks live provider credentials, prove the broken routed state in `agent-browser` and pair it with a repo-bound CLI or broker-backed fallback proof for the healthy state instead of substituting a temporary standalone HTML harness. When a Next loader shells out to repo-bound CLI commands, bridge the resulting web state to the active browser org/team/project scope (or explicitly overlay browser-scoped shared-project results) before you treat it as parity, and capture evidence of both the browser mutation and the corresponding scoped CLI/broker comparison rather than only generic navigation screenshots. If the change is strictly a non-visual seam or an outage fallback that cannot be exercised meaningfully in the browser, record a justified `agent-browser` deviation and pair it with the strongest available unit/CLI/curl proof instead of silently skipping browser verification. -7. Run focused tests, then the smallest meaningful `typecheck`/`check` commands for the touched surfaces. When invoking `bun test` from the repo root against files outside `./tests`, use absolute paths or explicit `./`-prefixed paths that Bun actually honors in this repo so targeted commands do not silently skip files. Stop any runtime processes or watchers you started. -8. If the feature depends on missing backend contracts, missing runtime wiring, or a requirement that would make the web app mandatory for local workflows, return to the orchestrator with a concrete blocker report. - -## Example Handoff - -```json -{ - "salientSummary": "Added the apps/web account shell and browser-owned sign-in/account entrypoints, then verified identity continuity between the browser, broker current-user endpoint, and ./dist/hack auth status. The new shell is keyboard navigable and honors reduced-motion preferences.", - "whatWasImplemented": "Created the initial authenticated app shell in apps/web, wired browser-owned sign-in/account routes to the broker-backed auth/session APIs, and added context rendering for the active user/org/team. Updated the shared auth wiring so the broker no longer serves the primary interactive auth shell for this slice.", - "whatWasLeftUndone": "Project registration and integration-management pages were not part of this feature and remain for later slices.", - "verification": { - "commandsRun": [ - { - "command": "bun test apps/web tests/auth-command.test.ts services/auth-broker/tests/session-auth.test.ts", - "exitCode": 0, - "observation": "Focused web + auth continuity tests passed after the new shell and handoff wiring landed." - }, - { - "command": "bun run typecheck", - "exitCode": 0, - "observation": "Workspace typecheck passed with the new apps/web additions." - }, - { - "command": "hack up -d && hack open --json", - "exitCode": 0, - "observation": "Hack-managed runtime exposed the routed local host used for browser verification." - } - ], - "interactiveChecks": [ - { - "action": "Used agent-browser to sign in from the routed web host and land on the account shell.", - "observed": "The shell rendered the signed-in user plus active org/team context, and the broker current-user endpoint matched the same identity." - }, - { - "action": "Navigated the shell with keyboard-only input and with reduced-motion enabled.", - "observed": "Focus states remained visible, navigation stayed usable, and motion effects collapsed to reduced-motion-safe transitions." - }, - { - "action": "Compared the signed-in shell context with ./dist/hack auth status --json.", - "observed": "Browser, broker, and CLI all reported the same active user/org/team context after sign-in handoff." - } - ] - }, - "tests": { - "added": [ - { - "file": "apps/web/app/(auth)/sign-in/page.test.tsx", - "cases": [ - { - "name": "sign-in route consumes the shared provider contract", - "verifies": "The browser-owned auth UX renders the same enabled providers the broker exposes." - } - ] - }, - { - "file": "apps/web/app/(app)/account-shell.test.tsx", - "cases": [ - { - "name": "account shell renders the active user and org/team context", - "verifies": "The web shell stays in parity with broker current-user state after login." - } - ] - } - ] - }, - "discoveredIssues": [] -} -``` - -## When to Return to Orchestrator - -- The feature needs backend contracts or durable state that do not exist yet. -- The local runtime cannot expose a stable routed host for browser verification. -- The UI slice cannot be completed without violating CLI optionality or auth ownership rules. -- Browser verification reveals a wider architectural mismatch that should be decomposed into a follow-up backend/platform feature first. -- The pre-existing dirty tree or stash snapshot cannot be isolated into a feature-owned allowlist without risking cross-feature contamination. diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 72898aca..f2e32b50 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -19,6 +19,43 @@ jobs: - name: Gitleaks scan uses: gacts/gitleaks@v1 + runtime-images: + runs-on: ubuntu-latest + permissions: + contents: read + steps: + - name: Checkout + uses: actions/checkout@v4 + - name: Setup Bun + uses: oven-sh/setup-bun@v1 + with: + bun-version: "1.3.9" + - name: Setup Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Install dependencies + run: bun install + - name: Build full runtime image + run: | + bun run scripts/build-node-runtime-image.ts \ + --variant node-runtime \ + --platform linux/amd64 \ + --tag hack-runtime-ci:full + - name: Smoke full runtime image + run: | + docker run --rm --entrypoint hack hack-runtime-ci:full --help >/tmp/hack-full-help.txt + grep -q "hack" /tmp/hack-full-help.txt + - name: Build slim runtime image + run: | + bun run scripts/build-node-runtime-image.ts \ + --variant slim \ + --platform linux/amd64 \ + --tag hack-runtime-ci:slim + - name: Smoke slim runtime image defaults + run: | + docker run --rm --entrypoint sh hack-runtime-ci:slim -lc 'command -v bun >/dev/null && command -v hack >/dev/null && test "${HACK_EXECUTION_MODE}" = "codex" && test "${HACK_DAEMON_DISABLE_DOCKER_EVENTS}" = "1" && test "${HACK_SETUP_SYNC_MODE}" = "warn" && hack --help >/tmp/hack-help.txt && grep -q "Usage:" /tmp/hack-help.txt' + - name: Smoke slim runtime mounted-project env flow + run: bash scripts/portable-container-smoke.sh hack-runtime-ci:slim linux/amd64 + test: runs-on: macos-14 steps: diff --git a/.github/workflows/release-node-runtime-image.yml b/.github/workflows/release-node-runtime-image.yml index 638c9d67..992cf3b4 100644 --- a/.github/workflows/release-node-runtime-image.yml +++ b/.github/workflows/release-node-runtime-image.yml @@ -1,4 +1,4 @@ -name: Publish Node Runtime Image +name: Publish Runtime Images on: workflow_dispatch: @@ -35,6 +35,13 @@ jobs: - name: Setup Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Docker Hub + uses: docker/login-action@v3 + with: + registry: docker.io + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Login to GHCR uses: docker/login-action@v3 with: @@ -64,31 +71,47 @@ jobs: exit 1 fi - IMAGE="ghcr.io/${{ github.repository_owner }}/hack-node-runtime" - TAG_ARGS="--tag ${IMAGE}:${VERSION}" + DOCKERHUB_IMAGE="docker.io/hackdance/hack" + GHCR_IMAGE="ghcr.io/${{ github.repository_owner }}/hack" + FULL_TAG_ARGS="--tag ${DOCKERHUB_IMAGE}:${VERSION} --tag ${GHCR_IMAGE}:${VERSION}" + SLIM_TAG_ARGS="--tag ${DOCKERHUB_IMAGE}:${VERSION}-slim --tag ${GHCR_IMAGE}:${VERSION}-slim" if [ "${EVENT_NAME}" = "push" ] || [ "${INPUT_INCLUDE_LATEST}" = "true" ]; then - TAG_ARGS="${TAG_ARGS} --tag ${IMAGE}:latest" + FULL_TAG_ARGS="${FULL_TAG_ARGS} --tag ${DOCKERHUB_IMAGE}:latest --tag ${GHCR_IMAGE}:latest" + SLIM_TAG_ARGS="${SLIM_TAG_ARGS} --tag ${DOCKERHUB_IMAGE}:slim --tag ${GHCR_IMAGE}:slim" fi { echo "version=${VERSION}" - echo "image=${IMAGE}" - echo "tag_args=${TAG_ARGS}" + echo "dockerhub_image=${DOCKERHUB_IMAGE}" + echo "ghcr_image=${GHCR_IMAGE}" + echo "full_tag_args=${FULL_TAG_ARGS}" + echo "slim_tag_args=${SLIM_TAG_ARGS}" } >> "$GITHUB_OUTPUT" - - name: Build and push node runtime image + - name: Build and push full runtime image + run: | + bun run scripts/build-node-runtime-image.ts \ + --variant node-runtime \ + --push \ + --platform linux/amd64,linux/arm64 \ + ${{ steps.tags.outputs.full_tag_args }} + + - name: Build and push slim runtime image run: | bun run scripts/build-node-runtime-image.ts \ + --variant slim \ --push \ --platform linux/amd64,linux/arm64 \ - ${{ steps.tags.outputs.tag_args }} + ${{ steps.tags.outputs.slim_tag_args }} - name: Summary run: | { - echo "### Node runtime image published" + echo "### Runtime images published" echo "" - echo "- Image: \`${{ steps.tags.outputs.image }}\`" echo "- Version: \`${{ steps.tags.outputs.version }}\`" - echo "- Tags: \`${{ steps.tags.outputs.tag_args }}\`" + echo "- Docker Hub repo: \`${{ steps.tags.outputs.dockerhub_image }}\`" + echo "- GHCR repo: \`${{ steps.tags.outputs.ghcr_image }}\`" + echo "- Full tags: \`${{ steps.tags.outputs.full_tag_args }}\`" + echo "- Slim tags: \`${{ steps.tags.outputs.slim_tag_args }}\`" } >> "$GITHUB_STEP_SUMMARY" 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/AGENTS.md b/AGENTS.md index a7eb2a17..360e8bfe 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -230,7 +230,12 @@ Most formatting and common issues are automatically fixed by Biome. Run `bun x u ## hack CLI (local dev + MCP) -Use `hack` as the single interface for local runtime orchestration (compose, DNS/TLS, logs, persistent project workspaces). +Use `hack` as the single interface for local-first runtime orchestration (compose, DNS/TLS, logs, env, persistent project workspaces, and optional local tickets). + +Product boundary: +- Supported v3 surface: project init, up/down/restart, open, logs, env, host exec/shell, sessions, doctor, daemon, and optional local tickets. +- Removed surfaces: hosted auth/account/org/team flows, web dashboard, built-in GitHub workflows, and built-in Linear sync. +- Unsupported experimental: remote/gateway/node/dispatch. Do not use these as the default path unless explicitly requested. Operating rules: - Prefer `hack` over raw `docker` / `docker compose` for project workflows. @@ -266,7 +271,9 @@ TLS + valid-hostname constraints: Project files (managed vs generated): - Source-of-truth files: `.hack/docker-compose.yml`, `.hack/hack.config.json`, `.hack/hack.env.default.yaml`, and optional `.hack/hack.env..yaml`. +- Worktree-local env override files: `.hack/hack.env.local.yaml` and `.hack/hack.env..local.yaml`. - Local-only files: `.hack.secret.key`, optional `.hack/.env` compatibility output, `.hack/.env.state.json`, and `.hack/.internal/` (runtime/local machine state; keep gitignored). +- Linked worktrees can inherit secret decryption through the git common dir; use `HACK_ENV_SECRET_KEY` in CI/managed containers. - Generated (do not hand-edit): `.hack/.internal/compose.override.yml`, `.hack/.internal/compose.env.override.yml`, `.hack/.branch/compose..override.yml`. - Managed via CLI: `.hack/.internal/extra-hosts.json` (use `hack internal extra-hosts ...` commands). - Lifecycle runtime files: `.hack/.internal/lifecycle/state.json`, `.hack/.internal/lifecycle/*.log`. @@ -296,6 +303,8 @@ Logs (default is compose): Lifecycle + startup: - Put host setup in `.hack/hack.config.json` under `startup`/`lifecycle` (not ad-hoc terminal tabs). - Use `lifecycle.up.before` for pre-start hooks and `lifecycle.processes` for long-running host tasks. +- For fixed-port host helpers such as SSM tunnels or local proxies, set `singleton.ports` and usually `onConflict: "adopt"` so Hack reuses a healthy existing listener instead of starting duplicate tunnel stacks. +- `singleton` is a listener guard, not process ownership transfer; adopted external processes are left running on `hack down`. - Inspect lifecycle status via `hack projects --details` and stream via `hack logs `. Workspaces (mux-managed, tmux-first by default): @@ -309,6 +318,7 @@ Workspaces (mux-managed, tmux-first by default): Host-side env helpers: - One-off host command with injected env: `hack host exec --env qa --scope api -- bun db:migrate` +- Host commands default to a host-local env view; use `--target compose` when you explicitly want container-oriented addresses. - `--scope` selects which env scope to inject; it does not move execution into that service container. - Interactive host shell with injected env: `hack host shell --env qa --scope api` - Run inside an already-running service container: `hack exec api -- bun test` @@ -323,7 +333,8 @@ Global infra: - Start/stop/status: `hack global up`, `hack global down`, `hack global status` - Use `hack global up` before Loki/Grafana queries if global logging is offline. -Remote nodes + dispatch: +Unsupported experimental remote nodes + dispatch: +- These commands are source-available but outside the supported v3 product contract. - Pair/register nodes: `hack node pair ...`, then verify with `hack node list` and `hack node status --watch`. - Repair SSH for remote Git/mutagen: `hack node ssh setup --node `. - On node host, inspect workspace map via `hack node workspace list|resolve|attach|remove`. @@ -374,15 +385,8 @@ Agent setup (CLI-first): - MCP install (explicit): `hack mcp install --all --scope project` -## Learned User Preferences - -- Prefer Tailwind utilities and shadcn components for auth and similar UI instead of bespoke CSS class stacks such as `.auth-*` when utilities can express the same layout and states. -- After substantive `apps/web` UI changes, verify in a real browser and check the console for runtime warnings. -- When running shadcn CLI init or updates in `apps/web`, merge generated output with existing providers, registries, and project-specific styles rather than overwriting custom shell or auth wiring. - ## Learned Workspace Facts -- Better Auth runs in `services/auth-broker`, not inside the Next app. Browser GitHub sign-in uses a `redirect_uri` on the **auth broker** host (for example `auth..hack.gy`), not on the primary web app host (`.hack.gy`). -- On the auth broker host this repo may use two GitHub callback paths: Better Auth at `/api/auth/callback/github` and the broker custom flow at `/gh/callback`. The GitHub OAuth app must allow the exact `redirect_uri` emitted in the live authorize request. -- Keep `services/auth-broker` as the auth authority (sessions, provider callbacks, CLI-related flows); treat `apps/web` as browser UX and thin BFF/proxy. See `docs/guides/auth-broker-callbacks.md` for callback and handoff wording. -- `apps/web` theme switching uses a small custom theme context plus layout bootstrap rather than `next-themes` `ThemeProvider`, to avoid Next.js 16 / React client warnings about ` - {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