diff --git a/.beads/.gitignore b/.beads/.gitignore deleted file mode 100644 index 4a7a77df9..000000000 --- a/.beads/.gitignore +++ /dev/null @@ -1,39 +0,0 @@ -# SQLite databases -*.db -*.db?* -*.db-journal -*.db-wal -*.db-shm - -# Daemon runtime files -daemon.lock -daemon.log -daemon.pid -bd.sock -sync-state.json -last-touched - -# Local version tracking (prevents upgrade notification spam after git ops) -.local_version - -# Legacy database files -db.sqlite -bd.db - -# Worktree redirect file (contains relative path to main repo's .beads/) -# Must not be committed as paths would be wrong in other clones -redirect - -# Merge artifacts (temporary files from 3-way merge) -beads.base.jsonl -beads.base.meta.json -beads.left.jsonl -beads.left.meta.json -beads.right.jsonl -beads.right.meta.json - -# NOTE: Do NOT add negation patterns (e.g., !issues.jsonl) here. -# They would override fork protection in .git/info/exclude, allowing -# contributors to accidentally commit upstream issue databases. -# The JSONL files (issues.jsonl, interactions.jsonl) and config files -# are tracked by git by default since no pattern above ignores them. diff --git a/.beads/README.md b/.beads/README.md deleted file mode 100644 index 50f281f03..000000000 --- a/.beads/README.md +++ /dev/null @@ -1,81 +0,0 @@ -# Beads - AI-Native Issue Tracking - -Welcome to Beads! This repository uses **Beads** for issue tracking - a modern, AI-native tool designed to live directly in your codebase alongside your code. - -## What is Beads? - -Beads is issue tracking that lives in your repo, making it perfect for AI coding agents and developers who want their issues close to their code. No web UI required - everything works through the CLI and integrates seamlessly with git. - -**Learn more:** [github.com/steveyegge/beads](https://github.com/steveyegge/beads) - -## Quick Start - -### Essential Commands - -```bash -# Create new issues -bd create "Add user authentication" - -# View all issues -bd list - -# View issue details -bd show - -# Update issue status -bd update --status in_progress -bd update --status done - -# Sync with git remote -bd sync -``` - -### Working with Issues - -Issues in Beads are: -- **Git-native**: Stored in `.beads/issues.jsonl` and synced like code -- **AI-friendly**: CLI-first design works perfectly with AI coding agents -- **Branch-aware**: Issues can follow your branch workflow -- **Always in sync**: Auto-syncs with your commits - -## Why Beads? - -✨ **AI-Native Design** -- Built specifically for AI-assisted development workflows -- CLI-first interface works seamlessly with AI coding agents -- No context switching to web UIs - -🚀 **Developer Focused** -- Issues live in your repo, right next to your code -- Works offline, syncs when you push -- Fast, lightweight, and stays out of your way - -🔧 **Git Integration** -- Automatic sync with git commits -- Branch-aware issue tracking -- Intelligent JSONL merge resolution - -## Get Started with Beads - -Try Beads in your own projects: - -```bash -# Install Beads -curl -sSL https://raw.githubusercontent.com/steveyegge/beads/main/scripts/install.sh | bash - -# Initialize in your repo -bd init - -# Create your first issue -bd create "Try out Beads" -``` - -## Learn More - -- **Documentation**: [github.com/steveyegge/beads/docs](https://github.com/steveyegge/beads/tree/main/docs) -- **Quick Start Guide**: Run `bd quickstart` -- **Examples**: [github.com/steveyegge/beads/examples](https://github.com/steveyegge/beads/tree/main/examples) - ---- - -*Beads: Issue tracking that moves at the speed of thought* ⚡ diff --git a/.beads/config.yaml b/.beads/config.yaml deleted file mode 100644 index f2427856e..000000000 --- a/.beads/config.yaml +++ /dev/null @@ -1,62 +0,0 @@ -# Beads Configuration File -# This file configures default behavior for all bd commands in this repository -# All settings can also be set via environment variables (BD_* prefix) -# or overridden with command-line flags - -# Issue prefix for this repository (used by bd init) -# If not set, bd init will auto-detect from directory name -# Example: issue-prefix: "myproject" creates issues like "myproject-1", "myproject-2", etc. -# issue-prefix: "" - -# Use no-db mode: load from JSONL, no SQLite, write back after each command -# When true, bd will use .beads/issues.jsonl as the source of truth -# instead of SQLite database -# no-db: false - -# Disable daemon for RPC communication (forces direct database access) -# no-daemon: false - -# Disable auto-flush of database to JSONL after mutations -# no-auto-flush: false - -# Disable auto-import from JSONL when it's newer than database -# no-auto-import: false - -# Enable JSON output by default -# json: false - -# Default actor for audit trails (overridden by BD_ACTOR or --actor) -# actor: "" - -# Path to database (overridden by BEADS_DB or --db) -# db: "" - -# Auto-start daemon if not running (can also use BEADS_AUTO_START_DAEMON) -# auto-start-daemon: true - -# Debounce interval for auto-flush (can also use BEADS_FLUSH_DEBOUNCE) -# flush-debounce: "5s" - -# Git branch for beads commits (bd sync will commit to this branch) -# IMPORTANT: Set this for team projects so all clones use the same sync branch. -# This setting persists across clones (unlike database config which is gitignored). -# Can also use BEADS_SYNC_BRANCH env var for local override. -# If not set, bd sync will require you to run 'bd config set sync.branch '. -# sync-branch: "beads-sync" - -# Multi-repo configuration (experimental - bd-307) -# Allows hydrating from multiple repositories and routing writes to the correct JSONL -# repos: -# primary: "." # Primary repo (where this database lives) -# additional: # Additional repos to hydrate from (read-only) -# - ~/beads-planning # Personal planning repo -# - ~/work-planning # Work planning repo - -# Integration settings (access with 'bd config get/set') -# These are stored in the database, not in this file: -# - jira.url -# - jira.project -# - linear.url -# - linear.api-key -# - github.org -# - github.repo diff --git a/.beads/interactions.jsonl b/.beads/interactions.jsonl deleted file mode 100644 index e69de29bb..000000000 diff --git a/.beads/issues.jsonl b/.beads/issues.jsonl deleted file mode 100644 index 2c064f80f..000000000 --- a/.beads/issues.jsonl +++ /dev/null @@ -1,21 +0,0 @@ -{"id":"hack-cli-0j0","title":"Codex skills for hack CLI","description":"Define and ship a hack CLI skill (Codex) with install path + setup command, based on OpenAI skills guidelines.","status":"closed","priority":2,"issue_type":"epic","created_at":"2026-01-02T12:18:30.84287-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:58.370794-05:00","closed_at":"2026-01-02T12:40:58.370794-05:00","close_reason":"Codex skill packaged and setup command added."} -{"id":"hack-cli-0j0.1","title":"Define hack skill scope + triggers","description":"Summarize how hack CLI skill should trigger (local dev + logs + run + branches) and what to include vs references.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T12:18:36.776647-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:46.329635-05:00","closed_at":"2026-01-02T12:40:46.329635-05:00","close_reason":"Defined skill scope in SKILL.md description.","dependencies":[{"issue_id":"hack-cli-0j0.1","depends_on_id":"hack-cli-0j0","type":"parent-child","created_at":"2026-01-02T12:18:36.779406-05:00","created_by":"hack"}]} -{"id":"hack-cli-0j0.2","title":"Create hack CLI skill package","description":"Add .codex/skills/hack-cli/SKILL.md (and references/) with CLI-first guidance; keep it small and focused.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T12:18:39.93898-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:50.491709-05:00","closed_at":"2026-01-02T12:40:50.491709-05:00","close_reason":"Added .codex/skills/hack-cli/SKILL.md and install template.","dependencies":[{"issue_id":"hack-cli-0j0.2","depends_on_id":"hack-cli-0j0","type":"parent-child","created_at":"2026-01-02T12:18:39.942226-05:00","created_by":"hack"}]} -{"id":"hack-cli-0j0.3","title":"Add hack setup codex (install/check/remove)","description":"Install the hack skill into .codex/skills (repo-local default), with optional user scope; support check/remove.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T12:18:43.24699-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:54.450502-05:00","closed_at":"2026-01-02T12:40:54.450502-05:00","close_reason":"hack setup codex installs local or global skill.","dependencies":[{"issue_id":"hack-cli-0j0.3","depends_on_id":"hack-cli-0j0","type":"parent-child","created_at":"2026-01-02T12:18:43.250238-05:00","created_by":"hack"}]} -{"id":"hack-cli-1q7","title":"Auto-start global infra on login","description":"Investigate launchctl/brew services approach to run hack global up on boot and document setup.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T13:23:55.97883-05:00","created_by":"hack","updated_at":"2026-01-02T13:23:55.97883-05:00"} -{"id":"hack-cli-3r3","title":"Add agent init client launcher","description":"Allow hack agent init to open Cursor deeplinks or invoke Codex/Claude CLI directly.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T13:58:10.435511-05:00","created_by":"hack","updated_at":"2026-01-02T13:58:22.143327-05:00","closed_at":"2026-01-02T13:58:22.143327-05:00","close_reason":"Closed"} -{"id":"hack-cli-5j1","title":"Add agent init patterns guidance","description":"Provide a patterns/examples prompt agents can call for dependency/ops setup guidance during init.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T14:05:14.320195-05:00","created_by":"hack","updated_at":"2026-01-02T14:07:29.412021-05:00","closed_at":"2026-01-02T14:07:29.412021-05:00","close_reason":"Closed"} -{"id":"hack-cli-6jo","title":"Validate OpenTUI packaging for hack-cli","description":"Verify OpenTUI works with bun build --compile and release packaging. Check Zig requirement, bundle size impact, and runtime behavior on macOS/Linux.","acceptance_criteria":"Document compile/build steps and any blockers; confirm whether OpenTUI can ship in release artifacts.","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T22:28:01.007668-05:00","created_by":"hack","updated_at":"2026-01-02T22:28:01.007668-05:00","labels":["opentui","packaging","tui"]} -{"id":"hack-cli-8e6","title":"Agent setup overhaul (CLI-first, MCP optional)","description":"Introduce hack setup command family with local-default installs; move MCP to opt-in for no-shell clients; align docs + templates.","status":"closed","priority":1,"issue_type":"epic","created_at":"2026-01-02T12:17:48.853089-05:00","created_by":"hack","updated_at":"2026-01-02T12:41:02.998435-05:00","closed_at":"2026-01-02T12:41:02.998435-05:00","close_reason":"CLI-first setup implemented with setup command, docs, and tests."} -{"id":"hack-cli-8e6.1","title":"Add hack setup command + wiring","description":"Define hack setup \u003ccursor|claude|codex|agents|mcp\u003e with local-default installs, plus --check/--remove flags and help text.","notes":"Starting setup command skeleton + wiring.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T12:17:58.33201-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:22.822472-05:00","closed_at":"2026-01-02T12:40:22.822472-05:00","close_reason":"Added hack setup command + wiring.","dependencies":[{"issue_id":"hack-cli-8e6.1","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:17:58.335299-05:00","created_by":"hack"}]} -{"id":"hack-cli-8e6.2","title":"Cursor integration (.cursor/rules/hack.mdc)","description":"Add Cursor rules template with BEGIN/END markers; support install/check/remove; default local, optional global (~/.cursor/rules).","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T12:18:02.028541-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:26.376067-05:00","closed_at":"2026-01-02T12:40:26.376067-05:00","close_reason":"Cursor rules template + install/check/remove implemented.","dependencies":[{"issue_id":"hack-cli-8e6.2","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:18:02.031454-05:00","created_by":"hack"}]} -{"id":"hack-cli-8e6.3","title":"Claude Code hooks + agent prime","description":"Implement hack setup claude to write hooks (SessionStart/PreCompact) that call hack agent prime; default project-local, optional global; support check/remove.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T12:18:05.510339-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:30.529649-05:00","closed_at":"2026-01-02T12:40:30.529649-05:00","close_reason":"Claude hooks + hack agent prime implemented.","dependencies":[{"issue_id":"hack-cli-8e6.3","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:18:05.514058-05:00","created_by":"hack"}]} -{"id":"hack-cli-8e6.4","title":"Refactor hack init + MCP flow to opt-in","description":"Remove default MCP/AGENTS/CLAUDE prompts from init; add setup prompt with local defaults; keep MCP install for no-shell clients only.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T12:18:19.489672-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:34.180217-05:00","closed_at":"2026-01-02T12:40:34.180217-05:00","close_reason":"Init now prompts for setup integrations; MCP/docs moved to opt-in.","dependencies":[{"issue_id":"hack-cli-8e6.4","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:18:19.492654-05:00","created_by":"hack"}]} -{"id":"hack-cli-8e6.5","title":"Docs + templates: CLI-first agent setup","description":"Update README/AGENTS/CLAUDE templates to emphasize hack setup + CLI-first usage; MCP optional for no-shell clients; document local/global scope.","status":"closed","priority":1,"issue_type":"task","created_at":"2026-01-02T12:18:22.890526-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:37.943896-05:00","closed_at":"2026-01-02T12:40:37.943896-05:00","close_reason":"README + agent doc templates updated for CLI-first setup.","dependencies":[{"issue_id":"hack-cli-8e6.5","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:18:22.893869-05:00","created_by":"hack"}]} -{"id":"hack-cli-8e6.6","title":"Tests for setup integrations","description":"Add unit tests for setup install/check/remove + agent prime output; include cursor rules/claude hooks/codex skill paths.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T12:18:26.828144-05:00","created_by":"hack","updated_at":"2026-01-02T12:40:42.30064-05:00","closed_at":"2026-01-02T12:40:42.30064-05:00","close_reason":"Added setup tests for cursor/claude/codex + agent primer.","dependencies":[{"issue_id":"hack-cli-8e6.6","depends_on_id":"hack-cli-8e6","type":"parent-child","created_at":"2026-01-02T12:18:26.830966-05:00","created_by":"hack"}]} -{"id":"hack-cli-cgk","title":"Run bun test for agent init changes","status":"open","priority":2,"issue_type":"task","created_at":"2026-01-02T13:03:29.233764-05:00","created_by":"hack","updated_at":"2026-01-02T13:03:29.233764-05:00"} -{"id":"hack-cli-cse","title":"Avoid static IP conflicts for global proxy","description":"Generate global proxy/CoreDNS configs without static IPs so project containers cannot conflict with reserved addresses.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T13:34:03.329016-05:00","created_by":"hack","updated_at":"2026-01-02T13:34:09.638251-05:00","closed_at":"2026-01-02T13:34:09.638251-05:00","close_reason":"Closed"} -{"id":"hack-cli-dg0","title":"Decide hack up default TUI behavior","description":"After the OpenTUI spike, decide whether hack up (no -d) should launch the TUI by default and define flags/env escape hatches.","acceptance_criteria":"Decision recorded in spec/docs with final default and fallback behavior.","status":"open","priority":3,"issue_type":"task","created_at":"2026-01-02T22:28:04.428365-05:00","created_by":"hack","updated_at":"2026-01-02T22:28:04.428365-05:00","labels":["tui","ux"]} -{"id":"hack-cli-i5a","title":"Prototype hack tui with OpenTUI","description":"Build a minimal hack tui command using OpenTUI: service list panel + aggregate log stream (NDJSON from hack logs --json --follow). Read-only, no actions. Target: run against current project and exit cleanly.","acceptance_criteria":"hack tui renders a basic two-panel UI; logs stream in aggregate; handles Ctrl+C cleanly; uses OpenTUI core only.","status":"in_progress","priority":2,"issue_type":"task","created_at":"2026-01-02T22:27:57.377673-05:00","created_by":"hack","updated_at":"2026-01-03T09:58:50.939328-05:00","labels":["opentui","spike","tui"]} -{"id":"hack-cli-s52","title":"Agent-assisted init prompt","description":"Add hack agent init command to print a repo-aware prompt/runbook (compose/Docker/DB/migration signals + suggested services) for agents to complete setup.","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T12:52:38.701287-05:00","created_by":"hack","updated_at":"2026-01-02T13:03:31.820664-05:00","closed_at":"2026-01-02T13:03:31.820664-05:00","close_reason":"Closed"} -{"id":"hack-cli-uja","title":"Prompt to start global infra and detect ingress IP conflicts","status":"closed","priority":2,"issue_type":"task","created_at":"2026-01-02T13:22:03.263889-05:00","created_by":"hack","updated_at":"2026-01-02T13:23:52.082141-05:00","closed_at":"2026-01-02T13:23:52.082141-05:00","close_reason":"Closed"} diff --git a/.beads/metadata.json b/.beads/metadata.json deleted file mode 100644 index c787975e1..000000000 --- a/.beads/metadata.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "database": "beads.db", - "jsonl_export": "issues.jsonl" -} \ No newline at end of file diff --git a/.codex/skills/hack-tickets/SKILL.md b/.codex/skills/hack-tickets/SKILL.md new file mode 100644 index 000000000..29076202b --- /dev/null +++ b/.codex/skills/hack-tickets/SKILL.md @@ -0,0 +1,62 @@ +--- +name: hack-tickets +description: > + Use the hack tickets extension (git-backed JSONL event log) to create/list/show/sync lightweight tickets. + Trigger when asked to track work items, decisions, or bugs inside a repo without external issue trackers. + Prefer `hack x tickets ...` commands; store lives on branch `hack/tickets` by default. +--- + +# hack tickets + +This repo uses the hack tickets extension (`dance.hack.tickets`). + +## Enable + +Enable globally: + +- `hack config set --global 'controlPlane.extensions["dance.hack.tickets"].enabled' true` + +Or per-project by adding `.hack/hack.config.json`: + +```json +{ + "controlPlane": { + "extensions": { + "dance.hack.tickets": { "enabled": true } + } + } +} +``` + +## Commands + +- Create: `hack x tickets create --title "..." [--body "..."] [--body-file ] [--body-stdin] [--depends-on "..."] [--blocks "..."] [--actor "..."] [--json]` +- List: `hack x tickets list [--json]` +- Tui: `hack x tickets tui` +- Show: `hack x tickets show [--json]` +- Update: `hack x tickets update [--title "..."] [--body "..."] [--depends-on "..."] [--blocks "..."] [--clear-depends-on] [--clear-blocks] [--json]` +- Status: `hack x tickets status [--json]` +- Sync: `hack x tickets sync [--json]` + +Recommended body template (Markdown): + +```md +## Context +## Goals +## Notes +## Links +``` + +Tip: use `--body-stdin` for multi-line markdown. + +## Data model + +- Tickets are derived from an append-only event log (JSONL). +- Local state lives in `.hack/tickets/` (gitignored on the main branch). +- Sync writes commits to a dedicated branch (`hack/tickets` by default) and pushes to your remote. + +## Tips + +- Keep ticket titles short; put detail in `--body`. +- Use `--json` for agent workflows and piping. +- Run `hack x tickets sync` before opening PRs if you want tickets to travel with the repo. diff --git a/.github/workflows/release-macos-app.yml b/.github/workflows/release-macos-app.yml new file mode 100644 index 000000000..9e9eaf25a --- /dev/null +++ b/.github/workflows/release-macos-app.yml @@ -0,0 +1,213 @@ +name: Release macOS App + +on: + release: + types: [published] + workflow_call: + inputs: + tag: + description: 'Release tag to attach artifact to' + required: true + type: string + workflow_dispatch: + inputs: + tag: + description: 'Release tag to attach artifact to (e.g., v0.1.0)' + required: false + +env: + APP_NAME: "Hack Desktop" + SCHEME: HackDesktop + PROJECT_PATH: apps/macos + +jobs: + build-and-notarize: + runs-on: macos-14 + permissions: + contents: write + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Xcode + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '15.4' + + - name: Install xcodegen + run: brew install xcodegen + + - name: Generate Xcode project + working-directory: ${{ env.PROJECT_PATH }} + run: xcodegen generate + + - name: Import certificate + env: + CERTIFICATE_P12: ${{ secrets.APPLE_CERTIFICATE_P12 }} + CERTIFICATE_PASSWORD: ${{ secrets.APPLE_CERTIFICATE_PASSWORD }} + run: | + # Create temporary keychain + KEYCHAIN_PATH=$RUNNER_TEMP/app-signing.keychain-db + KEYCHAIN_PASSWORD=$(openssl rand -base64 32) + + security create-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + security set-keychain-settings -lut 21600 "$KEYCHAIN_PATH" + security unlock-keychain -p "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Import certificate + echo "$CERTIFICATE_P12" | base64 --decode > $RUNNER_TEMP/certificate.p12 + security import $RUNNER_TEMP/certificate.p12 \ + -P "$CERTIFICATE_PASSWORD" \ + -A \ + -t cert \ + -f pkcs12 \ + -k "$KEYCHAIN_PATH" + + # Allow codesign to access keychain + security set-key-partition-list -S apple-tool:,apple:,codesign: -s -k "$KEYCHAIN_PASSWORD" "$KEYCHAIN_PATH" + + # Add to search list + security list-keychain -d user -s "$KEYCHAIN_PATH" + + echo "KEYCHAIN_PATH=$KEYCHAIN_PATH" >> $GITHUB_ENV + + - name: Build and archive + working-directory: ${{ env.PROJECT_PATH }} + env: + KEYCHAIN: ${{ env.KEYCHAIN_PATH }} + run: | + xcodebuild archive \ + -scheme "$SCHEME" \ + -configuration Release \ + -archivePath "$RUNNER_TEMP/HackDesktop.xcarchive" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + OTHER_CODE_SIGN_FLAGS="--keychain $KEYCHAIN_PATH" + + - name: Export app + env: + TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + cat > $RUNNER_TEMP/ExportOptions.plist << EOF + + + + + method + developer-id + teamID + $TEAM_ID + signingStyle + manual + signingCertificate + Developer ID Application + + + EOF + + xcodebuild -exportArchive \ + -archivePath "$RUNNER_TEMP/HackDesktop.xcarchive" \ + -exportPath "$RUNNER_TEMP/export" \ + -exportOptionsPlist "$RUNNER_TEMP/ExportOptions.plist" + + - name: Notarize app + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + # Create ZIP for notarization + ditto -c -k --keepParent "$RUNNER_TEMP/export/$APP_NAME.app" "$RUNNER_TEMP/app.zip" + + # Submit for notarization + xcrun notarytool submit "$RUNNER_TEMP/app.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + # Staple the notarization ticket + xcrun stapler staple "$RUNNER_TEMP/export/$APP_NAME.app" + + - name: Create DMG + run: | + # Install create-dmg + brew install create-dmg + + # Create DMG + create-dmg \ + --volname "$APP_NAME" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "$APP_NAME.app" 150 190 \ + --app-drop-link 450 190 \ + --hide-extension "$APP_NAME.app" \ + "$RUNNER_TEMP/HackDesktop.dmg" \ + "$RUNNER_TEMP/export/$APP_NAME.app" || true + + # Fallback if create-dmg fails (it returns non-zero even on success sometimes) + if [ ! -f "$RUNNER_TEMP/HackDesktop.dmg" ]; then + hdiutil create -volname "$APP_NAME" \ + -srcfolder "$RUNNER_TEMP/export/$APP_NAME.app" \ + -ov -format UDZO \ + "$RUNNER_TEMP/HackDesktop.dmg" + fi + + - name: Notarize DMG + env: + APPLE_ID: ${{ secrets.APPLE_ID }} + APPLE_ID_PASSWORD: ${{ secrets.APPLE_ID_PASSWORD }} + APPLE_TEAM_ID: ${{ secrets.APPLE_TEAM_ID }} + run: | + xcrun notarytool submit "$RUNNER_TEMP/HackDesktop.dmg" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + + xcrun stapler staple "$RUNNER_TEMP/HackDesktop.dmg" + + - name: Get version + id: version + run: | + VERSION=$(defaults read "$RUNNER_TEMP/export/$APP_NAME.app/Contents/Info.plist" CFBundleShortVersionString) + echo "version=$VERSION" >> $GITHUB_OUTPUT + + - name: Rename artifacts + env: + VERSION: ${{ steps.version.outputs.version }} + run: | + mv "$RUNNER_TEMP/HackDesktop.dmg" "$RUNNER_TEMP/HackDesktop-$VERSION-macOS.dmg" + + # Also create a ZIP of the app + ditto -c -k --keepParent \ + "$RUNNER_TEMP/export/$APP_NAME.app" \ + "$RUNNER_TEMP/HackDesktop-$VERSION-macOS.zip" + + - name: Upload artifacts + uses: actions/upload-artifact@v4 + with: + name: macos-app + path: | + ${{ runner.temp }}/HackDesktop-*.dmg + ${{ runner.temp }}/HackDesktop-*.zip + + - name: Upload to release + if: github.event_name == 'release' || github.event.inputs.tag != '' || inputs.tag != '' + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} + RELEASE_TAG: ${{ github.event.release.tag_name || github.event.inputs.tag || inputs.tag }} + VERSION: ${{ steps.version.outputs.version }} + run: | + gh release upload "$RELEASE_TAG" \ + "$RUNNER_TEMP/HackDesktop-$VERSION-macOS.dmg" \ + "$RUNNER_TEMP/HackDesktop-$VERSION-macOS.zip" \ + --clobber + + - name: Cleanup keychain + if: always() + run: | + if [ -n "$KEYCHAIN_PATH" ] && [ -f "$KEYCHAIN_PATH" ]; then + security delete-keychain "$KEYCHAIN_PATH" || true + fi diff --git a/.gitignore b/.gitignore index c008e38aa..dcc9ebf03 100644 --- a/.gitignore +++ b/.gitignore @@ -33,10 +33,32 @@ report.[0-9]_.[0-9]_.[0-9]_.[0-9]_.json # Finder (MacOS) folder config .DS_Store +# Xcode +*.xcodeproj +*.xcworkspace +DerivedData +.build +.swiftpm +xcuserdata +xcshareddata + +# macOS app +apps/macos/build/ +apps/macos/vendor/ghostty/ +apps/macos/.ghostty/ + +# Zig build artifacts +.zig-cache/ +zig-cache/ +zig-out/ + +# hack tickets +.hack/tickets/ + SPECS/ _DOCS TODO.md REVIEW.md -dist \ No newline at end of file +dist diff --git a/.hack/README.md b/.hack/README.md new file mode 100644 index 000000000..0d930ede5 --- /dev/null +++ b/.hack/README.md @@ -0,0 +1,14 @@ +# hack-cli + +This repo is dogfooding the tickets extension. + +- Enablement: `.hack/hack.config.json` +- Usage: + - `hack x tickets setup` + - `hack x tickets create --title "..." --body-stdin` + - `hack x tickets list` + - `hack x tickets show T-00001` + - `hack x tickets status T-00001 in_progress` + - `hack x tickets sync` + +No services are required for this repo; `docker-compose.yml` is intentionally empty. diff --git a/.hack/docker-compose.yml b/.hack/docker-compose.yml new file mode 100644 index 000000000..ad189ddbe --- /dev/null +++ b/.hack/docker-compose.yml @@ -0,0 +1 @@ +services: {} diff --git a/.hack/hack.config.json b/.hack/hack.config.json new file mode 100644 index 000000000..f08517b4f --- /dev/null +++ b/.hack/hack.config.json @@ -0,0 +1,10 @@ +{ + "$schema": "https://schemas.hack/hack.config.schema.json", + "name": "hack-cli", + "dev_host": "hack-cli.hack", + "controlPlane": { + "extensions": { + "dance.hack.tickets": { "enabled": true } + } + } +} diff --git a/.releaserc.json b/.releaserc.json index ef3f2e126..907f07f86 100644 --- a/.releaserc.json +++ b/.releaserc.json @@ -12,7 +12,7 @@ [ "@semantic-release/git", { - "assets": ["CHANGELOG.md", "package.json", "bun.lock"], + "assets": ["CHANGELOG.md", "package.json", "bun.lock", "apps/macos/Config/Base.xcconfig"], "message": "chore(release): ${nextRelease.version}\n\n${nextRelease.notes}" } ] diff --git a/.tool-versions b/.tool-versions index 379b77b8b..b18a4ca85 100644 --- a/.tool-versions +++ b/.tool-versions @@ -1,2 +1,3 @@ bun 1.3.5 nodejs 24.10.0 +zig 0.15.2 diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 000000000..4b613881d --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,3 @@ +{ + "sweetpad.build.xcodeWorkspacePath": "apps/macos/HackDesktop.xcodeproj/project.xcworkspace" +} \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 8fd43f834..e0d35e922 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,8 +1,3 @@ ---- -description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" -alwaysApply: false ---- Default to using Bun instead of Node.js. @@ -43,12 +38,38 @@ TypeScript (strict). Runtimes: Bun 1.3+, Node 23. Prettier: 2 spaces, no semicol Always default to useing named paramaters in functions eg myFunction({ ctx, other }) vs myFunction(ctx, other) Never use any types and always default to leveraging generics and smart types to sensure the best possible tpye inference across the project. + +## Tickets (git-backed) + +This project uses `hack` tickets (extension: `dance.hack.tickets`). + +Common commands: +- Create: `hack x tickets create --title "..." --body-stdin [--depends-on "T-00001"] [--blocks "T-00002"]` +- List: `hack x tickets list` +- Tui: `hack x tickets tui` +- Show: `hack x tickets show T-00001` +- Update: `hack x tickets update T-00001 [--title "..."] [--body "..."] [--depends-on "..."] [--blocks "..."]` +- Status: `hack x tickets status T-00001 in_progress` +- Sync: `hack x tickets sync` + +Recommended body template (Markdown): +```md +## Context +## Goals +## Notes +## Links +``` + +Tip: use `--body-stdin` for multi-line markdown. + +Data lives in `.hack/tickets/` (gitignored on the main branch) and syncs to hidden ref `refs/hack/tickets` by default. + ## Landing the Plane (Session Completion) **When ending a work session**, you MUST complete ALL steps below. **MANDATORY WORKFLOW:** -1. **File issues for remaining work** - Create issues for anything that needs follow-up +1. **File tickets for remaining work** - Create tickets for anything that needs follow-up 2. **Run quality gates** (if code changed) - Tests, linters, builds 3. **Update issue status** - Close finished work, update in-progress items -4. **Hand off** - Provide context for next session \ No newline at end of file +4. **Hand off** - Provide context for next session diff --git a/CLAUDE.md b/CLAUDE.md index c7ae75d97..0aa95dd69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,8 +1,3 @@ ---- -description: Use Bun instead of Node.js, npm, pnpm, or vite. -globs: "*.ts, *.tsx, *.html, *.css, *.js, *.jsx, package.json" -alwaysApply: false ---- Default to using Bun instead of Node.js. @@ -41,3 +36,60 @@ test("hello world", () => { TypeScript (strict). Runtimes: Bun 1.3+, Node 23. Prettier: 2 spaces, no semicolons, double quotes, width 100. Import order: @ianvs/prettier-plugin-sort-imports + Tailwind plugin. ESLint (flat): any disallowed; unused vars warned (prefix \_ to ignore). Naming: React components PascalCase; files kebab-case (e.g., user-profile.ts); packages @repo/. Comments: avoid writing inline comments everywhere, unless absolutely necessary for a todo or an important thing to take note of. Instead write tsdoc style block level comments at the method/class/function/route level. Focus on comments that provide value in regards to better type inference and clarity of usage. Using things like params/returns/etc.. Always default to useing named paramaters in functions eg myFunction({ ctx, other }) vs myFunction(ctx, other) +Never use any types and always default to leveraging generics and smart types to sensure the best possible tpye inference across the project. + + +## Tickets (git-backed) + +This project uses `hack` tickets (extension: `dance.hack.tickets`). + +Common commands: +- Create: `hack x tickets create --title "..." --body-stdin [--depends-on "T-00001"] [--blocks "T-00002"]` +- List: `hack x tickets list` +- Tui: `hack x tickets tui` +- Show: `hack x tickets show T-00001` +- Update: `hack x tickets update T-00001 [--title "..."] [--body "..."] [--depends-on "..."] [--blocks "..."]` +- Status: `hack x tickets status T-00001 in_progress` +- Sync: `hack x tickets sync` + +Recommended body template (Markdown): +```md +## Context +## Goals +## Notes +## Links +``` + +Tip: use `--body-stdin` for multi-line markdown. + +Data lives in `.hack/tickets/` (gitignored on the main branch) and syncs to hidden ref `refs/hack/tickets` by default. + + +## Project Notes (Obsidian) + +This project uses Obsidian for project context, specs, research, and progress tracking. + +**Vault**: `~/.vaults/core` +**Project folder**: `30 Projects/Hack CLI/Notes/` + +**When to write notes**: +- Project analysis and architecture reviews +- Research findings (e.g., Ghostty VT, libraries, APIs) +- Specs and design decisions +- Session summaries with progress updates +- Any context that should persist across sessions + +**How to write notes**: +- Use the `obsidian` skill to interact with the vault +- Name notes with date prefix: `YYYY-MM-DD — Title.md` +- Include tags: `#hack-cli #substrate` + +## Landing the Plane (Session Completion) +**When ending a work session**, you MUST complete ALL steps below. + +**MANDATORY WORKFLOW:** +1. **File tickets for remaining work** - Create tickets for anything that needs follow-up +2. **Run quality gates** (if code changed) - Tests, linters, builds +3. **Update issue status** - Close finished work, update in-progress items +4. **Write session summary to Obsidian** - Key decisions, progress, blockers +5. **Hand off** - Provide context for next session diff --git a/LICENSE b/LICENSE new file mode 100644 index 000000000..ad030bcfe --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 HACK DANCE LLC + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md index 79f8b51ab..d5da165ff 100644 --- a/README.md +++ b/README.md @@ -3,7 +3,7 @@
-

+

  █████   █████   █████████     █████████  █████   ████
 ░░███   ░░███   ███░░░░░███   ███░░░░░███░░███   ███░ 
  ░███    ░███  ░███    ░███  ███     ░░░  ░███  ███   
@@ -53,7 +53,7 @@ Neither scales. Both slow you down in dumb, repeatable ways.
 ## Installation
 
 ### Prerequisites
-- **macOS**: more support planned.
+- **macOS**: supported.
 - **Docker + Compose**: [Orbstack](https://docs.orbstack.dev/quick-start) | [Docker Desktop](https://www.docker.com/get-started/) 
 
 ### Install
@@ -104,12 +104,16 @@ If anything fails, use `hack logs --pretty` and summarize next steps.
 
 If the agent cannot run shell commands, use MCP instead: `hack setup mcp` and `hack mcp serve`.
 
-### Initialize a repo (manual)
+### macOS app (experimental)
+Source lives in `apps/macos`. See `apps/macos/README.md` for XcodeGen and build/run steps.
+Quick commands (repo root):
+
 ```bash
-cd /path/to/your-repo
-hack init
-hack up --detach
-hack open
+bun run macos:project-gen
+bun run macos:open
+bun run macos:dev
+bun run macos:build
+bun run macos:test
 ```
 
 ### Configuration (.hack/hack.config.json)
@@ -125,6 +129,17 @@ hack open
 *Optional `[internal]` settings (container DNS/TLS)*
 - `dns`: use CoreDNS to resolve `*.hack` inside containers (default: `true`)
 - `tls`: mount Caddy Local CA + set common SSL env vars (default: `true`)
+- `extra_hosts`: static Compose `extra_hosts` entries (hostname → IP/target), merged into the internal override
+
+If you need **dynamic** `extra_hosts` (e.g. Pulumi outputs / local tunnels that change), use:
+
+```bash
+hack internal extra-hosts set  
+hack internal extra-hosts unset 
+hack internal extra-hosts list
+```
+
+This writes `.hack/.internal/extra-hosts.json` and is merged into `extra_hosts` when you run `hack up` / `hack restart`.
 
 *Optional `[oauth]` settings (OAuth-safe alias host)*
 - `enabled`: when true, `hack init` generates Caddy labels so routed services answer on both:
@@ -152,16 +167,16 @@ hack config set logs.snapshot_backend "compose"
 
 ## Commands (high level)
 
-- **Global**: `hack global install|up|down|status|logs|logs-reset|ca|cert|trust`
-- **Project**: `hack init|up|down|restart|ps|run|logs|open|tui`
-- **Config**: `hack config get|set`
-- **Projects**: `hack projects|prune`
-- **Status**: `hack status` (shortcut for `hack projects --details`)
-- **Branch**: `hack branch add|list|remove|open`
-- **Agents**: `hack setup cursor|claude|codex|agents|mcp`, `hack agent prime|init|patterns`, `hack mcp serve|install|print`
-- **Diagnostics**: `hack doctor|daemon|log-pipe`
-- **Secrets**: `hack secrets get|set|delete`
-- **Crash override**: `hack the planet`
+Run `hack help` (or `hack help `) for full usage.
+
+Common:
+- `hack global install|up|down`
+- `hack init|up|down|logs|open|tui`
+- `hack status`
+- `hack remote setup`
+- `hack gateway enable`
+
+Full command table + flags: `docs/cli.md`.
 
 Run `hack help ` for detailed help.
 
@@ -192,6 +207,27 @@ hack daemon stop
 hack daemon logs
 ```
 
+### launchd integration (macOS)
+
+On macOS, you can install `hackd` as a launchd service for automatic management:
+
+```bash
+# Install with auto-start on login
+hack daemon install --run-at-load
+
+# Install without auto-start (manual start/stop via launchd)
+hack daemon install
+
+# Uninstall the launchd service
+hack daemon uninstall
+```
+
+Options:
+- `--run-at-load` / `--no-run-at-load`: Start automatically on login
+- `--gui-only` / `--no-gui-only`: Only run in GUI sessions (default: GUI only)
+
+The service uses label `dance.hack.hackd` and writes to `~/Library/LaunchAgents/`.
+
 Use it when:
 - You run `hack projects --json` / `hack ps --json` frequently (scripts, agent workflows, TUI).
 - You want faster status snapshots without shelling out to Docker each time.
@@ -202,6 +238,107 @@ Skip it when:
 
 If it is not running (or version-mismatched), the CLI falls back to direct Docker calls.
 
+## Control plane + extensions (optional)
+
+`hack` includes a small control-plane kernel so features like jobs, tickets, and remote access can
+ship as extensions without bloating the core CLI.
+
+- Extension commands run via `hack x  `.
+- Global control-plane config lives at `~/.hack/hack.config.json` (`hack config set --global ...`).
+- Per-project overrides live in `.hack/hack.config.json` and win over global values.
+- `controlPlane.gateway.enabled` is project-scoped and implicitly enables the gateway extension.
+
+See `SPECS/control-plane/consolidated.md` and `docs/extensions.md` for the extension SDK surface.
+
+### Gateway (remote access)
+
+The gateway exposes `hackd` over HTTP/WS with token auth. It binds to `127.0.0.1` by default and
+should be exposed via a Zero Trust/VPN or SSH tunnel when needed. `hack remote setup` is the
+one-command flow that enables the gateway, creates a token, and can configure + start exposure
+(Cloudflare/Tailscale/SSH) via prompts. It prints a QR by default (use `--no-qr` to skip).
+
+```bash
+hack remote setup
+# or:
+hack gateway setup
+# or manually:
+hack gateway enable
+hack daemon stop && hack daemon start
+hack x gateway token-create
+```
+
+Gateway tokens default to read-only. For non-GET requests, set
+`controlPlane.gateway.allowWrites = true` globally and create a write-scoped token:
+
+```bash
+hack config set --global 'controlPlane.gateway.allowWrites' true
+```
+
+```bash
+hack x gateway token-create --scope write
+```
+
+Current gateway API (HTTP/WS):
+- status/metrics/projects/ps (`/v1/*`)
+- supervisor jobs: list/create/show/cancel + log/event stream (`/control-plane/*`)
+- supervisor shells: create/show + PTY stream (write token + allowWrites)
+- CLI shell client: `hack x supervisor shell` (gateway + write token required)
+
+Interactive shells are available over the gateway WebSocket; the CLI can attach with
+`hack x supervisor shell` (write token + allowWrites required). Use SSH/Zero Trust for a full
+terminal UI, or run commands via supervisor jobs.
+
+See `docs/gateway-api.md` for full API usage, structured workflow patterns, and a runnable demo.
+
+Remote access options (recommended order):
+1) SSH tunnel to the gateway port for quick, ad-hoc access.
+2) Zero Trust/VPN (Tailscale, Cloudflare, etc.) for persistent access.
+3) Optional Caddy route (`https://gateway.hack`) for local convenience.
+
+Note: Cloudflare Tunnel is ideal for the gateway HTTP/WS surface. It is not a direct SSH
+replacement on iOS; use Tailscale/VPN for SSH access from mobile clients.
+If you already use Cloudflare WARP, configure a private network route to your laptop and
+use that IP/hostname for SSH in your mobile client.
+
+Remote helper:
+- `hack remote` shows status and offers to run setup when needed.
+- `hack remote status` prints gateway + exposure status.
+- `hack remote qr` prints a QR payload for SSH or gateway usage (confirm before sharing).
+- `hack remote monitor` opens a mini TUI (status + gateway audit log tail).
+
+Cloudflare tunnel helper:
+
+```bash
+hack x cloudflare tunnel-setup --hostname gateway.example.com
+hack x cloudflare tunnel-start
+```
+
+Tailscale helper:
+
+```bash
+hack x tailscale setup
+hack x tailscale status
+```
+
+DNS note: `cloudflared tunnel route dns  ` creates the required CNAME to
+`.cfargotunnel.com` in your Cloudflare zone (proxied).
+
+### Supervisor (jobs + shells)
+
+The supervisor is the job/shell runner that powers agent workflows and remote execution. It can run
+commands, stream logs, and host PTY-backed shells. Use it locally with `hack x supervisor` or
+remotely over the gateway.
+
+Docs: `docs/supervisor.md`.
+
+## Docs
+
+Start here:
+- `docs/README.md` (index)
+- `docs/architecture.md`
+- `docs/gateway.md`
+- `docs/extensions.md`
+
 ## Agent setup (CLI-first)
 
 Use `hack setup` to install local integrations. Default is project scope; add `--global` for user scope.
@@ -290,13 +427,22 @@ hack branch open feature-x
 ```
 
 
-## Service-to-service connections (DB/Redis)
+## Service-to-service connections (HTTP vs DB/Redis)
 
 If your app runs in Docker (the default in `hack`), don’t connect to `127.0.0.1` / `localhost` for Postgres/Redis.
 Inside a container, `localhost` is that container, not the other compose services.
 
-With CoreDNS enabled (`internal.dns: true`), containers can use the same `https://*.hack` URLs as your host.
-If CoreDNS isn’t running (or you disable it), use the Compose service hostname on the default network:
+If you previously ran everything on your host and used `localhost:PORT`, update those references when you
+move into containers:
+
+- **HTTP services**: use the same `https://*.hack` hostname you open from the host (whatever you configured in Caddy labels, e.g. `https://api.myapp.hack`).
+- **Non-HTTP services** (DB/Redis/etc.): use the Compose service hostname (e.g. `db`, `redis`).
+
+For HTTP services, use the same `https://*.hack` URLs you use on the host. `hack up` injects internal DNS,
+TLS trust, and `extra_hosts` mappings so `*.hack` resolves reliably inside containers. If you see `ENOTFOUND`
+inside containers, run `hack restart` to refresh the host mappings.
+
+For non-HTTP services, use the Compose service hostname on the default network:
 
 - `Postgres: db:5432`
 - `Redis: redis:6379`
@@ -388,7 +534,8 @@ hack up --project my-project
 OAuth providers (notably Google) require `localhost` or a host that ends with a real public suffix.
 
 We keep `.hack` as the primary local dev domain, and optionally expose an alias domain for OAuth flows.
-Default: `*.hack.gy` → `127.0.0.1` (via dnsmasq + OS resolver).
+If the OAuth alias is enabled, `hack global install` configures `*.hack.gy` to resolve to `127.0.0.1`
+via dnsmasq + the OS resolver.
 
 If you use Next.js (or another dev server that cares about dev origins), configure its dev allowlist to include the proxy domains.
 Next.js supports `allowedDevOrigins` (wildcards supported) in `next.config.js`:
@@ -431,20 +578,19 @@ Use `--out ` if you want certs written somewhere else.
 
 ## Internal DNS (containers)
 
-`hack global install` runs CoreDNS on the `hack-dev` network and pins Caddy + CoreDNS to stable IPs.
-CoreDNS answers `*.hack` and `*.hack.*` with Caddy’s IP so containers can use the same `https://*.hack`
-URLs as the host. All other DNS is forwarded to Docker’s resolver.
+`hack global install` runs CoreDNS on the `hack-dev` network. CoreDNS answers `*.hack` and `*.hack.*` with
+Caddy’s current IP so containers can use the same `https://*.hack` URLs as the host.
 
-When `internal.tls` is enabled, `hack up` mounts the Caddy Local CA into each container and sets common
-SSL env vars so HTTPS to `*.hack` is trusted inside containers.
+Some runtimes don’t honor custom DNS for `*.hack` reliably, so `hack up` also injects `extra_hosts` mappings
+to the Caddy IP. If the Caddy IP changes, `hack status`, `hack doctor`, and the TUI show a warning; fix it
+with `hack restart` to refresh the mapping.
 
-If you created the `hack-dev` network before this feature, recreate it once so the static IPs can be
-assigned (better runtime compatibility):
+If you also need `extra_hosts` for non-`*.hack` hostnames (common when you run host-local tunnels/proxies
+and want containers to reach them by their real domain), use `hack internal extra-hosts set` to write a
+repo-local `.hack/.internal/extra-hosts.json` that gets merged into the generated Compose override.
 
-```bash
-docker network rm hack-dev
-hack global install
-```
+When `internal.tls` is enabled, `hack up` mounts the Caddy Local CA into each container and sets common
+SSL env vars so HTTPS to `*.hack` is trusted inside containers.
 
 If you update `hack`, rerun `hack global install` once to refresh the CoreDNS config.
 
@@ -604,11 +750,12 @@ See examples:
 
 - Logs missing in Grafana: ensure Alloy is running (`hack global status`) and try `{app="docker"}` in Explore.
 
-- `ENOTFOUND` for `*.hack`/`*.hack.gy` inside containers: refresh CoreDNS config with `hack global install`,
-  then restart CoreDNS: `docker compose -f ~/.hack/caddy/docker-compose.yml restart coredns`.
+- `ENOTFOUND` for `*.hack` inside containers: run `hack restart` to refresh `extra_hosts` mappings (check
+  `hack status` or the TUI for Caddy IP mismatch warnings).
 
 - `EAI_AGAIN` for external domains inside containers (e.g. `api.clerk.com`): CoreDNS isn’t forwarding.
-  Run `hack global install` and restart CoreDNS as above.
+  Run `hack global install` and restart CoreDNS:
+  `docker compose -f ~/.hack/caddy/docker-compose.yml restart coredns`.
 
 - `hack global up` warns about `hack-dev` network labels or missing subnet: remove the network and reinstall:
   `docker network rm hack-dev` then `hack global install`.
@@ -701,7 +848,8 @@ See `PACKAGING.md` for details.
 
 See also:
 - [Examples](examples/next-app/README.md)
-- [Architecture](ARCHITECTURE.md)
+- [Docs](docs/README.md)
+- [Architecture](docs/architecture.md)
 
 
 

(back to top)

diff --git a/apps/macos/512@2x.png b/apps/macos/512@2x.png new file mode 100644 index 000000000..18e4c953a Binary files /dev/null and b/apps/macos/512@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/Contents.json b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 000000000..64dc11ee7 --- /dev/null +++ b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,68 @@ +{ + "images" : [ + { + "filename" : "icon_16x16.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "16x16" + }, + { + "filename" : "icon_16x16@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "16x16" + }, + { + "filename" : "icon_32x32.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "32x32" + }, + { + "filename" : "icon_32x32@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "32x32" + }, + { + "filename" : "icon_128x128.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "128x128" + }, + { + "filename" : "icon_128x128@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "128x128" + }, + { + "filename" : "icon_256x256.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "256x256" + }, + { + "filename" : "icon_256x256@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "256x256" + }, + { + "filename" : "icon_512x512.png", + "idiom" : "mac", + "scale" : "1x", + "size" : "512x512" + }, + { + "filename" : "icon_512x512@2x.png", + "idiom" : "mac", + "scale" : "2x", + "size" : "512x512" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png new file mode 100644 index 000000000..bb31502e1 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png new file mode 100644 index 000000000..d3a7cf055 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_128x128@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png new file mode 100644 index 000000000..8a5abdc5a Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png new file mode 100644 index 000000000..306a677f5 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_16x16@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png new file mode 100644 index 000000000..d3a7cf055 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png new file mode 100644 index 000000000..9391944c9 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_256x256@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png new file mode 100644 index 000000000..306a677f5 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png new file mode 100644 index 000000000..3bf741970 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_32x32@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png new file mode 100644 index 000000000..9391944c9 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512.png differ diff --git a/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png new file mode 100644 index 000000000..1a2538432 Binary files /dev/null and b/apps/macos/App/Assets.xcassets/AppIcon.appiconset/icon_512x512@2x.png differ diff --git a/apps/macos/App/Assets.xcassets/Contents.json b/apps/macos/App/Assets.xcassets/Contents.json new file mode 100644 index 000000000..73c00596a --- /dev/null +++ b/apps/macos/App/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/apps/macos/App/HackDesktopApp.swift b/apps/macos/App/HackDesktopApp.swift new file mode 100644 index 000000000..0c96fc6cd --- /dev/null +++ b/apps/macos/App/HackDesktopApp.swift @@ -0,0 +1,23 @@ +import SwiftUI + +import DashboardFeature +import HackCLIService + +@main +struct HackDesktopApp: App { + @State private var model = DashboardModel(client: HackCLIClient()) + + var body: some Scene { + WindowGroup { + DashboardView() + .environment(model) + } + .defaultSize(width: 1100, height: 720) + + MenuBarExtra("Hack", systemImage: "square.stack.3d.up") { + MenuBarView() + .environment(model) + } + .menuBarExtraStyle(.menu) + } +} diff --git a/apps/macos/App/Info.plist b/apps/macos/App/Info.plist new file mode 100644 index 000000000..47ef031b0 --- /dev/null +++ b/apps/macos/App/Info.plist @@ -0,0 +1,22 @@ + + + + + CFBundleDevelopmentRegion + $(DEVELOPMENT_LANGUAGE) + CFBundleExecutable + $(EXECUTABLE_NAME) + CFBundleIdentifier + $(PRODUCT_BUNDLE_IDENTIFIER) + CFBundleInfoDictionaryVersion + 6.0 + CFBundleName + $(PRODUCT_NAME) + CFBundlePackageType + APPL + CFBundleShortVersionString + 1.0 + CFBundleVersion + 1 + + diff --git a/apps/macos/Config/Base.xcconfig b/apps/macos/Config/Base.xcconfig new file mode 100644 index 000000000..a161ff61c --- /dev/null +++ b/apps/macos/Config/Base.xcconfig @@ -0,0 +1,10 @@ +SWIFT_VERSION = 5.9 +SWIFT_STRICT_CONCURRENCY = complete +ENABLE_USER_SCRIPT_SANDBOXING = YES +DEAD_CODE_STRIPPING = YES + +PRODUCT_NAME = Hack Desktop +PRODUCT_BUNDLE_IDENTIFIER = dance.hack.desktop +MACOSX_DEPLOYMENT_TARGET = 14.0 +MARKETING_VERSION = 1.1.0 +CURRENT_PROJECT_VERSION = 1 diff --git a/apps/macos/Config/Debug.xcconfig b/apps/macos/Config/Debug.xcconfig new file mode 100644 index 000000000..f2f056858 --- /dev/null +++ b/apps/macos/Config/Debug.xcconfig @@ -0,0 +1,3 @@ +#include "Base.xcconfig" + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = DEBUG diff --git a/apps/macos/Config/Release.xcconfig b/apps/macos/Config/Release.xcconfig new file mode 100644 index 000000000..85eda09cf --- /dev/null +++ b/apps/macos/Config/Release.xcconfig @@ -0,0 +1,3 @@ +#include "Base.xcconfig" + +SWIFT_ACTIVE_COMPILATION_CONDITIONS = RELEASE diff --git a/apps/macos/Experiments/GhosttyVTBridge/build.zig b/apps/macos/Experiments/GhosttyVTBridge/build.zig new file mode 100644 index 000000000..c23d23164 --- /dev/null +++ b/apps/macos/Experiments/GhosttyVTBridge/build.zig @@ -0,0 +1,127 @@ +const std = @import("std"); + +const UnicodeTables = struct { + props_output: std.Build.LazyPath, + symbols_output: std.Build.LazyPath, + + pub fn init( + b: *std.Build, + ghostty_path: []const u8, + uucode_dep: *std.Build.Dependency, + ) !UnicodeTables { + const props_source = b.pathJoin(&.{ ghostty_path, "src", "unicode", "props_uucode.zig" }); + const symbols_source = b.pathJoin(&.{ ghostty_path, "src", "unicode", "symbols_uucode.zig" }); + + const props_exe = b.addExecutable(.{ + .name = "props-unigen", + .root_module = b.createModule(.{ + .root_source_file = .{ .cwd_relative = props_source }, + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), + .use_llvm = true, + }); + + const symbols_exe = b.addExecutable(.{ + .name = "symbols-unigen", + .root_module = b.createModule(.{ + .root_source_file = .{ .cwd_relative = symbols_source }, + .target = b.graph.host, + .strip = false, + .omit_frame_pointer = false, + .unwind_tables = .sync, + }), + .use_llvm = true, + }); + + const uucode_module = uucode_dep.module("uucode"); + props_exe.root_module.addImport("uucode", uucode_module); + symbols_exe.root_module.addImport("uucode", uucode_module); + + const props_run = b.addRunArtifact(props_exe); + const symbols_run = b.addRunArtifact(symbols_exe); + + const wf = b.addWriteFiles(); + const props_output = wf.addCopyFile(props_run.captureStdOut(), "props.zig"); + const symbols_output = wf.addCopyFile(symbols_run.captureStdOut(), "symbols.zig"); + + return .{ + .props_output = props_output, + .symbols_output = symbols_output, + }; + } + + pub fn addModuleImport(self: *const UnicodeTables, module: *std.Build.Module) void { + module.addAnonymousImport("unicode_tables", .{ + .root_source_file = self.props_output, + }); + module.addAnonymousImport("symbols_tables", .{ + .root_source_file = self.symbols_output, + }); + } +}; + +pub fn build(b: *std.Build) !void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const ghostty_path = b.option( + []const u8, + "ghostty", + "Path to the ghostty repo (expected to contain src/lib_vt.zig)", + ) orelse "../vendor/ghostty"; + + const ghostty_vt_source = b.pathJoin(&.{ ghostty_path, "src", "lib_vt.zig" }); + const ghostty_vt_impl = b.createModule(.{ + .root_source_file = .{ .cwd_relative = ghostty_vt_source }, + .target = target, + .optimize = optimize, + }); + + const uucode_config_path = b.pathJoin(&.{ ghostty_path, "src", "build", "uucode_config.zig" }); + const uucode = b.dependency("uucode", .{ + .build_config_path = std.Build.LazyPath{ .cwd_relative = uucode_config_path }, + }); + const unicode_tables = try UnicodeTables.init(b, ghostty_path, uucode); + unicode_tables.addModuleImport(ghostty_vt_impl); + + const TerminalArtifact = enum { + ghostty, + lib, + }; + + const opts = b.addOptions(); + opts.addOption(TerminalArtifact, "artifact", .lib); + opts.addOption(bool, "c_abi", false); + opts.addOption(bool, "oniguruma", false); + opts.addOption(bool, "simd", false); + opts.addOption(bool, "slow_runtime_safety", false); + opts.addOption(bool, "kitty_graphics", false); + opts.addOption(bool, "tmux_control_mode", false); + + ghostty_vt_impl.addOptions("terminal_options", opts); + + const ghostty_vt = b.createModule(.{ + .root_source_file = b.path("src/ghostty_vt_wrapper.zig"), + .imports = &.{.{ .name = "ghostty-vt-impl", .module = ghostty_vt_impl }}, + .target = target, + .optimize = optimize, + }); + + const bridge_module = b.createModule(.{ + .root_source_file = b.path("src/bridge.zig"), + .target = target, + .optimize = optimize, + }); + bridge_module.link_libc = true; + + const lib = b.addLibrary(.{ + .name = "hack_ghostty_vt", + .root_module = bridge_module, + .linkage = .dynamic, + }); + lib.root_module.addImport("ghostty-vt", ghostty_vt); + b.installArtifact(lib); +} diff --git a/apps/macos/Experiments/GhosttyVTBridge/build.zig.zon b/apps/macos/Experiments/GhosttyVTBridge/build.zig.zon new file mode 100644 index 000000000..8946e9911 --- /dev/null +++ b/apps/macos/Experiments/GhosttyVTBridge/build.zig.zon @@ -0,0 +1,13 @@ +.{ + .name = .ghostty_vt_bridge, + .version = "0.0.0", + .paths = .{""}, + .fingerprint = 0x6687d7ad5c774929, + .minimum_zig_version = "0.15.2", + .dependencies = .{ + .uucode = .{ + .url = "https://deps.files.ghostty.org/uucode-31655fba3c638229989cc524363ef5e3c7b580c1.tar.gz", + .hash = "uucode-0.1.0-ZZjBPicPTQDlG6OClzn2bPu7ICkkkyWrTB6aRsBr-A1E", + }, + }, +} diff --git a/apps/macos/Experiments/GhosttyVTBridge/src/bridge.zig b/apps/macos/Experiments/GhosttyVTBridge/src/bridge.zig new file mode 100644 index 000000000..53d48e2b0 --- /dev/null +++ b/apps/macos/Experiments/GhosttyVTBridge/src/bridge.zig @@ -0,0 +1,239 @@ +const std = @import("std"); +const ghostty_vt = @import("ghostty-vt"); + +const TerminalHandle = struct { + alloc: std.mem.Allocator, + terminal: ghostty_vt.Terminal, + stream: ghostty_vt.ReadonlyStream, + render_state: ghostty_vt.RenderState, +}; + +const RenderColor = extern struct { + r: u8, + g: u8, + b: u8, + a: u8, +}; + +const RenderCell = extern struct { + codepoint: u32, + fg: RenderColor, + bg: RenderColor, + wide: u8, + flags: u16, + _pad: u8 = 0, +}; + +const RenderSnapshot = extern struct { + rows: u16, + cols: u16, + cursor_x: u16, + cursor_y: u16, + cursor_visible: u8, + cursor_style: u8, + cursor_wide_tail: u8, + _pad0: u8 = 0, + default_fg: RenderColor, + default_bg: RenderColor, + cursor_color: RenderColor, + cell_count: usize, + cells: [*]RenderCell, +}; + +fn toRenderColor(rgb: ghostty_vt.color.RGB) RenderColor { + return .{ .r = rgb.r, .g = rgb.g, .b = rgb.b, .a = 255 }; +} + +fn toCellCount(value: u32) ?u16 { + if (value == 0) return null; + return std.math.cast(u16, value); +} + +export fn hack_ghostty_vt_create(cols: u32, rows: u32) ?*TerminalHandle { + const cols_u16 = toCellCount(cols) orelse return null; + const rows_u16 = toCellCount(rows) orelse return null; + const alloc = std.heap.c_allocator; + var handle = alloc.create(TerminalHandle) catch return null; + handle.alloc = alloc; + handle.terminal = ghostty_vt.Terminal.init(alloc, .{ .cols = cols_u16, .rows = rows_u16 }) catch { + alloc.destroy(handle); + return null; + }; + handle.stream = handle.terminal.vtStream(); + handle.render_state = .empty; + return handle; +} + +export fn hack_ghostty_vt_destroy(handle: ?*TerminalHandle) void { + if (handle == null) return; + handle.?.stream.deinit(); + handle.?.render_state.deinit(handle.?.alloc); + handle.?.terminal.deinit(handle.?.alloc); + handle.?.alloc.destroy(handle.?); +} + +export fn hack_ghostty_vt_resize(handle: ?*TerminalHandle, cols: u32, rows: u32) void { + if (handle == null) return; + const cols_u16 = toCellCount(cols) orelse return; + const rows_u16 = toCellCount(rows) orelse return; + handle.?.terminal.resize(handle.?.alloc, cols_u16, rows_u16) catch {}; +} + +export fn hack_ghostty_vt_feed(handle: ?*TerminalHandle, bytes: [*]const u8, len: usize) void { + if (handle == null) return; + if (len == 0) return; + _ = handle.?.stream.nextSlice(bytes[0..len]) catch {}; +} + +export fn hack_ghostty_vt_plain_string(handle: ?*TerminalHandle, out_len: ?*usize) ?[*]u8 { + if (handle == null) return null; + const str = handle.?.terminal.plainString(handle.?.alloc) catch return null; + if (out_len) |ptr| { + ptr.* = str.len; + } + return @constCast(str.ptr); +} + +export fn hack_ghostty_vt_html_string(handle: ?*TerminalHandle, out_len: ?*usize) ?[*]u8 { + if (handle == null) return null; + + var builder: std.Io.Writer.Allocating = .init(handle.?.alloc); + errdefer builder.deinit(); + + const opts = ghostty_vt.formatter.Options{ + .emit = .html, + .unwrap = false, + .trim = false, + .palette = &handle.?.terminal.colors.palette.current, + }; + + var formatter: ghostty_vt.formatter.TerminalFormatter = .init(&handle.?.terminal, opts); + formatter.extra = .none; + + formatter.format(&builder.writer) catch return null; + const slice = builder.toOwnedSlice() catch return null; + if (out_len) |ptr| { + ptr.* = slice.len; + } + return @constCast(slice.ptr); +} + +export fn hack_ghostty_vt_free_string(ptr: ?[*]u8, len: usize) void { + if (ptr == null or len == 0) return; + std.heap.c_allocator.free(ptr.?[0..len]); +} + +export fn hack_ghostty_vt_render_snapshot(handle: ?*TerminalHandle) ?*RenderSnapshot { + if (handle == null) return null; + const alloc = handle.?.alloc; + handle.?.render_state.update(alloc, &handle.?.terminal) catch return null; + const state = &handle.?.render_state; + const rows: usize = @intCast(state.rows); + const cols: usize = @intCast(state.cols); + if (rows == 0 or cols == 0) return null; + const cell_count = rows * cols; + + const cells = alloc.alloc(RenderCell, cell_count) catch return null; + errdefer alloc.free(cells); + const snapshot = alloc.create(RenderSnapshot) catch return null; + errdefer alloc.destroy(snapshot); + + const default_fg = toRenderColor(state.colors.foreground); + const default_bg = toRenderColor(state.colors.background); + const cursor_color = if (state.colors.cursor) |rgb| toRenderColor(rgb) else default_fg; + + snapshot.* = .{ + .rows = @intCast(state.rows), + .cols = @intCast(state.cols), + .cursor_x = 0, + .cursor_y = 0, + .cursor_visible = 0, + .cursor_style = @intFromEnum(state.cursor.visual_style), + .cursor_wide_tail = 0, + .default_fg = default_fg, + .default_bg = default_bg, + .cursor_color = cursor_color, + .cell_count = cell_count, + .cells = cells.ptr, + }; + + if (state.cursor.viewport) |viewport| { + if (state.cursor.visible) { + snapshot.cursor_visible = 1; + snapshot.cursor_x = @intCast(viewport.x); + snapshot.cursor_y = @intCast(viewport.y); + snapshot.cursor_wide_tail = if (viewport.wide_tail) 1 else 0; + } + } + + const row_cells = state.row_data.items(.cells); + for (0..rows) |row| { + const cell_list = row_cells[row]; + const raws = cell_list.items(.raw); + const styles = cell_list.items(.style); + const graphemes = cell_list.items(.grapheme); + for (0..cols) |col| { + const raw = raws[col]; + const style = if (raw.style_id == 0) ghostty_vt.Style{} else styles[col]; + var codepoint: u32 = 0; + switch (raw.content_tag) { + .codepoint => codepoint = raw.content.codepoint, + .codepoint_grapheme => { + const cluster = graphemes[col]; + if (cluster.len > 0) { + codepoint = cluster[0]; + } else { + codepoint = raw.content.codepoint; + } + }, + else => codepoint = 0, + } + + var fg = toRenderColor(style.fg(.{ + .default = state.colors.foreground, + .palette = &state.colors.palette, + })); + var bg = if (style.bg(&raw, &state.colors.palette)) |rgb| + toRenderColor(rgb) + else + default_bg; + + if (style.flags.inverse) { + const tmp = fg; + fg = bg; + bg = tmp; + } + if (style.flags.invisible) { + fg = bg; + } + + var flags: u16 = 0; + if (style.flags.bold) flags |= 1 << 0; + if (style.flags.italic) flags |= 1 << 1; + if (style.flags.underline != .none) flags |= 1 << 2; + if (style.flags.inverse) flags |= 1 << 3; + if (style.flags.faint) flags |= 1 << 4; + if (style.flags.strikethrough) flags |= 1 << 5; + if (style.flags.blink) flags |= 1 << 6; + if (style.flags.invisible) flags |= 1 << 7; + + cells[row * cols + col] = .{ + .codepoint = codepoint, + .fg = fg, + .bg = bg, + .wide = @intFromEnum(raw.wide), + .flags = flags, + ._pad = 0, + }; + } + } + + return snapshot; +} + +export fn hack_ghostty_vt_render_snapshot_free(snapshot: ?*RenderSnapshot) void { + if (snapshot == null) return; + const alloc = std.heap.c_allocator; + alloc.free(snapshot.?.cells[0..snapshot.?.cell_count]); + alloc.destroy(snapshot.?); +} diff --git a/apps/macos/Experiments/GhosttyVTBridge/src/ghostty_vt_wrapper.zig b/apps/macos/Experiments/GhosttyVTBridge/src/ghostty_vt_wrapper.zig new file mode 100644 index 000000000..a77168375 --- /dev/null +++ b/apps/macos/Experiments/GhosttyVTBridge/src/ghostty_vt_wrapper.zig @@ -0,0 +1,10 @@ +const ghostty_vt_impl = @import("ghostty-vt-impl"); + +pub const Terminal = ghostty_vt_impl.Terminal; +pub const ReadonlyStream = @typeInfo(@TypeOf(Terminal.vtStream)).@"fn".return_type.?; +pub const formatter = ghostty_vt_impl.formatter; +pub const RenderState = ghostty_vt_impl.RenderState; +pub const Style = ghostty_vt_impl.Style; +pub const color = ghostty_vt_impl.color; +pub const cursor = ghostty_vt_impl.cursor; +pub const page = ghostty_vt_impl.page; diff --git a/apps/macos/Icon-iOS-Default-1024x1024@1x.png b/apps/macos/Icon-iOS-Default-1024x1024@1x.png new file mode 100644 index 000000000..1a2538432 Binary files /dev/null and b/apps/macos/Icon-iOS-Default-1024x1024@1x.png differ diff --git a/apps/macos/Package.swift b/apps/macos/Package.swift new file mode 100644 index 000000000..4122d3505 --- /dev/null +++ b/apps/macos/Package.swift @@ -0,0 +1,38 @@ +// swift-tools-version: 5.9 +import PackageDescription + +let package = Package( + name: "HackDesktop", + platforms: [.macOS(.v14)], + products: [ + .library(name: "HackDesktopModels", targets: ["HackDesktopModels"]), + .library(name: "HackCLIService", targets: ["HackCLIService"]), + .library(name: "GhosttyTerminal", targets: ["GhosttyTerminal"]), + .library(name: "DashboardFeature", targets: ["DashboardFeature"]) + ], + targets: [ + .target( + name: "HackDesktopModels", + path: "Packages/Shared/Models/Sources/HackDesktopModels" + ), + .target( + name: "HackCLIService", + dependencies: ["HackDesktopModels"], + path: "Packages/Services/HackCLI/Sources/HackCLIService" + ), + .target( + name: "GhosttyTerminal", + path: "Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal" + ), + .target( + name: "DashboardFeature", + dependencies: ["HackCLIService", "HackDesktopModels", "GhosttyTerminal"], + path: "Packages/Features/DashboardFeature/Sources/DashboardFeature" + ), + .testTarget( + name: "HackDesktopModelsTests", + dependencies: ["HackDesktopModels"], + path: "Packages/Shared/Models/Tests/HackDesktopModelsTests" + ) + ] +) diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift new file mode 100644 index 000000000..b86ee1b7b --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/AdaptiveStyles.swift @@ -0,0 +1,184 @@ +import SwiftUI + +/// Adaptive button style that uses Liquid Glass on macOS 26+ +struct AdaptiveProminentButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + if #available(macOS 26, *) { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .glassEffect(.regular.tint(Color.accentColor.opacity(0.2))) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } else { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .background(Color.accentColor) + .foregroundStyle(.white) + .clipShape(RoundedRectangle(cornerRadius: 8, style: .continuous)) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } + } +} + +/// Adaptive secondary button style +struct AdaptiveSecondaryButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + if #available(macOS 26, *) { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(.primary) + .glassEffect(.regular) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } else { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(.primary) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(.quaternary) + ) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } + } +} + +/// Adaptive destructive button style for stop/delete actions +struct AdaptiveDestructiveButtonStyle: ButtonStyle { + func makeBody(configuration: Configuration) -> some View { + if #available(macOS 26, *) { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(.red) + .glassEffect(.regular.tint(Color.red.opacity(0.15))) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } else { + configuration.label + .padding(.horizontal, 12) + .padding(.vertical, 6) + .foregroundStyle(.red) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(Color.red.opacity(0.1)) + ) + .overlay( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .stroke(Color.red.opacity(0.3), lineWidth: 1) + ) + .opacity(configuration.isPressed ? 0.8 : 1.0) + } + } +} + +extension ButtonStyle where Self == AdaptiveProminentButtonStyle { + static var adaptiveProminent: AdaptiveProminentButtonStyle { .init() } +} + +extension ButtonStyle where Self == AdaptiveSecondaryButtonStyle { + static var adaptiveSecondary: AdaptiveSecondaryButtonStyle { .init() } +} + +extension ButtonStyle where Self == AdaptiveDestructiveButtonStyle { + static var adaptiveDestructive: AdaptiveDestructiveButtonStyle { .init() } +} + +extension View { + /// Apply glass background effect on macOS 26+, material on older versions + @ViewBuilder + func adaptiveGlassBackground(cornerRadius: CGFloat = 12) -> some View { + if #available(macOS 26, *) { + self + .background(.regularMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .clipShape(RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + .glassEffect(.regular) + } else { + self + .background(.ultraThinMaterial, in: RoundedRectangle(cornerRadius: cornerRadius, style: .continuous)) + } + } + + /// Apply subtle glass tint on macOS 26+ + @ViewBuilder + func adaptiveGlassTint(_ color: Color, opacity: Double = 0.15) -> some View { + if #available(macOS 26, *) { + self.glassEffect(.regular.tint(color.opacity(opacity))) + } else { + self + } + } + + /// Adaptive window background - transparent on macOS 26+ + @ViewBuilder + func adaptiveWindowBackground() -> some View { + if #available(macOS 26, *) { + self + .background(.clear) + .toolbarBackgroundVisibility(.hidden, for: .windowToolbar) + } else { + self + .background(.ultraThinMaterial) + .toolbarBackground(.ultraThinMaterial, for: .windowToolbar) + .toolbarBackground(.visible, for: .windowToolbar) + } + } + + /// Adaptive detail view background + @ViewBuilder + func adaptiveDetailBackground() -> some View { + if #available(macOS 26, *) { + self.background(.regularMaterial) + } else { + self.background(.ultraThinMaterial) + } + } + + /// Adaptive sidebar background + @ViewBuilder + func adaptiveSidebarBackground() -> some View { + if #available(macOS 26, *) { + self + .scrollContentBackground(.hidden) + .background(.clear) + } else { + self + .scrollContentBackground(.hidden) + .background(.ultraThinMaterial) + } + } + + /// Adaptive toolbar button (secondary style) + @ViewBuilder + func adaptiveToolbarButton() -> some View { + if #available(macOS 26, *) { + self.buttonStyle(.glass) + } else { + self.buttonStyle(.bordered) + } + } + + /// Adaptive toolbar button (prominent style) + @ViewBuilder + func adaptiveToolbarButtonProminent() -> some View { + if #available(macOS 26, *) { + self.buttonStyle(.glassProminent) + } else { + self.buttonStyle(.borderedProminent) + } + } + + /// Adaptive footer background + @ViewBuilder + func adaptiveFooterBackground() -> some View { + if #available(macOS 26, *) { + self.background(.regularMaterial) + } else { + self.background(.ultraThinMaterial) + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DaemonStatus+Resolution.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DaemonStatus+Resolution.swift new file mode 100644 index 000000000..18de00a1a --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DaemonStatus+Resolution.swift @@ -0,0 +1,26 @@ +import HackDesktopModels + +extension DaemonStatus { + var resolvedLabel: DaemonStatusLabel { + if let status { + return status + } + if apiOk == true { + return .running + } + if processRunning == true || running { + return .starting + } + if pid != nil || socketExists == true { + return .stale + } + return .stopped + } +} + +extension Optional where Wrapped == DaemonStatus { + var resolvedLabel: DaemonStatusLabel? { + guard let status = self else { return nil } + return status.resolvedLabel + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift new file mode 100644 index 000000000..8662fccd2 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardModel.swift @@ -0,0 +1,228 @@ +import Foundation +import Observation + +import HackCLIService +import HackDesktopModels + +public enum SidebarItem: Hashable, Identifiable { + case runtime + case gateway + case project(String) + + public var id: String { + switch self { + case .runtime: + return "runtime" + case .gateway: + return "gateway" + case let .project(id): + return "project:\(id)" + } + } +} + +public enum ProjectTab: String, CaseIterable { + case overview = "Overview" + case logs = "Logs" + case shell = "Shell" +} + +@Observable +@MainActor +public final class DashboardModel { + public private(set) var projects: [ProjectSummary] = [] + public private(set) var daemonStatus: DaemonStatus? = nil + public private(set) var globalStatus: GlobalStatusResponse? = nil + public private(set) var runtimeOk: Bool? = nil + public private(set) var runtimeError: String? = nil + public private(set) var runtimeCheckedAt: String? = nil + public private(set) var runtimeLastOkAt: String? = nil + public private(set) var runtimeResetAt: String? = nil + public private(set) var runtimeResetCount: Int? = nil + public private(set) var lastUpdated: Date? = nil + public var selectedItem: SidebarItem? = .runtime + public var selectedProjectTab: ProjectTab = .overview + public var errorMessage: String? = nil + public var statusMessage: String? = nil + public var isRefreshing = false + + private let client: HackCLIClient + private var refreshTask: Task? = nil + private var statusClearTask: Task? = nil + + public init(client: HackCLIClient) { + self.client = client + } + + public var selectedProject: ProjectSummary? { + guard case let .project(id) = selectedItem else { return nil } + return projects.first { $0.id == id } + } + + public var gatewayExposures: [GatewayExposure] { + globalStatus?.gateway?.exposures ?? [] + } + + public var runtimeOverallOk: Bool? { + if runtimeOk == false { return false } + if let summaryOk = globalStatus?.summary.ok { return summaryOk } + return runtimeOk + } + + var gatewaySummaryState: GatewaySummaryState? { + let gatewayEnabled = globalStatus?.gateway?.gatewayEnabled ?? globalStatus?.summary.gatewayEnabled + if globalStatus?.gateway == nil && gatewayEnabled == nil && gatewayExposures.isEmpty { + return nil + } + return GatewaySummaryState.resolve(exposures: gatewayExposures, gatewayEnabled: gatewayEnabled) + } + + public func start() { + guard refreshTask == nil else { return } + refreshTask = Task { [weak self] in + guard let self else { return } + await refresh() + while !Task.isCancelled { + try? await Task.sleep(for: .seconds(12)) + await refresh() + } + } + } + + public func refresh() async { + isRefreshing = true + errorMessage = nil + defer { + isRefreshing = false + lastUpdated = Date() + } + + async let projectsTask = fetchProjects() + async let daemonTask = fetchDaemonStatus() + async let globalTask = fetchGlobalStatus() + + let errors = await [projectsTask, daemonTask, globalTask].compactMap { $0 } + if !errors.isEmpty { + errorMessage = errors.joined(separator: "\n") + } + } + + public func startDaemon() async { + await runAction(message: "Starting hackd…") { + try await self.client.startDaemon() + } + } + + public func stopDaemon() async { + await runAction(message: "Stopping hackd…") { + try await self.client.stopDaemon() + } + } + + public func restartDaemon() async { + await runAction(message: "Restarting hackd…") { + try await self.client.restartDaemon() + } + } + + public func clearDaemon() async { + await runAction(message: "Clearing hackd state…") { + try await self.client.clearDaemon() + } + } + + public func startProject(_ project: ProjectSummary) async { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return + } + await runAction(message: "Starting \(project.name)…") { + try await self.client.startProject(path: path) + } + } + + public func stopProject(_ project: ProjectSummary) async { + guard let path = resolveProjectPath(project) else { + errorMessage = "Missing project path for \(project.name)" + return + } + await runAction(message: "Stopping \(project.name)…") { + try await self.client.stopProject(path: path) + } + } + + public func showLogs(for project: ProjectSummary) { + selectedItem = .project(project.id) + selectedProjectTab = .logs + } + + public func showShell(for project: ProjectSummary) { + selectedItem = .project(project.id) + selectedProjectTab = .shell + } + + private func resolveProjectPath(_ project: ProjectSummary) -> String? { + project.repoRoot ?? project.projectDir + } + + private func fetchProjects() async -> String? { + do { + let response = try await client.fetchProjects(includeGlobal: true) + projects = response.projects.sorted { $0.name.localizedCaseInsensitiveCompare($1.name) == .orderedAscending } + runtimeOk = response.runtimeOk + runtimeError = response.runtimeError + runtimeCheckedAt = response.runtimeCheckedAt + runtimeLastOkAt = response.runtimeLastOkAt + runtimeResetAt = response.runtimeResetAt + runtimeResetCount = response.runtimeResetCount + if selectedItem == nil { + selectedItem = .runtime + } + if case let .project(id) = selectedItem, !projects.contains(where: { $0.id == id }) { + selectedItem = projects.first.map { .project($0.id) } ?? .runtime + } + return nil + } catch { + return error.localizedDescription + } + } + + private func fetchDaemonStatus() async -> String? { + do { + daemonStatus = try await client.daemonStatus() + return nil + } catch { + return error.localizedDescription + } + } + + private func fetchGlobalStatus() async -> String? { + do { + globalStatus = try await client.fetchGlobalStatus() + return nil + } catch { + return error.localizedDescription + } + } + + private func runAction(message: String, action: @escaping () async throws -> Void) async { + statusMessage = message + statusClearTask?.cancel() + + do { + try await action() + statusMessage = "Done" + } catch { + statusMessage = nil + errorMessage = error.localizedDescription + return + } + + await refresh() + + statusClearTask = Task { [weak self] in + try? await Task.sleep(for: .seconds(2)) + self?.statusMessage = nil + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift new file mode 100644 index 000000000..044b4887e --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DashboardView.swift @@ -0,0 +1,294 @@ +import AppKit +import SwiftUI + +import HackDesktopModels + + +public struct DashboardView: View { + @Environment(DashboardModel.self) private var model + + public init() {} + + public var body: some View { + @Bindable var model = model + + NavigationSplitView { + sidebar + } detail: { + detail + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .adaptiveDetailBackground() + } + .navigationSplitViewStyle(.balanced) + .navigationSplitViewColumnWidth(min: 240, ideal: 320, max: 460) + .navigationTitle("Hack Desktop") + .adaptiveWindowBackground() + .task { + model.start() + } + .toolbar { + ToolbarItemGroup(placement: .primaryAction) { + Button("Refresh") { + Task { await model.refresh() } + } + .adaptiveToolbarButton() + if canStopDaemon { + Button("Stop hackd") { + Task { await model.stopDaemon() } + } + .adaptiveToolbarButton() + } else if canStartDaemon { + Button("Start hackd") { + Task { await model.startDaemon() } + } + .adaptiveToolbarButtonProminent() + } + } + } + } + + private var sidebar: some View { + @Bindable var model = model + + let extensionProjects = model.projects.filter { $0.isExtensionOnly } + let runtimeProjects = model.projects.filter { !$0.isExtensionOnly } + return List(selection: $model.selectedItem) { + Section("System") { + RuntimeRowView(isHealthy: model.runtimeOverallOk) + .tag(SidebarItem.runtime) + .contextMenu { runtimeContextMenu } + GatewayRowView(state: model.gatewaySummaryState) + .tag(SidebarItem.gateway) + .contextMenu { gatewayContextMenu } + } + Section("Projects") { + if runtimeProjects.isEmpty { + VStack(alignment: .leading, spacing: 4) { + Text("No projects registered") + .font(.subheadline) + .foregroundStyle(.secondary) + Text("Run `hack init` in a project directory to register it.") + .font(.caption) + .foregroundStyle(.tertiary) + } + .padding(.vertical, 4) + } else { + ForEach(runtimeProjects) { project in + ProjectRowView(project: project, runtimeHealthy: model.runtimeOverallOk) + .tag(SidebarItem.project(project.id)) + .contextMenu { projectContextMenu(for: project) } + } + } + } + if !extensionProjects.isEmpty { + Section("Extensions") { + ForEach(extensionProjects) { project in + ProjectRowView(project: project, runtimeHealthy: model.runtimeOverallOk) + .tag(SidebarItem.project(project.id)) + .contextMenu { projectContextMenu(for: project) } + } + } + } + } + .listStyle(.sidebar) + .listRowSeparator(.hidden) + .adaptiveSidebarBackground() + .safeAreaInset(edge: .bottom) { + footer + } + } + + private var detail: some View { + Group { + switch model.selectedItem { + case .runtime: + RuntimeDetailView() + case .gateway: + GatewayDetailView() + case let .project(id): + if let project = model.projects.first(where: { $0.id == id }) { + ProjectDetailView(project: project) + } else { + ContentUnavailableView("Project missing", systemImage: "exclamationmark.triangle") + } + case .none: + ContentUnavailableView("Select a sidebar item", systemImage: "square.stack") + } + } + } + + private var footer: some View { + VStack(alignment: .leading, spacing: 8) { + if let errorMessage = model.errorMessage { + Text(errorMessage) + .font(.caption) + .foregroundStyle(.red) + } + HStack { + Text(runtimeLabel) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + if let statusMessage = model.statusMessage { + BadgePill(label: statusMessage, tint: .secondary) + } + } + } + .padding(.horizontal, 12) + .padding(.vertical, 8) + .adaptiveFooterBackground() + } + + private var runtimeLabel: String { + if model.runtimeOverallOk == true { + return "Runtime: ok" + } + if model.runtimeOk == false, let error = model.runtimeError, !error.isEmpty { + return "Runtime: \(error)" + } + if model.runtimeOverallOk == false { + return "Runtime: degraded" + } + return "Runtime: unknown" + } + + private var daemonIsRunning: Bool { + model.daemonStatus?.resolvedLabel == .running + } + + private var daemonIsStarting: Bool { + model.daemonStatus?.resolvedLabel == .starting + } + + private var canStartDaemon: Bool { + !(daemonIsRunning || daemonIsStarting) + } + + private var canStopDaemon: Bool { + daemonIsRunning || daemonIsStarting + } + + @ViewBuilder + private var runtimeContextMenu: some View { + if canStopDaemon { + Button { + Task { await model.stopDaemon() } + } label: { + Label("Stop hackd", systemImage: "stop.fill") + } + } else { + Button { + Task { await model.startDaemon() } + } label: { + Label("Start hackd", systemImage: "play.fill") + } + } + + Button { + Task { await model.restartDaemon() } + } label: { + Label("Restart hackd", systemImage: "arrow.clockwise") + } + .disabled(!daemonIsRunning) + + Divider() + + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.triangle.2.circlepath") + } + } + + @ViewBuilder + private var gatewayContextMenu: some View { + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.triangle.2.circlepath") + } + + if let configPath = gatewayConfigPath { + Divider() + + Button { + NSWorkspace.shared.selectFile(configPath, inFileViewerRootedAtPath: "") + } label: { + Label("Show Config in Finder", systemImage: "folder") + } + } + } + + private var gatewayConfigPath: String? { + let home = FileManager.default.homeDirectoryForCurrentUser + let configPath = home.appendingPathComponent(".hack/gateway.yml").path + return FileManager.default.fileExists(atPath: configPath) ? configPath : nil + } + + @ViewBuilder + private func projectContextMenu(for project: ProjectSummary) -> some View { + let isRunning = project.runtimeStatus == .running || project.status == .running + + if isRunning { + Button { + Task { await model.stopProject(project) } + } label: { + Label("Stop", systemImage: "stop.fill") + } + } else { + Button { + Task { await model.startProject(project) } + } label: { + Label("Start", systemImage: "play.fill") + } + } + + Divider() + + Button { + model.showLogs(for: project) + } label: { + Label("View Logs", systemImage: "text.alignleft") + } + + Button { + model.showShell(for: project) + } label: { + Label("Open Shell", systemImage: "terminal") + } + + if let devHost = project.devHost, let url = URL(string: "https://\(devHost)") { + Divider() + + Button { + NSWorkspace.shared.open(url) + } label: { + Label("Open in Browser", systemImage: "safari") + } + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(url.absoluteString, forType: .string) + } label: { + Label("Copy URL", systemImage: "doc.on.doc") + } + } + + if let path = project.repoRoot ?? project.projectDir { + Divider() + + Button { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) + } label: { + Label("Show in Finder", systemImage: "folder") + } + + Button { + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(path, forType: .string) + } label: { + Label("Copy Path", systemImage: "doc.on.doc") + } + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DetailRows.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DetailRows.swift new file mode 100644 index 000000000..69b465364 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/DetailRows.swift @@ -0,0 +1,28 @@ +import SwiftUI + +struct DetailRowItem: Identifiable { + let id = UUID() + let label: String + let value: String +} + +struct DetailRows: View { + let rows: [DetailRowItem] + + var body: some View { + Grid(alignment: .leading, horizontalSpacing: 16, verticalSpacing: 8) { + ForEach(rows) { row in + GridRow { + Text(row.label) + .font(.caption) + .foregroundStyle(.secondary) + .frame(width: 120, alignment: .leading) + Text(row.value) + .font(.subheadline) + .textSelection(.enabled) + .frame(maxWidth: .infinity, alignment: .leading) + } + } + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift new file mode 100644 index 000000000..867167b0b --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayDetailView.swift @@ -0,0 +1,214 @@ +import SwiftUI + +import HackDesktopModels + +struct GatewayDetailView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + + var body: some View { + NavigationStack { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + overviewCard + exposuresCard + tokensCard + warningsCard + } + .padding(24) + } + .navigationDestination(for: GatewayExposure.self) { exposure in + GatewayExposureDetailView(exposure: exposure) + } + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Label("Gateway", systemImage: "arrow.triangle.branch") + .font(.title2.weight(.semibold)) + StatusPill(text: gatewayStatusText, tone: gatewayStatusTone) + Spacer() + if let configUrl { + Button("Open Config") { + openURL(configUrl) + } + .adaptiveToolbarButton() + } + } + Text("Remote gateway configuration and exposures") + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + + private var overviewCard: some View { + GlassCard(title: "Overview", systemImage: "network") { + DetailRows(rows: overviewRows) + } + } + + private var exposuresCard: some View { + GlassCard(title: "Exposures", systemImage: "point.3.filled.connected.trianglepath.dotted") { + if exposures.isEmpty { + Text("No gateway exposures configured") + .font(.caption) + .foregroundStyle(.secondary) + } else { + VStack(alignment: .leading, spacing: 12) { + ForEach(Array(exposures.enumerated()), id: \.element.id) { index, exposure in + NavigationLink(value: exposure) { + exposureRow(exposure) + } + .buttonStyle(.plain) + if index < exposures.count - 1 { + Divider() + } + } + } + } + } + } + + private var tokensCard: some View { + let rows = tokenRows + return Group { + if rows.isEmpty { + EmptyView() + } else { + GlassCard(title: "Tokens", systemImage: "key") { + DetailRows(rows: rows) + } + } + } + } + + private var warningsCard: some View { + let warnings = gatewayWarnings + return Group { + if warnings.isEmpty { + EmptyView() + } else { + GlassCard(title: "Warnings", systemImage: "exclamationmark.triangle") { + VStack(alignment: .leading, spacing: 4) { + ForEach(warnings, id: \.self) { warning in + Text("• \(warning)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private var gateway: GatewayStatus? { + model.globalStatus?.gateway + } + + private var exposures: [GatewayExposure] { + model.gatewayExposures + } + + private var gatewaySummaryState: GatewaySummaryState? { + model.gatewaySummaryState + } + + private var gatewayStatusText: String { + gatewaySummaryState?.label ?? "Unknown" + } + + private var gatewayStatusTone: StatusTone { + gatewaySummaryState?.tone ?? .neutral + } + + private var overviewRows: [DetailRowItem] { + let configured = gateway?.gatewayEnabled ?? model.globalStatus?.summary.gatewayEnabled + return [ + DetailRowItem(label: "Status", value: gatewaySummaryState?.label ?? "—"), + DetailRowItem(label: "Configured", value: yesNo(configured)), + DetailRowItem(label: "Gateway URL", value: gateway?.gatewayUrl ?? "—"), + DetailRowItem(label: "Bind", value: gateway?.gatewayBind ?? "—"), + DetailRowItem(label: "Port", value: gateway?.gatewayPort.map(String.init) ?? "—"), + DetailRowItem(label: "Allow writes", value: yesNo(gateway?.allowWrites)), + DetailRowItem(label: "Projects enabled", value: gateway?.gatewayProjectsEnabled.map(String.init) ?? "—"), + DetailRowItem(label: "Projects", value: gateway?.gatewayProjects ?? "—"), + DetailRowItem(label: "Config path", value: gateway?.configPath ?? "—") + ] + } + + private var tokenRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if let tokensActive = gateway?.tokensActive { + rows.append(DetailRowItem(label: "Active", value: String(tokensActive))) + } + if let tokensRead = gateway?.tokensRead { + rows.append(DetailRowItem(label: "Read", value: String(tokensRead))) + } + if let tokensWrite = gateway?.tokensWrite { + rows.append(DetailRowItem(label: "Write", value: String(tokensWrite))) + } + if let tokensRevoked = gateway?.tokensRevoked { + rows.append(DetailRowItem(label: "Revoked", value: String(tokensRevoked))) + } + return rows + } + + private var gatewayWarnings: [String] { + gateway?.warnings ?? [] + } + + private var configUrl: URL? { + guard let path = gateway?.configPath, !path.isEmpty else { return nil } + return URL(fileURLWithPath: path) + } + + private func yesNo(_ value: Bool?) -> String { + guard let value else { return "—" } + return value ? "Yes" : "No" + } + + private func exposureIcon(_ exposure: GatewayExposure) -> String { + switch exposure.id { + case "lan": + return "wifi" + case "tailscale": + return "link" + case "cloudflare": + return "cloud" + default: + return "network" + } + } + + private func exposureRow(_ exposure: GatewayExposure) -> some View { + VStack(alignment: .leading, spacing: 4) { + HStack(spacing: 8) { + Label(exposure.label, systemImage: exposureIcon(exposure)) + .font(.subheadline.weight(.medium)) + Spacer() + if let dependencyLabel = exposure.dependencyStatusLabel, + let dependencyColor = exposure.dependencyStatusColor { + BadgePill(label: dependencyLabel, tint: dependencyColor) + } + StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) + Image(systemName: "chevron.right") + .font(.caption) + .foregroundStyle(.tertiary) + } + if let detail = exposure.detail, !detail.isEmpty { + Text(detail) + .font(.caption) + .foregroundStyle(.secondary) + } + if let url = exposure.url, !url.isEmpty { + Text(url) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + .padding(.vertical, 4) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift new file mode 100644 index 000000000..dc63bdd73 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposure+Status.swift @@ -0,0 +1,208 @@ +import SwiftUI + +import HackDesktopModels + +extension GatewayExposure { + var resolvedState: State { + state ?? (enabled ? .configured : .disabled) + } + + var statusLabel: String { + switch resolvedState { + case .running: + return "Running" + case .configured: + return "Configured" + case .needsConfig: + return "Needs setup" + case .blocked: + return "Blocked" + case .disabled: + return "Disabled" + case .unknown: + return "Unknown" + } + } + + var statusTone: StatusTone { + switch resolvedState { + case .running: + return .good + case .configured, .needsConfig, .blocked: + return .warn + case .disabled, .unknown: + return .neutral + } + } + + var statusColor: Color { + switch resolvedState { + case .running: + return .green + case .configured, .needsConfig, .blocked: + return .orange + case .disabled, .unknown: + return .secondary + } + } + + var dependencyStatusLabel: String? { + switch dependencyStatus { + case .builtIn: + return "Built-in" + case .installed: + guard let dependencyName else { return nil } + return "\(dependencyName) ok" + case .missing: + guard let dependencyName else { return nil } + return "\(dependencyName) missing" + case .unknown: + guard let dependencyName else { return nil } + return "\(dependencyName) unknown" + case .none: + return nil + } + } + + var dependencyStatusTone: StatusTone? { + switch dependencyStatus { + case .builtIn: + return .neutral + case .installed: + return .good + case .missing: + return .warn + case .unknown: + return .neutral + case .none: + return nil + } + } + + var dependencyStatusColor: Color? { + guard let dependencyStatusTone else { return nil } + switch dependencyStatusTone { + case .good: + return .green + case .warn: + return .orange + case .neutral: + return .secondary + } + } + + var isVisibleInSidebar: Bool { + resolvedState != .disabled + } + + private enum DependencyStatus { + case builtIn + case installed + case missing + case unknown + } + + private var dependencyStatus: DependencyStatus? { + let normalizedDetail = (detail ?? "").lowercased() + if normalizedDetail.contains("extension disabled") { + return nil + } + + switch id { + case "lan": + return .builtIn + case "tailscale": + if normalizedDetail.contains("not installed") { + return .missing + } + if normalizedDetail.contains("status failed") { + return .unknown + } + return .installed + case "cloudflare": + if normalizedDetail.contains("cloudflared not installed") { + return .missing + } + if resolvedState == .needsConfig && normalizedDetail.contains("missing hostname") { + return .unknown + } + return .installed + default: + return nil + } + } + + private var dependencyName: String? { + switch id { + case "tailscale": + return "tailscale" + case "cloudflare": + return "cloudflared" + default: + return nil + } + } +} + +enum GatewaySummaryState { + case running + case configured + case disabled + case unknown + + static func resolve(exposures: [GatewayExposure], gatewayEnabled: Bool?) -> GatewaySummaryState { + if exposures.isEmpty { + if gatewayEnabled == true { return .configured } + if gatewayEnabled == false { return .disabled } + return .unknown + } + + if exposures.contains(where: { $0.resolvedState == .running }) { + return .running + } + if exposures.contains(where: { [.configured, .needsConfig, .blocked].contains($0.resolvedState) }) { + return .configured + } + if exposures.allSatisfy({ $0.resolvedState == .disabled }) { + if gatewayEnabled == true { return .configured } + if gatewayEnabled == false { return .disabled } + } + + return .unknown + } + + var label: String { + switch self { + case .running: + return "Enabled" + case .configured: + return "Configured" + case .disabled: + return "Disabled" + case .unknown: + return "Unknown" + } + } + + var tone: StatusTone { + switch self { + case .running: + return .good + case .configured: + return .warn + case .disabled, .unknown: + return .neutral + } + } + + var statusDotColor: Color? { + switch self { + case .running: + return .green + case .configured: + return .orange + case .disabled, .unknown: + return nil + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift new file mode 100644 index 000000000..d6c657f08 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GatewayExposureDetailView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +import HackDesktopModels + +struct GatewayExposureDetailView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + + let exposure: GatewayExposure + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + overviewCard + gatewayCard + } + .padding(24) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Label(exposure.label, systemImage: iconName) + .font(.title2.weight(.semibold)) + StatusPill(text: exposure.statusLabel, tone: exposure.statusTone) + Spacer() + if let url = exposureUrl { + Button("Open URL") { + openURL(url) + } + .buttonStyle(.bordered) + } + } + if let detail = exposure.detail, !detail.isEmpty { + Text(detail) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + } + + private var overviewCard: some View { + GlassCard(title: "Overview", systemImage: "rectangle.stack") { + DetailRows(rows: [ + DetailRowItem(label: "Status", value: exposure.statusLabel), + DetailRowItem(label: "Dependency", value: exposure.dependencyStatusLabel ?? "—"), + DetailRowItem(label: "URL", value: exposure.url ?? "—"), + DetailRowItem(label: "Detail", value: exposure.detail ?? "—") + ]) + } + } + + private var gatewayCard: some View { + let rows = gatewayRows + return Group { + if rows.isEmpty { + EmptyView() + } else { + GlassCard(title: "Gateway", systemImage: "arrow.triangle.branch") { + DetailRows(rows: rows) + } + } + } + } + + private var gatewayRows: [DetailRowItem] { + guard let gateway = model.globalStatus?.gateway else { return [] } + return [ + DetailRowItem(label: "Gateway URL", value: gateway.gatewayUrl ?? "—"), + DetailRowItem(label: "Bind", value: gateway.gatewayBind ?? "—"), + DetailRowItem(label: "Port", value: gateway.gatewayPort.map(String.init) ?? "—"), + DetailRowItem(label: "Config path", value: gateway.configPath ?? "—") + ] + } + + private var exposureUrl: URL? { + guard let url = exposure.url, !url.isEmpty else { return nil } + return URL(string: url) + } + + private var iconName: String { + switch exposure.id { + case "lan": + return "wifi" + case "tailscale": + return "link" + case "cloudflare": + return "cloud" + default: + return "network" + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift new file mode 100644 index 000000000..7d1b4bd68 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalSession.swift @@ -0,0 +1,288 @@ +import AppKit +import Foundation +import Observation + +import GhosttyTerminal +import HackCLIService +import HackDesktopModels + +@MainActor +@Observable +final class GhosttyTerminalSession { + enum Mode { + case logs(path: String) + case shell(workingDirectory: URL) + } + + let project: ProjectSummary + let mode: Mode + + var snapshot: GhosttyRenderSnapshot? = nil + var renderVersion: Int = 0 + var statusMessage: String = "Preparing terminal…" + var isAvailable: Bool = false + + private var terminal: GhosttyTerminal? + private var pty: PtyProcess? + private var refreshTask: Task? + private var hasPendingRefresh = false + private var isStarted = false + private var hasReceivedInitialSize = false + private var pendingStart = false + private var lastCols = 120 + private var lastRows = 32 + + var allowsInput: Bool { + if case .shell = mode { + return true + } + return false + } + + init(project: ProjectSummary) { + self.project = project + if let path = project.projectDir ?? project.repoRoot { + self.mode = .logs(path: path) + } else { + self.mode = .logs(path: "") + } + configureTerminal() + } + + init(project: ProjectSummary, mode: Mode) { + self.project = project + self.mode = mode + configureTerminal() + } + + private func configureTerminal() { + if let terminal = GhosttyVTRuntime.shared.makeTerminal(cols: lastCols, rows: lastRows) { + self.terminal = terminal + applyTheme() + self.isAvailable = true + } else { + self.isAvailable = false + self.statusMessage = GhosttyVTRuntime.shared.loadMessage ?? "Ghostty VT unavailable" + } + } + + func start() { + guard !isStarted else { return } + + // Wait for the view to report its actual size before launching + // This ensures the process gets the correct terminal dimensions + guard hasReceivedInitialSize else { + pendingStart = true + return + } + + isStarted = true + guard isAvailable else { return } + + var environment = buildEnvironment() + // Ensure terminal size is in environment for programs that check + environment["COLUMNS"] = String(lastCols) + environment["LINES"] = String(lastRows) + let command = resolveCommand(in: environment) + + do { + let pty = try PtyProcess( + executableURL: command.executableURL, + arguments: command.arguments, + environment: command.environment, + cols: lastCols, + rows: lastRows, + workingDirectory: command.workingDirectory + ) + pty.process.terminationHandler = { [weak self] proc in + DispatchQueue.main.async { + self?.statusMessage = "Session exited (\(proc.terminationStatus))" + } + } + pty.masterFileHandle.readabilityHandler = { [weak self] handle in + let data = handle.availableData + guard !data.isEmpty else { return } + Task { @MainActor in + self?.feed(data) + } + } + self.pty = pty + statusMessage = allowsInput ? "Shell ready" : "Streaming logs…" + startRefreshLoop() + } catch { + statusMessage = "Failed to start session: \(error.localizedDescription)" + } + } + + func stop() { + refreshTask?.cancel() + refreshTask = nil + pty?.terminate() + pty = nil + isStarted = false + } + + func resize(cols: Int, rows: Int) { + lastCols = cols + lastRows = rows + terminal?.resize(cols: cols, rows: rows) + pty?.resize(cols: cols, rows: rows) + hasPendingRefresh = true + + // After receiving first size from view, start the process if pending + if !hasReceivedInitialSize { + hasReceivedInitialSize = true + if pendingStart { + pendingStart = false + start() + } + } + } + + func send(_ data: Data) { + guard allowsInput else { return } + pty?.send(data) + } + + func sendControl(_ data: Data) { + guard !data.isEmpty else { return } + let isControl = data.allSatisfy { $0 < 0x20 || $0 == 0x7F } + guard isControl else { return } + if data == Data([0x03]) { + pty?.interrupt() + } + pty?.send(data) + } + + private func feed(_ data: Data) { + terminal?.feed(data) + hasPendingRefresh = true + } + + private func startRefreshLoop() { + refreshTask?.cancel() + refreshTask = Task { [weak self] in + while let self, !Task.isCancelled { + try? await Task.sleep(nanoseconds: 16_666_667) + guard self.hasPendingRefresh else { continue } + self.hasPendingRefresh = false + guard let terminal = self.terminal else { continue } + guard let snapshot = terminal.renderSnapshot() else { continue } + self.snapshot = snapshot + self.renderVersion &+= 1 + } + } + } + + private func buildEnvironment() -> [String: String] { + var environment = HackCLILocator.buildEnvironment() + environment["TERM"] = "xterm-256color" + environment["TERM_PROGRAM"] = "HackDesktop" + environment["TERM_PROGRAM_VERSION"] = "1.0.0" + if environment["COLORTERM"] == nil { + environment["COLORTERM"] = "truecolor" + } + if environment["LANG"] == nil { + environment["LANG"] = "en_US.UTF-8" + } + if environment["LC_ALL"] == nil { + environment["LC_ALL"] = "en_US.UTF-8" + } + return environment + } + + private func applyTheme() { + guard let terminal else { return } + + // Enable newline mode (LNM) so LF acts as CR+LF + // This is needed for proper log output formatting + terminal.feed(Data("\u{1B}[20h".utf8)) + + let foreground = "#E6E6E6" + let background = "#0B0C0F" + let cursor = "#F5C2E7" + let palette = [ + "#0B0C0F", + "#E06C75", + "#98C379", + "#E5C07B", + "#61AFEF", + "#C678DD", + "#56B6C2", + "#C8CCD4", + "#5C6370", + "#E06C75", + "#98C379", + "#E5C07B", + "#61AFEF", + "#C678DD", + "#56B6C2", + "#FFFFFF" + ] + + var sequences: [String] = [ + osc("10", foreground), + osc("11", background), + osc("12", cursor) + ] + + for (index, color) in palette.enumerated() { + sequences.append(osc("4", "\(index);\(color)")) + } + + let payload = sequences.joined() + terminal.feed(Data(payload.utf8)) + } + + private func osc(_ ps: String, _ pt: String) -> String { + "\u{1B}]\(ps);\(pt)\u{07}" + } + + private func resolveCommand(in environment: [String: String]) -> (executableURL: URL, arguments: [String], environment: [String: String], workingDirectory: URL?) { + switch mode { + case let .logs(path): + if path.isEmpty { + return ( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["echo", "Missing project path"], + environment: environment, + workingDirectory: nil + ) + } + + if let hackPath = HackCLILocator.resolveHackExecutable(in: environment) { + return ( + executableURL: URL(fileURLWithPath: hackPath), + arguments: ["logs", "--pretty", "--path", path], + environment: environment, + workingDirectory: URL(fileURLWithPath: path) + ) + } + + return ( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["hack", "logs", "--pretty", "--path", path], + environment: environment, + workingDirectory: URL(fileURLWithPath: path) + ) + case let .shell(workingDirectory): + let zshPath = "/bin/zsh" + if FileManager.default.isExecutableFile(atPath: zshPath) { + return ( + executableURL: URL(fileURLWithPath: zshPath), + arguments: ["-l"], + environment: environment, + workingDirectory: workingDirectory + ) + } + + return ( + executableURL: URL(fileURLWithPath: "/usr/bin/env"), + arguments: ["zsh", "-l"], + environment: environment, + workingDirectory: workingDirectory + ) + } + } + +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalTextView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalTextView.swift new file mode 100644 index 000000000..2cc06067c --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalTextView.swift @@ -0,0 +1,773 @@ +import AppKit +import CoreText +import GhosttyTerminal +import SwiftUI + +/** + * Terminal font configuration loaded from Ghostty config or defaults. + */ +struct TerminalFontConfig { + let fontFamily: String + let fontSize: CGFloat + let fontThicken: Bool + let lineHeightPadding: CGFloat + + static let `default` = TerminalFontConfig( + fontFamily: "FantasqueSansM Nerd Font Mono", + fontSize: 14, + fontThicken: true, + lineHeightPadding: 0 + ) + + /** + * Loads font configuration from Ghostty config file. + * Falls back to defaults if config not found or parsing fails. + */ + static func loadFromGhosttyConfig() -> TerminalFontConfig { + let configPath = FileManager.default.homeDirectoryForCurrentUser + .appendingPathComponent(".config/ghostty/config") + + guard let contents = try? String(contentsOf: configPath, encoding: .utf8) else { + return .default + } + + var fontFamily = TerminalFontConfig.default.fontFamily + var fontSize = TerminalFontConfig.default.fontSize + var fontThicken = TerminalFontConfig.default.fontThicken + + for line in contents.components(separatedBy: .newlines) { + let trimmed = line.trimmingCharacters(in: .whitespaces) + guard !trimmed.hasPrefix("#"), !trimmed.isEmpty else { continue } + + let parts = trimmed.split(separator: "=", maxSplits: 1) + guard parts.count == 2 else { continue } + + let key = parts[0].trimmingCharacters(in: .whitespaces) + let value = parts[1].trimmingCharacters(in: .whitespaces) + .trimmingCharacters(in: CharacterSet(charactersIn: "\"")) + + switch key { + case "font-family": + fontFamily = value + case "font-size": + if let size = Double(value) { + fontSize = CGFloat(size) + } + case "font-thicken": + fontThicken = value == "true" + default: + break + } + } + + return TerminalFontConfig( + fontFamily: fontFamily, + fontSize: fontSize, + fontThicken: fontThicken, + lineHeightPadding: 2 + ) + } + + /** + * Resolves the NSFont for this configuration. + * Falls back through several options if the preferred font isn't available. + */ + func resolveFont() -> NSFont { + if let font = NSFont(name: fontFamily, size: fontSize) { + return font + } + + let fallbacks = [ + "FantasqueSansM Nerd Font Mono", + "JetBrainsMono Nerd Font Mono", + "FiraCode Nerd Font Mono", + "Menlo", + "Monaco" + ] + + for fallback in fallbacks where fallback != fontFamily { + if let font = NSFont(name: fallback, size: fontSize) { + return font + } + } + + return NSFont.monospacedSystemFont(ofSize: fontSize, weight: .regular) + } +} + +struct GhosttyTerminalTextView: NSViewRepresentable { + @Bindable var session: GhosttyTerminalSession + + func makeCoordinator() -> Coordinator { + Coordinator() + } + + func makeNSView(context: Context) -> TerminalRenderView { + let view = TerminalRenderView() + view.translatesAutoresizingMaskIntoConstraints = false + view.setContentHuggingPriority(.defaultLow, for: .horizontal) + view.setContentHuggingPriority(.defaultLow, for: .vertical) + view.setContentCompressionResistancePriority(.defaultLow, for: .horizontal) + view.setContentCompressionResistancePriority(.defaultLow, for: .vertical) + view.overrideBackgroundColor = NSColor.black.withAlphaComponent(0.96) + view.backgroundAlpha = 1.0 + view.onKeyDown = { event in + context.coordinator.handleKey(event) + } + view.onLayout = { _ in + context.coordinator.updateSize(in: view) + } + context.coordinator.renderView = view + return view + } + + func updateNSView(_ nsView: TerminalRenderView, context: Context) { + context.coordinator.session = session + context.coordinator.ensureFocus(in: nsView) + context.coordinator.updateSize(in: nsView) + + if context.coordinator.lastRenderVersion != session.renderVersion { + context.coordinator.lastRenderVersion = session.renderVersion + nsView.snapshot = session.snapshot + } + } + + @MainActor + final class Coordinator { + weak var session: GhosttyTerminalSession? + weak var renderView: TerminalRenderView? + var lastRenderVersion: Int = -1 + private var lastCols: Int = 0 + private var lastRows: Int = 0 + private let fontConfig = TerminalFontConfig.loadFromGhosttyConfig() + private lazy var baseFont: NSFont = fontConfig.resolveFont() + + func handleKey(_ event: NSEvent) { + guard let session else { return } + + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if event.modifierFlags.contains(.command) { + return + } + + guard let data = encode(event: event) else { return } + let isControlData = data.count == 1 && (data[0] < 0x20 || data[0] == 0x7F) + if isControlData || flags.contains(.control) { + session.sendControl(data) + return + } + if session.allowsInput { + session.send(data) + } + } + + private func encode(event: NSEvent) -> Data? { + let flags = event.modifierFlags.intersection(.deviceIndependentFlagsMask) + if flags.contains(.control), let chars = event.charactersIgnoringModifiers, let scalar = chars.unicodeScalars.first { + let value = scalar.value + if value < 0x20 { + return Data([UInt8(value)]) + } + if value == 0x20 { + return Data([0x00]) + } + if value >= 0x40 && value <= 0x5F { + return Data([UInt8(value - 0x40)]) + } + if value >= 0x61 && value <= 0x7A { + return Data([UInt8(value - 0x60)]) + } + } + + switch event.keyCode { + case 36: + return Data([0x0D]) + case 48: + return Data([0x09]) + case 51: + return Data([0x7F]) + case 53: + return Data([0x1B]) + case 117: + return Data("\u{1B}[3~".utf8) + case 115: + return Data("\u{1B}[H".utf8) + case 119: + return Data("\u{1B}[F".utf8) + case 116: + return Data("\u{1B}[5~".utf8) + case 121: + return Data("\u{1B}[6~".utf8) + case 123: + return Data("\u{1B}[D".utf8) + case 124: + return Data("\u{1B}[C".utf8) + case 125: + return Data("\u{1B}[B".utf8) + case 126: + return Data("\u{1B}[A".utf8) + default: + break + } + + if let chars = event.characters, !chars.isEmpty { + return Data(chars.utf8) + } + + return nil + } + + func updateSize(in view: TerminalRenderView) { + guard let session else { return } + let bounds = view.bounds + guard bounds.width > 0, bounds.height > 0 else { return } + + let ctFont = baseFont as CTFont + var glyph = CTFontGetGlyphWithName(ctFont, "W" as CFString) + if glyph == 0 { + var chars: [UniChar] = [UniChar(0x57)] + var glyphs: [CGGlyph] = [0] + _ = CTFontGetGlyphsForCharacters(ctFont, &chars, &glyphs, 1) + glyph = glyphs[0] + } + var advance = CGSize.zero + if glyph != 0 { + _ = CTFontGetAdvancesForGlyphs(ctFont, .horizontal, &glyph, &advance, 1) + } + let fallbackSize = ("W" as NSString).size(withAttributes: [.font: baseFont]) + let maxAdvance = baseFont.maximumAdvancement.width + let widthCandidate = max(maxAdvance, fallbackSize.width) + // Use actual glyph advance to match what terminal programs expect + let cellWidth = max(7.0, max(widthCandidate, advance.width)) + let rawLineHeight = ceil(baseFont.ascender - baseFont.descender + baseFont.leading) + let lineHeight = max(1, rawLineHeight + fontConfig.lineHeightPadding) + let baselineOffset = floor(baseFont.ascender) + (fontConfig.lineHeightPadding / 2) + view.font = baseFont + view.cellSize = CGSize(width: cellWidth, height: lineHeight) + view.baselineOffset = baselineOffset + + let insets = view.contentInsets + let usableWidth = max(1, bounds.width - insets.left - insets.right) + let usableHeight = max(1, bounds.height - insets.top - insets.bottom) + let cols = max(1, Int(floor(usableWidth / cellWidth))) + let rows = max(1, Int(floor(usableHeight / lineHeight))) + + guard cols != lastCols || rows != lastRows else { return } + lastCols = cols + lastRows = rows + session.resize(cols: cols, rows: rows) + } + + func ensureFocus(in view: TerminalRenderView) { + guard view.window != nil else { return } + guard view.window?.firstResponder !== view else { return } + DispatchQueue.main.async { + view.window?.makeFirstResponder(view) + } + } + } +} + +final class TerminalRenderView: NSView { + private static let cornerRadius: CGFloat = 16 + + var snapshot: GhosttyRenderSnapshot? { + didSet { + needsDisplay = true + } + } + var font: NSFont = TerminalFontConfig.loadFromGhosttyConfig().resolveFont() { + didSet { + fontCache.removeAll() + needsDisplay = true + } + } + var cellSize: CGSize = CGSize(width: 8, height: 16) { + didSet { + needsDisplay = true + } + } + var baselineOffset: CGFloat = 0 { + didSet { + needsDisplay = true + } + } + var backgroundAlpha: CGFloat = 0.94 { + didSet { + needsDisplay = true + } + } + var overrideBackgroundColor: NSColor? { + didSet { + needsDisplay = true + } + } + var contentInsets: NSEdgeInsets = NSEdgeInsets(top: 8, left: 12, bottom: 8, right: 12) + var onKeyDown: ((NSEvent) -> Void)? + var onLayout: ((NSSize) -> Void)? + + private var fontCache: [UInt8: NSFont] = [:] + + override init(frame frameRect: NSRect) { + super.init(frame: frameRect) + configureLayer() + } + + required init?(coder: NSCoder) { + super.init(coder: coder) + configureLayer() + } + + override var acceptsFirstResponder: Bool { true } + override var isFlipped: Bool { true } + + override func keyDown(with event: NSEvent) { + onKeyDown?(event) + } + + override func mouseDown(with event: NSEvent) { + window?.makeFirstResponder(self) + super.mouseDown(with: event) + } + + override func viewDidMoveToWindow() { + super.viewDidMoveToWindow() + window?.makeFirstResponder(self) + } + + override func layout() { + super.layout() + onLayout?(bounds.size) + } + + override func draw(_ dirtyRect: NSRect) { + guard let snapshot else { return } + let context = NSGraphicsContext.current?.cgContext + context?.saveGState() + defer { context?.restoreGState() } + + let baseBackground = nsColor(snapshot.defaultBackground) + let baseForeground = nsColor(snapshot.defaultForeground) + let effectiveBackground = overrideBackgroundColor ?? baseBackground + let renderBackground = effectiveBackground.withAlphaComponent( + min(effectiveBackground.alphaComponent, backgroundAlpha) + ) + renderBackground.setFill() + bounds.fill() + let effectiveForeground = adjustedForeground(baseForeground, for: effectiveBackground) + + let startX = contentInsets.left + let startY = contentInsets.top + let maxCols = min(snapshot.cols, Int((bounds.width - contentInsets.left - contentInsets.right) / cellSize.width)) + let maxRows = min(snapshot.rows, Int((bounds.height - contentInsets.top - contentInsets.bottom) / cellSize.height)) + guard maxCols > 0, maxRows > 0 else { return } + + for row in 0..= 2 { + continue + } + + let x = startX + CGFloat(col) * cellSize.width + let y = startY + CGFloat(row) * cellSize.height + let width = cell.wide == 1 ? cellSize.width * 2 : cellSize.width + let cellRect = CGRect(x: x, y: y, width: width, height: cellSize.height) + + let inverse = (cell.flags & 0x8) != 0 + let fgIsDefault = isDefaultColor(cell.foreground, fallback: snapshot.defaultForeground) + let bgIsDefault = isDefaultColor(cell.background, fallback: snapshot.defaultBackground) + var fg = fgIsDefault ? effectiveForeground : nsColor(cell.foreground) + var bg = bgIsDefault ? effectiveBackground : nsColor(cell.background) + if inverse { + swap(&fg, &bg) + } + if (cell.flags & 0x10) != 0 { + fg = fg.withAlphaComponent(fg.alphaComponent * 0.6) + } + + if !bgIsDefault || inverse { + bg.setFill() + cellRect.fill() + } + + if cell.codepoint == 0 || (cell.flags & 0x80) != 0 { + continue + } + + guard let scalar = UnicodeScalar(cell.codepoint) else { continue } + let string = String(scalar) + let font = fontForCellFlags(cell.flags) + + let adjustedFg = adjustedForeground(fg, for: bgIsDefault ? effectiveBackground : bg) + let attributes: [NSAttributedString.Key: Any] = [ + .font: font, + .foregroundColor: adjustedFg, + .underlineStyle: (cell.flags & 0x4) != 0 ? NSUnderlineStyle.single.rawValue : 0, + .strikethroughStyle: (cell.flags & 0x20) != 0 ? NSUnderlineStyle.single.rawValue : 0 + ] + + let codepoint = cell.codepoint + + if codepoint >= 0x2500 && codepoint <= 0x257F { + context?.saveGState() + drawBoxDrawingCharacter(codepoint, in: cellRect, color: adjustedFg) + context?.restoreGState() + } else if codepoint >= 0x2580 && codepoint <= 0x259F { + context?.saveGState() + drawBlockElement(codepoint, in: cellRect, color: adjustedFg) + context?.restoreGState() + } else if let ctx = context { + // Use Core Text for reliable text rendering in flipped view + let attrString = NSAttributedString(string: string, attributes: attributes) + let line = CTLineCreateWithAttributedString(attrString) + + // Get typographic bounds for proper positioning + var ascent: CGFloat = 0 + var descent: CGFloat = 0 + var leading: CGFloat = 0 + CTLineGetTypographicBounds(line, &ascent, &descent, &leading) + + // In flipped view, position baseline correctly + // The text origin should place the baseline at the right vertical position + let textY = y + ascent + (cellSize.height - ascent - descent) / 2 + + ctx.saveGState() + ctx.textMatrix = CGAffineTransform(scaleX: 1, y: -1) + ctx.textPosition = CGPoint(x: x, y: textY) + CTLineDraw(line, ctx) + ctx.restoreGState() + } + } + } + + drawCursor( + snapshot: snapshot, + startX: startX, + startY: startY, + defaultBackground: effectiveBackground, + defaultForeground: effectiveForeground + ) + } + + private func drawCursor( + snapshot: GhosttyRenderSnapshot, + startX: CGFloat, + startY: CGFloat, + defaultBackground: NSColor, + defaultForeground: NSColor + ) { + guard snapshot.cursor.isVisible else { return } + let maxCols = Int((bounds.width - contentInsets.left - contentInsets.right) / cellSize.width) + let maxRows = Int((bounds.height - contentInsets.top - contentInsets.bottom) / cellSize.height) + var col = snapshot.cursor.x + let row = min(snapshot.cursor.y, maxRows - 1) + guard col >= 0, row >= 0 else { return } + guard col < maxCols, row < maxRows else { return } + + if snapshot.cursor.wideTail, col > 0 { + col -= 1 + } + let x = startX + CGFloat(col) * cellSize.width + let y = startY + CGFloat(row) * cellSize.height + let width = cellSize.width * (snapshot.cursor.wideTail ? 2 : 1) + let height = cellSize.height + let rect = CGRect(x: x, y: y, width: width, height: height) + + let cursorRaw = snapshot.cursorColor + var color = nsColor(cursorRaw) + if cursorRaw.alpha == 0 || cursorRaw == snapshot.defaultBackground { + color = defaultForeground.withAlphaComponent(0.85) + } + color.setFill() + + switch snapshot.cursor.style { + case .bar: + CGRect(x: rect.minX, y: rect.minY, width: max(1, rect.width * 0.15), height: rect.height).fill() + case .underline: + CGRect(x: rect.minX, y: rect.maxY - max(1, rect.height * 0.1), width: rect.width, height: max(1, rect.height * 0.1)).fill() + case .block: + NSBezierPath(rect: rect).fill() + case .blockHollow: + color.setStroke() + NSBezierPath(rect: rect).stroke() + } + } + + private func configureLayer() { + wantsLayer = true + layer?.cornerRadius = Self.cornerRadius + layer?.cornerCurve = .continuous + layer?.masksToBounds = true + } + + private func nsColor(_ color: GhosttyRenderColor) -> NSColor { + let alpha = color.alpha == 0 ? UInt8(255) : color.alpha + return NSColor( + calibratedRed: CGFloat(color.red) / 255.0, + green: CGFloat(color.green) / 255.0, + blue: CGFloat(color.blue) / 255.0, + alpha: CGFloat(alpha) / 255.0 + ) + } + + private func isDefaultColor(_ color: GhosttyRenderColor, fallback: GhosttyRenderColor) -> Bool { + color.alpha == 0 || color == fallback + } + + private func adjustedForeground(_ fg: NSColor, for bg: NSColor) -> NSColor { + guard let fgRGB = fg.usingColorSpace(.deviceRGB), let bgRGB = bg.usingColorSpace(.deviceRGB) else { + return fg + } + let fgLum = 0.2126 * fgRGB.redComponent + 0.7152 * fgRGB.greenComponent + 0.0722 * fgRGB.blueComponent + let bgLum = 0.2126 * bgRGB.redComponent + 0.7152 * bgRGB.greenComponent + 0.0722 * bgRGB.blueComponent + let contrast = abs(fgLum - bgLum) + guard contrast < 0.25 else { return fg } + if bgLum < 0.5 { + return NSColor(white: 0.92, alpha: fgRGB.alphaComponent) + } + return NSColor(white: 0.08, alpha: fgRGB.alphaComponent) + } + + private func fontForCellFlags(_ flags: UInt16) -> NSFont { + var key: UInt8 = 0 + if (flags & 0x1) != 0 { key |= 0x1 } + if (flags & 0x2) != 0 { key |= 0x2 } + if let cached = fontCache[key] { return cached } + + // For regular text, return the base font directly + // This avoids font family loss from withSymbolicTraits + guard key != 0 else { + fontCache[0] = font + return font + } + + // For bold/italic, use NSFontManager which preserves the font family better + let manager = NSFontManager.shared + var styled = font + if (flags & 0x1) != 0 { + styled = manager.convert(styled, toHaveTrait: .boldFontMask) + } + if (flags & 0x2) != 0 { + styled = manager.convert(styled, toHaveTrait: .italicFontMask) + } + fontCache[key] = styled + return styled + } + + /** + * Draws box-drawing characters (U+2500-U+257F) programmatically. + * This ensures clean connections between adjacent cells regardless of font. + */ + private func drawBoxDrawingCharacter(_ codepoint: UInt32, in rect: CGRect, color: NSColor) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + + let lineWidth: CGFloat = max(1, min(rect.width, rect.height) * 0.08) + context.setStrokeColor(color.cgColor) + context.setFillColor(color.cgColor) + context.setLineWidth(lineWidth) + context.setLineCap(.square) + + let midX = rect.midX + let midY = rect.midY + let minX = rect.minX + let maxX = rect.maxX + let minY = rect.minY + let maxY = rect.maxY + + let index = Int(codepoint - 0x2500) + let lines = boxDrawingLines[min(index, boxDrawingLines.count - 1)] + + if lines.left { context.move(to: CGPoint(x: minX, y: midY)); context.addLine(to: CGPoint(x: midX, y: midY)) } + if lines.right { context.move(to: CGPoint(x: midX, y: midY)); context.addLine(to: CGPoint(x: maxX, y: midY)) } + if lines.up { context.move(to: CGPoint(x: midX, y: minY)); context.addLine(to: CGPoint(x: midX, y: midY)) } + if lines.down { context.move(to: CGPoint(x: midX, y: midY)); context.addLine(to: CGPoint(x: midX, y: maxY)) } + + context.strokePath() + } + + /** + * Draws block elements (U+2580-U+259F) programmatically. + */ + private func drawBlockElement(_ codepoint: UInt32, in rect: CGRect, color: NSColor) { + guard let context = NSGraphicsContext.current?.cgContext else { return } + context.setFillColor(color.cgColor) + + let fillRect: CGRect + switch codepoint { + case 0x2580: // ▀ Upper half + fillRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width, height: rect.height / 2) + case 0x2584: // ▄ Lower half + fillRect = CGRect(x: rect.minX, y: rect.midY, width: rect.width, height: rect.height / 2) + case 0x2588: // █ Full block + fillRect = rect + case 0x258C: // ▌ Left half + fillRect = CGRect(x: rect.minX, y: rect.minY, width: rect.width / 2, height: rect.height) + case 0x2590: // ▐ Right half + fillRect = CGRect(x: rect.midX, y: rect.minY, width: rect.width / 2, height: rect.height) + case 0x2591: // ░ Light shade (25%) + context.setFillColor(color.withAlphaComponent(0.25).cgColor) + fillRect = rect + case 0x2592: // ▒ Medium shade (50%) + context.setFillColor(color.withAlphaComponent(0.5).cgColor) + fillRect = rect + case 0x2593: // ▓ Dark shade (75%) + context.setFillColor(color.withAlphaComponent(0.75).cgColor) + fillRect = rect + default: + fillRect = rect + } + context.fill(fillRect) + } + + /// Lookup table for box-drawing character line segments + private var boxDrawingLines: [(left: Bool, right: Bool, up: Bool, down: Bool)] { + [ + // 2500-250F: Basic horizontal/vertical lines + (true, true, false, false), // ─ 2500 + (true, true, false, false), // ━ 2501 (heavy) + (false, false, true, true), // │ 2502 + (false, false, true, true), // ┃ 2503 (heavy) + (true, true, false, false), // ┄ 2504 (triple dash) + (true, true, false, false), // ┅ 2505 + (false, false, true, true), // ┆ 2506 + (false, false, true, true), // ┇ 2507 + (true, true, false, false), // ┈ 2508 (quadruple dash) + (true, true, false, false), // ┉ 2509 + (false, false, true, true), // ┊ 250A + (false, false, true, true), // ┋ 250B + (false, true, false, true), // ┌ 250C (down and right) + (false, true, false, true), // ┍ 250D + (false, true, false, true), // ┎ 250E + (false, true, false, true), // ┏ 250F + + // 2510-251F: More corners + (true, false, false, true), // ┐ 2510 (down and left) + (true, false, false, true), // ┑ 2511 + (true, false, false, true), // ┒ 2512 + (true, false, false, true), // ┓ 2513 + (false, true, true, false), // └ 2514 (up and right) + (false, true, true, false), // ┕ 2515 + (false, true, true, false), // ┖ 2516 + (false, true, true, false), // ┗ 2517 + (true, false, true, false), // ┘ 2518 (up and left) + (true, false, true, false), // ┙ 2519 + (true, false, true, false), // ┚ 251A + (true, false, true, false), // ┛ 251B + (false, true, true, true), // ├ 251C (vertical and right) + (false, true, true, true), // ┝ 251D + (false, true, true, true), // ┞ 251E + (false, true, true, true), // ┟ 251F + + // 2520-252F: T-junctions + (false, true, true, true), // ┠ 2520 + (false, true, true, true), // ┡ 2521 + (false, true, true, true), // ┢ 2522 + (false, true, true, true), // ┣ 2523 + (true, false, true, true), // ┤ 2524 (vertical and left) + (true, false, true, true), // ┥ 2525 + (true, false, true, true), // ┦ 2526 + (true, false, true, true), // ┧ 2527 + (true, false, true, true), // ┨ 2528 + (true, false, true, true), // ┩ 2529 + (true, false, true, true), // ┪ 252A + (true, false, true, true), // ┫ 252B + (true, true, false, true), // ┬ 252C (down and horizontal) + (true, true, false, true), // ┭ 252D + (true, true, false, true), // ┮ 252E + (true, true, false, true), // ┯ 252F + + // 2530-253F: More T-junctions + (true, true, false, true), // ┰ 2530 + (true, true, false, true), // ┱ 2531 + (true, true, false, true), // ┲ 2532 + (true, true, false, true), // ┳ 2533 + (true, true, true, false), // ┴ 2534 (up and horizontal) + (true, true, true, false), // ┵ 2535 + (true, true, true, false), // ┶ 2536 + (true, true, true, false), // ┷ 2537 + (true, true, true, false), // ┸ 2538 + (true, true, true, false), // ┹ 2539 + (true, true, true, false), // ┺ 253A + (true, true, true, false), // ┻ 253B + (true, true, true, true), // ┼ 253C (cross) + (true, true, true, true), // ┽ 253D + (true, true, true, true), // ┾ 253E + (true, true, true, true), // ┿ 253F + + // 2540-254F: Cross variants + (true, true, true, true), // ╀ 2540 + (true, true, true, true), // ╁ 2541 + (true, true, true, true), // ╂ 2542 + (true, true, true, true), // ╃ 2543 + (true, true, true, true), // ╄ 2544 + (true, true, true, true), // ╅ 2545 + (true, true, true, true), // ╆ 2546 + (true, true, true, true), // ╇ 2547 + (true, true, true, true), // ╈ 2548 + (true, true, true, true), // ╉ 2549 + (true, true, true, true), // ╊ 254A + (true, true, true, true), // ╋ 254B + (true, true, false, false), // ╌ 254C (light double dash) + (true, true, false, false), // ╍ 254D + (false, false, true, true), // ╎ 254E + (false, false, true, true), // ╏ 254F + + // 2550-255F: Double lines + (true, true, false, false), // ═ 2550 + (false, false, true, true), // ║ 2551 + (false, true, false, true), // ╒ 2552 + (false, true, false, true), // ╓ 2553 + (false, true, false, true), // ╔ 2554 + (true, false, false, true), // ╕ 2555 + (true, false, false, true), // ╖ 2556 + (true, false, false, true), // ╗ 2557 + (false, true, true, false), // ╘ 2558 + (false, true, true, false), // ╙ 2559 + (false, true, true, false), // ╚ 255A + (true, false, true, false), // ╛ 255B + (true, false, true, false), // ╜ 255C + (true, false, true, false), // ╝ 255D + (false, true, true, true), // ╞ 255E + (false, true, true, true), // ╟ 255F + + // 2560-256F: More double line combinations + (false, true, true, true), // ╠ 2560 + (true, false, true, true), // ╡ 2561 + (true, false, true, true), // ╢ 2562 + (true, false, true, true), // ╣ 2563 + (true, true, false, true), // ╤ 2564 + (true, true, false, true), // ╥ 2565 + (true, true, false, true), // ╦ 2566 + (true, true, true, false), // ╧ 2567 + (true, true, true, false), // ╨ 2568 + (true, true, true, false), // ╩ 2569 + (true, true, true, true), // ╪ 256A + (true, true, true, true), // ╫ 256B + (true, true, true, true), // ╬ 256C + (false, false, false, true), // ╭ 256D (arc down and right) + (false, false, false, true), // ╮ 256E (arc down and left) + (false, false, true, false), // ╯ 256F (arc up and left) + + // 2570-257F: Arcs and diagonals + (false, false, true, false), // ╰ 2570 (arc up and right) + (true, true, true, true), // ╱ 2571 (diagonal) + (true, true, true, true), // ╲ 2572 (diagonal) + (true, true, true, true), // ╳ 2573 (diagonal cross) + (true, false, false, false), // ╴ 2574 (left) + (false, false, true, false), // ╵ 2575 (up) + (false, true, false, false), // ╶ 2576 (right) + (false, false, false, true), // ╷ 2577 (down) + (true, false, false, false), // ╸ 2578 (heavy left) + (false, false, true, false), // ╹ 2579 (heavy up) + (false, true, false, false), // ╺ 257A (heavy right) + (false, false, false, true), // ╻ 257B (heavy down) + (true, true, false, false), // ╼ 257C (light left heavy right) + (false, false, true, true), // ╽ 257D (light up heavy down) + (true, true, false, false), // ╾ 257E (heavy left light right) + (false, false, true, true), // ╿ 257F (heavy up light down) + ] + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalView.swift new file mode 100644 index 000000000..76455441f --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GhosttyTerminalView.swift @@ -0,0 +1,9 @@ +import SwiftUI + +struct GhosttyTerminalView: View { + @Bindable var session: GhosttyTerminalSession + + var body: some View { + GhosttyTerminalTextView(session: session) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift new file mode 100644 index 000000000..c54e40d2e --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/GlassCard.swift @@ -0,0 +1,56 @@ +import SwiftUI + +struct GlassCard: View { + let title: String? + let systemImage: String? + @ViewBuilder let content: Content + + init(title: String? = nil, systemImage: String? = nil, @ViewBuilder content: () -> Content) { + self.title = title + self.systemImage = systemImage + self.content = content() + } + + var body: some View { + VStack(alignment: .leading, spacing: 12) { + if let title { + HStack(spacing: 8) { + if let systemImage { + Image(systemName: systemImage) + .foregroundStyle(.secondary) + } + Text(title) + .font(.headline) + } + } + content + } + .padding(16) + .cardBackground() + } +} + +private extension View { + @ViewBuilder + func cardBackground() -> some View { + if #available(macOS 26, *) { + self + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.regularMaterial) + ) + .clipShape(RoundedRectangle(cornerRadius: 16, style: .continuous)) + .glassEffect(.regular) + } else { + self + .background( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .fill(.thinMaterial) + ) + .overlay( + RoundedRectangle(cornerRadius: 16, style: .continuous) + .stroke(.primary.opacity(0.08), lineWidth: 1) + ) + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift new file mode 100644 index 000000000..58f5ba18d --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LabelBadge.swift @@ -0,0 +1,40 @@ +import SwiftUI + +struct BadgePill: View { + let label: String + let tint: Color + + var body: some View { + if #available(macOS 26, *) { + Text(label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(tint) + .glassEffect(.regular.tint(tint.opacity(0.15))) + } else { + Text(label) + .font(.caption2.weight(.semibold)) + .padding(.horizontal, 8) + .padding(.vertical, 4) + .foregroundStyle(tint) + .background( + Capsule(style: .continuous) + .fill(.thinMaterial) + .overlay( + Capsule(style: .continuous) + .stroke(tint.opacity(0.35), lineWidth: 1) + ) + ) + } + } +} + +struct LabelBadge: View { + let label: String + let color: Color + + var body: some View { + BadgePill(label: label, tint: color) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LogsView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LogsView.swift new file mode 100644 index 000000000..d936bbc4b --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/LogsView.swift @@ -0,0 +1,95 @@ +import SwiftUI + +import HackDesktopModels + +struct LogsView: View { + let project: ProjectSummary + let embedded: Bool + let onClose: (() -> Void)? + @State private var session: GhosttyTerminalSession + + init(project: ProjectSummary, embedded: Bool = false, onClose: (() -> Void)? = nil) { + self.project = project + self.embedded = embedded + self.onClose = onClose + _session = State(initialValue: GhosttyTerminalSession(project: project)) + } + + var body: some View { + @Bindable var session = session + + VStack(alignment: .leading, spacing: embedded ? 12 : 16) { + if !embedded { + header(session: session) + } else { + embeddedHeader(session: session) + } + if session.isAvailable { + GhosttyTerminalView(session: session) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .terminalSurface() + } else { + unavailableView + } + Spacer(minLength: 0) + } + .padding(embedded ? .horizontal : .all, embedded ? 24 : 20) + .padding(.bottom, embedded ? 24 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { session.start() } + .onDisappear { session.stop() } + } + + private var unavailableView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Terminal unavailable") + .font(.subheadline.weight(.medium)) + } + Text("Run `bun run macos:ghostty:setup` to build the Ghostty VT library.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + } + + @ViewBuilder + private func embeddedHeader(session: GhosttyTerminalSession) -> some View { + HStack { + Text(session.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + + @ViewBuilder + private func header(session: GhosttyTerminalSession) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("Logs") + .font(.title2) + .bold() + Text(project.name) + .font(.headline) + Text(session.statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + if let onClose { + Button("Back") { + onClose() + } + .adaptiveToolbarButton() + } + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift new file mode 100644 index 000000000..3881061f1 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/MenuBarView.swift @@ -0,0 +1,302 @@ +import AppKit +import SwiftUI + +import HackDesktopModels + +public struct MenuBarView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + + public init() {} + + public var body: some View { + // System Status + systemStatusSection + + Divider() + + // Projects + projectsSection + + Divider() + + // Footer + Button("Refresh") { + Task { await model.refresh() } + } + .keyboardShortcut("r", modifiers: .command) + + Button("Open Hack Desktop") { + activateApp() + } + .keyboardShortcut("o", modifiers: .command) + + Divider() + + Button("Quit") { + NSApp.terminate(nil) + } + .keyboardShortcut("q", modifiers: .command) + } + + // MARK: - System Status + + @ViewBuilder + private var systemStatusSection: some View { + let runtimeIcon = runtimeStatusIcon + let gatewayIcon = gatewayStatusIcon + let daemonIcon = daemonStatusIcon + + Button { + // No action - informational + } label: { + Label("Runtime: \(runtimeStatusText)", systemImage: runtimeIcon) + } + .disabled(true) + + Button { + // No action - informational + } label: { + Label("Gateway: \(gatewayStatusText)", systemImage: gatewayIcon) + } + .disabled(true) + + // hackd submenu + Menu { + if canStartDaemon { + Button("Start hackd") { + Task { await model.startDaemon() } + } + } + if canStopDaemon { + Button("Stop hackd") { + Task { await model.stopDaemon() } + } + } + if canRestartDaemon { + Button("Restart hackd") { + Task { await model.restartDaemon() } + } + } + if canClearDaemon { + Divider() + Button("Clear Stale State") { + Task { await model.clearDaemon() } + } + } + } label: { + Label("hackd: \(daemonStatusText)", systemImage: daemonIcon) + } + } + + // MARK: - Projects + + @ViewBuilder + private var projectsSection: some View { + let projects = Array(runningProjects.prefix(8)) + + if projects.isEmpty { + Button { + // No action + } label: { + Label("No running projects", systemImage: "tray") + } + .disabled(true) + } else { + ForEach(projects) { project in + projectMenu(for: project) + } + + if runningProjects.count > 8 { + Button { + activateApp() + } label: { + Label("+ \(runningProjects.count - 8) more...", systemImage: "ellipsis") + } + } + } + } + + @ViewBuilder + private func projectMenu(for project: ProjectSummary) -> some View { + let icon = projectIcon(for: project) + + Menu { + if project.status == .running { + Button("Stop") { + Task { await model.stopProject(project) } + } + } else { + Button("Start") { + Task { await model.startProject(project) } + } + } + + if let url = devUrl(for: project) { + Divider() + Button("Open in Browser") { + openURL(url) + } + } + + Divider() + + Button("View Logs") { + activateApp() + model.showLogs(for: project) + } + + Button("Open Shell") { + activateApp() + model.showShell(for: project) + } + + if let path = project.repoRoot ?? project.projectDir { + Divider() + Button("Show in Finder") { + NSWorkspace.shared.selectFile(nil, inFileViewerRootedAtPath: path) + } + } + } label: { + Label(projectLabel(for: project), systemImage: icon) + } + } + + // MARK: - Status Helpers + + private var runtimeStatusText: String { + if model.runtimeOverallOk == true { return "Healthy" } + if model.runtimeOverallOk == false { return "Degraded" } + return "Unknown" + } + + private var runtimeStatusIcon: String { + if model.runtimeOverallOk == true { return "checkmark.circle.fill" } + if model.runtimeOverallOk == false { return "exclamationmark.triangle.fill" } + return "questionmark.circle" + } + + private var gatewayStatusText: String { + model.gatewaySummaryState?.label ?? "Unknown" + } + + private var gatewayStatusIcon: String { + guard let state = model.gatewaySummaryState else { return "questionmark.circle" } + switch state { + case .running: return "checkmark.circle.fill" + case .configured: return "gear.circle" + case .disabled: return "minus.circle" + case .unknown: return "questionmark.circle" + } + } + + private var daemonStatusText: String { + switch model.daemonStatus?.resolvedLabel { + case .running: return "Running" + case .starting: return "Starting" + case .stale: return "Stale" + case .stopped: return "Stopped" + case .none: return "Unknown" + } + } + + private var daemonStatusIcon: String { + switch model.daemonStatus?.resolvedLabel { + case .running: return "checkmark.circle.fill" + case .starting: return "clock.fill" + case .stale: return "exclamationmark.triangle.fill" + case .stopped: return "stop.circle" + case .none: return "questionmark.circle" + } + } + + private var canStartDaemon: Bool { + let label = model.daemonStatus?.resolvedLabel + return label != .running && label != .starting + } + + private var canStopDaemon: Bool { + let label = model.daemonStatus?.resolvedLabel + return label == .running || label == .starting + } + + private var canRestartDaemon: Bool { + model.daemonStatus?.resolvedLabel == .running + } + + private var canClearDaemon: Bool { + model.daemonStatus?.stale == true + } + + // MARK: - Project Helpers + + private func projectLabel(for project: ProjectSummary) -> String { + if let host = project.devHost { + return "\(project.name) — \(host)" + } + return project.name + } + + private func projectIcon(for project: ProjectSummary) -> String { + guard project.isRuntimeConfigured else { return "puzzlepiece" } + + let status = project.runtimeStatus ?? fallbackStatus(for: project) + if status == .running && model.runtimeOverallOk == false { + return "exclamationmark.circle.fill" + } + + switch status { + case .running: return "circle.fill" + case .stopped: return "circle" + case .missing: return "xmark.circle.fill" + case .unknown: return "questionmark.circle" + case .notConfigured: return "circle" + } + } + + private func fallbackStatus(for project: ProjectSummary) -> ProjectRuntimeStatus { + switch project.status { + case .running: return .running + case .stopped: return .stopped + case .missing: return .missing + case .unknown, .unregistered: return .unknown + } + } + + private var menuProjects: [ProjectSummary] { + model.projects.sorted { left, right in + let leftRank = projectRank(left) + let rightRank = projectRank(right) + if leftRank != rightRank { return leftRank < rightRank } + return left.name.localizedCaseInsensitiveCompare(right.name) == .orderedAscending + } + } + + private func projectRank(_ project: ProjectSummary) -> Int { + if project.status == .running { return 0 } + if project.isRuntimeConfigured { return 1 } + if project.isExtensionOnly { return 2 } + return 3 + } + + private var runtimeProjects: [ProjectSummary] { + menuProjects.filter { $0.isRuntimeConfigured } + } + + private var runningProjects: [ProjectSummary] { + runtimeProjects.filter { $0.status == .running } + } + + private func devUrl(for project: ProjectSummary) -> URL? { + guard let host = project.devHost, !host.isEmpty else { return nil } + if host.contains("://") { return URL(string: host) } + return URL(string: "https://\(host)") + } + + private func activateApp() { + NSApp.activate(ignoringOtherApps: true) + if let window = NSApp.windows.first(where: { $0.canBecomeMain }) { + window.makeKeyAndOrderFront(nil) + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift new file mode 100644 index 000000000..ea5f04171 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectDetailView.swift @@ -0,0 +1,265 @@ +import SwiftUI + +import HackDesktopModels + +struct ProjectDetailView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + + let project: ProjectSummary + + var body: some View { + @Bindable var model = model + VStack(alignment: .leading, spacing: 0) { + headerWithTabs + .padding(.horizontal, 24) + .padding(.top, 24) + .padding(.bottom, 16) + + tabContent + } + } + + private var headerWithTabs: some View { + @Bindable var model = model + + return VStack(alignment: .leading, spacing: 16) { + header + if project.isRuntimeConfigured { + HStack(spacing: 16) { + Picker("Tab", selection: $model.selectedProjectTab) { + ForEach(ProjectTab.allCases, id: \.self) { tab in + Text(tab.rawValue).tag(tab) + } + } + .pickerStyle(.segmented) + .frame(maxWidth: 280) + + Spacer() + + primaryActionsBar + } + } else { + primaryActionsBar + } + } + } + + @ViewBuilder + private var tabContent: some View { + switch model.selectedProjectTab { + case .overview: + overviewContent + case .logs: + LogsView(project: project, embedded: true) + case .shell: + ShellView(project: project, embedded: true) + } + } + + private var overviewContent: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + if !project.isRuntimeConfigured { + runtimeNotConfiguredCard + } + overviewCard + pathsCard + } + .padding(.horizontal, 24) + .padding(.bottom, 24) + } + } + + private var header: some View { + VStack(alignment: .leading, spacing: 12) { + HStack(alignment: .center, spacing: 12) { + Image(systemName: project.isRuntimeConfigured ? "cube.transparent.fill" : "puzzlepiece.extension.fill") + .font(.title) + .foregroundStyle(project.isRuntimeConfigured ? .blue : .purple) + VStack(alignment: .leading, spacing: 4) { + Text(project.name) + .font(.title2.weight(.semibold)) + if let devHost = project.devHost { + Text(devHost) + .font(.subheadline) + .foregroundStyle(.secondary) + } else if let featureSummary = project.featureSummary { + Text(featureSummary) + .font(.subheadline) + .foregroundStyle(.secondary) + } + } + Spacer() + if project.isRuntimeConfigured { + RuntimeStatusBadge(status: runtimeStatus, runtimeHealthy: runtimeHealthy) + } else { + if let label = project.featureLabel { + LabelBadge(label: label, color: .purple) + } else { + LabelBadge(label: "Extensions", color: .purple) + } + } + } + } + } + + private var primaryActionsBar: some View { + HStack(spacing: 12) { + if canStart { + Button { + Task { await model.startProject(project) } + } label: { + Label("Start", systemImage: "play.fill") + } + .adaptiveToolbarButtonProminent() + } + + if canStop { + Button { + Task { await model.stopProject(project) } + } label: { + Label("Stop", systemImage: "stop.fill") + } + .buttonStyle(.adaptiveDestructive) + } + + if devUrl != nil { + Button { + if let url = devUrl { openURL(url) } + } label: { + Label("Open", systemImage: "arrow.up.right") + } + .adaptiveToolbarButton() + } + } + } + + private var runtimeNotConfiguredCard: some View { + GlassCard { + VStack(alignment: .leading, spacing: 12) { + HStack(spacing: 10) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + .font(.title3) + Text("Runtime not configured") + .font(.headline) + } + Text("This project uses extensions but doesn't have a runtime configuration. Runtime features like start/stop, logs, and shell access require a hack.json or docker-compose setup.") + .font(.subheadline) + .foregroundStyle(.secondary) + .fixedSize(horizontal: false, vertical: true) + if let features = project.featureSummary { + HStack(spacing: 6) { + Text("Available:") + .font(.caption) + .foregroundStyle(.secondary) + Text(features) + .font(.caption.weight(.medium)) + .foregroundStyle(.primary) + } + .padding(.top, 4) + } + } + } + } + + private var overviewCard: some View { + GlassCard(title: "Overview", systemImage: "rectangle.stack") { + DetailRows(rows: overviewRows) + } + } + + private var pathsCard: some View { + let rows = pathRows + return Group { + if rows.isEmpty { + EmptyView() + } else { + GlassCard(title: "Paths", systemImage: "folder") { + DetailRows(rows: rows) + } + } + } + } + + private var overviewRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if let devHost = project.devHost { + rows.append(DetailRowItem(label: "Dev host", value: devHost)) + } + if let services = project.definedServices, !services.isEmpty { + rows.append(DetailRowItem(label: "Services", value: services.joined(separator: ", "))) + } + if let featureSummary = project.featureSummary { + rows.append(DetailRowItem(label: "Features", value: featureSummary)) + } + if project.isRuntimeConfigured { + rows.append(DetailRowItem(label: "Runtime", value: runtimeStatusValue)) + rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) + rows.append(DetailRowItem(label: "Status", value: project.status.rawValue)) + } else { + rows.append(DetailRowItem(label: "Runtime", value: "Not configured")) + rows.append(DetailRowItem(label: "Kind", value: project.kind.rawValue)) + } + return rows + } + + private var pathRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if let repoRoot = project.repoRoot { + rows.append(DetailRowItem(label: "Repo root", value: repoRoot)) + } + if let projectDir = project.projectDir, projectDir != project.repoRoot { + rows.append(DetailRowItem(label: "Project dir", value: projectDir)) + } + return rows + } + + private var devUrl: URL? { + guard let host = project.devHost, !host.isEmpty else { return nil } + if host.contains("://") { + return URL(string: host) + } + return URL(string: "https://\(host)") + } + + private var canStart: Bool { + project.isRuntimeConfigured && (project.status == .stopped || project.status == .unknown || project.status == .unregistered) + } + + private var canStop: Bool { + project.isRuntimeConfigured && project.status == .running + } + + private var runtimeStatus: ProjectRuntimeStatus { + project.runtimeStatus ?? fallbackRuntimeStatus + } + + private var runtimeHealthy: Bool? { + model.runtimeOverallOk + } + + private var runtimeStatusValue: String { + let base = project.runtimeStatusLabel + if runtimeHealthy == false, runtimeStatus == .running { + return "\(base) (degraded)" + } + return base + } + + private var fallbackRuntimeStatus: ProjectRuntimeStatus { + switch project.status { + case .running: + return .running + case .stopped: + return .stopped + case .missing: + return .missing + case .unknown: + return .unknown + case .unregistered: + return .unknown + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRowView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRowView.swift new file mode 100644 index 000000000..c0918ca8f --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectRowView.swift @@ -0,0 +1,65 @@ +import SwiftUI + +import HackDesktopModels + +struct ProjectRowView: View { + let project: ProjectSummary + let runtimeHealthy: Bool? + + var body: some View { + VStack(alignment: .leading, spacing: 4) { + HStack { + Image(systemName: project.isRuntimeConfigured ? "cube.transparent" : "puzzlepiece") + .foregroundStyle(.secondary) + .font(.subheadline) + Text(project.name) + .font(.subheadline.weight(.semibold)) + .lineLimit(1) + .frame(maxWidth: .infinity, alignment: .leading) + .layoutPriority(1) + Spacer() + if project.isRuntimeConfigured, let dotStatus = sidebarStatus { + RuntimeStatusDot(status: dotStatus, runtimeHealthy: runtimeHealthy) + } + } + if let devHost = project.devHost { + Text(devHost) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } else if let featureSummary = project.featureSummary { + Text(featureSummary) + .font(.caption) + .foregroundStyle(.secondary) + .lineLimit(1) + } + } + .padding(.vertical, 4) + } + + private var sidebarStatus: ProjectRuntimeStatus? { + guard project.isRuntimeConfigured else { return nil } + let runtimeStatus = project.runtimeStatus ?? fallbackRuntimeStatus + switch runtimeStatus { + case .running, .missing, .unknown: + return runtimeStatus + case .stopped, .notConfigured: + return nil + } + } + + private var fallbackRuntimeStatus: ProjectRuntimeStatus { + switch project.status { + case .running: + return .running + case .stopped: + return .stopped + case .missing: + return .missing + case .unknown: + return .unknown + case .unregistered: + return .unknown + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift new file mode 100644 index 000000000..eca70808f --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ProjectSummary+Capabilities.swift @@ -0,0 +1,69 @@ +import HackDesktopModels + +extension ProjectSummary { + var featureList: [String] { + features ?? [] + } + + var featureLabel: String? { + let names = featureList.map { displayFeatureName($0) } + guard !names.isEmpty else { return nil } + if names.count == 1 { return names[0] } + return "Extensions" + } + + var featureSummary: String? { + let names = featureList.map { displayFeatureName($0) } + guard !names.isEmpty else { return nil } + return names.joined(separator: " · ") + } + + var isRuntimeConfigured: Bool { + if let runtimeConfigured { + return runtimeConfigured + } + return definedServices != nil + } + + var isExtensionOnly: Bool { + !isRuntimeConfigured && !featureList.isEmpty + } + + var runtimeStatusLabel: String { + if let runtimeStatus { + return displayRuntimeStatus(runtimeStatus) + } + return status.rawValue.replacingOccurrences(of: "_", with: " ") + } + + private func displayFeatureName(_ feature: String) -> String { + switch feature { + case "tickets": + return "Tickets" + case "cloudflare": + return "Cloudflare" + case "tailscale": + return "Tailscale" + default: + if let trimmed = feature.split(separator: ".").last { + return trimmed.capitalized + } + return feature.capitalized + } + } + + private func displayRuntimeStatus(_ status: ProjectRuntimeStatus) -> String { + switch status { + case .running: + return "Running" + case .stopped: + return "Stopped" + case .missing: + return "Missing" + case .unknown: + return "Unknown" + case .notConfigured: + return "Not configured" + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift new file mode 100644 index 000000000..7e6b45bdc --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/RuntimeDetailView.swift @@ -0,0 +1,431 @@ +import SwiftUI + +import HackDesktopModels + +struct RuntimeDetailView: View { + @Environment(DashboardModel.self) private var model + @Environment(\.openURL) private var openURL + @State private var showDaemonDetails = false + @State private var showRuntimeDetails = false + + var body: some View { + ScrollView { + VStack(alignment: .leading, spacing: 20) { + header + statusSummaryBar + daemonCard + runtimeCard + globalServicesSection + } + .padding(24) + } + } + + private var statusSummaryBar: some View { + HStack(spacing: 16) { + statusIndicator( + label: "Daemon", + isOk: daemonIsRunning, + icon: daemonIsRunning ? "bolt.horizontal.fill" : "bolt.horizontal" + ) + statusIndicator( + label: "Runtime", + isOk: model.runtimeOverallOk == true, + icon: model.runtimeOverallOk == true ? "checkmark.seal.fill" : "checkmark.seal" + ) + statusIndicator( + label: "Services", + isOk: model.globalStatus?.summary.ok == true, + icon: model.globalStatus?.summary.ok == true ? "network" : "network.slash" + ) + Spacer() + } + .padding(.horizontal, 4) + } + + private func statusIndicator(label: String, isOk: Bool, icon: String) -> some View { + HStack(spacing: 4) { + Image(systemName: icon) + .foregroundStyle(isOk ? .green : .orange) + .font(.caption) + Text(label) + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.horizontal, 8) + .padding(.vertical, 4) + .background( + RoundedRectangle(cornerRadius: 8, style: .continuous) + .fill(isOk ? Color.green.opacity(0.1) : Color.orange.opacity(0.1)) + ) + } + + private var header: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(alignment: .center, spacing: 12) { + Label("Runtime", systemImage: "gauge") + .font(.title2.weight(.semibold)) + StatusPill(text: runtimeStatusText, tone: runtimeStatusTone) + Spacer() + if canStopDaemon { + Button(daemonActionTitle) { + Task { await model.stopDaemon() } + } + .adaptiveToolbarButton() + } else if canStartDaemon { + Button(daemonActionTitle) { + Task { await model.startDaemon() } + } + .adaptiveToolbarButtonProminent() + } + } + Text("Local daemon, runtime health, and global services") + .font(.subheadline) + .foregroundStyle(.secondary) + if let generatedAt = model.globalStatus?.generatedAt, !generatedAt.isEmpty { + Text("Last updated: \(generatedAt)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private var daemonCard: some View { + GlassCard(title: "Daemon", systemImage: "bolt.horizontal.circle") { + if canStartDaemon { + daemonStoppedGuidance + Divider() + } + HStack(spacing: 12) { + Button("Open Logs") { + if let logUrl = logUrl { + openURL(logUrl) + } + } + .disabled(logUrl == nil) + Button("Refresh Status") { + Task { await model.refresh() } + } + if canRestartDaemon { + Button("Restart") { + Task { await model.restartDaemon() } + } + } + if canClearDaemon { + Button("Clear State") { + Task { await model.clearDaemon() } + } + } + } + Divider() + DetailRows(rows: daemonPrimaryRows) + DisclosureGroup(isExpanded: $showDaemonDetails) { + DetailRows(rows: daemonDetailRows) + .padding(.top, 8) + } label: { + Text("Details") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(.top, 8) + } + } + + private var daemonStoppedGuidance: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "info.circle.fill") + .foregroundStyle(.blue) + Text("Daemon not running") + .font(.subheadline.weight(.medium)) + } + Text("The hack daemon manages your local development environment. Start it to enable project monitoring, logs, and gateway access.") + .font(.caption) + .foregroundStyle(.secondary) + Button { + Task { await model.startDaemon() } + } label: { + Label("Start hackd", systemImage: "play.fill") + } + .adaptiveToolbarButtonProminent() + .padding(.top, 4) + } + .padding(.vertical, 4) + } + + private var runtimeCard: some View { + GlassCard(title: "Runtime health", systemImage: "checkmark.seal") { + HStack { + StatusPill(text: runtimeStatusText, tone: runtimeStatusTone) + Spacer() + } + if let error = model.runtimeError, !error.isEmpty, model.runtimeOk != true { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + DetailRows(rows: runtimeRows) + } + } + + @ViewBuilder + private var globalServicesSection: some View { + if let status = model.globalStatus { + globalSummaryCard(summary: status.summary, generatedAt: status.generatedAt) + if let caddy = status.caddy { + composeCard(title: "Caddy", systemImage: "globe", group: caddy) + } + if let logging = status.logging { + composeCard(title: "Logging", systemImage: "waveform.path.ecg", group: logging) + } + if let networks = status.networks { + networksCard(networks) + } + } else { + GlassCard(title: "Global services", systemImage: "network.slash") { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Status unavailable") + .font(.subheadline.weight(.medium)) + } + Text("Global services status requires the daemon to be running. These services include Caddy (reverse proxy), logging infrastructure, and Docker networks.") + .font(.caption) + .foregroundStyle(.secondary) + if canStartDaemon { + Button { + Task { await model.startDaemon() } + } label: { + Label("Start hackd", systemImage: "play.fill") + } + .adaptiveToolbarButtonProminent() + .padding(.top, 4) + } else { + Button { + Task { await model.refresh() } + } label: { + Label("Refresh", systemImage: "arrow.clockwise") + } + .adaptiveToolbarButton() + .padding(.top, 4) + } + } + } + } + } + + private func globalSummaryCard(summary: GlobalStatusSummary, generatedAt: String?) -> some View { + GlassCard(title: "Global summary", systemImage: "network") { + DetailRows(rows: [ + DetailRowItem(label: "Overall", value: summary.ok ? "Healthy" : "Degraded"), + DetailRowItem(label: "Caddy", value: summary.caddyOk ? "Healthy" : "Degraded"), + DetailRowItem(label: "Logging", value: summary.loggingOk ? "Healthy" : "Degraded"), + DetailRowItem(label: "Networks", value: summary.networksOk ? "Healthy" : "Degraded") + ]) + if let generatedAt, !generatedAt.isEmpty { + Text("Generated at \(generatedAt)") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func composeCard(title: String, systemImage: String, group: ComposeStatusGroup) -> some View { + GlassCard(title: title, systemImage: systemImage) { + HStack { + StatusPill(text: group.ok ? "Healthy" : "Degraded", tone: group.ok ? .good : .warn) + Spacer() + } + if let error = group.error, !error.isEmpty, !group.ok { + Text(error) + .font(.caption) + .foregroundStyle(.red) + } + if !group.services.isEmpty { + VStack(alignment: .leading, spacing: 10) { + ForEach(group.services, id: \.name) { service in + VStack(alignment: .leading, spacing: 2) { + HStack { + Text(service.name) + .font(.subheadline.weight(.medium)) + Spacer() + Text(service.status) + .font(.caption) + .foregroundStyle(.secondary) + } + if !service.ports.isEmpty { + Text(service.ports) + .font(.caption2) + .foregroundStyle(.secondary) + } + } + } + } + } else { + Text("No services reported") + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + + private func networksCard(_ group: NetworkStatusGroup) -> some View { + GlassCard(title: "Networks", systemImage: "point.3.filled.connected.trianglepath.dotted") { + DetailRows(rows: [ + DetailRowItem(label: "Status", value: group.ok ? "Healthy" : "Degraded"), + DetailRowItem(label: "Networks", value: "\(group.networks.count)"), + DetailRowItem(label: "Missing", value: group.missing.isEmpty ? "None" : group.missing.joined(separator: ", ")) + ]) + if !group.networks.isEmpty { + VStack(alignment: .leading, spacing: 8) { + ForEach(group.networks, id: \.id) { network in + HStack { + Text(network.name) + .font(.subheadline.weight(.medium)) + Spacer() + Text(network.driver) + .font(.caption) + .foregroundStyle(.secondary) + } + } + } + } + } + } + + private var daemonRows: [DetailRowItem] { + [ + DetailRowItem(label: "Status", value: daemonStatusText), + DetailRowItem(label: "API ok", value: yesNo(model.daemonStatus?.apiOk)), + DetailRowItem(label: "Process running", value: yesNo(model.daemonStatus?.processRunning)), + DetailRowItem(label: "Stale", value: yesNo(model.daemonStatus?.stale)), + DetailRowItem(label: "Stale reason", value: staleReasonText), + DetailRowItem(label: "PID", value: model.daemonStatus?.pid.map(String.init) ?? "—"), + DetailRowItem(label: "Socket", value: model.daemonStatus?.socketPath ?? "—"), + DetailRowItem(label: "Socket exists", value: yesNo(model.daemonStatus?.socketExists)), + DetailRowItem(label: "Log", value: model.daemonStatus?.logPath ?? "—"), + DetailRowItem(label: "Log exists", value: yesNo(model.daemonStatus?.logExists)), + DetailRowItem(label: "Last refresh", value: lastUpdatedText) + ] + } + + private var daemonPrimaryRows: [DetailRowItem] { + [ + DetailRowItem(label: "Status", value: daemonStatusText), + DetailRowItem(label: "API", value: yesNo(model.daemonStatus?.apiOk)), + DetailRowItem(label: "Process", value: yesNo(model.daemonStatus?.processRunning)), + DetailRowItem(label: "Last refresh", value: lastUpdatedText) + ] + } + + private var daemonDetailRows: [DetailRowItem] { + var rows: [DetailRowItem] = [] + if model.daemonStatus?.stale == true { + rows.append(DetailRowItem(label: "Stale", value: "Yes")) + rows.append(DetailRowItem(label: "Stale reason", value: staleReasonText)) + } + if let pid = model.daemonStatus?.pid { + rows.append(DetailRowItem(label: "PID", value: String(pid))) + } + if let socket = model.daemonStatus?.socketPath { + rows.append(DetailRowItem(label: "Socket", value: socket)) + rows.append(DetailRowItem(label: "Socket exists", value: yesNo(model.daemonStatus?.socketExists))) + } + if let log = model.daemonStatus?.logPath { + rows.append(DetailRowItem(label: "Log", value: log)) + rows.append(DetailRowItem(label: "Log exists", value: yesNo(model.daemonStatus?.logExists))) + } + return rows + } + + private var runtimeRows: [DetailRowItem] { + [ + DetailRowItem(label: "Checked at", value: model.runtimeCheckedAt ?? "—"), + DetailRowItem(label: "Last ok at", value: model.runtimeLastOkAt ?? "—"), + DetailRowItem(label: "Reset at", value: model.runtimeResetAt ?? "—"), + DetailRowItem(label: "Reset count", value: model.runtimeResetCount.map(String.init) ?? "—") + ] + } + + private var logUrl: URL? { + guard let logPath = model.daemonStatus?.logPath, !logPath.isEmpty else { return nil } + return URL(fileURLWithPath: logPath) + } + + private var daemonActionTitle: String { + canStopDaemon ? "Stop hackd" : "Start hackd" + } + + private var daemonStatusText: String { + guard let label = model.daemonStatus?.resolvedLabel else { return "Unknown" } + switch label { + case .running: + return "Running" + case .starting: + return "Starting" + case .stale: + return "Stale" + case .stopped: + return "Stopped" + } + } + + private var canStartDaemon: Bool { + !(daemonIsRunning || daemonIsStarting) + } + + private var canStopDaemon: Bool { + daemonIsRunning || daemonIsStarting + } + + private var canRestartDaemon: Bool { + daemonIsRunning || daemonIsStarting + } + + private var canClearDaemon: Bool { + model.daemonStatus?.stale == true + } + + private var daemonIsRunning: Bool { + model.daemonStatus?.resolvedLabel == .running + } + + private var daemonIsStarting: Bool { + model.daemonStatus?.resolvedLabel == .starting + } + + private var runtimeStatusText: String { + if model.runtimeOverallOk == true { return "Healthy" } + if model.runtimeOverallOk == false { return "Degraded" } + return "Unknown" + } + + private var runtimeStatusTone: StatusTone { + if model.runtimeOverallOk == true { return .good } + if model.runtimeOverallOk == false { return .warn } + return .neutral + } + + private var lastUpdatedText: String { + guard let date = model.lastUpdated else { return "—" } + let formatter = RelativeDateTimeFormatter() + formatter.unitsStyle = .short + return formatter.localizedString(for: date, relativeTo: Date()) + } + + private var staleReasonText: String { + guard let reason = model.daemonStatus?.staleReason else { return "—" } + switch reason { + case .pidNotRunning: + return "PID not running" + case .socketOnly: + return "Socket only" + } + } + + private func yesNo(_ value: Bool?) -> String { + guard let value else { return "—" } + return value ? "Yes" : "No" + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ShellView.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ShellView.swift new file mode 100644 index 000000000..7b7687a68 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/ShellView.swift @@ -0,0 +1,101 @@ +import SwiftUI + +import HackDesktopModels + +struct ShellView: View { + let project: ProjectSummary + let embedded: Bool + let onClose: (() -> Void)? + @State private var session: GhosttyTerminalSession + + init(project: ProjectSummary, embedded: Bool = false, onClose: (() -> Void)? = nil) { + self.project = project + self.embedded = embedded + self.onClose = onClose + let workingDirectory = project.repoRoot ?? project.projectDir ?? FileManager.default.homeDirectoryForCurrentUser.path + _session = State( + initialValue: GhosttyTerminalSession( + project: project, + mode: .shell(workingDirectory: URL(fileURLWithPath: workingDirectory)) + ) + ) + } + + var body: some View { + @Bindable var session = session + + VStack(alignment: .leading, spacing: embedded ? 12 : 16) { + if !embedded { + header(session: session) + } else { + embeddedHeader(session: session) + } + if session.isAvailable { + GhosttyTerminalView(session: session) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .terminalSurface() + } else { + unavailableView + } + Spacer(minLength: 0) + } + .padding(embedded ? .horizontal : .all, embedded ? 24 : 20) + .padding(.bottom, embedded ? 24 : 0) + .frame(maxWidth: .infinity, maxHeight: .infinity, alignment: .topLeading) + .onAppear { session.start() } + .onDisappear { session.stop() } + } + + private var unavailableView: some View { + VStack(alignment: .leading, spacing: 8) { + HStack(spacing: 8) { + Image(systemName: "exclamationmark.triangle.fill") + .foregroundStyle(.orange) + Text("Terminal unavailable") + .font(.subheadline.weight(.medium)) + } + Text("Run `bun run macos:ghostty:setup` to build the Ghostty VT library.") + .font(.caption) + .foregroundStyle(.secondary) + } + .padding(16) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 12, style: .continuous) + .fill(.ultraThinMaterial) + ) + } + + @ViewBuilder + private func embeddedHeader(session: GhosttyTerminalSession) -> some View { + HStack { + Text(session.statusMessage) + .font(.caption) + .foregroundStyle(.secondary) + Spacer() + } + } + + @ViewBuilder + private func header(session: GhosttyTerminalSession) -> some View { + HStack(alignment: .center) { + VStack(alignment: .leading, spacing: 6) { + Text("Shell") + .font(.title2) + .bold() + Text(project.name) + .font(.headline) + Text(session.statusMessage) + .font(.subheadline) + .foregroundStyle(.secondary) + } + Spacer() + if let onClose { + Button("Back") { + onClose() + } + .adaptiveToolbarButton() + } + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusBadge.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusBadge.swift new file mode 100644 index 000000000..b3221a75d --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusBadge.swift @@ -0,0 +1,191 @@ +import SwiftUI + +import HackDesktopModels + +struct StatusBadge: View { + let status: ProjectStatus + + var body: some View { + BadgePill(label: statusLabel, tint: tint) + } + + private var statusLabel: String { + switch status { + case .running: + "Running" + case .stopped: + "Stopped" + case .missing: + "Missing" + case .unregistered: + "Unregistered" + case .unknown: + "Unknown" + } + } + + private var tint: Color { + switch status { + case .running: + .green + case .stopped: + .orange + case .missing: + .red + case .unregistered: + .secondary + case .unknown: + .secondary + } + } +} + +struct RuntimeStatusBadge: View { + let status: ProjectRuntimeStatus + var runtimeHealthy: Bool? = nil + + var body: some View { + BadgePill(label: statusLabel, tint: tint) + } + + private var statusLabel: String { + switch status { + case .running: + "Running" + case .stopped: + "Stopped" + case .missing: + "Missing" + case .unknown: + "Unknown" + case .notConfigured: + "Not configured" + } + } + + private var tint: Color { + if isDegradedRunning { + return .orange + } + return switch status { + case .running: + .green + case .stopped: + .orange + case .missing: + .red + case .unknown: + .secondary + case .notConfigured: + .secondary + } + } + + private var isDegradedRunning: Bool { + status == .running && runtimeHealthy == false + } +} + +struct StatusDot: View { + let status: ProjectStatus + + var body: some View { + statusDotView(tint: tint, label: statusLabel) + } + + private var statusLabel: String { + switch status { + case .running: + "Running" + case .stopped: + "Stopped" + case .missing: + "Missing" + case .unregistered: + "Unregistered" + case .unknown: + "Unknown" + } + } + + private var tint: Color { + switch status { + case .running: + .green + case .stopped: + .orange + case .missing: + .red + case .unregistered: + .secondary + case .unknown: + .secondary + } + } +} + +struct RuntimeStatusDot: View { + let status: ProjectRuntimeStatus + var runtimeHealthy: Bool? = nil + + var body: some View { + statusDotView(tint: tint, label: statusLabel) + } + + private var statusLabel: String { + switch status { + case .running: + "Running" + case .stopped: + "Stopped" + case .missing: + "Missing" + case .unknown: + "Unknown" + case .notConfigured: + "Not configured" + } + } + + private var tint: Color { + if isDegradedRunning { + return .orange + } + return switch status { + case .running: + .green + case .stopped: + .orange + case .missing: + .red + case .unknown: + .secondary + case .notConfigured: + .secondary + } + } + + private var isDegradedRunning: Bool { + status == .running && runtimeHealthy == false + } +} + +@ViewBuilder +private func statusDotView(tint: Color, label: String) -> some View { + if #available(macOS 26, *) { + Circle() + .fill(tint) + .frame(width: 10, height: 10) + .glassEffect(.regular.tint(tint.opacity(0.3))) + .accessibilityLabel(label) + } else { + Circle() + .fill(tint.opacity(0.7)) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(tint.opacity(0.4), lineWidth: 1) + ) + .accessibilityLabel(label) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusPill.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusPill.swift new file mode 100644 index 000000000..8c5bf56bd --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/StatusPill.swift @@ -0,0 +1,27 @@ +import SwiftUI + +enum StatusTone { + case good + case warn + case neutral +} + +struct StatusPill: View { + let text: String + let tone: StatusTone + + var body: some View { + BadgePill(label: text, tint: tint) + } + + private var tint: Color { + switch tone { + case .good: + return .green + case .warn: + return .orange + case .neutral: + return .secondary + } + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift new file mode 100644 index 000000000..25360e654 --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/SystemSidebarRows.swift @@ -0,0 +1,85 @@ +import SwiftUI + +import HackDesktopModels + +struct RuntimeRowView: View { + let isHealthy: Bool? + + var body: some View { + HStack(spacing: 8) { + Label("Runtime", systemImage: "gauge") + .font(.subheadline.weight(.medium)) + Spacer() + if let statusColor { + StatusDotView(color: statusColor) + } + } + .padding(.vertical, 4) + } + + private var statusColor: Color? { + guard let isHealthy else { return nil } + return isHealthy ? .green : .orange + } +} + +struct GatewayRowView: View { + let state: GatewaySummaryState? + + var body: some View { + HStack(spacing: 8) { + Label("Gateway", systemImage: "arrow.triangle.branch") + .font(.subheadline.weight(.medium)) + Spacer() + if let statusColor { + StatusDotView(color: statusColor) + } + } + .padding(.vertical, 4) + } + + private var statusColor: Color? { + state?.statusDotColor + } +} + +struct GatewayExposureRowView: View { + let exposure: GatewayExposure + + var body: some View { + HStack(spacing: 8) { + Label(exposure.label, systemImage: iconName) + .font(.subheadline) + Spacer() + StatusDotView(color: exposure.statusColor) + } + .padding(.vertical, 2) + } + + private var iconName: String { + switch exposure.id { + case "lan": + return "wifi" + case "tailscale": + return "link" + case "cloudflare": + return "cloud" + default: + return "network" + } + } +} + +private struct StatusDotView: View { + let color: Color + + var body: some View { + Circle() + .fill(color.opacity(0.7)) + .frame(width: 8, height: 8) + .overlay( + Circle() + .stroke(color.opacity(0.4), lineWidth: 1) + ) + } +} diff --git a/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalSurface.swift b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalSurface.swift new file mode 100644 index 000000000..c8b3382aa --- /dev/null +++ b/apps/macos/Packages/Features/DashboardFeature/Sources/DashboardFeature/TerminalSurface.swift @@ -0,0 +1,27 @@ +import SwiftUI + +private let terminalCornerRadius: CGFloat = 16 + +struct TerminalSurface: ViewModifier { + func body(content: Content) -> some View { + content + .background( + RoundedRectangle(cornerRadius: terminalCornerRadius, style: .continuous) + .fill(.ultraThinMaterial) + .overlay( + RoundedRectangle(cornerRadius: terminalCornerRadius, style: .continuous) + .fill(Color.black.opacity(0.97)) + ) + ) + .overlay( + RoundedRectangle(cornerRadius: terminalCornerRadius, style: .continuous) + .stroke(Color.white.opacity(0.08), lineWidth: 1) + ) + } +} + +extension View { + func terminalSurface() -> some View { + modifier(TerminalSurface()) + } +} diff --git a/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift b/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift new file mode 100644 index 000000000..0176b17c6 --- /dev/null +++ b/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/GhosttyTerminal.swift @@ -0,0 +1,352 @@ +import Darwin +import Foundation + +@frozen +public struct GhosttyRenderColor: Equatable { + public let red: UInt8 + public let green: UInt8 + public let blue: UInt8 + public let alpha: UInt8 + + public init(red: UInt8, green: UInt8, blue: UInt8, alpha: UInt8) { + self.red = red + self.green = green + self.blue = blue + self.alpha = alpha + } +} + +@frozen +public struct GhosttyRenderCell: Equatable { + public let codepoint: UInt32 + public let foreground: GhosttyRenderColor + public let background: GhosttyRenderColor + public let wide: UInt8 + public let flags: UInt16 + + public init( + codepoint: UInt32, + foreground: GhosttyRenderColor, + background: GhosttyRenderColor, + wide: UInt8, + flags: UInt16 + ) { + self.codepoint = codepoint + self.foreground = foreground + self.background = background + self.wide = wide + self.flags = flags + } +} + +public enum GhosttyCursorStyle: UInt8 { + case bar = 0 + case block = 1 + case underline = 2 + case blockHollow = 3 +} + +@frozen +public struct GhosttyCursor { + public let x: Int + public let y: Int + public let isVisible: Bool + public let style: GhosttyCursorStyle + public let wideTail: Bool + + public init(x: Int, y: Int, isVisible: Bool, style: GhosttyCursorStyle, wideTail: Bool) { + self.x = x + self.y = y + self.isVisible = isVisible + self.style = style + self.wideTail = wideTail + } +} + +@frozen +public struct GhosttyRenderSnapshot { + public let rows: Int + public let cols: Int + public let cursor: GhosttyCursor + public let defaultForeground: GhosttyRenderColor + public let defaultBackground: GhosttyRenderColor + public let cursorColor: GhosttyRenderColor + public let cells: [GhosttyRenderCell] + + public init( + rows: Int, + cols: Int, + cursor: GhosttyCursor, + defaultForeground: GhosttyRenderColor, + defaultBackground: GhosttyRenderColor, + cursorColor: GhosttyRenderColor, + cells: [GhosttyRenderCell] + ) { + self.rows = rows + self.cols = cols + self.cursor = cursor + self.defaultForeground = defaultForeground + self.defaultBackground = defaultBackground + self.cursorColor = cursorColor + self.cells = cells + } +} + +public final class GhosttyVTRuntime { + public static let shared = GhosttyVTRuntime() + + public let isAvailable: Bool + public let loadMessage: String? + + private let libraryHandle: UnsafeMutableRawPointer? + private let functions: Functions? + + private init() { + let resolved = GhosttyVTRuntime.resolveLibraryPath() + guard let path = resolved?.path else { + isAvailable = false + loadMessage = "Ghostty VT library not found" + libraryHandle = nil + functions = nil + return + } + + _ = dlerror() + let handle = dlopen(path, RTLD_NOW) + guard let handle else { + let error = dlerror().map { String(cString: $0) } + isAvailable = false + if let error { + loadMessage = "Failed to load Ghostty VT at \(path): \(error)" + } else { + loadMessage = "Failed to load Ghostty VT at \(path)" + } + libraryHandle = nil + functions = nil + return + } + + guard let functions = Functions(handle: handle) else { + isAvailable = false + loadMessage = "Missing Ghostty VT symbols" + dlclose(handle) + libraryHandle = nil + self.functions = nil + return + } + + isAvailable = true + loadMessage = nil + libraryHandle = handle + self.functions = functions + } + + deinit { + if let libraryHandle { + dlclose(libraryHandle) + } + } + + public func makeTerminal(cols: Int, rows: Int) -> GhosttyTerminal? { + guard let functions, isAvailable else { return nil } + return GhosttyTerminal(functions: functions, cols: cols, rows: rows) + } + + private static func resolveLibraryPath() -> URL? { + if let envPath = ProcessInfo.processInfo.environment["HACK_GHOSTTY_VT_LIB"] { + let envURL = URL(fileURLWithPath: envPath) + if FileManager.default.fileExists(atPath: envURL.path) { + return envURL + } + } + + let home = FileManager.default.homeDirectoryForCurrentUser + let defaultPath = home + .appendingPathComponent("Library/Application Support/Hack/ghostty/lib") + .appendingPathComponent("libhack_ghostty_vt.dylib") + if FileManager.default.fileExists(atPath: defaultPath.path) { + return defaultPath + } + + return nil + } + + fileprivate struct Functions { + typealias Create = @convention(c) (UInt32, UInt32) -> UnsafeMutableRawPointer? + typealias Destroy = @convention(c) (UnsafeMutableRawPointer?) -> Void + typealias Resize = @convention(c) (UnsafeMutableRawPointer?, UInt32, UInt32) -> Void + typealias Feed = @convention(c) (UnsafeMutableRawPointer?, UnsafePointer?, Int) -> Void + typealias PlainString = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?) -> UnsafeMutablePointer? + typealias HtmlString = @convention(c) (UnsafeMutableRawPointer?, UnsafeMutablePointer?) -> UnsafeMutablePointer? + typealias FreeString = @convention(c) (UnsafeMutablePointer?, Int) -> Void + typealias RenderSnapshot = @convention(c) (UnsafeMutableRawPointer?) -> UnsafeMutableRawPointer? + typealias RenderSnapshotFree = @convention(c) (UnsafeMutableRawPointer?) -> Void + + let create: Create + let destroy: Destroy + let resize: Resize + let feed: Feed + let plainString: PlainString + let htmlString: HtmlString? + let freeString: FreeString + let renderSnapshot: RenderSnapshot + let renderSnapshotFree: RenderSnapshotFree + + init?(handle: UnsafeMutableRawPointer) { + guard + let create = dlsym(handle, "hack_ghostty_vt_create"), + let destroy = dlsym(handle, "hack_ghostty_vt_destroy"), + let resize = dlsym(handle, "hack_ghostty_vt_resize"), + let feed = dlsym(handle, "hack_ghostty_vt_feed"), + let plainString = dlsym(handle, "hack_ghostty_vt_plain_string"), + let freeString = dlsym(handle, "hack_ghostty_vt_free_string"), + let renderSnapshot = dlsym(handle, "hack_ghostty_vt_render_snapshot"), + let renderSnapshotFree = dlsym(handle, "hack_ghostty_vt_render_snapshot_free") + else { + return nil + } + + let htmlString = dlsym(handle, "hack_ghostty_vt_html_string") + + self.create = unsafeBitCast(create, to: Create.self) + self.destroy = unsafeBitCast(destroy, to: Destroy.self) + self.resize = unsafeBitCast(resize, to: Resize.self) + self.feed = unsafeBitCast(feed, to: Feed.self) + self.plainString = unsafeBitCast(plainString, to: PlainString.self) + self.htmlString = htmlString.map { unsafeBitCast($0, to: HtmlString.self) } + self.freeString = unsafeBitCast(freeString, to: FreeString.self) + self.renderSnapshot = unsafeBitCast(renderSnapshot, to: RenderSnapshot.self) + self.renderSnapshotFree = unsafeBitCast(renderSnapshotFree, to: RenderSnapshotFree.self) + } + } +} + +public final class GhosttyTerminal { + private let functions: GhosttyVTRuntime.Functions + private let handle: UnsafeMutableRawPointer + + fileprivate init?(functions: GhosttyVTRuntime.Functions, cols: Int, rows: Int) { + guard let handle = functions.create(UInt32(cols), UInt32(rows)) else { + return nil + } + self.functions = functions + self.handle = handle + } + + deinit { + functions.destroy(handle) + } + + public func resize(cols: Int, rows: Int) { + functions.resize(handle, UInt32(cols), UInt32(rows)) + } + + public func feed(_ data: Data) { + data.withUnsafeBytes { buffer in + functions.feed(handle, buffer.bindMemory(to: UInt8.self).baseAddress, data.count) + } + } + + public func plainString() -> String { + var length = 0 + guard let ptr = functions.plainString(handle, &length), length > 0 else { + return "" + } + let buffer = UnsafeBufferPointer(start: ptr, count: length) + let text = String(decoding: buffer, as: UTF8.self) + functions.freeString(ptr, length) + return text + } + + public func htmlString() -> String? { + guard let htmlString = functions.htmlString else { return nil } + var length = 0 + guard let ptr = htmlString(handle, &length), length > 0 else { + return "" + } + let buffer = UnsafeBufferPointer(start: ptr, count: length) + let text = String(decoding: buffer, as: UTF8.self) + functions.freeString(ptr, length) + return text + } + + public func renderSnapshot() -> GhosttyRenderSnapshot? { + guard let snapshotPtr = functions.renderSnapshot(handle) else { return nil } + defer { functions.renderSnapshotFree(snapshotPtr) } + + let raw = snapshotPtr.bindMemory(to: GhosttyRenderSnapshotRaw.self, capacity: 1).pointee + let cellCount = Int(raw.cell_count) + guard cellCount > 0 else { return nil } + let cellBuffer = UnsafeBufferPointer(start: raw.cells, count: cellCount) + let cells = cellBuffer.map { GhosttyRenderCell(raw: $0) } + + let cursorStyle = GhosttyCursorStyle(rawValue: raw.cursor_style) ?? .block + let cursor = GhosttyCursor( + x: Int(raw.cursor_x), + y: Int(raw.cursor_y), + isVisible: raw.cursor_visible != 0, + style: cursorStyle, + wideTail: raw.cursor_wide_tail != 0 + ) + + return GhosttyRenderSnapshot( + rows: Int(raw.rows), + cols: Int(raw.cols), + cursor: cursor, + defaultForeground: GhosttyRenderColor(raw.default_fg), + defaultBackground: GhosttyRenderColor(raw.default_bg), + cursorColor: GhosttyRenderColor(raw.cursor_color), + cells: cells + ) + } +} + +struct GhosttyRenderColorRaw { + var r: UInt8 + var g: UInt8 + var b: UInt8 + var a: UInt8 +} + +struct GhosttyRenderCellRaw { + var codepoint: UInt32 + var fg: GhosttyRenderColorRaw + var bg: GhosttyRenderColorRaw + var wide: UInt8 + var flags: UInt16 + var _pad: UInt8 +} + +struct GhosttyRenderSnapshotRaw { + var rows: UInt16 + var cols: UInt16 + var cursor_x: UInt16 + var cursor_y: UInt16 + var cursor_visible: UInt8 + var cursor_style: UInt8 + var cursor_wide_tail: UInt8 + var _pad0: UInt8 + var default_fg: GhosttyRenderColorRaw + var default_bg: GhosttyRenderColorRaw + var cursor_color: GhosttyRenderColorRaw + var cell_count: Int + var cells: UnsafeMutablePointer +} + +private extension GhosttyRenderColor { + init(_ raw: GhosttyRenderColorRaw) { + self.init(red: raw.r, green: raw.g, blue: raw.b, alpha: raw.a) + } +} + +private extension GhosttyRenderCell { + init(raw: GhosttyRenderCellRaw) { + self.init( + codepoint: raw.codepoint, + foreground: GhosttyRenderColor(raw.fg), + background: GhosttyRenderColor(raw.bg), + wide: raw.wide, + flags: raw.flags + ) + } +} diff --git a/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/PtyProcess.swift b/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/PtyProcess.swift new file mode 100644 index 000000000..c1eb25b7e --- /dev/null +++ b/apps/macos/Packages/Services/GhosttyTerminal/Sources/GhosttyTerminal/PtyProcess.swift @@ -0,0 +1,111 @@ +import Darwin +import Foundation + +public enum PtyProcessError: LocalizedError { + case openFailed(Int32) + case launchFailed(Error) + + public var errorDescription: String? { + switch self { + case let .openFailed(code): + return "openpty failed (\(code))" + case let .launchFailed(error): + return "Failed to launch process: \(error.localizedDescription)" + } + } +} + +public final class PtyProcess { + public let process: Process + public let masterFileHandle: FileHandle + + private let slaveHandle: FileHandle + private let masterFd: Int32 + private var isClosed = false + + public init( + executableURL: URL, + arguments: [String], + environment: [String: String], + cols: Int, + rows: Int, + workingDirectory: URL? = nil + ) throws { + var master: Int32 = 0 + var slave: Int32 = 0 + var term = termios() + _ = tcgetattr(STDIN_FILENO, &term) + var size = winsize() + size.ws_col = UInt16(clamping: cols) + size.ws_row = UInt16(clamping: rows) + + if openpty(&master, &slave, nil, &term, &size) != 0 { + throw PtyProcessError.openFailed(errno) + } + + let masterHandle = FileHandle(fileDescriptor: master, closeOnDealloc: false) + let slaveHandle = FileHandle(fileDescriptor: slave, closeOnDealloc: false) + + let process = Process() + process.executableURL = executableURL + process.arguments = arguments + process.environment = environment + process.currentDirectoryURL = workingDirectory + process.standardInput = slaveHandle + process.standardOutput = slaveHandle + process.standardError = slaveHandle + + do { + try process.run() + } catch { + masterHandle.closeFile() + slaveHandle.closeFile() + throw PtyProcessError.launchFailed(error) + } + + self.process = process + self.masterFileHandle = masterHandle + self.slaveHandle = slaveHandle + self.masterFd = master + } + + deinit { + closeHandles() + } + + public func resize(cols: Int, rows: Int) { + guard !isClosed else { return } + var size = winsize() + size.ws_col = UInt16(clamping: cols) + size.ws_row = UInt16(clamping: rows) + _ = ioctl(masterFd, TIOCSWINSZ, &size) + } + + public func send(_ data: Data) { + guard !isClosed else { return } + masterFileHandle.write(data) + } + + public func interrupt() { + guard !isClosed else { return } + if process.isRunning { + let pid = process.processIdentifier + _ = killpg(pid, SIGINT) + _ = kill(pid, SIGINT) + } + } + + public func terminate() { + guard !isClosed else { return } + process.terminate() + closeHandles() + } + + private func closeHandles() { + guard !isClosed else { return } + isClosed = true + masterFileHandle.readabilityHandler = nil + masterFileHandle.closeFile() + slaveHandle.closeFile() + } +} diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift new file mode 100644 index 000000000..25bc47e1c --- /dev/null +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLIClient.swift @@ -0,0 +1,157 @@ +import Foundation + +import HackDesktopModels + +public enum HackCLIError: LocalizedError, Equatable { + case commandFailed(exitCode: Int, stderr: String) + case emptyOutput + case invalidJson + + public var errorDescription: String? { + switch self { + case let .commandFailed(exitCode, stderr): + return "hack exited with code \(exitCode): \(stderr)" + case .emptyOutput: + return "hack returned empty output" + case .invalidJson: + return "hack returned invalid JSON" + } + } +} + +public actor HackCLIClient { + public init() {} + + public func fetchProjects(includeGlobal: Bool) async throws -> ProjectListResponse { + var args = ["projects", "--json"] + if includeGlobal { + args.append("--include-global") + } + + let result = try await run(args) + return try decode(ProjectListResponse.self, from: result.stdout) + } + + public func daemonStatus() async throws -> DaemonStatus { + let result = try await run(["daemon", "status", "--json"], allowNonZeroExit: true) + return try decode(DaemonStatus.self, from: result.stdout) + } + + public func fetchGlobalStatus() async throws -> GlobalStatusResponse { + let result = try await run(["global", "status", "--json"], allowNonZeroExit: true) + return try decode(GlobalStatusResponse.self, from: result.stdout) + } + + public func startDaemon() async throws { + _ = try await run(["daemon", "start"]) + } + + public func stopDaemon() async throws { + _ = try await run(["daemon", "stop"]) + } + + public func restartDaemon() async throws { + _ = try await run(["daemon", "restart"]) + } + + public func clearDaemon() async throws { + _ = try await run(["daemon", "clear"]) + } + + public func startProject(path: String) async throws { + _ = try await run(["up", "--path", path, "--detach"]) + } + + public func stopProject(path: String) async throws { + _ = try await run(["down", "--path", path]) + } + + private func decode(_ type: T.Type, from text: String) throws -> T { + let trimmed = text.trimmingCharacters(in: .whitespacesAndNewlines) + if trimmed.isEmpty { + throw HackCLIError.emptyOutput + } + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + guard let data = trimmed.data(using: .utf8) else { + throw HackCLIError.invalidJson + } + do { + return try decoder.decode(T.self, from: data) + } catch { + throw HackCLIError.invalidJson + } + } + + private func run( + _ args: [String], + allowNonZeroExit: Bool = false + ) async throws -> CLIResult { + let process = Process() + let environment = HackCLILocator.buildEnvironment() + process.environment = environment + + if let hackPath = HackCLILocator.resolveHackExecutable(in: environment) { + process.executableURL = URL(fileURLWithPath: hackPath) + process.arguments = args + } else { + process.executableURL = URL(fileURLWithPath: "/usr/bin/env") + process.arguments = ["hack"] + args + } + + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + let exitCode = await withCheckedContinuation { continuation in + process.terminationHandler = { proc in + continuation.resume(returning: Int(proc.terminationStatus)) + } + + do { + try process.run() + } catch { + stdoutPipe.fileHandleForReading.closeFile() + stderrPipe.fileHandleForReading.closeFile() + continuation.resume(returning: 127) + } + } + + async let stdoutData = stdoutPipe.fileHandleForReading.readToEnd() + async let stderrData = stderrPipe.fileHandleForReading.readToEnd() + + let stdoutBytes: Data? + let stderrBytes: Data? + + do { + stdoutBytes = try await stdoutData + } catch { + stdoutBytes = nil + } + + do { + stderrBytes = try await stderrData + } catch { + stderrBytes = nil + } + + let stdout = String(decoding: stdoutBytes ?? Data(), as: UTF8.self) + let stderr = String(decoding: stderrBytes ?? Data(), as: UTF8.self) + + if exitCode != 0 && !allowNonZeroExit { + throw HackCLIError.commandFailed(exitCode: exitCode, stderr: stderr.trimmingCharacters(in: .whitespacesAndNewlines)) + } + + return CLIResult(stdout: stdout, stderr: stderr, exitCode: exitCode) + } + +} + +private struct CLIResult { + let stdout: String + let stderr: String + let exitCode: Int +} diff --git a/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift new file mode 100644 index 000000000..743e153d1 --- /dev/null +++ b/apps/macos/Packages/Services/HackCLI/Sources/HackCLIService/HackCLILocator.swift @@ -0,0 +1,46 @@ +import Foundation + +public enum HackCLILocator { + public static func buildEnvironment() -> [String: String] { + var env = ProcessInfo.processInfo.environment + let home = (env["HOME"] ?? NSHomeDirectory()).trimmingCharacters(in: .whitespacesAndNewlines) + let homeBinPaths = home.isEmpty + ? [] + : [ + "\(home)/.hack/bin", + "\(home)/.local/bin", + "\(home)/.bun/bin", + "\(home)/.cargo/bin" + ] + let defaultPaths = [ + "/opt/homebrew/bin", + "/usr/local/bin", + "/usr/bin", + "/bin", + "/usr/sbin", + "/sbin" + ] + let existing = env["PATH"]?.split(separator: ":").map(String.init) ?? [] + let merged = existing + + homeBinPaths.filter { !existing.contains($0) } + + defaultPaths.filter { !existing.contains($0) } + env["PATH"] = merged.joined(separator: ":") + return env + } + + public static func resolveHackExecutable(in env: [String: String]) -> String? { + let fileManager = FileManager.default + if let override = env["HACK_CLI_PATH"], fileManager.isExecutableFile(atPath: override) { + return override + } + + guard let pathValue = env["PATH"] else { return nil } + for entry in pathValue.split(separator: ":") { + let candidate = String(entry) + "/hack" + if fileManager.isExecutableFile(atPath: candidate) { + return candidate + } + } + return nil + } +} diff --git a/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift new file mode 100644 index 000000000..5a1015a27 --- /dev/null +++ b/apps/macos/Packages/Shared/Models/Sources/HackDesktopModels/Models.swift @@ -0,0 +1,341 @@ +import Foundation + +public enum ProjectStatus: String, Decodable { + case running + case stopped + case missing + case unregistered + case unknown +} + +public enum ProjectRuntimeStatus: String, Decodable { + case running + case stopped + case missing + case unknown + case notConfigured = "not_configured" +} + +public enum ProjectKind: String, Decodable { + case registered + case unregistered +} + +public struct ProjectSummary: Decodable, Identifiable, Hashable { + public let projectId: String? + public let name: String + public let devHost: String? + public let repoRoot: String? + public let projectDir: String? + public let definedServices: [String]? + public let extensionsEnabled: [String]? + public let features: [String]? + public let runtimeConfigured: Bool? + public let runtimeStatus: ProjectRuntimeStatus? + public let kind: ProjectKind + public let status: ProjectStatus + + public var id: String { projectId ?? name } + + public init( + projectId: String?, + name: String, + devHost: String?, + repoRoot: String?, + projectDir: String?, + definedServices: [String]?, + extensionsEnabled: [String]?, + features: [String]?, + runtimeConfigured: Bool?, + runtimeStatus: ProjectRuntimeStatus?, + kind: ProjectKind, + status: ProjectStatus + ) { + self.projectId = projectId + self.name = name + self.devHost = devHost + self.repoRoot = repoRoot + self.projectDir = projectDir + self.definedServices = definedServices + self.extensionsEnabled = extensionsEnabled + self.features = features + self.runtimeConfigured = runtimeConfigured + self.runtimeStatus = runtimeStatus + self.kind = kind + self.status = status + } +} + +public struct ProjectListResponse: Decodable { + public let generatedAt: String? + public let filter: String? + public let includeGlobal: Bool? + public let includeUnregistered: Bool? + public let runtimeOk: Bool? + public let runtimeError: String? + public let runtimeCheckedAt: String? + public let runtimeLastOkAt: String? + public let runtimeResetAt: String? + public let runtimeResetCount: Int? + public let projects: [ProjectSummary] + + public init( + generatedAt: String?, + filter: String?, + includeGlobal: Bool?, + includeUnregistered: Bool?, + runtimeOk: Bool?, + runtimeError: String?, + runtimeCheckedAt: String?, + runtimeLastOkAt: String?, + runtimeResetAt: String?, + runtimeResetCount: Int?, + projects: [ProjectSummary] + ) { + self.generatedAt = generatedAt + self.filter = filter + self.includeGlobal = includeGlobal + self.includeUnregistered = includeUnregistered + self.runtimeOk = runtimeOk + self.runtimeError = runtimeError + self.runtimeCheckedAt = runtimeCheckedAt + self.runtimeLastOkAt = runtimeLastOkAt + self.runtimeResetAt = runtimeResetAt + self.runtimeResetCount = runtimeResetCount + self.projects = projects + } +} + +public enum DaemonStatusLabel: String, Decodable { + case running + case starting + case stale + case stopped +} + +public enum DaemonStaleReason: String, Decodable { + case pidNotRunning = "pid_not_running" + case socketOnly = "socket_only" +} + +public struct DaemonStatus: Decodable, Hashable { + public let status: DaemonStatusLabel? + public let running: Bool + public let apiOk: Bool? + public let processRunning: Bool? + public let stale: Bool? + public let staleReason: DaemonStaleReason? + public let pid: Int? + public let socketPath: String? + public let socketExists: Bool? + public let logPath: String? + public let logExists: Bool? + + public init( + status: DaemonStatusLabel?, + running: Bool, + apiOk: Bool?, + processRunning: Bool?, + stale: Bool?, + staleReason: DaemonStaleReason?, + pid: Int?, + socketPath: String?, + socketExists: Bool?, + logPath: String?, + logExists: Bool? + ) { + self.status = status + self.running = running + self.apiOk = apiOk + self.processRunning = processRunning + self.stale = stale + self.staleReason = staleReason + self.pid = pid + self.socketPath = socketPath + self.socketExists = socketExists + self.logPath = logPath + self.logExists = logExists + } +} + +public struct GlobalStatusResponse: Decodable { + public let generatedAt: String? + public let caddy: ComposeStatusGroup? + public let logging: ComposeStatusGroup? + public let networks: NetworkStatusGroup? + public let gateway: GatewayStatus? + public let summary: GlobalStatusSummary + + public init( + generatedAt: String?, + caddy: ComposeStatusGroup?, + logging: ComposeStatusGroup?, + networks: NetworkStatusGroup?, + gateway: GatewayStatus?, + summary: GlobalStatusSummary + ) { + self.generatedAt = generatedAt + self.caddy = caddy + self.logging = logging + self.networks = networks + self.gateway = gateway + self.summary = summary + } +} + +public struct GlobalStatusSummary: Decodable { + public let ok: Bool + public let caddyOk: Bool + public let loggingOk: Bool + public let networksOk: Bool + public let gatewayEnabled: Bool? + + public init( + ok: Bool, + caddyOk: Bool, + loggingOk: Bool, + networksOk: Bool, + gatewayEnabled: Bool? + ) { + self.ok = ok + self.caddyOk = caddyOk + self.loggingOk = loggingOk + self.networksOk = networksOk + self.gatewayEnabled = gatewayEnabled + } +} + +public struct ComposeStatusGroup: Decodable { + public let ok: Bool + public let error: String? + public let services: [ComposeServiceStatus] + + public init(ok: Bool, error: String?, services: [ComposeServiceStatus]) { + self.ok = ok + self.error = error + self.services = services + } +} + +public struct ComposeServiceStatus: Decodable, Hashable { + public let service: String + public let name: String + public let status: String + public let ports: String + + public init(service: String, name: String, status: String, ports: String) { + self.service = service + self.name = name + self.status = status + self.ports = ports + } +} + +public struct NetworkStatusGroup: Decodable { + public let ok: Bool + public let missing: [String] + public let networks: [NetworkStatus] + + public init(ok: Bool, missing: [String], networks: [NetworkStatus]) { + self.ok = ok + self.missing = missing + self.networks = networks + } +} + +public struct NetworkStatus: Decodable, Hashable { + public let name: String + public let id: String + public let driver: String + public let scope: String + + public init(name: String, id: String, driver: String, scope: String) { + self.name = name + self.id = id + self.driver = driver + self.scope = scope + } +} + +public struct GatewayStatus: Decodable { + public let configPath: String? + public let gatewayUrl: String? + public let gatewayBind: String? + public let gatewayPort: Int? + public let allowWrites: Bool? + public let gatewayEnabled: Bool? + public let gatewayProjectsEnabled: Int? + public let tokensActive: Int? + public let tokensRevoked: Int? + public let tokensWrite: Int? + public let tokensRead: Int? + public let gatewayProjects: String? + public let exposures: [GatewayExposure]? + public let warnings: [String]? + + public init( + configPath: String?, + gatewayUrl: String?, + gatewayBind: String?, + gatewayPort: Int?, + allowWrites: Bool?, + gatewayEnabled: Bool?, + gatewayProjectsEnabled: Int?, + tokensActive: Int?, + tokensRevoked: Int?, + tokensWrite: Int?, + tokensRead: Int?, + gatewayProjects: String?, + exposures: [GatewayExposure]?, + warnings: [String]? + ) { + self.configPath = configPath + self.gatewayUrl = gatewayUrl + self.gatewayBind = gatewayBind + self.gatewayPort = gatewayPort + self.allowWrites = allowWrites + self.gatewayEnabled = gatewayEnabled + self.gatewayProjectsEnabled = gatewayProjectsEnabled + self.tokensActive = tokensActive + self.tokensRevoked = tokensRevoked + self.tokensWrite = tokensWrite + self.tokensRead = tokensRead + self.gatewayProjects = gatewayProjects + self.exposures = exposures + self.warnings = warnings + } +} + +public struct GatewayExposure: Decodable, Identifiable, Hashable { + public enum State: String, Decodable { + case disabled + case needsConfig = "needs_config" + case configured + case running + case blocked + case unknown + } + + public let id: String + public let label: String + public let enabled: Bool + public let state: State? + public let detail: String? + public let url: String? + + public init( + id: String, + label: String, + enabled: Bool, + state: State?, + detail: String?, + url: String? + ) { + self.id = id + self.label = label + self.enabled = enabled + self.state = state + self.detail = detail + self.url = url + } +} diff --git a/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift new file mode 100644 index 000000000..4f22788db --- /dev/null +++ b/apps/macos/Packages/Shared/Models/Tests/HackDesktopModelsTests/ProjectListResponseTests.swift @@ -0,0 +1,47 @@ +import XCTest + +@testable import HackDesktopModels + +final class ProjectListResponseTests: XCTestCase { + func testDecodesProjectListResponse() throws { + let json = """ + { + "generated_at": "2026-01-13T00:00:00Z", + "include_global": true, + "include_unregistered": false, + "runtime_ok": true, + "runtime_error": null, + "runtime_checked_at": "2026-01-13T00:00:01Z", + "projects": [ + { + "project_id": "proj-1", + "name": "hack-cli", + "dev_host": "hack-cli.test", + "repo_root": "/repo", + "project_dir": "/repo", + "defined_services": ["api"], + "extensions_enabled": ["dance.hack.tickets"], + "features": ["tickets"], + "runtime_configured": true, + "runtime_status": "running", + "kind": "registered", + "status": "running" + } + ] + } + """ + + let decoder = JSONDecoder() + decoder.keyDecodingStrategy = .convertFromSnakeCase + + let data = Data(json.utf8) + let response = try decoder.decode(ProjectListResponse.self, from: data) + + XCTAssertEqual(response.generatedAt, "2026-01-13T00:00:00Z") + XCTAssertEqual(response.runtimeOk, true) + XCTAssertEqual(response.projects.count, 1) + XCTAssertEqual(response.projects.first?.name, "hack-cli") + XCTAssertEqual(response.projects.first?.status, .running) + XCTAssertEqual(response.projects.first?.runtimeStatus, .running) + } +} diff --git a/apps/macos/README.md b/apps/macos/README.md new file mode 100644 index 000000000..fc2d12306 --- /dev/null +++ b/apps/macos/README.md @@ -0,0 +1,122 @@ +# Hack Desktop (macOS) + +Native macOS app for managing local hack projects and daemon status. + +## Requirements + +- macOS 14+ +- Xcode 15.1+ +- XcodeGen (`brew install xcodegen`) + +## What it does + +- Lists local hack projects with status and details. +- Actions: start/stop project, open URL, open logs (experimental). +- Shows hackd status with start/stop controls. +- Menu bar item for quick status + actions. + +## Quick commands (repo root) + +```bash +bun run macos:project-gen +bun run macos:open +bun run macos:dev +bun run macos:build +bun run macos:test +bun run macos:ghostty:setup +``` + +## Generate the Xcode project + +```bash +xcodegen -c +``` + +This generates `HackDesktop.xcodeproj`. Do not edit the generated project directly. + +## Build (CLI) + +From `apps/macos`: + +```bash +swift build +swift test +``` + +## Run + +Open `HackDesktop.xcodeproj` in Xcode and run the app. The app uses the `hack` CLI +(via your PATH) to fetch status and run actions. + +If `hack` is not in PATH for GUI apps, ensure it lives in `~/.hack/bin`, +`/opt/homebrew/bin`, or `/usr/local/bin` (all are added to PATH by the app). You +can also set `HACK_CLI_PATH` to an absolute path for the binary. + +## Data source + +The app shells out to `hack` for JSON data (projects + daemon status). It does not +talk to hackd directly yet. + +## Ghostty VT (experimental) + +The logs/shell views can stream PTY output through Ghostty's VT core and render +a snapshot of the terminal grid. + +### Architecture + +``` +PTY bytes -> Ghostty VT (zig) -> formatter (HTML/plain) -> AppKit NSTextView +``` + +- VT parsing + grid state live in Zig (`apps/macos/Experiments/GhosttyVTBridge`). +- Swift feeds PTY bytes and asks for a formatted snapshot (HTML/plain). +- Rendering is AppKit-based (no Ghostty renderer yet). + +### Docs + +- `apps/macos/docs/ghostty-vt.md` — VT sequences, cursor behavior, colors, and renderer + integration notes. + +### Setup + +```bash +bun run macos:ghostty:setup +``` + +This script: + +- clones the Ghostty repo into `apps/macos/vendor/ghostty` +- builds the VT bridge via Zig +- installs `libhack_ghostty_vt.dylib` into: + `~/Library/Application Support/Hack/ghostty/lib` +- ad-hoc signs the dylib for local loading + +Zig version is validated against Ghostty's `minimum_zig_version` (currently +0.15.2.x). If you use mise: `mise install zig@0.15.2`. + +You can override the library path with `HACK_GHOSTTY_VT_LIB`. + +### Current limitations + +- Snapshot rendering only (HTML/plain output). No GPU renderer yet. +- Cursor is represented by the NSTextView selection/caret, not Ghostty. +- Styling depends on Ghostty's formatter output + our base styles. +- External protocols (OSC 8 hyperlinks, OSC 21 Kitty colors) are only preserved + insofar as Ghostty's formatter outputs them. + +### Using the Ghostty renderer (future) + +To render directly with Ghostty's renderer, we would need to: + +- expose renderer APIs from Ghostty via the Zig bridge +- provide a Metal-backed surface (likely MTKView) +- feed Ghostty grid updates into the renderer each frame + +That work is not wired up yet. + +## Project ownership (what to edit) + +- `project.yml`: target structure + dependencies. Regenerate Xcode project from this. +- `Config/Base.xcconfig`: versions, bundle id, product name, deployment target. +- `App/Info.plist`: Info.plist keys only (kept in repo as source of truth). +- Generated `.xcodeproj`: never edit manually. diff --git a/apps/macos/docs/ghostty-vt.md b/apps/macos/docs/ghostty-vt.md new file mode 100644 index 000000000..e5d4b01df --- /dev/null +++ b/apps/macos/docs/ghostty-vt.md @@ -0,0 +1,139 @@ +# Ghostty VT integration notes + +This document summarizes the Ghostty VT concepts we rely on and what it would +take to move from the current HTML snapshot renderer to Ghostty's native +renderer. + +## Control sequences (VT concepts) + +Ghostty’s VT core interprets control sequences written to a PTY. The key families: + +- **C0 control characters** (`0x00`–`0x20`): single‑byte controls like `BEL`, `BS`, + `TAB`, `LF`, `CR`. These affect cursor movement and terminal state. +- **Escape sequences**: start with `ESC` (`0x1b`) and a final byte. These are + discrete sequences (for example `ESC D`). +- **C1/Fe sequences** (escape + introducer): + - **CSI** (`ESC [`): integer parameters (cursor, screen, modes, styles). + - **OSC** (`ESC ]`): string payloads (title, palette, clipboard, hyperlinks). + - **DCS** (`ESC P`): integer params + data payload (capability queries). + - **APC** (`ESC _`): used by complex protocols like Kitty graphics. + - **SOS/PM**: ignored by Ghostty. + +These sequences are the primary “API” a terminal consumes — all VT control sequences +are just bytes written to the PTY input stream. + +## Cursor behavior + +- The cursor is always present, even when visually hidden. +- It represents the active location for printed characters and + location‑sensitive sequences (e.g. erase line). +- **Initial position**: top‑left of the active screen. +- **Pending wrap state**: set when printing at the rightmost column, causing the + next character to wrap. This affects backspace behavior and is a common source + of prompt bugs. + +## Colors + +Ghostty emulates: + +- **256‑color palette** +- **Special colors** (bold/underline/blink/reverse/italic styling). Query/change + via `OSC 4` or `OSC 5`. +- **Dynamic colors** (foreground, background, cursor). Query/change via + `OSC 10–12`. + +Color specifications accept: + +- Hex RGB (`rgb:rr/gg/bb` or `#rrggbb`, with 4–16 bit channels) +- Intensity RGB (`rgbi:r/g/b`, 0–1) +- Named colors (X11 color names) + +## VT reference + +Ghostty’s VT reference page enumerates supported sequences (ESC, CSI, OSC). +We rely on that list for compatibility, but Ghostty supports more than the +current documentation lists. + +## External protocols + +Ghostty exposes external protocol support through standard control sequences, +including: + +- `OSC 8` hyperlinks (URI + label) +- `OSC 21` Kitty color protocol +- `DCS`/device attribute exchanges for terminal queries + +## What would it take to use Ghostty’s renderer? + +Right now we do: + +``` +PTY bytes -> Ghostty VT -> HTML snapshot -> NSTextView +``` + +To render with Ghostty’s native renderer we’d need to: + +1. **Expose renderer APIs** via the Zig bridge (likely from `renderer.zig`). +2. **Provide a Metal-backed surface** (MTKView) for the Ghostty renderer. +3. **Drive a render loop** and pass grid/cursor updates to the renderer. +4. **Map input + selection** to Ghostty’s expectations (cursor, selection, IME). +5. **Handle DPI/scale and resize** so cell metrics remain correct. + +This would give us true terminal rendering (cursor, attributes, glyph metrics) +and avoid HTML snapshot limitations. + +### Concrete integration checklist + +**Bridge (Zig → C ABI)** + +- Export renderer lifecycle: + - `ghostty_renderer_create(config, metal_device, command_queue, surface)` + - `ghostty_renderer_destroy` + - `ghostty_renderer_resize(px_width, px_height, scale)` +- Export VT hooks: + - `ghostty_vt_feed(bytes)` + - `ghostty_vt_resize(cols, rows)` + - `ghostty_vt_set_palette(...)` + - `ghostty_vt_get_grid_snapshot` (for debug/inspection) +- Export frame render: + - `ghostty_renderer_draw()` (called from MTKView draw loop) + +**Swift/Metal wiring** + +- Create an `MTKView` (layer-backed) as the terminal surface. +- Hold `MTLDevice` + `MTLCommandQueue` for the renderer. +- Drive a render loop (MTKView delegate) and call `renderer_draw`. +- Invalidate/re-render on: + - PTY data arrival + - Cursor blink timer + - Size/scale changes + +**Sizing + layout** + +- On view resize: + - Compute columns/rows from pixel size and Ghostty cell metrics. + - Call `vt_resize(cols, rows)` and `ioctl(TIOCSWINSZ)` on the PTY. +- Track backing scale factor (Retina) and send scale to the renderer. + +**Input + IME** + +- Map `NSEvent` to VT input (including Ctrl/Alt/Shift combos). +- Ensure `TERM` is set to a capable value (`xterm-256color` or Ghostty’s terminfo). +- Support paste + clipboard (OSC 52 if needed later). + +**Visual parity** + +- Configure background/foreground palette and cursor color. +- Use Ghostty cursor rendering (no NSTextView caret). +- Keep selection + copy behavior in Ghostty (optional for v1). + +**Testing** + +- Run `vttest` or known ANSI test suites in the embedded shell. +- Validate wrapping, cursor positioning, color attributes, and Unicode width. + +## Links + +- Ghostty VT Concepts: sequences, cursor, colors +- Ghostty VT Reference (CSI/OSC lists) +- Ghostty VT External protocols diff --git a/apps/macos/project.yml b/apps/macos/project.yml new file mode 100644 index 000000000..c9daef0cf --- /dev/null +++ b/apps/macos/project.yml @@ -0,0 +1,25 @@ +name: HackDesktop +options: + bundleIdPrefix: dance.hack + deploymentTarget: + macOS: "14.0" +packages: + HackDesktop: + path: . +targets: + HackDesktop: + type: application + platform: macOS + sources: + - App + configFiles: + Debug: Config/Debug.xcconfig + Release: Config/Release.xcconfig + settings: + base: + ASSETCATALOG_COMPILER_APPICON_NAME: AppIcon + dependencies: + - package: HackDesktop + product: DashboardFeature + info: + path: App/Info.plist diff --git a/apps/macos/scripts/build-release.sh b/apps/macos/scripts/build-release.sh new file mode 100755 index 000000000..13893361d --- /dev/null +++ b/apps/macos/scripts/build-release.sh @@ -0,0 +1,203 @@ +#!/bin/bash +set -euo pipefail + +# Build, sign, notarize, and package Hack Desktop for release +# +# Prerequisites: +# - Xcode with Developer ID Application certificate in keychain +# - App-specific password for notarization +# +# Environment variables (or will prompt): +# APPLE_ID - Your Apple ID email +# APPLE_ID_PASSWORD - App-specific password (create at appleid.apple.com) +# APPLE_TEAM_ID - Your Apple Developer Team ID +# +# Usage: +# ./scripts/build-release.sh +# APPLE_ID=you@example.com APPLE_TEAM_ID=XXXXXXXXXX ./scripts/build-release.sh + +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +PROJECT_DIR="$(cd "$SCRIPT_DIR/.." && pwd)" +BUILD_DIR="$PROJECT_DIR/build" +APP_NAME="Hack Desktop" +SCHEME="HackDesktop" + +# Load .env.local if it exists +if [ -f "$PROJECT_DIR/.env.local" ]; then + set -a + source "$PROJECT_DIR/.env.local" + set +a +fi + +# Colors +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[0;33m' +BLUE='\033[0;34m' +NC='\033[0m' + +log() { echo -e "${BLUE}==>${NC} $1"; } +success() { echo -e "${GREEN}✓${NC} $1"; } +warn() { echo -e "${YELLOW}⚠${NC} $1"; } +error() { echo -e "${RED}✗${NC} $1" >&2; exit 1; } + +# Check prerequisites +command -v xcodebuild >/dev/null || error "xcodebuild not found. Install Xcode." +command -v xcrun >/dev/null || error "xcrun not found. Install Xcode Command Line Tools." + +# Prompt for credentials if not set +if [ -z "${APPLE_ID:-}" ]; then + read -p "Apple ID (email): " APPLE_ID +fi + +if [ -z "${APPLE_ID_PASSWORD:-}" ]; then + echo "App-specific password (create at appleid.apple.com → Sign-In & Security → App-Specific Passwords)" + read -sp "Password: " APPLE_ID_PASSWORD + echo +fi + +if [ -z "${APPLE_TEAM_ID:-}" ]; then + read -p "Apple Team ID: " APPLE_TEAM_ID +fi + +# Clean build directory +log "Cleaning build directory..." +rm -rf "$BUILD_DIR" +mkdir -p "$BUILD_DIR" + +# Generate Xcode project if needed +if [ ! -f "$PROJECT_DIR/HackDesktop.xcodeproj/project.pbxproj" ]; then + log "Generating Xcode project..." + cd "$PROJECT_DIR" + xcodegen generate +fi + +# Build and archive +log "Building and archiving..." +cd "$PROJECT_DIR" +xcodebuild archive \ + -scheme "$SCHEME" \ + -configuration Release \ + -archivePath "$BUILD_DIR/HackDesktop.xcarchive" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" \ + | xcbeautify 2>/dev/null || xcodebuild archive \ + -scheme "$SCHEME" \ + -configuration Release \ + -archivePath "$BUILD_DIR/HackDesktop.xcarchive" \ + CODE_SIGN_STYLE=Manual \ + CODE_SIGN_IDENTITY="Developer ID Application" \ + DEVELOPMENT_TEAM="$APPLE_TEAM_ID" + +success "Archive created" + +# Export app +log "Exporting app..." +cat > "$BUILD_DIR/ExportOptions.plist" << EOF + + + + + method + developer-id + teamID + $APPLE_TEAM_ID + signingStyle + manual + signingCertificate + Developer ID Application + + +EOF + +xcodebuild -exportArchive \ + -archivePath "$BUILD_DIR/HackDesktop.xcarchive" \ + -exportPath "$BUILD_DIR/export" \ + -exportOptionsPlist "$BUILD_DIR/ExportOptions.plist" + +success "App exported" + +# Verify signature +log "Verifying code signature..." +codesign -vvv --deep --strict "$BUILD_DIR/export/$APP_NAME.app" +success "Code signature valid" + +# Notarize app +log "Submitting app for notarization (this may take a few minutes)..." +ditto -c -k --keepParent "$BUILD_DIR/export/$APP_NAME.app" "$BUILD_DIR/app.zip" + +xcrun notarytool submit "$BUILD_DIR/app.zip" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + +success "App notarized" + +# Staple notarization ticket +log "Stapling notarization ticket to app..." +xcrun stapler staple "$BUILD_DIR/export/$APP_NAME.app" +success "Ticket stapled" + +# Get version +VERSION=$(defaults read "$BUILD_DIR/export/$APP_NAME.app/Contents/Info.plist" CFBundleShortVersionString) +log "Version: $VERSION" + +# Create DMG +log "Creating DMG..." +if command -v create-dmg >/dev/null; then + create-dmg \ + --volname "$APP_NAME" \ + --window-pos 200 120 \ + --window-size 600 400 \ + --icon-size 100 \ + --icon "$APP_NAME.app" 150 190 \ + --app-drop-link 450 190 \ + --hide-extension "$APP_NAME.app" \ + "$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" \ + "$BUILD_DIR/export/$APP_NAME.app" || true +fi + +# Fallback to hdiutil if create-dmg failed or not installed +if [ ! -f "$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" ]; then + hdiutil create -volname "$APP_NAME" \ + -srcfolder "$BUILD_DIR/export/$APP_NAME.app" \ + -ov -format UDZO \ + "$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" +fi + +success "DMG created" + +# Notarize DMG +log "Submitting DMG for notarization..." +xcrun notarytool submit "$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" \ + --apple-id "$APPLE_ID" \ + --password "$APPLE_ID_PASSWORD" \ + --team-id "$APPLE_TEAM_ID" \ + --wait + +xcrun stapler staple "$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" +success "DMG notarized and stapled" + +# Create ZIP +log "Creating ZIP..." +ditto -c -k --keepParent \ + "$BUILD_DIR/export/$APP_NAME.app" \ + "$BUILD_DIR/HackDesktop-$VERSION-macOS.zip" +success "ZIP created" + +# Summary +echo +echo -e "${GREEN}Build complete!${NC}" +echo +echo "Artifacts:" +echo " $BUILD_DIR/HackDesktop-$VERSION-macOS.dmg" +echo " $BUILD_DIR/HackDesktop-$VERSION-macOS.zip" +echo " $BUILD_DIR/export/$APP_NAME.app" +echo +echo "To install locally:" +echo " open \"$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg\"" +echo +echo "To upload to a GitHub release:" +echo " gh release upload vX.X.X \"$BUILD_DIR/HackDesktop-$VERSION-macOS.dmg\" \"$BUILD_DIR/HackDesktop-$VERSION-macOS.zip\"" diff --git a/bun.lock b/bun.lock index 558e34432..641763bea 100644 --- a/bun.lock +++ b/bun.lock @@ -9,6 +9,7 @@ "@clack/prompts": "1.0.0-alpha.9", "@modelcontextprotocol/sdk": "1.25.1", "@opentui/core": "^0.1.68", + "qrcode": "^1.5.4", "zod": "4.3.4", }, "devDependencies": { @@ -20,6 +21,7 @@ "@semantic-release/git": "10.0.1", "@semantic-release/release-notes-generator": "14.1.0", "@types/bun": "latest", + "@types/qrcode": "^1.5.6", "commitizen": "4.3.1", "cz-conventional-changelog": "3.3.0", "husky": "9.1.7", @@ -225,6 +227,8 @@ "@types/normalize-package-data": ["@types/normalize-package-data@2.4.4", "", {}, "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA=="], + "@types/qrcode": ["@types/qrcode@1.5.6", "", { "dependencies": { "@types/node": "*" } }, "sha512-te7NQcV2BOvdj2b1hCAHzAoMNuj65kNBMz0KBaxM6c3VGBOhU0dURQKOtH8CFNI/dsKkwlv32p26qYQTWoB5bw=="], + "@webgpu/types": ["@webgpu/types@0.1.68", "", {}, "sha512-3ab1B59Ojb6RwjOspYLsTpCzbNB3ZaamIAxBMmvnNkiDoLTZUOBXZ9p5nAYVEkQlDdf6qAZWi1pqj9+ypiqznA=="], "JSONStream": ["JSONStream@1.3.5", "", { "dependencies": { "jsonparse": "^1.2.0", "through": ">=2.2.7 <3" }, "bin": { "JSONStream": "./bin.js" } }, "sha512-E+iruNOY8VV9s4JEbe1aNEm6MiszPRr/UfcHMz0TQh1BXSxHK+ASV1R6W4HpjBhSeS+54PIsAMCBmwD06LLsqQ=="], @@ -305,6 +309,8 @@ "callsites": ["callsites@3.1.0", "", {}, "sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ=="], + "camelcase": ["camelcase@5.3.1", "", {}, "sha512-L28STB170nwWS63UjtlEOE3dldQApaJXZkOI1uMFfzf3rRuPegHaHesyee+YxQ+W6SvRDQV6UrdOdRiR153wJg=="], + "chalk": ["chalk@2.4.2", "", { "dependencies": { "ansi-styles": "^3.2.1", "escape-string-regexp": "^1.0.5", "supports-color": "^5.3.0" } }, "sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ=="], "char-regex": ["char-regex@1.0.2", "", {}, "sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw=="], @@ -379,6 +385,8 @@ "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decamelize": ["decamelize@1.2.0", "", {}, "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA=="], + "dedent": ["dedent@0.7.0", "", {}, "sha512-Q6fKUPqnAHAyhiUgFU7BUzLiv0kd8saH9al7tnu5Q/okj6dnupxyTgFIBjVzJATdfIAm9NAsvXNzjaKa+bxVyA=="], "deep-extend": ["deep-extend@0.6.0", "", {}, "sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA=="], @@ -393,6 +401,8 @@ "diff": ["diff@8.0.2", "", {}, "sha512-sSuxWU5j5SR9QQji/o2qMvqRNYRDOcBTgsJ/DeCf4iSN4gW+gNMXM7wFIP+fdXZxoNiAnHUTGjCr+TSWXdRDKg=="], + "dijkstrajs": ["dijkstrajs@1.0.3", "", {}, "sha512-qiSlmBq9+BCdCA/L46dw8Uy93mloxsPSbwnm5yrKn2vMPiy8KyAskTF6zuV/j5BMsmOGZDPs7KjU+mjb670kfA=="], + "dir-glob": ["dir-glob@3.0.1", "", { "dependencies": { "path-type": "^4.0.0" } }, "sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA=="], "dot-prop": ["dot-prop@5.3.0", "", { "dependencies": { "is-obj": "^2.0.0" } }, "sha512-QM8q3zDe58hqUqjraQOmzZ1LIH9SWQJTlEKCH4kJ2oQvLZk7RbQXvtDM2XEq3fwkV9CCvvH4LA0AV+ogFsBM2Q=="], @@ -471,7 +481,7 @@ "find-root": ["find-root@1.1.0", "", {}, "sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng=="], - "find-up": ["find-up@7.0.0", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="], + "find-up": ["find-up@4.1.0", "", { "dependencies": { "locate-path": "^5.0.0", "path-exists": "^4.0.0" } }, "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw=="], "find-up-simple": ["find-up-simple@1.0.1", "", {}, "sha512-afd4O7zpqHeRyg4PfDQsXmlDe2PfdHtJt6Akt8jOWaApLOZk5JXs6VMR29lz03pRe9mpykrRCYIYxaJYcfpncQ=="], @@ -641,7 +651,7 @@ "load-json-file": ["load-json-file@4.0.0", "", { "dependencies": { "graceful-fs": "^4.1.2", "parse-json": "^4.0.0", "pify": "^3.0.0", "strip-bom": "^3.0.0" } }, "sha512-Kx8hMakjX03tiGTLAIdJ+lL0htKnXjEZN6hk/tozf/WOuYGdZBJrZ+rCJRbVCugsjB3jMLn9746NsQIf5VjBMw=="], - "locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], + "locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="], "lodash": ["lodash@4.17.21", "", {}, "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="], @@ -759,9 +769,9 @@ "p-is-promise": ["p-is-promise@3.0.0", "", {}, "sha512-Wo8VsW4IRQSKVXsJCn7TomUaVtyfjVDn3nUP7kE967BQk0CwFpdbZs0X0uk5sW9mkBa9eNM7hCMaG93WUAwxYQ=="], - "p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], + "p-limit": ["p-limit@2.3.0", "", { "dependencies": { "p-try": "^2.0.0" } }, "sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w=="], - "p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], + "p-locate": ["p-locate@4.1.0", "", { "dependencies": { "p-limit": "^2.2.0" } }, "sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A=="], "p-map": ["p-map@7.0.4", "", {}, "sha512-tkAQEw8ysMzmkhgw8k+1U/iPhWNhykKnSk4Rd5zLoPJCuJaGRPo6YposrZgaxHKzDHdDWWZvE/Sk7hsL2X/CpQ=="], @@ -769,7 +779,7 @@ "p-timeout": ["p-timeout@6.1.4", "", {}, "sha512-MyIV3ZA/PmyBN/ud8vV9XzwTrNtR4jFrObymZYnZqMmW0zA8Z17vnT0rBgFE/TlohB+YCHqXMgZzb3Csp49vqg=="], - "p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], + "p-try": ["p-try@2.2.0", "", {}, "sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ=="], "pako": ["pako@1.0.11", "", {}, "sha512-4hLB8Py4zZce5s4yd9XzopqwVv/yGNhV1Bl8NTmCq1763HeK2+EwVTv+leGeL13Dnh2wfbqowVPXCIO0z4taYw=="], @@ -793,7 +803,7 @@ "parseurl": ["parseurl@1.3.3", "", {}, "sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ=="], - "path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + "path-exists": ["path-exists@4.0.0", "", {}, "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w=="], "path-is-absolute": ["path-is-absolute@1.0.1", "", {}, "sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg=="], @@ -819,7 +829,7 @@ "planck": ["planck@1.4.2", "", { "peerDependencies": { "stage-js": "^1.0.0-alpha.12" } }, "sha512-mNbhnV3g8X2rwGxzcesjmN8BDA6qfXgQxXVMkWau9MCRlQY0RLNEkyHlVp6yFy/X6qrzAXyNONCnZ1cGDLrNew=="], - "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "pngjs": ["pngjs@5.0.0", "", {}, "sha512-40QW5YalBNfQo5yRYmiw7Yz6TKKVr3h6970B2YE+3fQpsWcrbj1PzJgxeJ19DRQjhMbKPIuMY8rFaXc8moolVw=="], "prettier": ["prettier@3.7.4", "", { "bin": { "prettier": "bin/prettier.cjs" } }, "sha512-v6UNi1+3hSlVvv8fSaoUbggEM5VErKmmpGA7Pl3HF8V6uKY7rvClBOJlH6yNwQtfTueNkGVpOv/mtWL9L4bgRA=="], @@ -835,6 +845,8 @@ "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="], + "qrcode": ["qrcode@1.5.4", "", { "dependencies": { "dijkstrajs": "^1.0.1", "pngjs": "^5.0.0", "yargs": "^15.3.1" }, "bin": { "qrcode": "bin/qrcode" } }, "sha512-1ca71Zgiu6ORjHqFBDpnSMTR2ReToX4l1Au1VFLyVeBTFavzQnv5JxMFr3ukHVKpSrSA2MCk0lNJSykjUfz7Zg=="], + "qs": ["qs@6.14.1", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-4EK3+xJl8Ts67nLYNwqw/dsFVnCf+qR7RgXSK9jEEm9unao3njwMDdmsdvoKBKHzxd7tCYz5e5M+SnMjdtXGQQ=="], "range-parser": ["range-parser@1.2.1", "", {}, "sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg=="], @@ -857,6 +869,8 @@ "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "require-main-filename": ["require-main-filename@2.0.0", "", {}, "sha512-NKN5kMDylKuldxYLSUfrbo5Tuzh4hd+2E8NPPX02mZtn1VuREQToYe/ZdlJy+J3uCpfaiGF05e7B8W0iXbQHmg=="], + "resolve-dir": ["resolve-dir@1.0.1", "", { "dependencies": { "expand-tilde": "^2.0.0", "global-modules": "^1.0.0" } }, "sha512-R7uiTjECzvOsWSfdM0QKFNBVFcK27aHOUwdvK53BcW8zqnGdYp0Fbj82cy54+2A4P2tFM22J5kRfe1R+lM/1yg=="], "resolve-from": ["resolve-from@5.0.0", "", {}, "sha512-qYg9KP24dD5qka9J47d0aVky0N+b4fTU89LN9iDnjB5waksiC49rvMB0PrUJQGoTmH50XPiqOvAjDfaijGxYZw=="], @@ -887,6 +901,8 @@ "serve-static": ["serve-static@2.2.1", "", { "dependencies": { "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "parseurl": "^1.3.3", "send": "^1.2.0" } }, "sha512-xRXBn0pPqQTVQiC8wyQrKs2MOlX24zQ0POGaj0kultvoOCstBQM5yvOhAVSUwOMjQtTvsPWoNCHfPGwaaQJhTw=="], + "set-blocking": ["set-blocking@2.0.0", "", {}, "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw=="], + "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], @@ -1033,6 +1049,8 @@ "which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="], + "which-module": ["which-module@2.0.1", "", {}, "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ=="], + "word-wrap": ["word-wrap@1.2.5", "", {}, "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA=="], "wordwrap": ["wordwrap@1.0.0", "", {}, "sha512-gvVzJFlPycKc5dZN4yPkP8w7Dc37BtP1yczEneOb4uq34pXZcvrtRTmWV8W+Ume+XCxKgbjM+nevkyFPMybd4Q=="], @@ -1077,10 +1095,14 @@ "@commitlint/read/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], + "@commitlint/top-level/find-up": ["find-up@7.0.0", "", { "dependencies": { "locate-path": "^7.2.0", "path-exists": "^5.0.0", "unicorn-magic": "^0.1.0" } }, "sha512-YyZM99iHrqLKjmt4LJDj58KI+fYyufRLBSYcqycxf//KpBk9FoewoGX0450m9nB44qrZnovzC2oeP5hUibxc/g=="], + "@commitlint/types/chalk": ["chalk@5.6.2", "", {}, "sha512-7NzBL0rN6fMUW+f7A6Io4h40qQlG+xGmtMxfbnH/K7TAtt8JQWVQK+6g0UXKMeVJoyV5EkkNsErQ8pVD3bLHbA=="], "@jimp/core/mime": ["mime@3.0.0", "", { "bin": { "mime": "cli.js" } }, "sha512-jSCU7/VB1loIWBZe14aEYHU/+1UMEHoaO7qxCOVJOw9GgH72VAWppxNcjU+x9a2k3GSIBXNKxXQFqRvvZ7vr3A=="], + "@jimp/js-png/pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], + "@jimp/plugin-blit/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], "@jimp/plugin-circle/zod": ["zod@3.25.76", "", {}, "sha512-gzUt/qt81nXsFGKIFcC3YnfEAx5NkunCfnDlvuBSSFS02bcXu4Lmea0AFIUwbLWxWPx3d9p8S5QoaujKcNQxcQ=="], @@ -1153,8 +1175,6 @@ "fdir/picomatch": ["picomatch@4.0.3", "", {}, "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q=="], - "find-up/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], - "git-raw-commits/meow": ["meow@12.1.1", "", {}, "sha512-BhXM0Au22RwUneMPwSCnyhTOizdWoIEPU9sp0Aqa1PnDMR5Wv2FGXYDjuzJEIX+Eo2Rb8xuYe5jrnm5QowQFkw=="], "git-raw-commits/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], @@ -1509,6 +1529,8 @@ "pkg-conf/find-up": ["find-up@2.1.0", "", { "dependencies": { "locate-path": "^2.0.0" } }, "sha512-NWzkk0jSJtTt08+FBFMvXoeZnOJD+jTtsRmBYbAIzJdX6l7dLgR7CTubCM5/eDdPUBvLCeVasP1brfVR/9/EZQ=="], + "qrcode/yargs": ["yargs@15.4.1", "", { "dependencies": { "cliui": "^6.0.0", "decamelize": "^1.2.0", "find-up": "^4.1.0", "get-caller-file": "^2.0.1", "require-directory": "^2.1.1", "require-main-filename": "^2.0.0", "set-blocking": "^2.0.0", "string-width": "^4.2.0", "which-module": "^2.0.0", "y18n": "^4.0.0", "yargs-parser": "^18.1.2" } }, "sha512-aePbxDmcYW++PaqBsJ+HYUFwCdv4LVvdnhBy78E57PIor8/OVvhMrADFFEDh8DHDFRv/O9i3lPhsENjO7QX0+A=="], + "rc/minimist": ["minimist@1.2.8", "", {}, "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA=="], "rc/strip-json-comments": ["strip-json-comments@2.0.1", "", {}, "sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ=="], @@ -1551,6 +1573,12 @@ "@commitlint/parse/conventional-commits-parser/split2": ["split2@4.2.0", "", {}, "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg=="], + "@commitlint/top-level/find-up/locate-path": ["locate-path@7.2.0", "", { "dependencies": { "p-locate": "^6.0.0" } }, "sha512-gvVijfZvn7R+2qyPX8mAuKcFGDf6Nc61GdvGafQsHL0sBIxfKzA+usWn4GFC/bk+QdwPUD4kWFJLhElipq+0VA=="], + + "@commitlint/top-level/find-up/path-exists": ["path-exists@5.0.0", "", {}, "sha512-RjhtfwJOxzcFmNOi6ltcbcu4Iu+FL3zEj83dk4kAS+fVpTxXLO1b38RvJgT/0QwvV/L3aY9TAnyv0EOqW4GoMQ=="], + + "@commitlint/top-level/find-up/unicorn-magic": ["unicorn-magic@0.1.0", "", {}, "sha512-lRfVq8fE8gz6QMBuDM6a+LO3IAzTi05H6gCVaUpir2E1Rwpo4ZUog45KpNXKC/Mn3Yb9UDuHumeFTo9iV/D9FQ=="], + "@semantic-release/git/execa/get-stream": ["get-stream@6.0.1", "", {}, "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg=="], "@semantic-release/git/execa/human-signals": ["human-signals@2.1.0", "", {}, "sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw=="], @@ -1631,6 +1659,12 @@ "pkg-conf/find-up/locate-path": ["locate-path@2.0.0", "", { "dependencies": { "p-locate": "^2.0.0", "path-exists": "^3.0.0" } }, "sha512-NCI2kiDkyR7VeEKm27Kda/iQHyKJe1Bu0FlTbYp3CqJu+9IFe9bLyAjMxf5ZDDbEg+iMPzB5zYyUTSm8wVTKmA=="], + "qrcode/yargs/cliui": ["cliui@6.0.0", "", { "dependencies": { "string-width": "^4.2.0", "strip-ansi": "^6.0.0", "wrap-ansi": "^6.2.0" } }, "sha512-t6wbgtoCXvAzst7QgXxJYqPt0usEfbgQdftEPbLL/cvv6HPE5VgvqCuAIDR0NgU52ds6rFwqrgakNLrHEjCbrQ=="], + + "qrcode/yargs/y18n": ["y18n@4.0.3", "", {}, "sha512-JKhqTOwSrqNA1NY5lSztJ1GrBiUodLMmIZuLiDaMRJ+itFd+ABVE8XBjOvIWL+rSqNDC74LCSFmlb/U4UZ4hJQ=="], + + "qrcode/yargs/yargs-parser": ["yargs-parser@18.1.3", "", { "dependencies": { "camelcase": "^5.0.0", "decamelize": "^1.2.0" } }, "sha512-o50j0JeToy/4K6OZcaQmW6lyXXKhq7csREXcDwk2omFPJEwUNOVtJKvmDr9EI1fAJZUyZcRF7kxGBWmRXudrCQ=="], + "readable-web-to-node-stream/readable-stream/buffer": ["buffer@6.0.3", "", { "dependencies": { "base64-js": "^1.3.1", "ieee754": "^1.2.1" } }, "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA=="], "readable-web-to-node-stream/readable-stream/string_decoder": ["string_decoder@1.3.0", "", { "dependencies": { "safe-buffer": "~5.2.0" } }, "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA=="], @@ -1651,6 +1685,8 @@ "wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + "@commitlint/top-level/find-up/locate-path/p-locate": ["p-locate@6.0.0", "", { "dependencies": { "p-limit": "^4.0.0" } }, "sha512-wPrq66Llhl7/4AGC6I+cqxT07LhXvWL08LNXz1fENOw0Ap4sRZZ/gZpTTJ5jpurzzzfS2W/Ge9BY3LgLjCShcw=="], + "@semantic-release/github/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], "@semantic-release/npm/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -1685,6 +1721,8 @@ "pkg-conf/find-up/locate-path/path-exists": ["path-exists@3.0.0", "", {}, "sha512-bpC7GYwiDYQ4wYLe+FA8lhRjhQCMcQGuSgGGqDkg/QerRWw9CmGRT0iSOVRSZJ29NMLZgIzqaljJ63oaL4NIJQ=="], + "qrcode/yargs/cliui/wrap-ansi": ["wrap-ansi@6.2.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA=="], + "readable-web-to-node-stream/readable-stream/string_decoder/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="], "semantic-release/aggregate-error/clean-stack/escape-string-regexp": ["escape-string-regexp@5.0.0", "", {}, "sha512-/veY75JbMK4j1yjvuUxuVsiS/hr/4iHs9FTT6cgTexxdE0Ly/glccBAkloH/DofkjRbZU3bnoj38mOmhkZ0lHw=="], @@ -1701,6 +1739,8 @@ "wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], + "@commitlint/top-level/find-up/locate-path/p-locate/p-limit": ["p-limit@4.0.0", "", { "dependencies": { "yocto-queue": "^1.0.0" } }, "sha512-5b0R4txpzjPWVw/cXXUResoD4hb6U/x9BH08L7nw+GN1sezDzPdxeRvpc9c433fZhBan/wusjbCsqwqm4EIBIQ=="], + "cli-highlight/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], "inquirer/chalk/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], @@ -1711,6 +1751,14 @@ "pkg-conf/find-up/locate-path/p-locate/p-limit": ["p-limit@1.3.0", "", { "dependencies": { "p-try": "^1.0.0" } }, "sha512-vvcXsLAJ9Dr5rQOPk7toZQZJApBl2K4J6dANSsEuh6QI41JYcsS/qhTGa9ErIUUgK3WNQoJYvylxvjqmiqEA9Q=="], + "qrcode/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="], + "semantic-release/yargs/cliui/wrap-ansi/ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="], + + "pkg-conf/find-up/locate-path/p-locate/p-limit/p-try": ["p-try@1.0.0", "", {}, "sha512-U1etNYuMJoIz3ZXSrrySFjsXQTWOx2/jdi86L+2pRvph/qMKL6sbcCYdH23fqsbm8TH2Gn0OybpT4eSFlCVHww=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="], + + "qrcode/yargs/cliui/wrap-ansi/ansi-styles/color-convert/color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="], } } diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 000000000..26f72fffc --- /dev/null +++ b/docs/README.md @@ -0,0 +1,29 @@ +# Docs + +This directory contains the public documentation for hack. Specs remain in `SPECS/` (working notes). + +## Core docs + +- [CLI reference](cli.md) +- [Architecture](architecture.md) +- [Gateway overview](gateway.md) +- [Gateway API](gateway-api.md) +- [Supervisor](supervisor.md) +- [Extensions](extensions.md) +- [Control-plane SDK](sdk.md) + +Quick diagnostics: +- `hack usage` (resource usage across running projects) +- `hack usage --watch` (live resource trends) + +## Guides + +- Remote setup (one command): `hack remote setup` +- [Initialize a project](guides/init-project.md) +- [Global settings](guides/global-settings.md) +- [Tickets (git-backed)](guides/tickets.md) +- [Expose the gateway over SSH](guides/remote-ssh.md) +- [Expose the gateway with Cloudflare](guides/remote-cloudflare.md) +- [Expose the gateway with Tailscale](guides/remote-tailscale.md) +- [Run remote supervisor jobs](guides/remote-supervisor.md) +- [Create a new extension](guides/create-extension.md) diff --git a/docs/architecture-diagram.svg b/docs/architecture-diagram.svg new file mode 100644 index 000000000..e9550baeb --- /dev/null +++ b/docs/architecture-diagram.svg @@ -0,0 +1,174 @@ + + + + + + + + + + + + + + + + + + + + + + + + hack-cli Architecture + Local Development Orchestration Platform + + + + GLOBAL INFRASTRUCTURE + ~/.hack/ + + + + Caddy + Reverse Proxy + :80 / :443 + + + + CoreDNS + *.hack resolver + Container DNS + + + + Logging + Loki + Alloy + Grafana + logs.hack + + + + hackd + Daemon + + Runtime Cache + + Gateway API :7788 + + Supervisor + PTY + + + + Docker + Container Runtime + Networks + Events + + + + PER-PROJECT + .hack/ + + + + Project A + https://project-a.hack + + docker-compose.yml + + hack.config.json + + + + Project B + https://project-b.hack + + docker-compose.yml + + hack.config.json + + + + Extensions + + Tickets + + Gateway + + Supervisor + + Cloudflare + + + + Network Isolation + Each project gets + its own Docker network + No port conflicts + Services use default ports + + + + CLIENTS + + + + CLI + hack up/down/logs + hack x tickets/gateway + + + + macOS Desktop + SwiftUI + Ghostty VT + Menu Bar + Terminal + + + + Remote + Gateway API + Tokens + HTTP/WS + + + + AI Agents + Claude / Cursor / Codex + MCP + Skills + + + + Tunnels + Cloudflare + Tailscale + + + + + + + + + + + + + + + + + + + KEY FLOWS + + + HTTP routing (*.hack → project) + + + Daemon control plane + + + Container management + + + Client connections + \ No newline at end of file diff --git a/ARCHITECTURE.md b/docs/architecture.md similarity index 72% rename from ARCHITECTURE.md rename to docs/architecture.md index b8aec29c2..4d1bb08b1 100644 --- a/ARCHITECTURE.md +++ b/docs/architecture.md @@ -1,5 +1,7 @@ # Architecture +![Architecture Diagram](./architecture-diagram.svg) + ## Why this exists (plain English) Running multiple local projects at the same time is messy when everything wants the same ports and @@ -11,12 +13,12 @@ Running multiple local projects at the same time is messy when everything wants - get fast local logs plus searchable history ## System overview - + `hack` is a Bun CLI that writes per-project Compose files under `.hack/` and manages a machine-wide proxy, DNS helpers, and logging stack under `~/.hack/`. - **Caddy** (docker-proxy) routes `*.hack` based on container labels. -- **CoreDNS** resolves `*.hack` inside containers to the Caddy IP. +- **CoreDNS** resolves `*.hack` inside containers to the Caddy IP, with `extra_hosts` mappings for resolver compatibility. - **Alloy + Loki + Grafana** capture logs and provide history. - **Schemas** are served by Caddy at `https://schemas.hack`. - **hackd (optional daemon)** caches Docker state for fast `hack projects --json` / `hack ps --json`. @@ -66,16 +68,21 @@ graph LR - CoreDNS for container DNS (`*.hack` → Caddy) - macOS DNS helper: dnsmasq + `/etc/resolver` for `*.hack` → `127.0.0.1` - Logging stack (Alloy → Loki → Grafana) + - Global config: `hack.config.json` (control plane defaults + extension settings) + - Gateway bind/port/allowWrites - Schemas hosted under `https://schemas.hack` - Networks: `hack-dev` (ingress) and `hack-logging` - Project scope (`.hack`) - `docker-compose.yml` defines services + Caddy labels - `hack.config.json` stores project name, dev host, log preferences, OAuth alias + - `controlPlane.gateway.enabled` marks the project as gateway-capable - Optional overrides: - - `.internal/compose.override.yml` (internal DNS/TLS injection) + - `.internal/compose.override.yml` (generated internal DNS/TLS injection) + - `.internal/extra-hosts.json` (user-managed extra_hosts merged into the override) - `.branch/compose..override.yml` (branch builds) + ## Internal DNS + TLS (containers) When `internal.dns` / `internal.tls` are enabled, `hack up` writes a Compose override that: @@ -83,8 +90,11 @@ When `internal.dns` / `internal.tls` are enabled, `hack up` writes a Compose ove - sets each service’s DNS to the CoreDNS container - mounts Caddy’s local CA cert into each service - sets common SSL env vars (Node, curl, git, requests) +- injects `extra_hosts` mappings for `*.hack` → current Caddy IP (for runtimes that ignore custom DNS) +- merges any repo-local `.hack/.internal/extra-hosts.json` entries (for host tunnels / dynamic domains) -This lets containers use the same `https://*.hack` hostnames as the host machine. +This lets containers use the same `https://*.hack` hostnames as the host machine. If Caddy’s IP changes, +`hack status`, `hack doctor`, and the TUI will warn; `hack restart` refreshes the mapping. ```mermaid graph LR @@ -130,6 +140,10 @@ sequenceDiagram NDJSON streaming (`hack logs --json`) emits `start`, `log`, and `end` events for MCP/TUI consumers. +Retention: +- Loki retention is set in the global Loki config (`~/.hack/logging/loki.yaml`), default `168h` in the template. +- Per-project overrides live in `hack.config.json` under `logs.retention_period` and apply when `hack down` prunes logs. + ```mermaid graph LR Containers["Compose containers"] -->|"stdout/stderr"| Alloy @@ -152,6 +166,12 @@ containers. It serves a small local API over a Unix socket at `~/.hack/daemon/ha If the daemon is not running (or version-mismatched), the CLI falls back to direct Docker calls. +Runtime health: +- The daemon treats the container runtime as ephemeral; it fingerprints the engine (socket + engine id) + and detects resets. +- When the runtime is unavailable, cached state is retained but marked stale; API responses include + `runtime_*` metadata and project `status` becomes `unknown`. + Why optional: - The CLI must keep working with zero background processes. - Not all users need cached JSON (especially if they only use interactive commands). @@ -166,6 +186,39 @@ graph LR Hackd -->|"cached state"| Cache["In-memory cache"] ``` +## Control plane + extensions + +The control plane keeps the core CLI minimal while adding features as extensions. `hackd` loads +extension manifests and exposes their APIs; the CLI dispatches extension commands via `hack x`. + +- **Gateway**: optional HTTP/WS access to `hackd` (localhost by default). +- **Supervisor**: job execution + streaming for agents. + +```mermaid +graph LR + CLI["hack CLI"] --> Hackd["hackd"] + Hackd --> ExtMgr["ExtensionManager"] + ExtMgr --> Gateway["Gateway"] + ExtMgr --> Supervisor["Supervisor"] + ExtMgr --> Tickets["Tickets"] + Remote["Remote client"] -->|HTTP/WS| Gateway + Gateway --> Hackd +``` + +### Gateway API + remote workflows + +The gateway exposes: +- `GET /v1/projects` with `project_id` for remote workflow routing +- job execution + streaming (`/control-plane/projects/:id/jobs`) +- PTY-backed shells (`/control-plane/projects/:id/shells`, WS stream) + +Current gateway routing: +- One gateway instance is active per machine (global config). +- Projects opt in with `controlPlane.gateway.enabled` in their project config. +- Remote clients route by `project_id` in the API paths. + +For full usage, security posture, and end-to-end examples, see `gateway-api.md`. + ## Branch builds `--branch ` generates a per-branch Compose override that: diff --git a/docs/cli.md b/docs/cli.md new file mode 100644 index 000000000..7941492bd --- /dev/null +++ b/docs/cli.md @@ -0,0 +1,939 @@ +# CLI Reference + +This reference mirrors the CLI spec in `src/cli/spec.ts`. +Run `hack help` or `hack help ` for interactive help. + +## Conventions + +- Commands that accept both `--path` and `--project` treat them as mutually exclusive. +- `--branch` runs branch-specific instances (compose project name + hostnames). +- `--profile` accepts comma-separated compose profiles. +- Options marked repeatable can be passed multiple times. + +## Top-level commands + +| Command | Summary | Group | +| --- | --- | --- | +| `hack global` | Manage machine-wide infra (DNS/TLS, Caddy proxy, logs) | Global | +| `hack projects` | Show all projects (registry + running docker compose) | Global | +| `hack status` | Show project status (shortcut for `hack projects --details`) | Global | +| `hack usage` | Show resource usage across running projects | Global | +| `hack init` | Initialize a repo (generate .hack/ with compose + config) | Project | +| `hack up` | Start project services (docker compose up) | Project | +| `hack down` | Stop project services (docker compose down) | Project | +| `hack restart` | Restart project services (down then up) | Project | +| `hack ps` | Show project status (docker compose ps) | Project | +| `hack logs` | Tail logs (compose by default; Loki for queries/history via --loki/--query) | Project | +| `hack run` | Run a one-off command in a service container (docker compose run --rm) | Project | +| `hack open` | Open a URL for the project (default: https://.hack) | Project | +| `hack tui` | Open the project TUI (services + logs) | Project | +| `hack branch` | Manage branch aliases for a project | Project | +| `hack config` | Read/write hack.config.json values | Project | +| `hack internal` | Manage hack-managed internal overrides | Internal | +| `hack gateway` | Manage gateway enablement | Extensions | +| `hack remote` | Remote workflow helpers | Extensions | +| `hack x` | Run extension commands | Extensions | +| `hack setup` | Install integrations for coding agents | Agents | +| `hack agent` | Agent utilities | Agents | +| `hack mcp` | Manage MCP server integrations for coding agents | Agents | +| `hack doctor` | Validate local setup (docker, networks, DNS, global infra, project config) | Diagnostics | +| `hack daemon` | Manage the local hack daemon (hackd) | Diagnostics | +| `hack log-pipe` | Read log lines from stdin and pretty-print them | Diagnostics | +| `hack help` | Show help for a command | Diagnostics | +| `hack version` | Print version | Diagnostics | +| `hack secrets` | Manage secrets in OS keychain (Bun.secrets) | Secrets | +| `hack the` | Fun commands | Fun | + +## Global commands + +### hack global + +Usage: `hack global ` + +Subcommands: + +| Subcommand | Summary | +| --- | --- | +| `install` | Bootstrap `~/.hack` and start Caddy + Grafana/Loki/Alloy | +| `up` | Start global infra containers | +| `down` | Stop global infra containers | +| `status` | Show status for global infra (containers + networks) | +| `logs` | Tail global infra logs (caddy|grafana|loki|alloy) | +| `ca` | Export Caddy Local CA cert (print path or PEM) | +| `cert` | Generate local TLS certs via mkcert (for non-Caddy services) | +| `trust` | Trust Caddy Local CA (macOS) so https://*.hack is trusted | +| `logs-reset` | Wipe Loki/Grafana volumes (fresh logs + dashboards) | + +#### hack global logs + +Usage: `hack global logs [service] [options]` + +Arguments: + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `service` | string | no | Filter to one global service (caddy, grafana, loki, alloy) | + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-f`, `--follow` | boolean | true | Follow logs (default) | +| `--no-follow` | boolean | false | Print logs and exit | +| `--tail ` | number | 200 | Tail last N log lines | +| `--pretty` | boolean | false | Pretty-print logs (best-effort JSON parsing + formatting) | + +#### hack global ca + +Usage: `hack global ca [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--print` | boolean | false | Print the CA cert PEM to stdout (instead of printing its path) | + +#### hack global cert + +Usage: `hack global cert [options]` + +Arguments: + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `hosts` | string[] | yes | One or more hostnames to generate certs for | + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--install` | boolean | false | Run mkcert -install before generating certs | +| `--out ` | string | `~/.hack/certs` | Directory for generated cert/key | + +### hack projects + +Usage: `hack projects [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--project ` | string | - | Filter to a registered project name | +| `--details` | boolean | false | Show per-project service tables | +| `--include-global` | boolean | false | Include global infra projects under `~/.hack` | +| `--all` | boolean | false | Include unregistered docker compose projects | +| `--json` | boolean | false | Output JSON (machine-readable) | + +Subcommand: + +#### hack projects prune + +Usage: `hack projects prune [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--include-global` | boolean | false | Include global infra projects under `~/.hack` | + +### hack status + +Usage: `hack status [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--project ` | string | - | Filter to a registered project name | +| `--include-global` | boolean | false | Include global infra projects under `~/.hack` | +| `--all` | boolean | false | Include unregistered docker compose projects | +| `--json` | boolean | false | Output JSON (machine-readable) | + +### hack usage + +Usage: `hack usage [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `--project ` | string | - | Filter to a registered project name | +| `--include-global` | boolean | false | Include global infra projects under `~/.hack` | +| `--watch` | boolean | false | Refresh usage continuously | +| `--interval ` | number | - | Refresh interval (ms) for `--watch` | +| `--no-host` | boolean | false | Skip host process metrics | +| `--json` | boolean | false | Output JSON (machine-readable); not supported with `--watch` | + +## Project commands + +### hack init + +Usage: `hack init [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--manual` | boolean | false | Skip discovery and define services manually (or generate a minimal compose in --auto) | +| `--auto` | boolean | false | Run non-interactive init with sensible defaults | +| `--name ` | string | - | Project slug (default: repo name) | +| `--dev-host ` | string | - | DEV_HOST override | +| `--oauth` | boolean | false | Enable OAuth-safe alias host | +| `--oauth-tld ` | string | `gy` | OAuth alias TLD override | +| `--no-discovery` | boolean | false | Skip discovery and generate a minimal compose | + +### hack up + +Usage: `hack up [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--branch ` | string | - | Run against a branch-specific instance | +| `-d`, `--detach` | boolean | false | Run in background (docker compose up -d) | +| `--profile ` | string | - | Enable one or more compose profiles | + +### hack down + +Usage: `hack down [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--branch ` | string | - | Run against a branch-specific instance | +| `--profile ` | string | - | Enable one or more compose profiles | + +### hack restart + +Usage: `hack restart [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--branch ` | string | - | Run against a branch-specific instance | +| `--profile ` | string | - | Enable one or more compose profiles | + +### hack ps + +Usage: `hack ps [options]` + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--branch ` | string | - | Run against a branch-specific instance | +| `--profile ` | string | - | Enable one or more compose profiles | +| `--json` | boolean | false | Output JSON (machine-readable) | + +### hack logs + +Usage: `hack logs [service] [options]` + +Arguments: + +| Name | Type | Required | Description | +| --- | --- | --- | --- | +| `service` | string | no | Filter logs by service (shortcut for `--services`) | + +Options: + +| Flag | Type | Default | Description | +| --- | --- | --- | --- | +| `-p`, `--path ` | string | - | Run against a repo path (overrides cwd search) | +| `--project ` | string | - | Target a registered project by name | +| `--branch ` | string | - | Run against a branch-specific instance | +| `-f`, `--follow` | boolean | true | Follow logs (default) | +| `--no-follow` | boolean | false | Print logs and exit | +| `--tail ` | number | 200 | Tail last N log lines | +| `--pretty` | boolean | false | Pretty-print logs (best-effort JSON parsing + formatting) | +| `--json` | boolean | false | Output JSON (NDJSON stream) | +| `--profile ` | string | - | Enable one or more compose profiles | +| `--compose` | boolean | false | Read logs from docker compose (bypass Loki) | +| `--loki` | boolean | false | Force Loki backend (no compose fallback) | +| `--services ` | string | - | Filter Loki logs by service(s), comma-separated | +| `--query ` | string | - | Raw LogQL selector/query | +| `--since