diff --git a/.claude-plugin/marketplace.json b/.claude-plugin/marketplace.json index e5e1ae10a0..11bdb8c156 100644 --- a/.claude-plugin/marketplace.json +++ b/.claude-plugin/marketplace.json @@ -1,7 +1,7 @@ { "$schema": "https://anthropic.com/claude-code/marketplace.schema.json", "name": "genesis-tools", - "version": "1.0.31", + "version": "1.0.32", "description": "Plugins for GenesisTools CLI development and management", "owner": { "name": "genesiscz", @@ -11,7 +11,7 @@ { "name": "genesis-tools", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", - "version": "1.0.31", + "version": "1.0.32", "author": { "name": "GenesisTools" }, diff --git a/README.md b/README.md index d6e169a166..834d545ad8 100644 --- a/README.md +++ b/README.md @@ -66,7 +66,7 @@ Commands are invoked manually with `/gt:`: | [`gt:automate`](#gtautomate) | Build or run multi-step `tools` CLI automation presets | | [`gt:timelog`](#gttimelog) | Sync Timely → Azure DevOps timelogs and fill Clarity PPM timesheets | | [`gt:claude-history`](#gtclaude-history-command) | Search conversation history by keywords, files, commits, or time range | -| [`gt:question`](#gtquestion) | Answer-only mode — research and explain without modifying code | +| [`gt:question`](#gtquestion) | Answer a question and preserve the Q→A to the local question store for later review | ### Skills @@ -83,7 +83,6 @@ Skills activate automatically when you mention relevant topics in conversation: | [`gt:typescript-error-fixer`](#gttypescript-error-fixer) | "fix type errors", "eliminate any types" | Systematic 4-phase TS error fixing with zero `any` tolerance | | [`gt:git-rebaser`](#gtgit-rebaser) | "rebase branches", "cascade rebase", "update child branches" | Guided rebase cascade for branch hierarchies with `--onto` | | [`gt:living-docs`](#gtliving-docs) | "bootstrap docs", "validate documentation", "audit docs" | Self-maintaining documentation system with context rules | -| [`gt:writing-plans`](#gtwriting-plans) | "write a plan", "create implementation plan" | Write bite-sized TDD implementation plans with exact file paths | | [`debugging-master`](#debugging-master) | "debug runtime", "why is this slow", "add logging" | Hypothesis-driven runtime debugging with instrumentation toolkit | --- @@ -152,14 +151,14 @@ Supports keyword search, file-based search, commit-based search, tool filtering, #### `gt:question` -Answer-only mode — Claude researches your question (reads files, searches code, browses the web) but **never modifies any files**. +Answer a question, then preserve the Q→A to the local question store so it can be reviewed later (`tools question log` / `tools question tail`). Also fires automatically right after you answer an important question interjected mid-session. ```bash /gt:question "how does the caching layer work?" /gt:question "what's the difference between timelog add and prepare-import?" ``` -Includes a refinement loop: after each answer, asks if you want it simpler, longer, shorter, or if it's good. +Captured via the `question_answer` MCP tool, falling back to the `tools question record` CLI. --- @@ -350,21 +349,6 @@ Philosophy: docs are a search index, not a textbook. Only document what can't be -#### `gt:writing-plans` - -Write comprehensive TDD implementation plans with bite-sized tasks, exact file paths, and complete code. - -
-What you get - -- Each step is one action (2-5 minutes): write failing test → verify fail → implement → verify pass → commit -- Exact file paths, exact commands with expected output -- Execution handoff: subagent-driven (this session) or parallel session - -Plans are saved to `.claude/plans/YYYY-MM-DD-.md`. - -
- #### `debugging-master` Hypothesis-driven runtime debugging with a structured instrumentation toolkit. Instrument code → reproduce → analyze real data. diff --git a/bun.lock b/bun.lock index 1e40f41729..5914f0cc1a 100644 --- a/bun.lock +++ b/bun.lock @@ -152,6 +152,7 @@ "devDependencies": { "@anthropic-ai/claude-code": "^2.1.45", "@biomejs/biome": "^2.3.12", + "@playwright/test": "1.57.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", @@ -187,6 +188,7 @@ "react-devtools-core": "^7.0.0", "tailwind-merge": "^3.5.0", "ts-morph": "^28.0.0", + "tw-animate-css": "^1.3.6", "vite-tsconfig-paths": "^6.1.1", "web-vitals": "^5.2.0", }, @@ -860,6 +862,8 @@ "@parcel/watcher-win32-x64": ["@parcel/watcher-win32-x64@2.5.6", "", { "os": "win32", "cpu": "x64" }, "sha512-hbQlYcCq5dlAX9Qx+kFb0FHue6vbjlf0FrNzSKdYK2APUf7tGfGxQCk2ihEREmbR6ZMc0MVAD5RIX/41gpUzTw=="], + "@playwright/test": ["@playwright/test@1.57.0", "", { "dependencies": { "playwright": "1.57.0" }, "bin": { "playwright": "cli.js" } }, "sha512-6TyEnHgd6SArQO8UO2OMTxshln3QMWBtPGrOCgs3wVEmQmwyuNtB10IZMfmYDE0riwNR1cu4q+pPcxMVtaG3TA=="], + "@pppp606/ink-chart": ["@pppp606/ink-chart@0.2.4", "", { "peerDependencies": { "ink": ">=6", "react": ">=19" }, "bin": { "ink-chart-demo": "bin/demo.js" } }, "sha512-iqzTFePJKgzypEMdziIIo/uRDtQGBnunWxsvmaHTIBBxQIK0sRDoOszJzDc0UNGRxM+bOSPtuC844sqH/lJorg=="], "@protobufjs/aspromise": ["@protobufjs/aspromise@1.1.2", "", {}, "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ=="], @@ -2808,6 +2812,10 @@ "platform": ["platform@1.3.6", "", {}, "sha512-fnWVljUchTro6RiCFvCXBbNhJc2NijN7oIQxbwsyL0buWJPG85v81ehlHI9fXrJsMNgTofEoWIQeClKpgxFLrg=="], + "playwright": ["playwright@1.57.0", "", { "dependencies": { "playwright-core": "1.57.0" }, "optionalDependencies": { "fsevents": "2.3.2" }, "bin": { "playwright": "cli.js" } }, "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw=="], + + "playwright-core": ["playwright-core@1.57.0", "", { "bin": { "playwright-core": "cli.js" } }, "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ=="], + "pngjs": ["pngjs@7.0.0", "", {}, "sha512-LKWqWJRhstyYo9pGvgor/ivk2w94eSjE3RGVuzLGlr3NmD8bf7RcYGze1mNdEHRP6TRP6rMuDHk5t44hnTRyow=="], "possible-typed-array-names": ["possible-typed-array-names@1.1.0", "", {}, "sha512-/+5VFTchJDoVj3bhoqi6UeymcD00DAwb1nJwamzPvHEszJ4FpF6SNNbUbOS8yI56qHzdV8eK0qEfOSiodkTdxg=="], @@ -3278,6 +3286,8 @@ "turndown-plugin-gfm": ["turndown-plugin-gfm@1.0.2", "", {}, "sha512-vwz9tfvF7XN/jE0dGoBei3FXWuvll78ohzCZQuOb+ZjWrs3a0XhQVomJEb2Qh4VHTPNRO4GPZh0V7VRbiWwkRg=="], + "tw-animate-css": ["tw-animate-css@1.4.0", "", {}, "sha512-7bziOlRqH0hJx80h/3mbicLW7o8qLsH5+RaLR2t+OHM3D0JlWGODQKQ4cxbK7WlvmUxpcj6Kgu6EKqjrGFe3QQ=="], + "type": ["type@2.7.3", "", {}, "sha512-8j+1QmAbPvLZow5Qpi6NCaN8FB60p/6x8/vfNqOk/hC+HuvFZhL4+WfekuhQLiqFZXOgQdrs3B+XxEmCc6b3FQ=="], "type-check": ["type-check@0.4.0", "", { "dependencies": { "prelude-ls": "^1.2.1" } }, "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew=="], @@ -3862,6 +3872,8 @@ "pkg-up/find-up": ["find-up@3.0.0", "", { "dependencies": { "locate-path": "^3.0.0" } }, "sha512-1yD6RmLI1XBfxugvORwlck6f75tYL+iR0jqwsOrOxMZyGYqUuDhJ0l4AXdO1iX/FTs9cBAMEk1gWSEx1kSbylg=="], + "playwright/fsevents": ["fsevents@2.3.2", "", { "os": "darwin" }, "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="], + "postcss/nanoid": ["nanoid@3.3.11", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w=="], "proper-lockfile/signal-exit": ["signal-exit@3.0.7", "", {}, "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ=="], diff --git a/package.json b/package.json index b6d3c76820..d05f646639 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "devDependencies": { "@anthropic-ai/claude-code": "^2.1.45", "@biomejs/biome": "^2.3.12", + "@playwright/test": "1.57.0", "@radix-ui/react-dialog": "^1.1.15", "@radix-ui/react-scroll-area": "^1.2.10", "@radix-ui/react-slot": "^1.2.4", @@ -58,6 +59,7 @@ "react-devtools-core": "^7.0.0", "tailwind-merge": "^3.5.0", "ts-morph": "^28.0.0", + "tw-animate-css": "^1.3.6", "vite-tsconfig-paths": "^6.1.1", "web-vitals": "^5.2.0" }, diff --git a/plugins/genesis-tools/.claude-plugin/plugin.json b/plugins/genesis-tools/.claude-plugin/plugin.json index ccc279ffdc..fba210f8e2 100644 --- a/plugins/genesis-tools/.claude-plugin/plugin.json +++ b/plugins/genesis-tools/.claude-plugin/plugin.json @@ -1,6 +1,6 @@ { "name": "genesis-tools", - "version": "1.0.31", + "version": "1.0.32", "description": "Skills and utilities for working with GenesisTools CLI toolkit. Provides guidance for discovering, executing, and troubleshooting genesis tools with integrated workflow support.", "author": { "name": "GenesisTools" diff --git a/plugins/genesis-tools/skills/claude-qa/SKILL.md b/plugins/genesis-tools/skills/claude-qa/SKILL.md deleted file mode 100644 index 95a05dfb3c..0000000000 --- a/plugins/genesis-tools/skills/claude-qa/SKILL.md +++ /dev/null @@ -1,21 +0,0 @@ ---- -name: claude-qa -description: Use IMMEDIATELY AFTER you have answered a substantive question, directive, or status-nudge the user interjected mid-session — e.g. "why did you pick X over Y", "anything left from the plan?", "what's next?", "what did we forget?", "pushed yet?", "did the tests pass?". Triggers on the user asking for rationale, status, or a check while you were doing other work. Does NOT trigger for routine task instructions you simply execute, or when the user invoked /question (that skill handles its own logging). ---- - -# Auto-log a mid-session Q→A - -You just answered an interjected question/directive/nudge. Capture it so the user can review it later without scrolling 8 agents. - -**Do this once, now, then continue your prior work:** - -1. Call the `question_answer` MCP tool with: - - `question`: the user's interjection, verbatim or lightly cleaned. - - `answer`: your **complete** answer to it in markdown (the real answer you just gave — rationale, links, refs — not a lossy one-liner). - - `tag`: `question` (asking why/what/how), `directive` (told you to do/decide something), or `action` (a "did you / should you" nudge that you acted on — include the result, e.g. "pushed @ abc1234, CI green"). - - `refs`: optional commits/files/URLs you referenced. - - `agentLabel`: if you are a subagent, your role/task in 2–4 words. -2. If `question_answer` is unavailable, fall back to `Bash: tools question record --q "…" --a-file --tag `. -3. Do not announce it beyond a terse "(logged)" if natural. Return to what you were doing. - -**Skip** if: the user invoked `/question` (handled there); the interjection was pure acknowledgement ("ok", "thanks", "continue"); or there was no substantive answer to capture. diff --git a/plugins/genesis-tools/skills/question/SKILL.md b/plugins/genesis-tools/skills/question/SKILL.md index 1e9d3827d3..aa525bf628 100644 --- a/plugins/genesis-tools/skills/question/SKILL.md +++ b/plugins/genesis-tools/skills/question/SKILL.md @@ -1,43 +1,34 @@ --- name: question -description: Answer-only mode — research and explain without modifying code. Use when the user wants an explanation/rationale ("why did you choose X", "how does Y work", "what are the tradeoffs"), not an implementation. Invocable as /question. +description: Answer a question and preserve the Q→A for later review. Use when the user runs `/question ` (answer it, then save), or right after you give a substantive answer worth keeping to a question interjected mid-session ("why X over Y", "how does Y work", decisions, rationale, tradeoffs). Not for routine instructions you execute or quick status checks/acknowledgements ("ok", "thanks", "pushed yet?"). argument-hint: "" -allowed-tools: - - Read - - Glob - - Grep - - LSP - - Task - - AskUserQuestion - - WebFetch - - WebSearch - - ToolSearch - - mcp__genesis-tools__question_answer - - Bash(read-only commands only — no file modifications) --- -# Question — Answer-Only Mode +# Question — answer & preserve -The user asked: **$ARGUMENTS** +This fires two ways. Branch on whether `$ARGUMENTS` is present: -You are in **answer-only mode**. Your ONLY job is to answer the question above. You MUST NOT modify/write/create files, propose code changes, say "I'll fix this", use Write/Edit/NotebookEdit, or create plans/TODOs. +**A. Direct invocation — `$ARGUMENTS` is set.** The user asked: **$ARGUMENTS** +Research if needed (read/search code, look things up), then give a clear, focused, self-contained answer — rationale, links, refs as warranted. Then preserve it (see **Log**). -You MAY: read & search code (Read/Glob/Grep/LSP), Task(Explore) for research, web search, read-only Bash, and the `question_answer` MCP tool (see "Logging" — its persistence runs in the MCP server process, not you editing files; this does not violate answer-only mode). +**B. Auto-trigger — `$ARGUMENTS` is empty.** You just answered a substantive question/directive/nudge the user interjected while you were doing other work. Capture that Q→A so they can review it later without scrolling back, then return to your prior work. -## Workflow +## Log — the whole point -1. **Research if needed** — read files, search code, look things up. -2. **Give a clear, focused answer** — match depth to complexity; complete and self-contained (rationale, links, refs, code snippets as warranted). This is NOT a lossy summary. -3. **Refinement loop** — ask via AskUserQuestion: +Call the `question_answer` MCP tool ONCE with: - - "Good answer, thanks" → settle (go to step 4). - - "Explain like I'm a junior" / "Longer" / "Shorter" → rewrite per selection, ask again. +- `question`: the user's question, verbatim or lightly cleaned. +- `answer`: your **complete** answer in markdown (the real answer — rationale, links, refs — not a lossy one-liner). +- `tag`: `question` (asking why/what/how), `directive` (told you to do/decide something), or `action` (a "did you / should you" nudge you acted on — include the result, e.g. "pushed @ abc1234, CI green"). +- `refs`: optional commits/files/URLs you referenced. +- `agentLabel`: if you are a subagent, your role/task in 2–4 words. -4. **MANDATORY closing step — log the final answer.** Unless the user passed `--no-log` in `$ARGUMENTS`, after the answer settles call the `question_answer` MCP tool ONCE with: `question` = the user's question (lightly cleaned), `answer` = your **final refined** answer in full markdown, `tag` = `question` (or `directive` if it was an instruction-shaped ask), `refs` = any commits/files/URLs cited. Then tell the user one line: "Logged ✓ (``)" or, if the tool returns a sink error, relay its `remedy` verbatim. If the `question_answer` tool is unavailable, fall back to `Bash: tools question record --q "…" --a-file --tag question`. Do not skip this step; it is the whole point of the mode. +If the `question_answer` MCP tool is unavailable, fall back to the CLI: -## Answer Style +```bash +tools question record --q "" --a-file --tag +``` -Default mid-length technical; junior = analogies/no jargon; longer = edge cases/examples; shorter = bullets only; combined applies both. - - +Then tell the user one line — "Logged ✓ (``)" — or, if the tool returns a sink error, relay its `remedy` verbatim. +**Skip logging** if: the interjection was pure acknowledgement ("ok", "thanks", "continue"); there was no substantive answer to capture; or the user passed `--no-log` in `$ARGUMENTS`. diff --git a/plugins/genesis-tools/skills/writing-plans/SKILL.md b/plugins/genesis-tools/skills/writing-plans/SKILL.md deleted file mode 100644 index 224e3f6a68..0000000000 --- a/plugins/genesis-tools/skills/writing-plans/SKILL.md +++ /dev/null @@ -1,117 +0,0 @@ ---- -name: gt:writing-plans -description: Write an implementation plan before a multi-step code task. -context: fork ---- - -# Writing Plans - -## Overview - -Write comprehensive implementation plans assuming the engineer has zero context for our codebase and questionable taste. Document everything they need to know: which files to touch for each task, code, testing, docs they might need to check, how to test it. Give them the whole plan as bite-sized tasks. DRY. YAGNI. TDD. Frequent commits. - -Assume they are a skilled developer, but know almost nothing about our toolset or problem domain. Assume they don't know good test design very well. - -**Announce at start:** "I'm using the writing-plans skill to create the implementation plan." - -**Context:** This should be run in a dedicated worktree (created by brainstorming skill). - -**Save plans to:** `.claude/plans/YYYY-MM-DD-.md` - -## Bite-Sized Task Granularity - -**Each step is one action (2-5 minutes):** -- "Write the failing test" - step -- "Run it to make sure it fails" - step -- "Implement the minimal code to make the test pass" - step -- "Run the tests and make sure they pass" - step -- "Commit" - step - -## Plan Document Header - -**Every plan MUST start with this header:** - -```markdown -# [Feature Name] Implementation Plan - -> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. - -**Goal:** [One sentence describing what this builds] - -**Architecture:** [2-3 sentences about approach] - -**Tech Stack:** [Key technologies/libraries] - ---- -``` - -## Task Structure - -````markdown -### Task N: [Component Name] - -**Files:** -- Create: `exact/path/to/file.py` -- Modify: `exact/path/to/existing.py:123-145` -- Test: `tests/exact/path/to/test.py` - -**Step 1: Write the failing test** - -```python -def test_specific_behavior(): - result = function(input) - assert result == expected -``` - -**Step 2: Run test to verify it fails** - -Run: `pytest tests/path/test.py::test_name -v` -Expected: FAIL with "function not defined" - -**Step 3: Write minimal implementation** - -```python -def function(input): - return expected -``` - -**Step 4: Run test to verify it passes** - -Run: `pytest tests/path/test.py::test_name -v` -Expected: PASS - -**Step 5: Commit** - -```bash -git add tests/path/test.py src/path/file.py -git commit -m "feat: add specific feature" -``` -```` - -## Remember -- Exact file paths always -- Complete code in plan (not "add validation") -- Exact commands with expected output -- Reference relevant skills with @ syntax -- DRY, YAGNI, TDD, frequent commits - -## Execution Handoff - -After saving the plan, offer execution choice: - -**"Plan complete and saved to `.claude/plans/.md`. Two execution options:** - -**1. Subagent-Driven (this session)** - I dispatch fresh subagent per task, review between tasks, fast iteration - -**2. Parallel Session (separate)** - Open new session with executing-plans, batch execution with checkpoints - -**Which approach?"** - -**If Subagent-Driven chosen:** -- **REQUIRED SUB-SKILL:** Use superpowers:subagent-driven-development -- Stay in this session -- Fresh subagent per task + code review - -**If Parallel Session chosen:** -- Guide them to open new session in worktree -- **REQUIRED SUB-SKILL:** New session uses superpowers:executing-plans diff --git a/src/Internal/commands/reas/index.ts b/src/Internal/commands/reas/index.ts index e69082f16b..2e8316e37a 100644 --- a/src/Internal/commands/reas/index.ts +++ b/src/Internal/commands/reas/index.ts @@ -21,14 +21,14 @@ import { parsePeriod, resolveDistrict, } from "@app/Internal/commands/reas/lib/config-builder"; +import { reasUiApp } from "@app/Internal/commands/reas/lib/ui-app"; import type { AnalysisFilters, FullAnalysis, TargetProperty } from "@app/Internal/commands/reas/types"; import { out } from "@app/logger"; import { isInteractive, suggestCommand } from "@app/utils/cli"; import { SafeJSON } from "@app/utils/json"; -import { isPortInUse } from "@app/utils/network"; +import * as p from "@app/utils/prompts/p"; import { stripAnsi } from "@app/utils/string"; import { formatTable } from "@app/utils/table"; -import * as p from "@clack/prompts"; import { Command } from "commander"; import pc from "picocolors"; @@ -81,7 +81,7 @@ async function resolveDistrictFromAddress(address: string): Promise parsePeriod(period)); const filters: AnalysisFilters = { estateType: "flat", - constructionType: propertyType, + constructionType: propertyTypeStr, disposition: parsedDisposition, periods: dateRanges, district: district, @@ -306,7 +308,7 @@ async function runInteractiveWizard(): Promise<{ filters: AnalysisFilters; targe price: Number(price), area: Number(area), disposition: parsedDisposition ?? "all", - constructionType: propertyType, + constructionType: propertyTypeStr, monthlyRent: Number(rent), monthlyCosts: Number(monthlyCosts), district: district.name, @@ -475,45 +477,15 @@ async function runSearch(query: string, options: ReasOptions): Promise { async function runReasAnalysis(options: ReasOptions): Promise { if (options.dashboard) { - const { resolve } = await import("node:path"); - const { spawn } = await import("node:child_process"); - const configPath = resolve(import.meta.dir, "ui/vite.config.ts"); const portStr = options.dashboardPort ?? "3072"; - const port = Number.parseInt(portStr, 10); + const portArg = portStr ? Number.parseInt(portStr, 10) : undefined; - if (Number.isNaN(port) || port < 1 || port > 65535) { - out.error(`Invalid --dashboard-port value: "${portStr}". Please provide a valid port number (1-65535).`); + if (portArg !== undefined && (Number.isNaN(portArg) || portArg < 1 || portArg > 65535)) { + out.error(`Invalid --dashboard-port value: "${portStr}". Provide 1-65535.`); return; } - const portOccupied = await isPortInUse(port); - - if (portOccupied) { - out.error(`Port ${port} is already in use.`); - out.println( - suggestCommand("tools internal reas --dashboard", { - add: ["--dashboard-port", ""], - }) - ); - return; - } - - out.println(`Starting REAS dashboard on port ${port}...`); - const child = spawn("bun", ["--bun", "vite", "dev", "--strictPort", "-c", configPath, "--port", String(port)], { - stdio: "inherit", - }); - - const exitCode = await new Promise((resolveExit) => { - child.once("error", (err: Error) => { - out.error("Dashboard failed:", err); - resolveExit(1); - }); - child.once("close", (code) => { - resolveExit(code ?? 0); - }); - }); - - process.exit(exitCode); + await reasUiApp.up({ port: portArg }); return; } @@ -587,6 +559,8 @@ export function registerReasCommand(program: Command): void { await runReasAnalysis(opts); }); + reas.addCommand(reasUiApp.commanderCommand); + // ---- Subcommand: listings ---- reas.command("listings") .description("Browse stored listings from the database") diff --git a/src/Internal/commands/reas/lib/analysis-service.ts b/src/Internal/commands/reas/lib/analysis-service.ts index 8f5c5ad426..27ecb6301f 100644 --- a/src/Internal/commands/reas/lib/analysis-service.ts +++ b/src/Internal/commands/reas/lib/analysis-service.ts @@ -1,8 +1,4 @@ import { analyzeActiveVsSold } from "@app/Internal/commands/reas/analysis/active-vs-sold"; -import { configureLogger } from "@app/logger"; - -// Enable file logging at debug level so all outbound API calls (logged by ApiClient) are captured -configureLogger({ logToFile: true, level: "debug" }); import { analyzeComparables } from "@app/Internal/commands/reas/analysis/comparables"; import { analyzeDiscount } from "@app/Internal/commands/reas/analysis/discount"; diff --git a/src/Internal/commands/reas/lib/ui-app.ts b/src/Internal/commands/reas/lib/ui-app.ts new file mode 100644 index 0000000000..9d347d145d --- /dev/null +++ b/src/Internal/commands/reas/lib/ui-app.ts @@ -0,0 +1,21 @@ +import { resolve } from "node:path"; +import { buildViteDevCmd, defineDashboardApp } from "@app/utils/DashboardApp"; +import { PROJECT_ROOT } from "@app/utils/paths"; + +const reasUiConfigPath = resolve(import.meta.dir, "../ui/vite.config.ts"); + +export const reasUiApp = defineDashboardApp({ + type: "ui", + key: "reas", + name: "REAS Analyzer", + description: "Launch the REAS Analyzer dashboard", + commandName: "ui", + aliases: ["dashboard"], + spawn: { + cmd: buildViteDevCmd({ configPath: reasUiConfigPath, strictPort: true }), + cwd: PROJECT_ROOT, + }, + readiness: { kind: "http", path: "/" }, + openBrowser: { enabled: true }, + launchd: { available: true }, +}); diff --git a/src/Internal/commands/reas/ui/src/routes/api/export-pdf.tsx b/src/Internal/commands/reas/ui/src/routes/api/export-pdf.tsx index 55c44ea306..56d6d7af6a 100644 --- a/src/Internal/commands/reas/ui/src/routes/api/export-pdf.tsx +++ b/src/Internal/commands/reas/ui/src/routes/api/export-pdf.tsx @@ -1,5 +1,4 @@ import { isDashboardExport } from "@app/Internal/commands/reas/lib/api-export"; -import { exportDashboardToPdf } from "@app/Internal/commands/reas/lib/pdf-export"; import { apiHandler, jsonBody } from "@app/Internal/commands/reas/ui/src/server/api-utils"; import { createFileRoute } from "@tanstack/react-router"; @@ -17,6 +16,7 @@ export const Route = createFileRoute("/api/export-pdf")({ return Response.json({ error: "Invalid dashboard export payload" }, { status: 400 }); } + const { exportDashboardToPdf } = await import("@app/Internal/commands/reas/lib/pdf-export"); const pdf = await exportDashboardToPdf(body); const responseBody = new ArrayBuffer(pdf.byteLength); new Uint8Array(responseBody).set(pdf); diff --git a/src/azure-devops/README.md b/src/azure-devops/README.md index 0ecf567ede..67dfe8cc29 100644 --- a/src/azure-devops/README.md +++ b/src/azure-devops/README.md @@ -135,6 +135,14 @@ tools azure-devops --create --type Bug --title "Error in checkout" --severity "A This will: - Display a code and URL - Open the URL in your browser + + If that fails (e.g. `AADSTS530036` — Conditional Access blocks the device-code flow), + use the interactive browser flow with the Azure DevOps scope: + + ```bash + az login --scope 499b84ac-1321-427f-aa17-267ca6975798/.default --allow-no-subscriptions + ``` + - Enter the code to authenticate ### Configure for Your Project @@ -469,6 +477,12 @@ Ensure you're logged in: az login --allow-no-subscriptions --use-device-code ``` +If that fails with `AADSTS530036` (Conditional Access policy blocks device code), use: + +```bash +az login --scope 499b84ac-1321-427f-aa17-267ca6975798/.default --allow-no-subscriptions +``` + ### SSL Issues (Proxy/Corporate Environments) If SSL errors occur: diff --git a/src/azure-devops/cli.utils.ts b/src/azure-devops/cli.utils.ts index a48ddcbd7b..a3276a14c9 100644 --- a/src/azure-devops/cli.utils.ts +++ b/src/azure-devops/cli.utils.ts @@ -1,10 +1,13 @@ -import { out } from "@app/logger"; - /** * Azure DevOps CLI Tool - CLI Utilities * * This file contains CLI-related utilities like error messages and user prompts. + * + * `az login` command suggestions live in `./lib/az-cli.utils.ts` — extract the + * helpers from there instead of hand-rolling the command strings. */ +import { azLoginSuggestionBlock, extractTenantFromStderr } from "@app/azure-devops/lib/az-cli.utils"; +import { out } from "@app/logger"; const SSL_PROXY_GUIDE = ` 🔐 SSL Certificate Error (Proxy Detected) @@ -27,17 +30,17 @@ Quick fixes: More info: https://learn.microsoft.com/cli/azure/use-cli-effectively#work-behind-a-proxy `; -const AUTH_GUIDE = ` +function authGuide(): string { + return ` 🔐 Azure CLI Authentication Required You need to log in to Azure CLI first. Run: - az login --allow-no-subscriptions --use-device-code +${azLoginSuggestionBlock()} This will: -1. Display a code and URL -2. Open the URL in your browser -3. Enter the code to authenticate +1. Display a code and URL (first form), or +2. Open a browser tab for interactive auth (second form) Prerequisites: 1. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli @@ -45,6 +48,7 @@ Prerequisites: Documentation: https://learn.microsoft.com/en-us/azure/devops/cli/?view=azure-devops `; +} /** * Check if an error message indicates an SSL/proxy certificate issue @@ -92,30 +96,27 @@ export class AzAuthError extends Error { } /** - * Build a working `az login` command from auth-error stderr. - * - * `az` itself suggests `az login --tenant --scope /.default`, but that - * form fails for accounts with no subscriptions in the tenant (common in - * enterprise setups — e.g. ČEZ — where the user has tenant-level access only). - * We always emit `--allow-no-subscriptions --use-device-code`, which works in - * both cases and doesn't require an interactive browser session. + * Build a working `az login` suggestion from auth-error stderr. * - * Returns null if stderr doesn't look like an auth error. + * Delegates to `azLoginSuggestionBlock` so the actual command strings live in + * one place (`lib/az-cli.utils.ts`). Returns null if stderr doesn't look like + * an auth error. */ export function extractAzLoginSuggestion(stderr: string): string | null { if (!stderr) { return null; } - const tenantMatch = stderr.match(/--tenant\s+"?([0-9a-fA-F-]{36})"?/); - const tenant = tenantMatch?.[1]; - + const tenant = extractTenantFromStderr(stderr); if (tenant) { - return `az login --tenant "${tenant}" --allow-no-subscriptions --use-device-code`; + // Trim the leading indent from the shared block so the suggestion + // embeds cleanly in single-line error messages (callers already + // prefix the field name, e.g. "Fix: ..."). + return azLoginSuggestionBlock({ tenant, indent: "" }); } if (/AADSTS|az login|multi-factor|Presented multi-factor/i.test(stderr)) { - return "az login --allow-no-subscriptions --use-device-code"; + return azLoginSuggestionBlock({ indent: "" }); } return null; @@ -139,7 +140,7 @@ export function exitWithSslGuide(error?: unknown): never { * Print authentication guide and exit */ export function exitWithAuthGuide(error?: unknown): never { - out.println(AUTH_GUIDE); + out.println(authGuide()); if (error instanceof Error && error.stack && process.env.DEBUG) { out.error("\nStacktrace:\n"); diff --git a/src/azure-devops/index.ts b/src/azure-devops/index.ts index 535d698b73..61835f09a1 100644 --- a/src/azure-devops/index.ts +++ b/src/azure-devops/index.ts @@ -14,6 +14,7 @@ */ import { exitWithAuthGuide, exitWithSslGuide, isAuthError, isSslError } from "@app/azure-devops/cli.utils"; +import { azLoginSuggestionBlock } from "@app/azure-devops/lib/az-cli.utils"; import { logger, out } from "@app/logger"; import * as p from "@app/utils/prompts/p"; import { inquirerBackend } from "@app/utils/prompts/p/inquirer-backend"; @@ -118,7 +119,8 @@ Timelog Subcommands: First-Time Setup: 1. Install Azure CLI: https://learn.microsoft.com/en-us/cli/azure/install-azure-cli 2. Install extension: az extension add --name azure-devops - 3. Login: az login --allow-no-subscriptions --use-device-code + 3. Login: +${azLoginSuggestionBlock({ indent: " " })} 4. Configure: tools azure-devops configure "https://dev.azure.com/MyOrg/MyProject/_workitems" Examples: diff --git a/src/azure-devops/lib/ado-configure.ts b/src/azure-devops/lib/ado-configure.ts index be002e8616..8b199ffca6 100644 --- a/src/azure-devops/lib/ado-configure.ts +++ b/src/azure-devops/lib/ado-configure.ts @@ -1,6 +1,7 @@ import { existsSync, mkdirSync, writeFileSync } from "node:fs"; import { join } from "node:path"; import { Api, AZURE_DEVOPS_RESOURCE_ID } from "@app/azure-devops/api"; +import { azLoginSuggestionBlock } from "@app/azure-devops/lib/az-cli.utils"; import type { AzureConfig } from "@app/azure-devops/types"; import { parseAzureDevOpsUrl } from "@app/azure-devops/url-parser"; import { SafeJSON } from "@app/utils/json"; @@ -9,8 +10,8 @@ import { $ } from "bun"; export async function checkAzureCliLogin(): Promise { try { await $`az account show`.quiet(); - } catch { - throw new Error("Azure CLI not logged in. Run: az login --allow-no-subscriptions --use-device-code"); + } catch (error) { + throw new Error(`Azure CLI not logged in. Run:\n${azLoginSuggestionBlock()}`, { cause: error }); } } diff --git a/src/azure-devops/lib/az-cli.utils.ts b/src/azure-devops/lib/az-cli.utils.ts new file mode 100644 index 0000000000..9332773de2 --- /dev/null +++ b/src/azure-devops/lib/az-cli.utils.ts @@ -0,0 +1,67 @@ +/** + * Single source of truth for `az login` recommendations across the codebase. + * + * Two canonical commands: + * 1. **Primary** — device-code flow with `--allow-no-subscriptions`. Works + * for most accounts; no browser session needed; tolerates accounts with + * no Azure subscriptions in the tenant (common in enterprise setups + * where the user has tenant-level access only). + * 2. **Fallback** — interactive browser with the Azure DevOps OAuth scope. + * Required on tenants whose Conditional Access policy blocks the + * device-code flow (returns `AADSTS530036`). + * + * Both commands gain `--tenant ""` when a tenant UUID can be extracted + * from the failing `az` stderr. + * + * Use the helpers below instead of hand-rolling the strings — keeps the + * recommendations consistent everywhere they surface (error throws, auth + * guide banner, README cross-links). + */ + +/** Azure DevOps' well-known OAuth resource ID. Used in `--scope /.default`. */ +export const AZURE_DEVOPS_RESOURCE_ID = "499b84ac-1321-427f-aa17-267ca6975798"; + +export interface AzLoginCommandOptions { + /** Optional tenant UUID to embed in the commands (when known). */ + tenant?: string; +} + +function tenantArg(opts: AzLoginCommandOptions): string { + return opts.tenant ? ` --tenant "${opts.tenant}"` : ""; +} + +/** Device-code flow. Recommended default. */ +export function azLoginPrimaryCommand(opts: AzLoginCommandOptions = {}): string { + return `az login${tenantArg(opts)} --allow-no-subscriptions --use-device-code`; +} + +/** Browser flow with the Azure DevOps scope. Fallback for Conditional-Access-locked tenants. */ +export function azLoginFallbackCommand(opts: AzLoginCommandOptions = {}): string { + return `az login${tenantArg(opts)} --scope ${AZURE_DEVOPS_RESOURCE_ID}/.default --allow-no-subscriptions`; +} + +/** + * Multi-line block listing both commands with the "if that doesn't work" hint. + * Suitable for error messages, throws, and CLI banners. + * + * @param indent String prepended to each command line. Default `" "` (two + * spaces) — matches the visual style of nested CLI hints. + */ +export function azLoginSuggestionBlock(opts: AzLoginCommandOptions & { indent?: string } = {}): string { + const indent = opts.indent ?? " "; + const primary = azLoginPrimaryCommand(opts); + const fallback = azLoginFallbackCommand(opts); + return `${indent}${primary}\n${indent}If that doesn't work (AADSTS530036 etc.), use:\n${indent}${fallback}`; +} + +/** + * Extract a tenant UUID from an `az login` failure's stderr if one is + * mentioned (typically `... --tenant b233f9e1-...`). + * + * Returns null when no tenant hint is present — callers fall back to the + * tenant-less form. + */ +export function extractTenantFromStderr(stderr: string): string | null { + const match = stderr.match(/--tenant\s+"?([0-9a-fA-F-]{36})"?/); + return match?.[1] ?? null; +} diff --git a/src/clarity/index.ts b/src/clarity/index.ts index 1c604ba7f3..d63a132a7c 100644 --- a/src/clarity/index.ts +++ b/src/clarity/index.ts @@ -1,8 +1,8 @@ -import { spawn } from "node:child_process"; import { existsSync } from "node:fs"; import { resolve } from "node:path"; import { out } from "@app/logger"; import { runTool } from "@app/utils/cli"; +import { buildViteDevCmd, defineDashboardApp } from "@app/utils/DashboardApp"; import { PROJECT_ROOT } from "@app/utils/paths"; import { Command } from "commander"; import { registerConfigureCommand } from "./commands/configure.js"; @@ -21,82 +21,42 @@ registerTimesheetCommand(program); registerFillCommand(program); registerLinkCommand(program); -program - .command("ui") - .alias("dashboard") - .description("Launch the Clarity dashboard web UI") - .action(async () => { - const uiDir = resolve(import.meta.dirname, "ui"); - const configPath = resolve(uiDir, "vite.config.ts"); - const viteEntry = resolve(PROJECT_ROOT, "node_modules", "vite", "bin", "vite.js"); - const url = "http://localhost:3071"; - - if (!existsSync(viteEntry)) { - out.error(`✗ Could not find vite at ${viteEntry}`); - out.error(` Run "bun install" in ${PROJECT_ROOT} first.`); - process.exit(1); - } - - if (!existsSync(configPath)) { - out.error(`✗ Vite config missing: ${configPath}`); - process.exit(1); - } - +const uiDir = resolve(import.meta.dirname, "ui"); +const configPath = resolve(uiDir, "vite.config.ts"); +const viteEntry = resolve(PROJECT_ROOT, "node_modules", "vite", "bin", "vite.js"); + +if (!existsSync(viteEntry)) { + out.error(`✗ Could not find vite at ${viteEntry}`); + out.error(` Run "bun install" in ${PROJECT_ROOT} first.`); + process.exit(1); +} + +if (!existsSync(configPath)) { + out.error(`✗ Vite config missing: ${configPath}`); + process.exit(1); +} + +const clarityUi = defineDashboardApp({ + type: "ui", + key: "clarity", + name: "Clarity Timelog", + description: "Launch the Clarity dashboard web UI", + commandName: "ui", + aliases: ["dashboard"], + spawn: { + cmd: buildViteDevCmd({ configPath, strictPort: true }), + cwd: PROJECT_ROOT, + env: { CLARITY_PROJECT_CWD: process.cwd() }, + }, + preflight: async () => { const { failures } = await runClarityPreflight(); - if (failures.length > 0) { - // Soft warning — don't block startup. The user may want to open the UI - // and reconfigure (paste a fresh cURL, rotate a key, etc.) from Settings. - // Settings → Configuration status will surface the same errors inline. - out.warn("\n⚠ Clarity dashboard is starting with auth/connection issues — fix in UI Settings:\n"); - for (const f of failures) { - out.warn(` • [${f.service}] ${f.error}`); - if (f.fix) { - out.warn(` Fix: ${f.fix}`); - } - } - out.warn(""); - } - - out.println(`Starting Clarity dashboard at ${url} ...`); - out.println("(first start can take a few seconds; output below comes from Vite)\n"); - - // Spawn vite.js directly via bun rather than relying on node_modules/.bin/vite - // — on Windows the .bin entry is a .cmd shim that Bun.spawn can fail to resolve - // silently, leaving the user staring at a frozen terminal. - const child = spawn("bun", ["--bun", viteEntry, "dev", "-c", configPath, "--strictPort"], { - cwd: PROJECT_ROOT, - stdio: "inherit", - env: { ...process.env, CLARITY_PROJECT_CWD: process.cwd() }, - shell: process.platform === "win32", - }); - - child.on("error", (err) => { - out.error(`✗ Failed to start vite: ${err.message}`); - process.exit(1); - }); - - // Open browser after a short delay - setTimeout(() => { - if (process.platform === "darwin") { - spawn("open", [url], { stdio: "ignore", detached: true }).unref(); - } else if (process.platform === "win32") { - // `start` parses the first quoted token as a window title — pass an empty - // title first so URLs with special characters end up as the actual target. - spawn("cmd", ["/c", "start", "", url], { stdio: "ignore", detached: true }).unref(); - } else { - spawn("xdg-open", [url], { stdio: "ignore", detached: true }).unref(); - } - }, 2000); - - const exitCode: number = await new Promise((res) => { - child.on("exit", (code) => res(code ?? 1)); - }); - - if (exitCode !== 0) { - out.error(`\n✗ Vite exited with code ${exitCode}`); - } - - process.exit(exitCode); - }); + return { warnings: failures }; + }, + readiness: { kind: "http", path: "/" }, + openBrowser: { enabled: true }, + launchd: { available: true }, +}); + +program.addCommand(clarityUi.commanderCommand); await runTool(program, { tool: "clarity" }); diff --git a/src/claude-history-dashboard/src/server/conversations.ts b/src/claude-history-dashboard/src/server/conversations.ts index 51bc473820..beaa4bf55b 100644 --- a/src/claude-history-dashboard/src/server/conversations.ts +++ b/src/claude-history-dashboard/src/server/conversations.ts @@ -5,12 +5,12 @@ import { type DateRange, - getAllConversations, getAvailableProjects, getConversationBySessionId, getConversationStats, getConversationStatsWithCache, getQuickStatsFromCache, + getSessionListing, getStatsForDateRange, type SearchFilters, searchConversations, @@ -24,6 +24,8 @@ import { type SerializableStats, type SidebarSession, serializeResult, + serializeSessionMetadata, + toSidebarSession, } from "./serializers"; // Re-export types so existing consumers don't break @@ -42,42 +44,41 @@ export type { export const getConversations = createServerFn({ method: "GET" }) .inputValidator((filters: Omit) => filters) .handler(async ({ data: filters }) => { - const results = filters.query - ? await searchConversations(filters) - : await getAllConversations({ ...filters, limit: filters.limit || 50 }); - return results.map(serializeResult); + if (filters.query) { + const results = await searchConversations(filters); + return results.map(serializeResult); + } + + const listing = await getSessionListing({ + project: filters.project, + excludeSubagents: filters.agentsOnly ? false : filters.excludeAgents !== false, + limit: filters.limit || 50, + }); + + return listing.sessions.map(serializeSessionMetadata); }); /** * Lightweight session list for sidebar tree (no message content) */ export const getSidebarSessions = createServerFn({ method: "GET" }).handler(async () => { - const results = await getAllConversations({ limit: 500 }); + const { sessions } = await getSessionListing({ limit: 200, excludeSubagents: false }); - // Deduplicate by sessionId (same session can appear in multiple project dirs) const seen = new Set(); - const sessions: SidebarSession[] = []; + const sidebar: SidebarSession[] = []; - for (const r of results) { - const serialized = serializeResult(r); + for (const session of sessions) { + const item = toSidebarSession(session); - if (seen.has(serialized.sessionId)) { + if (seen.has(item.sessionId)) { continue; } - seen.add(serialized.sessionId); - sessions.push({ - sessionId: serialized.sessionId, - project: serialized.project, - summary: serialized.summary, - customTitle: serialized.customTitle, - timestamp: serialized.timestamp, - isSubagent: serialized.isSubagent, - messageCount: serialized.messageCount, - }); + seen.add(item.sessionId); + sidebar.push(item); } - return sessions; + return sidebar; }); /** @@ -131,7 +132,20 @@ export const getQuickStats = createServerFn({ method: "GET" }).handler(async () }; } - // No cache, compute synchronously (first load) + // No cache yet — warm incremental stats cache instead of full legacy scan + await getConversationStatsWithCache({ forceRefresh: false }); + const warmed = getQuickStatsFromCache(); + + if (warmed) { + return { + totalConversations: warmed.totalConversations, + totalMessages: warmed.totalMessages, + subagentCount: warmed.subagentCount, + projectCount: warmed.projectCount, + isCached: true, + }; + } + const stats = await getConversationStats(); return { totalConversations: stats.totalConversations, diff --git a/src/claude-history-dashboard/src/server/serializers.ts b/src/claude-history-dashboard/src/server/serializers.ts index b6a542f454..258c78d4da 100644 --- a/src/claude-history-dashboard/src/server/serializers.ts +++ b/src/claude-history-dashboard/src/server/serializers.ts @@ -1,3 +1,5 @@ +import type { SessionMetadataRecord } from "@app/claude/lib/history/cache"; +import { getFileIndex } from "@app/claude/lib/history/cache"; import type { getAllConversations } from "@app/claude/lib/history/search"; import type { ConversationMessage, ToolResultBlock, ToolUseBlock } from "@app/utils/claude/types"; @@ -93,6 +95,43 @@ export function serializeResult(result: Awaited", "Port to run the dashboard on", "3069") - .action(async (options) => { - const dashboardDir = resolve(import.meta.dir, "../../claude-history-dashboard"); - - out.println(chalk.cyan("Starting Claude History Dashboard...")); - out.println(chalk.dim(` Port: ${options.port}`)); - out.println(); - - const proc = spawn({ - cmd: [ - "bun", - "--bun", - "vite", - "dev", - "-c", - resolve(dashboardDir, "vite.config.ts"), - "--port", - options.port, - ], - cwd: PROJECT_ROOT, - stdio: ["inherit", "inherit", "inherit"], - }); - - await proc.exited; - }); + const dashboardDir = resolve(import.meta.dir, "../../claude-history-dashboard"); + const viteConfigPath = resolve(dashboardDir, "vite.config.ts"); + + const claudeHistoryApp = defineDashboardApp({ + type: "ui", + key: "claude-history", + name: "Claude History Browser", + description: "Search & browse Claude Code conversation history", + commandName: "dashboard", + spawn: { + cmd: buildViteDevCmd({ configPath: viteConfigPath, strictPort: true }), + cwd: PROJECT_ROOT, + }, + readiness: { kind: "http", path: "/" }, + openBrowser: { enabled: true }, + launchd: { available: true }, + }); + + historyCmd.addCommand(claudeHistoryApp.commanderCommand); } diff --git a/src/claude/lib/history/cache.ts b/src/claude/lib/history/cache.ts index d8e1dd6cee..bcf7940d88 100644 --- a/src/claude/lib/history/cache.ts +++ b/src/claude/lib/history/cache.ts @@ -677,6 +677,28 @@ export function getSessionMetadata(filePath: string): SessionMetadataRecord | nu return row ? rowToSessionMetadataRecord(row) : null; } +export function getSessionMetadataBySessionId(sessionId: string): SessionMetadataRecord | null { + const db = getDatabase(); + const byId = db + .query("SELECT * FROM session_metadata WHERE session_id = ? LIMIT 1") + .get(sessionId) as SessionMetadataRow | null; + + if (byId) { + return rowToSessionMetadataRecord(byId); + } + + const suffix = `${sep}${sessionId}.jsonl`; + const rows = db + .query("SELECT * FROM session_metadata WHERE file_path LIKE ? LIMIT 1") + .all(`%${suffix}`) as SessionMetadataRow[]; + + if (rows.length === 0) { + return null; + } + + return rowToSessionMetadataRecord(rows[0]); +} + export function upsertSessionMetadata(record: SessionMetadataRecord): void { const db = getDatabase(); db.query(` diff --git a/src/claude/lib/history/search.ts b/src/claude/lib/history/search.ts index 23ce91ce5c..e9dc3967d5 100644 --- a/src/claude/lib/history/search.ts +++ b/src/claude/lib/history/search.ts @@ -32,6 +32,7 @@ import { getFileIndex, getSessionMetadata, getSessionMetadataByDir, + getSessionMetadataBySessionId, invalidateDateRange, removeSessionMetadataBatch, type SessionMetadataRecord, @@ -1652,57 +1653,75 @@ export async function rgExtractSnippet(query: string, filePath: string): Promise // Get Conversation by Session ID // ============================================================================= +function buildSearchResultFromMessages(filePath: string, messages: ConversationMessage[]): SearchResult | null { + if (messages.length === 0) { + return null; + } + + const project = extractProjectName(filePath); + const isSubagent = filePath.includes(`${sep}subagents${sep}`) || basename(filePath).startsWith("agent-"); + const fileName = basename(filePath, ".jsonl"); + + let summary: string | undefined; + let customTitle: string | undefined; + let gitBranch: string | undefined; + let foundSessionId: string | undefined; + let firstTimestamp: Date | undefined; + + for (const msg of messages) { + if (msg.type === "summary") { + summary = (msg as SummaryMessage).summary; + } + + if (msg.type === "custom-title") { + customTitle = (msg as CustomTitleMessage).customTitle; + } + + if ("gitBranch" in msg && msg.gitBranch) { + gitBranch = msg.gitBranch as string; + } + + if ("sessionId" in msg && msg.sessionId) { + foundSessionId = msg.sessionId as string; + } + + if ("timestamp" in msg && msg.timestamp && !firstTimestamp) { + firstTimestamp = new Date(msg.timestamp as string); + } + } + + return { + filePath, + project, + sessionId: foundSessionId || fileName, + timestamp: firstTimestamp || new Date(), + summary, + customTitle, + gitBranch, + matchedMessages: messages, + isSubagent, + }; +} + export async function getConversationBySessionId(sessionId: string): Promise { - // Find all files and look for matching session + const cached = getSessionMetadataBySessionId(sessionId); + + if (cached) { + const messages = await parseJsonlFile(cached.filePath); + return buildSearchResultFromMessages(cached.filePath, messages); + } + const files = await findConversationFiles({}); for (const filePath of files) { const fileName = basename(filePath, ".jsonl"); - if (fileName === sessionId || filePath.includes(sessionId)) { - const messages = await parseJsonlFile(filePath); - if (messages.length === 0) { - continue; - } - const project = extractProjectName(filePath); - const isSubagent = filePath.includes(`${sep}subagents${sep}`) || basename(filePath).startsWith("agent-"); - - let summary: string | undefined; - let customTitle: string | undefined; - let gitBranch: string | undefined; - let foundSessionId: string | undefined; - let firstTimestamp: Date | undefined; - - for (const msg of messages) { - if (msg.type === "summary") { - summary = (msg as SummaryMessage).summary; - } - if (msg.type === "custom-title") { - customTitle = (msg as CustomTitleMessage).customTitle; - } - if ("gitBranch" in msg && msg.gitBranch) { - gitBranch = msg.gitBranch as string; - } - if ("sessionId" in msg && msg.sessionId) { - foundSessionId = msg.sessionId as string; - } - if ("timestamp" in msg && msg.timestamp && !firstTimestamp) { - firstTimestamp = new Date(msg.timestamp as string); - } - } - - return { - filePath, - project, - sessionId: foundSessionId || fileName, - timestamp: firstTimestamp || new Date(), - summary, - customTitle, - gitBranch, - matchedMessages: messages, - isSubagent, - }; + if (fileName !== sessionId) { + continue; } + + const messages = await parseJsonlFile(filePath); + return buildSearchResultFromMessages(filePath, messages); } return null; diff --git a/src/claude/mcp/server.ts b/src/claude/mcp/server.ts index 7c5cfca305..9f569a0d4d 100644 --- a/src/claude/mcp/server.ts +++ b/src/claude/mcp/server.ts @@ -7,11 +7,32 @@ import { handleQuestionAnswer, QUESTION_ANSWER_INPUT_SCHEMA, type QuestionAnswer const log = logger.child({ component: "claude:mcp" }); const QUESTION_ANSWER_DESCRIPTION = - "After answering a substantive user question/directive mid-session, call this with your COMPLETE answer " + - "(markdown ok) so it is captured for later review."; + "Preserve an important user question together with your COMPLETE answer (markdown ok) to the local " + + "question store for later review. Use when the user directly asks a question worth keeping — rationale " + + '("why did you choose X over Y"), design/architecture decisions, "how does Y work", tradeoff explanations ' + + "— or right after you answer a substantive question/directive/status-nudge the user interjected " + + 'mid-session. Not for routine task instructions you simply execute or pure acknowledgements ("ok", "thanks").'; + +const SERVER_INSTRUCTIONS = + "Genesis Tools — question/answer capture server.\n\n" + + "WHEN TO USE THE question_answer TOOL:\n" + + '- The user directly asks a question important enough to preserve for later review: rationale ("why did ' + + 'you choose X over Y"), design/architecture decisions, "how does Y work", tradeoff explanations.\n' + + "- Immediately AFTER you answer a substantive question, directive, or status-nudge the user interjected " + + 'mid-session (e.g. "what\'s left from the plan?", "pushed yet?", "did the tests pass?") — so the answer ' + + "isn't lost in scrollback.\n" + + "- Whenever the user invokes the /question skill directly.\n\n" + + "Call it with the user's question, your COMPLETE answer (markdown ok), a tag (question | directive | " + + "action), and optional refs. It persists to the local question store, browsable later with " + + "`tools question log` / `tools question tail`.\n\n" + + "DO NOT use for: routine task instructions you simply execute, pure acknowledgements " + + '("ok", "thanks", "continue"), or trivial lookups not worth preserving.'; export async function startMcpServer(): Promise { - const server = new Server({ name: "genesis-tools", version: "1.0.0" }, { capabilities: { tools: {} } }); + const server = new Server( + { name: "genesis-tools", version: "1.0.0" }, + { capabilities: { tools: {} }, instructions: SERVER_INSTRUCTIONS }, + ); server.setRequestHandler(ListToolsRequestSchema, async () => ({ tools: [ diff --git a/src/cmux/lib/cli.ts b/src/cmux/lib/cli.ts index 9230902aab..0ede13c057 100644 --- a/src/cmux/lib/cli.ts +++ b/src/cmux/lib/cli.ts @@ -1,3 +1,6 @@ +import { existsSync } from "node:fs"; +import { homedir } from "node:os"; +import { join } from "node:path"; import { logger } from "@app/logger"; import { SafeJSON } from "@app/utils/json"; @@ -7,10 +10,45 @@ export interface CmuxRunResult { stderr: string; } +const CMUX_FALLBACK_DIRS = [".local/bin", ".bun/bin", ".cargo/bin"]; +const CMUX_SYSTEM_DIRS = ["/opt/homebrew/bin", "/usr/local/bin", "/usr/bin"]; + +let cachedCmuxPath: string | null = null; + +function resolveCmuxPath(): string { + if (cachedCmuxPath) { + return cachedCmuxPath; + } + + const fromPath = Bun.which("cmux"); + if (fromPath) { + cachedCmuxPath = fromPath; + return fromPath; + } + + const home = homedir(); + const candidates = [ + ...CMUX_FALLBACK_DIRS.map((dir) => join(home, dir, "cmux")), + ...CMUX_SYSTEM_DIRS.map((dir) => join(dir, "cmux")), + ]; + + for (const candidate of candidates) { + if (existsSync(candidate)) { + logger.debug({ candidate }, "[cmux] resolved via fallback dir (not on PATH)"); + cachedCmuxPath = candidate; + return candidate; + } + } + + const searched = ["PATH", ...candidates].join(", "); + throw new Error(`cmux is not installed (or not found in ${searched})`); +} + export async function runCmux(args: string[], opts: { json?: boolean } = {}): Promise { const finalArgs = opts.json ? ["--json", ...args] : args; - logger.debug({ args: finalArgs }, "[cmux] spawn"); - const proc = Bun.spawn(["cmux", ...finalArgs], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); + const cmuxPath = resolveCmuxPath(); + logger.debug({ args: finalArgs, cmuxPath }, "[cmux] spawn"); + const proc = Bun.spawn([cmuxPath, ...finalArgs], { stdin: "ignore", stdout: "pipe", stderr: "pipe" }); const [stdout, stderr, exitCode] = await Promise.all([ new Response(proc.stdout).text(), new Response(proc.stderr).text(), diff --git a/src/cmux/lib/live-snapshot.ts b/src/cmux/lib/live-snapshot.ts index f848673f32..7aff664cc7 100644 --- a/src/cmux/lib/live-snapshot.ts +++ b/src/cmux/lib/live-snapshot.ts @@ -151,7 +151,7 @@ async function readSurfacePreview({ } try { - const response = await run(["capture-pane", "--workspace", workspace, "--surface", surface, "--lines", "40"]); + const response = await run(["capture-pane", "--workspace", workspace, "--surface", surface, "--lines", "200"]); if (response.code !== 0) { logger.debug({ workspace, surface, stderr: response.stderr.trim() }, "cmux surface preview failed"); diff --git a/src/dashboard/apps/web/package.json b/src/dashboard/apps/web/package.json index 279ac60250..cb6c17e3f5 100644 --- a/src/dashboard/apps/web/package.json +++ b/src/dashboard/apps/web/package.json @@ -3,7 +3,7 @@ "private": true, "type": "module", "scripts": { - "dev": "vite dev --port 3000", + "dev": "vite dev --port 3000 --host ${DASHBOARD_BIND_HOST:-127.0.0.1}", "build": "vite build", "preview": "node .output/server/index.mjs", "test": "vitest run", diff --git a/src/dashboard/apps/web/tsconfig.json b/src/dashboard/apps/web/tsconfig.json index ba26e30548..a1deb777f4 100644 --- a/src/dashboard/apps/web/tsconfig.json +++ b/src/dashboard/apps/web/tsconfig.json @@ -24,6 +24,8 @@ "@/*": ["./src/*"], "@ui": ["../../../utils/ui/index.ts"], "@ui/*": ["../../../utils/ui/*"], + "@app/logger/client": ["../../../logger/client.ts"], + "@app/utils/json": ["../../../utils/json.ts"], "@dashboard/shared": ["../../packages/shared/src/index.ts"], "@dashboard/shared/*": ["../../packages/shared/src/*"] } diff --git a/src/dashboard/apps/web/vite.config.ts b/src/dashboard/apps/web/vite.config.ts index 3d14f70e9d..94cd8a4c5b 100644 --- a/src/dashboard/apps/web/vite.config.ts +++ b/src/dashboard/apps/web/vite.config.ts @@ -1,3 +1,4 @@ +import { fileURLToPath } from "node:url"; import babel from "@rolldown/plugin-babel"; import tailwindcss from "@tailwindcss/vite"; import { devtools } from "@tanstack/devtools-vite"; @@ -19,16 +20,20 @@ const nitroConfig: NitroConfig = { scanDirs: ["./server"], }; -const dashboardDependency = (specifier: string) => new URL(`./node_modules/${specifier}`, import.meta.url).pathname; +const dashboardDependency = (specifier: string) => + fileURLToPath(new URL(`./node_modules/${specifier}`, import.meta.url)); + +const bindHost = process.env.DASHBOARD_BIND_HOST; const config = defineConfig({ server: { + ...(bindHost ? { host: bindHost } : {}), hmr: { overlay: false, }, }, plugins: [ - devtools(), + ...(process.env.DASHBOARD_DEVTOOLS === "1" ? [devtools()] : []), nitro(nitroConfig), // this is the plugin that enables path aliases viteTsConfigPaths({ @@ -54,11 +59,16 @@ const config = defineConfig({ "@tanstack/router-core", ], alias: [ - { find: "@ui", replacement: new URL("../../../utils/ui", import.meta.url).pathname }, + { find: "@ui", replacement: fileURLToPath(new URL("../../../utils/ui", import.meta.url)) }, { find: "@dashboard/shared", - replacement: new URL("../../packages/shared/src/index.ts", import.meta.url).pathname, + replacement: fileURLToPath(new URL("../../packages/shared/src/index.ts", import.meta.url)), + }, + { + find: "@app/logger/client", + replacement: fileURLToPath(new URL("../../../logger/client.ts", import.meta.url)), }, + { find: "@app/utils/json", replacement: fileURLToPath(new URL("../../../utils/json.ts", import.meta.url)) }, { find: "@radix-ui/react-avatar", replacement: dashboardDependency("@radix-ui/react-avatar") }, { find: "@radix-ui/react-dialog", replacement: dashboardDependency("@radix-ui/react-dialog") }, { diff --git a/src/dashboard/index.ts b/src/dashboard/index.ts index 838edc847f..2314166028 100644 --- a/src/dashboard/index.ts +++ b/src/dashboard/index.ts @@ -6,6 +6,7 @@ import { dirname, join } from "node:path"; import { fileURLToPath } from "node:url"; import { logger } from "@app/logger"; import { runTool } from "@app/utils/cli"; +import { defineDashboardApp } from "@app/utils/DashboardApp"; import { Command } from "commander"; import pc from "picocolors"; @@ -13,14 +14,6 @@ const DASHBOARD_DIR = dirname(fileURLToPath(import.meta.url)); const NODE_MODULES = join(DASHBOARD_DIR, "node_modules"); const DEFAULT_PORT = 3000; -interface LaunchOptions { - prod: boolean; - open: boolean; - install: boolean; - reinstall: boolean; - port: number; -} - function runToCompletion(cmd: string, args: string[]): Promise { const child = spawn(cmd, args, { cwd: DASHBOARD_DIR, stdio: "inherit" }); return new Promise((res) => child.on("exit", (code) => res(code ?? 1))); @@ -87,31 +80,30 @@ function openBrowser(url: string): void { child.unref(); } -async function launch(options: LaunchOptions): Promise { - const { prod, open, install, reinstall, port } = options; +async function launchProd(options: { + open: boolean; + install: boolean; + reinstall: boolean; + port: number; +}): Promise { + const { open, install, reinstall, port } = options; const url = `http://localhost:${port}/`; await ensureDeps(install, reinstall); - logger.info( - pc.cyan( - `▶ Starting dashboard (${prod ? "production build" : "dev"}) — ${pc.bold( - `bun run ${prod ? "build:prod" : "dev"}` - )}` - ) - ); + logger.info(pc.cyan(`▶ Starting dashboard (production build) — ${pc.bold("bun run build:prod")}`)); - if (prod) { - const buildCode = await runToCompletion("bun", ["run", "build:prod"]); - if (buildCode !== 0) { - logger.error(`Production build failed (exit ${buildCode}).`); - process.exit(buildCode); - } + const buildCode = await runToCompletion("bun", ["run", "build:prod"]); + if (buildCode !== 0) { + logger.error(`Production build failed (exit ${buildCode}).`); + process.exit(buildCode); + } - logger.info(pc.cyan("▶ Build complete — starting PM2 (ecosystem.config.cjs)")); - spawn("bunx", ["pm2", "start", "ecosystem.config.cjs"], { cwd: DASHBOARD_DIR, stdio: "inherit" }); - } else { - spawn("bun", ["run", "dev"], { cwd: DASHBOARD_DIR, stdio: "inherit" }); + logger.info(pc.cyan("▶ Build complete — starting PM2 (ecosystem.config.cjs)")); + const pm2Code = await runToCompletion("bunx", ["pm2", "start", "ecosystem.config.cjs"]); + if (pm2Code !== 0) { + logger.error(`PM2 start failed (exit ${pm2Code}).`); + process.exit(pm2Code); } if (!open) { @@ -129,6 +121,37 @@ async function launch(options: LaunchOptions): Promise { } } +const dashboardApp = defineDashboardApp({ + type: "ui", + key: "dashboard", + name: "Personal Dashboard", + description: "Tasks, timers, activity log, focus modes", + commandName: "dashboard", + port: DEFAULT_PORT, + spawn: { + cmd: ["bun", "run", "dev"], + cwd: DASHBOARD_DIR, + }, + preflight: async () => { + if (existsSync(NODE_MODULES)) { + return { warnings: [] }; + } + + return { + warnings: [ + { + service: "dashboard", + error: "src/dashboard/node_modules is missing.", + fix: `Run ${pc.bold("bun install")} in src/dashboard, or use bare \`tools dashboard\` to auto-install.`, + }, + ], + }; + }, + readiness: { kind: "http", path: "/" }, + openBrowser: { enabled: true }, + launchd: { available: true }, +}); + const program = new Command(); program @@ -146,15 +169,24 @@ program process.exit(1); } - await launch({ - prod: opts.prod, - open: opts.open, - install: opts.install, - reinstall: opts.reinstall, - port, - }); + if (opts.prod) { + await launchProd({ + open: opts.open, + install: opts.install, + reinstall: opts.reinstall, + port, + }); + return; + } + + await ensureDeps(opts.install, opts.reinstall); + await dashboardApp.up({ open: opts.open, port }); }); +for (const sub of dashboardApp.commanderCommand.commands) { + program.addCommand(sub); +} + await runTool(program, { tool: "dashboard" }).catch((err: unknown) => { logger.error("dashboard launcher failed", err); process.exit(1); diff --git a/src/dashboards/index.ts b/src/dashboards/index.ts new file mode 100644 index 0000000000..9d3043acca --- /dev/null +++ b/src/dashboards/index.ts @@ -0,0 +1,156 @@ +#!/usr/bin/env bun + +import { spawn } from "node:child_process"; +import { resolve } from "node:path"; +import { logger, out } from "@app/logger"; +import { runTool } from "@app/utils/cli"; +import { DASHBOARDS, type DashboardKey } from "@app/utils/ui/dashboards"; +import { Command } from "commander"; + +const ROOT = resolve(import.meta.dirname, "../.."); +const TOOLS = resolve(ROOT, "tools"); + +const TARGETS: ReadonlyArray<{ key: string; args: string[] }> = [ + { key: "youtube-server", args: ["youtube", "server"] }, + { key: "youtube", args: ["youtube", "ui"] }, + { key: "dev-dashboard", args: ["dev-dashboard", "ui"] }, + { key: "clarity", args: ["clarity", "ui"] }, + { key: "shops", args: ["shops", "ui"] }, + { key: "reas", args: ["internal", "reas", "ui"] }, + { key: "claude-history", args: ["claude", "history", "dashboard"] }, + { key: "dashboard", args: ["dashboard"] }, +]; + +function parseExcept(raw: string | undefined): Set { + if (!raw) { + return new Set(); + } + + return new Set( + raw + .split(",") + .map((part) => part.trim()) + .filter(Boolean) + ); +} + +function resolveTargets(except: Set): typeof TARGETS { + return TARGETS.filter((target) => !except.has(target.key)); +} + +async function execDashboard(args: string[], extra: string[] = []): Promise { + return new Promise((done) => { + const child = spawn(TOOLS, [...args, ...extra], { + cwd: ROOT, + stdio: "inherit", + env: { ...process.env, BROWSER: "none" }, + }); + + child.on("error", (err) => { + logger.warn({ err, args: [...args, ...extra] }, "dashboards orchestration spawn failed"); + done(1); + }); + + child.on("exit", (code) => { + done(code ?? 1); + }); + }); +} + +async function runVerb( + verb: "down" | "up" | "restart" | "status", + opts: { except?: string; open?: boolean } +): Promise { + const except = parseExcept(opts.except); + const targets = resolveTargets(except); + + if (targets.length === 0) { + out.warn("No dashboards matched (everything excluded?)."); + return; + } + + if (except.size > 0) { + out.log.info(`Skipping: ${[...except].join(", ")}`); + } + + const ordered = verb === "down" ? [...targets].reverse() : targets; + let failures = 0; + + for (const target of ordered) { + out.log.step(`${verb} ${target.key}…`); + const extra: string[] = [verb]; + + if ((verb === "up" || verb === "restart") && !opts.open) { + extra.push("--no-open"); + } + + const code = await execDashboard(target.args, extra); + + if (code !== 0) { + failures += 1; + out.warn(`${target.key} ${verb} exited ${code}`); + } + } + + if (failures > 0) { + out.warn(`${failures}/${ordered.length} dashboard ${verb} command(s) failed.`); + } else { + out.log.success(`${verb} complete for ${ordered.length} dashboard(s).`); + } +} + +const program = new Command() + .name("dashboards") + .description("Orchestrate all GenesisTools web dashboards (down/up/restart/status)"); + +program + .command("down") + .description("Stop all launchd-managed dashboards (UIs first, API servers last)") + .option("--except ", "comma-separated dashboard keys to skip") + .action(async (opts: { except?: string }) => { + await runVerb("down", { except: opts.except }); + }); + +program + .command("up") + .description("Start all dashboards (API servers first)") + .option("--except ", "comma-separated dashboard keys to skip") + .option("--open", "auto-open browsers (default: off)") + .action(async (opts: { except?: string; open?: boolean }) => { + await runVerb("up", { except: opts.except, open: opts.open ?? false }); + }); + +program + .command("restart") + .description("Restart all dashboards") + .option("--except ", "comma-separated dashboard keys to skip") + .option("--open", "auto-open browsers (default: off)") + .action(async (opts: { except?: string; open?: boolean }) => { + await runVerb("restart", { except: opts.except, open: opts.open ?? false }); + }); + +program + .command("status") + .description("Print status for each dashboard") + .option("--except ", "comma-separated dashboard keys to skip") + .action(async (opts: { except?: string }) => { + await runVerb("status", { except: opts.except }); + }); + +program + .command("list") + .description("List registered dashboard keys and ports") + .action(() => { + const lines = TARGETS.map((target) => { + const entry = DASHBOARDS[target.key as DashboardKey]; + const port = entry?.port ?? "?"; + return `${target.key.padEnd(16)} :${port} tools ${target.args.join(" ")}`; + }); + + out.println(lines.join("\n")); + }); + +await runTool(program, { tool: "dashboards" }).catch((err: unknown) => { + logger.error({ err }, "dashboards orchestration failed"); + process.exit(1); +}); diff --git a/src/dev-dashboard/e2e/playwright.config.ts b/src/dev-dashboard/e2e/playwright.config.ts new file mode 100644 index 0000000000..daec43adcc --- /dev/null +++ b/src/dev-dashboard/e2e/playwright.config.ts @@ -0,0 +1,11 @@ +import { defineConfig } from "@playwright/test"; + +export default defineConfig({ + testDir: import.meta.dirname, + timeout: 90_000, + retries: 0, + use: { + baseURL: process.env.DD_QA_BASE_URL ?? "http://localhost:3042", + trace: "retain-on-failure", + }, +}); diff --git a/src/dev-dashboard/e2e/qa.spec.ts b/src/dev-dashboard/e2e/qa.spec.ts new file mode 100644 index 0000000000..84ed2c737a --- /dev/null +++ b/src/dev-dashboard/e2e/qa.spec.ts @@ -0,0 +1,69 @@ +import { execFileSync } from "node:child_process"; +import { mkdtempSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { expect, test } from "@playwright/test"; + +const marker = `playwright-qa-${Date.now()}`; + +test("QA page renders markdown answers live over SSE with syntax highlighting", async ({ page }) => { + await page.goto("/qa"); + + const liveDot = page.locator('[title="Live stream connected"] span.animate-pulse'); + await expect(liveDot).toBeVisible(); + + const answerMd = `# Render check ${marker} + +Paragraph with **bold** and \`inline\` code. + +\`\`\`tsx +import type { ReactNode } from "react"; + +export function HarnessPanel({ title, children }: { title: string; children: ReactNode }) { + const enabled = true; + + return ( +
+

{title}

+
{children}
+
+ ); +} +\`\`\` + +Follow-up paragraph after the code fence. +`; + + const dir = mkdtempSync(join(tmpdir(), "dd-qa-")); + const answerFile = join(dir, "answer.md"); + writeFileSync(answerFile, answerMd, "utf8"); + + execFileSync( + "tools", + [ + "question", + "answer", + "--q", + `Does Playwright see rendered markdown for ${marker}?`, + "--a-file", + answerFile, + "--tag", + "question", + "--project", + "GenesisTools", + ], + { cwd: process.cwd(), stdio: "pipe" } + ); + + const card = page.locator(".dd-panel", { hasText: marker }); + await expect(card).toBeVisible({ timeout: 20_000 }); + + await expect(card.locator("strong", { hasText: "bold" })).toBeVisible(); + await expect(card.locator("pre code.language-tsx")).toBeVisible(); + await expect(card.locator("pre code .hljs-keyword").first()).toBeVisible(); + expect(await card.locator("pre code span[class*='hljs-']").count()).toBeGreaterThanOrEqual(5); + + const cardText = await card.innerText(); + expect(cardText).not.toContain("```tsx"); + expect(cardText).toContain("HarnessPanel"); +}); diff --git a/src/dev-dashboard/index.ts b/src/dev-dashboard/index.ts index 8eb0680fc2..8442dce57d 100644 --- a/src/dev-dashboard/index.ts +++ b/src/dev-dashboard/index.ts @@ -1,16 +1,16 @@ #!/usr/bin/env bun import { spawn } from "node:child_process"; -import { closeSync, existsSync, mkdirSync, openSync, readSync, statSync } from "node:fs"; -import { homedir } from "node:os"; -import { dirname, join, resolve } from "node:path"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; import { getConfig, saveConfig } from "@app/dev-dashboard/config"; import { createBasicAuthCredentials } from "@app/dev-dashboard/lib/auth"; import { startFrontProxy } from "@app/dev-dashboard/lib/front-proxy"; import { findFreePort } from "@app/dev-dashboard/lib/ttyd/free-port"; import { logger, out } from "@app/logger"; import { runTool } from "@app/utils/cli"; +import { defineDashboardApp } from "@app/utils/DashboardApp"; +import { waitForUrlReady } from "@app/utils/DashboardApp/readiness"; import { PROJECT_ROOT } from "@app/utils/paths"; -import { stripAnsi } from "@app/utils/string"; import { Command } from "commander"; const program = new Command() @@ -75,8 +75,26 @@ async function runUiServer(): Promise { // throws, the already-spawned Vite child would be orphaned. Kill it here. let frontProxy: ReturnType; + const internalUrl = `http://127.0.0.1:${internalPort}/`; + logger.info({ internalPort, publicPort: port }, "waiting for Vite before binding public front-proxy port"); + + const viteReady = await waitForUrlReady(internalUrl, 90_000); + + if (!viteReady.ready) { + try { + child.kill("SIGTERM"); + } catch (killErr) { + logger.debug({ err: killErr }, "failed terminating Vite after readiness timeout"); + } + + logger.error({ internalUrl, detail: viteReady.detail }, "Vite did not become ready"); + process.exit(1); + } + try { - frontProxy = startFrontProxy({ publicPort: port, internalPort }); + const bindHost = process.env.DASHBOARD_BIND_HOST ?? "0.0.0.0"; + frontProxy = startFrontProxy({ publicPort: port, internalPort, hostname: bindHost }); + logger.info({ publicPort: port, internalPort }, "front proxy listening — upstream Vite is ready"); } catch (err) { try { child.kill("SIGTERM"); @@ -121,37 +139,28 @@ async function runUiServer(): Promise { killChild(); }); - // Open the browser only AFTER Vite is actually serving — the page load is - // what triggers /api/ttyd/spawn, so a blind 2s timer opened it before Vite - // was up and the proxy spammed ECONNREFUSED for Vite + the just-spawned - // ttyd. Poll the internal Vite port; ttyd is now effectively deferred until - // Vite is ready (no extra ttyd-lifecycle changes needed). - void (async () => { - const deadline = Date.now() + 20_000; - const internalUrl = `http://127.0.0.1:${internalPort}/`; - while (Date.now() < deadline) { - try { - await fetch(internalUrl, { signal: AbortSignal.timeout(1000) }); - break; // any response (even 404) means Vite is listening - } catch (err) { - logger.debug({ err, internalUrl }, "vite readiness probe retry (not up yet)"); - await new Promise((r) => setTimeout(r, 250)); + // Foreground-only browser open (launchd/background restarts must not pop a tab). + // Lifecycle sets DASHBOARD_OPEN_BROWSER=1 when the user asked for --open. + if (process.env.DASHBOARD_OPEN_BROWSER === "1") { + void (async () => { + const ready = await waitForUrlReady(url, 20_000); + + if (!ready.ready) { + logger.warn({ url, detail: ready.detail }, "dev-dashboard browser open skipped — page not ready"); + return; } - } - // Best-effort browser open. Detached spawns have no "error" listener by - // default; an unhandled "error" (opener binary missing) would crash the - // dashboard, so swallow it. - const [cmd, args] = - process.platform === "darwin" - ? (["open", [url]] as const) - : process.platform === "win32" - ? (["cmd", ["/c", "start", "", url]] as const) - : (["xdg-open", [url]] as const); - const opener = spawn(cmd, args, { stdio: "ignore", detached: true }); - opener.on("error", (err) => logger.debug({ err, cmd }, "failed to auto-open browser")); - opener.unref(); - })(); + const [cmd, args] = + process.platform === "darwin" + ? (["open", [url]] as const) + : process.platform === "win32" + ? (["cmd", ["/c", "start", "", url]] as const) + : (["xdg-open", [url]] as const); + const opener = spawn(cmd, args, { stdio: "ignore", detached: true }); + opener.on("error", (err) => logger.debug({ err, cmd }, "failed to auto-open browser")); + opener.unref(); + })(); + } const exitCode: number = await new Promise((resolveExit) => { child.on("exit", (code) => resolveExit(code ?? 1)); @@ -164,206 +173,32 @@ async function runUiServer(): Promise { process.exit(exitCode); } -async function pidsListeningOn(port: number): Promise { - const proc = Bun.spawn(["lsof", "-ti", `tcp:${port}`, "-sTCP:LISTEN"], { - stdout: "pipe", - stderr: "ignore", - }); - const out = await new Response(proc.stdout).text(); - await proc.exited; - - return out - .split("\n") - .map((line) => Number.parseInt(line.trim(), 10)) - .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); -} - -async function pidsMatching(pattern: string): Promise { - const proc = Bun.spawn(["pgrep", "-f", pattern], { stdout: "pipe", stderr: "ignore" }); - const out = await new Response(proc.stdout).text(); - await proc.exited; - - return out - .split("\n") - .map((line) => Number.parseInt(line.trim(), 10)) - .filter((pid) => Number.isInteger(pid) && pid > 0 && pid !== process.pid); -} - -function signalPids(pids: number[], signal: NodeJS.Signals): void { - for (const pid of pids) { - try { - process.kill(pid, signal); - } catch (err) { - logger.debug({ err, pid, signal }, "dev-dashboard restart: kill failed (process already gone?)"); - } - } -} - -async function waitForPortFree(port: number, timeoutMs: number): Promise { - const deadline = Date.now() + timeoutMs; - - while (Date.now() < deadline) { - if ((await pidsListeningOn(port)).length === 0) { - return true; - } - - await Bun.sleep(200); - } - - return (await pidsListeningOn(port)).length === 0; -} - -async function stopRunningDashboard(port: number): Promise { - const pids = await pidsListeningOn(port); - - if (pids.length === 0) { - out.println(`No dev-dashboard listening on :${port}.`); - return; - } - - out.println(`Stopping dev-dashboard (pid ${pids.join(", ")}) on :${port} ...`); - // SIGTERM lets index.ts's handler stop the front-proxy and reap its Vite - // child gracefully; SIGKILL is the fallback if the port is still held. - signalPids(pids, "SIGTERM"); - - if (!(await waitForPortFree(port, 6000))) { - const stuck = await pidsListeningOn(port); - out.println(`Port :${port} still held by ${stuck.join(", ")}; sending SIGKILL.`); - signalPids(stuck, "SIGKILL"); - - if (!(await waitForPortFree(port, 4000))) { - // Abort here — proceeding would only fail later as an opaque - // EADDRINUSE inside runUiServer(). - throw new Error(`Port :${port} is still in use after SIGTERM+SIGKILL; aborting restart.`); - } - - // Only reachable on the force-kill path. A SIGKILLed parent can't reap - // its Vite child (the graceful SIGTERM path's shutdown handler does), - // so sweep the orphan here only — scoping it to this branch keeps a - // dev-dashboard running from another worktree/checkout untouched. - const orphanVite = await pidsMatching("src/dev-dashboard/ui/vite.config.ts"); - - if (orphanVite.length > 0) { - signalPids(orphanVite, "SIGTERM"); - } - } -} - -program.action(runUiServer); - -program.command("ui").alias("dashboard").description("Launch the dev-dashboard web UI").action(runUiServer); +const devDashboardApp = defineDashboardApp({ + type: "ui", + key: "dev-dashboard", + name: "Dev Dashboard", + description: "Launch the dev-dashboard front-proxy + Vite + ttyd", + commandName: "ui", + aliases: ["dashboard"], + bindHost: "0.0.0.0", + spawn: { + cmd: [process.execPath, process.argv[1], "__ui-server"], + cwd: PROJECT_ROOT, + }, + readiness: { kind: "http", path: "/", timeoutMs: 90_000 }, + openBrowser: { enabled: false }, + launchd: { available: true }, +}); program - .command("restart") - .description("Stop any running dev-dashboard, relaunch it detached in the background, then exit") + .command("__ui-server", { hidden: true }) + .description("Internal entry: front-proxy + Vite + ttyd") .action(async () => { - const { port } = await getConfig(); - await stopRunningDashboard(port); - - const url = `http://localhost:${port}`; - const logFile = join(homedir(), ".genesis-tools", "logs", "dev-dashboard.bg.log"); - mkdirSync(dirname(logFile), { recursive: true }); - const logFd = openSync(logFile, "a"); - - // Daemonize: the UI is Vite + an in-process front-proxy. Re-invoke THIS - // script via process.execPath + argv[1] so it works even when `tools` - // isn't on PATH (t2). Child stdout/stderr go to a LOG FILE, not pipes — - // the inherited fd stays valid after we exit, so the backgrounded Vite - // never hits EPIPE/SIGPIPE on parent exit (t1). We tail the file for - // readiness; output to a file is also already ANSI-free. - const child = spawn(process.execPath, [process.argv[1], "ui"], { - cwd: PROJECT_ROOT, - detached: true, - stdio: ["ignore", logFd, logFd], - env: process.env, - }); - closeSync(logFd); // the child holds its own dup'd fd now - - const READY = /ready in \d+\s*m?s|Local:\s*http|localhost:\d+|press h \+ enter/i; - const isTty = Boolean(process.stdout.isTTY); - let settled = false; - let pos = 0; - let acc = ""; - let poll: ReturnType | undefined; - let deadline: ReturnType | undefined; - - const finish = (note: string): void => { - if (settled) { - return; - } - - settled = true; - if (poll) { - clearInterval(poll); - } - - if (deadline) { - clearTimeout(deadline); - } - - out.println(`\n${note}`); - out.println(`dev-dashboard running in background → ${url} (pid ${child.pid})`); - out.println(`logs → ${logFile}`); - out.println(`stop it with: kill ${child.pid} (\`tools dev-dashboard restart\` relaunches a new one)`); - child.unref(); - process.exit(0); - }; - - // Tail the child's log file; echo new bytes (strip ANSI when our stdout - // isn't a TTY — t6) and match the accumulated stripped text (t3). - const drain = (): void => { - let size: number; - try { - size = statSync(logFile).size; - } catch { - return; - } - - if (size <= pos) { - return; - } - - const fd = openSync(logFile, "r"); - try { - const buf = Buffer.alloc(size - pos); - const read = readSync(fd, buf, 0, buf.length, pos); - pos += read; - const chunk = buf.subarray(0, read).toString(); - process.stdout.write(isTty ? chunk : stripAnsi(chunk)); - acc += stripAnsi(chunk); - if (acc.length > 4000) { - acc = acc.slice(-2000); - } - - if (READY.test(acc)) { - finish("✓ dev-dashboard is up."); - } - } finally { - closeSync(fd); - } - }; - - child.on("error", (err) => { - out.error(`Failed to launch dev-dashboard: ${err.message}`); - process.exit(1); - }); - child.on("exit", (code) => { - if (!settled) { - drain(); - out.error(`dev-dashboard exited before becoming ready (code ${code ?? "?"}) — see ${logFile}`); - process.exit(code ?? 1); - } - }); - - poll = setInterval(drain, 150); - // Safety net: detach + exit even if the ready marker never matches, - // rather than holding the terminal forever. - deadline = setTimeout( - () => finish("⚠ ready marker not seen in 30s — detaching anyway; check the URL."), - 30_000 - ); + await runUiServer(); }); +program.addCommand(devDashboardApp.commanderCommand); + const auth = program.command("auth").description("Manage dev-dashboard Basic Auth"); auth.command("reset") diff --git a/src/dev-dashboard/lib/front-proxy.test.ts b/src/dev-dashboard/lib/front-proxy.test.ts index ef0883f148..551e05681f 100644 --- a/src/dev-dashboard/lib/front-proxy.test.ts +++ b/src/dev-dashboard/lib/front-proxy.test.ts @@ -6,7 +6,7 @@ import { LOCAL_ORIGIN_HEADER, makeBasicAuthHeader, } from "@app/dev-dashboard/lib/auth"; -import { decideProxyAuth, isLoopbackOnlyOrigin } from "@app/dev-dashboard/lib/front-proxy"; +import { decideProxyAuth, isLongLivedProxiedStream, isLoopbackOnlyOrigin } from "@app/dev-dashboard/lib/front-proxy"; // Security regression net for the ttyd/WS auth gate. The front-proxy serves // /ttyd/* and every WS upgrade BEFORE the Vite auth middleware, so this gate is @@ -122,3 +122,10 @@ describe("LOCAL_ORIGIN_HEADER invariant", () => { expect(LOCAL_ORIGIN_HEADER).toBe(LOCAL_ORIGIN_HEADER.toLowerCase()); }); }); + +describe("isLongLivedProxiedStream", () => { + test("matches QA SSE route only", () => { + expect(isLongLivedProxiedStream("/api/qa/stream")).toBe(true); + expect(isLongLivedProxiedStream("/api/qa/log")).toBe(false); + }); +}); diff --git a/src/dev-dashboard/lib/front-proxy.ts b/src/dev-dashboard/lib/front-proxy.ts index cb116631df..34883f2fa7 100644 --- a/src/dev-dashboard/lib/front-proxy.ts +++ b/src/dev-dashboard/lib/front-proxy.ts @@ -19,6 +19,49 @@ import type { Server, ServerWebSocket } from "bun"; const TTYD_PATH = /^\/ttyd\/([0-9a-fA-F-]{36})(?:\/|$)/; +/** SSE and other streaming routes must not use the short upstream fetch timeout. */ +export function isLongLivedProxiedStream(pathname: string): boolean { + return pathname === "/api/qa/stream"; +} + +const UPSTREAM_RETRY_ATTEMPTS = 10; +const UPSTREAM_RETRY_MS = 250; + +function isConnectionRefused(err: unknown): boolean { + const code = (err as { code?: string })?.code; + return code === "ConnectionRefused" || code === "ECONNREFUSED"; +} + +export async function fetchProxiedUpstream(forwarded: Request, longLived: boolean): Promise { + for (let attempt = 0; attempt < UPSTREAM_RETRY_ATTEMPTS; attempt++) { + try { + const upstream = await fetch(forwarded, { + redirect: "manual", + ...(longLived ? {} : { signal: AbortSignal.timeout(15_000) }), + }); + + if ( + (upstream.status === 502 || upstream.status === 503 || upstream.status === 504) && + attempt < UPSTREAM_RETRY_ATTEMPTS - 1 + ) { + await Bun.sleep(UPSTREAM_RETRY_MS); + continue; + } + + return upstream; + } catch (err) { + if (isConnectionRefused(err) && attempt < UPSTREAM_RETRY_ATTEMPTS - 1) { + await Bun.sleep(UPSTREAM_RETRY_MS); + continue; + } + + throw err; + } + } + + throw new Error("fetchProxiedUpstream exhausted retries without a response"); +} + // LOCAL_ORIGIN_HEADER is the single source of truth in auth.ts (set/stripped // here, trusted by the Vite middleware — they must never desync). @@ -125,14 +168,19 @@ function normalizeCloseCode(code: number): number { // can't grow the queue unbounded while the upstream is slow/stalled. const MAX_WS_QUEUE = 256; -export function startFrontProxy(opts: { publicPort: number; internalPort: number }): Server { +export function startFrontProxy(opts: { + publicPort: number; + internalPort: number; + hostname?: string; +}): Server { const { publicPort, internalPort } = opts; + const hostname = opts.hostname ?? "0.0.0.0"; const viteHttp = `http://127.0.0.1:${internalPort}`; const viteWs = `ws://127.0.0.1:${internalPort}`; const server = Bun.serve({ port: publicPort, - hostname: "0.0.0.0", + hostname, idleTimeout: 0, async fetch(req, srv) { const url = new URL(req.url); @@ -216,10 +264,7 @@ export function startFrontProxy(opts: { publicPort: number; internalPort: number let upstream: Response; try { - upstream = await fetch(forwarded, { - redirect: "manual", - signal: AbortSignal.timeout(15_000), - }); + upstream = await fetchProxiedUpstream(forwarded, isLongLivedProxiedStream(url.pathname)); } catch (err) { // A refused connection is almost always the benign startup race // (upstream Vite/ttyd not listening yet) — log it at debug so it diff --git a/src/dev-dashboard/lib/qa-recency.test.ts b/src/dev-dashboard/lib/qa-recency.test.ts new file mode 100644 index 0000000000..d9fcfe39de --- /dev/null +++ b/src/dev-dashboard/lib/qa-recency.test.ts @@ -0,0 +1,32 @@ +import { describe, expect, test } from "bun:test"; +import { resolveQaRecency } from "./qa-recency"; + +const now = Date.parse("2026-05-20T12:00:00.000Z"); + +describe("resolveQaRecency", () => { + test("uses hot tier under 10 seconds", () => { + expect(resolveQaRecency(now - 4_000, now)).toEqual({ + tier: "hot", + relative: "4s ago", + ageMs: 4_000, + }); + }); + + test("uses fresh tier from 10s to under 30s", () => { + expect(resolveQaRecency(now - 18_000, now).tier).toBe("fresh"); + expect(resolveQaRecency(now - 18_000, now).relative).toBe("18s ago"); + }); + + test("uses recent tier from 30s to under 1 minute", () => { + expect(resolveQaRecency(now - 45_000, now).tier).toBe("recent"); + }); + + test("uses warm tier from 1 to under 5 minutes", () => { + expect(resolveQaRecency(now - 3 * 60_000, now).tier).toBe("warm"); + expect(resolveQaRecency(now - 3 * 60_000, now).relative).toBe("3m ago"); + }); + + test("cools down after 15 minutes", () => { + expect(resolveQaRecency(now - 20 * 60_000, now).tier).toBe("cool"); + }); +}); diff --git a/src/dev-dashboard/lib/qa-recency.ts b/src/dev-dashboard/lib/qa-recency.ts new file mode 100644 index 0000000000..d28ec5c14e --- /dev/null +++ b/src/dev-dashboard/lib/qa-recency.ts @@ -0,0 +1,57 @@ +export type QaRecencyTier = "hot" | "fresh" | "recent" | "warm" | "cool" | "muted" | "stale"; + +export interface QaRecency { + tier: QaRecencyTier; + relative: string; + ageMs: number; +} + +const SECOND = 1_000; +const MINUTE = 60 * SECOND; +const HOUR = 60 * MINUTE; +const DAY = 24 * HOUR; + +export function resolveQaRecency(ts: number, now = Date.now()): QaRecency { + const ageMs = Math.max(0, now - ts); + const ageSec = Math.floor(ageMs / SECOND); + + if (ageSec < 10) { + return { + tier: "hot", + relative: ageSec < 1 ? "just now" : `${ageSec}s ago`, + ageMs, + }; + } + + if (ageSec < 30) { + return { tier: "fresh", relative: `${ageSec}s ago`, ageMs }; + } + + if (ageSec < 60) { + return { tier: "recent", relative: `${ageSec}s ago`, ageMs }; + } + + const ageMin = Math.floor(ageMs / MINUTE); + + if (ageMin < 5) { + return { tier: "warm", relative: `${ageMin}m ago`, ageMs }; + } + + if (ageMin < 15) { + return { tier: "cool", relative: `${ageMin}m ago`, ageMs }; + } + + const ageHours = Math.floor(ageMs / HOUR); + + if (ageHours < 1) { + return { tier: "cool", relative: `${ageMin}m ago`, ageMs }; + } + + if (ageHours < 24) { + return { tier: "muted", relative: `${ageHours}h ago`, ageMs }; + } + + const ageDays = Math.floor(ageMs / DAY); + + return { tier: "stale", relative: `${ageDays}d ago`, ageMs }; +} diff --git a/src/dev-dashboard/lib/qa-render.test.ts b/src/dev-dashboard/lib/qa-render.test.ts new file mode 100644 index 0000000000..7a7a04f5d1 --- /dev/null +++ b/src/dev-dashboard/lib/qa-render.test.ts @@ -0,0 +1,57 @@ +import { describe, expect, test } from "bun:test"; +import { enrichQaEntry, renderQaAnswerHtml } from "@app/dev-dashboard/lib/qa-render"; +import type { QaEntry } from "@app/question/lib/types"; + +const baseEntry: QaEntry = { + id: "qa-test", + ts: Date.now(), + sessionId: "s1", + sessionTitle: null, + project: "GenesisTools", + repoRoot: "/tmp", + cwd: "/tmp", + branch: "main", + commitSha: null, + isWorktree: false, + worktreePath: null, + aiAgent: null, + agentLabel: null, + tag: "question", + question: "How does this component work?", + answerMd: "", + refs: [], + source: "cli", + turnUuid: null, +}; + +describe("renderQaAnswerHtml", () => { + test("renders fenced tsx with hljs classes", () => { + const html = renderQaAnswerHtml(`Intro paragraph. + +\`\`\`tsx +export function Widget({ title }: { title: string }) { + return
{title}
; +} +\`\`\` +`); + + expect(html).toContain("
");
+        expect(html).toContain("hljs");
+        expect(html).toContain("language-tsx");
+        expect(html).toContain("Widget");
+    });
+});
+
+describe("enrichQaEntry", () => {
+    test("builds preview html for answers over the line limit", () => {
+        const lines = Array.from({ length: 52 }, (_, i) => `line ${i + 1}`);
+        const entry = enrichQaEntry({
+            ...baseEntry,
+            answerMd: lines.join("\n"),
+        });
+
+        expect(entry.answerHtml).toContain("line 52");
+        expect(entry.answerHtmlPreview).toContain("line 50");
+        expect(entry.answerHtmlPreview).not.toContain("line 51");
+    });
+});
diff --git a/src/dev-dashboard/lib/qa-render.ts b/src/dev-dashboard/lib/qa-render.ts
new file mode 100644
index 0000000000..c6956ee7f1
--- /dev/null
+++ b/src/dev-dashboard/lib/qa-render.ts
@@ -0,0 +1,35 @@
+import { renderMarkdown } from "@app/dev-dashboard/lib/obsidian/markdown";
+import type { QaEntry } from "@app/question/lib/types";
+
+const noopWikilink = { resolveWikilink: () => null };
+
+/** Answers longer than this get a collapsible preview (first N lines when collapsed). */
+export const QA_ANSWER_PREVIEW_LINES = 50;
+
+export function isQaAnswerTruncated(answerMd: string): boolean {
+    return answerMd.split("\n").length > QA_ANSWER_PREVIEW_LINES;
+}
+
+export function renderQaAnswerHtml(answerMd: string): string {
+    return renderMarkdown(answerMd, noopWikilink).html;
+}
+
+export type EnrichedQaEntry = QaEntry & {
+    answerHtml: string;
+    answerHtmlPreview: string;
+};
+
+export function enrichQaEntry(entry: QaEntry): EnrichedQaEntry {
+    const lines = entry.answerMd.split("\n");
+    const answerHtml = renderQaAnswerHtml(entry.answerMd);
+    const truncated = isQaAnswerTruncated(entry.answerMd);
+
+    if (!truncated) {
+        return { ...entry, answerHtml, answerHtmlPreview: answerHtml };
+    }
+
+    const previewMd = `${lines.slice(0, QA_ANSWER_PREVIEW_LINES).join("\n")}\n…`;
+    const answerHtmlPreview = renderQaAnswerHtml(previewMd);
+
+    return { ...entry, answerHtml, answerHtmlPreview };
+}
diff --git a/src/dev-dashboard/lib/system/collector.test.ts b/src/dev-dashboard/lib/system/collector.test.ts
index e6704321c1..04f5bc88fd 100644
--- a/src/dev-dashboard/lib/system/collector.test.ts
+++ b/src/dev-dashboard/lib/system/collector.test.ts
@@ -4,6 +4,7 @@ import {
     parseBattery,
     parseCpuIdlePct,
     parseDfRoot,
+    parseMemoryFreePct,
     parseVmStat,
     parseWifiSsid,
 } from "./collector";
@@ -46,6 +47,17 @@ describe("parseCpuIdlePct", () => {
     });
 });
 
+describe("parseMemoryFreePct", () => {
+    test("parses system-wide free percentage", () => {
+        const out = "System-wide memory free percentage: 65%";
+        expect(parseMemoryFreePct(out)).toBe(65);
+    });
+
+    test("returns null when missing", () => {
+        expect(parseMemoryFreePct("no stats")).toBeNull();
+    });
+});
+
 describe("parseVmStat", () => {
     test("sums active + wired + compressed times page size", () => {
         const expected = (2287697 + 781045 + 2818240) * 16384;
diff --git a/src/dev-dashboard/lib/system/collector.ts b/src/dev-dashboard/lib/system/collector.ts
index 35dbcb0b04..f0d99341c0 100644
--- a/src/dev-dashboard/lib/system/collector.ts
+++ b/src/dev-dashboard/lib/system/collector.ts
@@ -17,6 +17,22 @@ export function parseCpuIdlePct(topOut: string): number | null {
     return Math.round((100 - idle) * 10) / 10;
 }
 
+export function parseMemoryFreePct(memoryPressureOut: string): number | null {
+    const m = memoryPressureOut.match(/System-wide memory free percentage:\s*(\d+)%/);
+
+    if (!m) {
+        return null;
+    }
+
+    const pct = Number.parseInt(m[1], 10);
+
+    if (Number.isNaN(pct)) {
+        return null;
+    }
+
+    return pct;
+}
+
 export function parseVmStat(vmStatOut: string, pageSize: number): { usedBytes: number } {
     const pages = (label: string): number => {
         const re = new RegExp(`${label.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")}\\s+(\\d+)`);
@@ -110,22 +126,24 @@ async function collectCpu(): Promise {
     return parseCpuIdlePct(out);
 }
 
-async function collectMem(): Promise<{ used: number | null; total: number | null }> {
-    const [vmStat, pageSizeOut, memSizeOut] = await Promise.all([
+async function collectMem(): Promise<{ used: number | null; total: number | null; freePct: number | null }> {
+    const [vmStat, pageSizeOut, memSizeOut, memoryPressure] = await Promise.all([
         runShell(["vm_stat"]),
         runShell(["sysctl", "-n", "hw.pagesize"]),
         runShell(["sysctl", "-n", "hw.memsize"]),
+        runShell(["memory_pressure"]),
     ]);
 
     const total = memSizeOut === null ? null : Number.parseInt(memSizeOut.trim(), 10);
     const pageSize = pageSizeOut === null ? null : Number.parseInt(pageSizeOut.trim(), 10);
+    const freePct = memoryPressure === null ? null : parseMemoryFreePct(memoryPressure);
 
     if (vmStat === null || pageSize === null || Number.isNaN(pageSize)) {
-        return { used: null, total: total !== null && !Number.isNaN(total) ? total : null };
+        return { used: null, total: total !== null && !Number.isNaN(total) ? total : null, freePct };
     }
 
     const { usedBytes } = parseVmStat(vmStat, pageSize);
-    return { used: usedBytes, total: total !== null && !Number.isNaN(total) ? total : null };
+    return { used: usedBytes, total: total !== null && !Number.isNaN(total) ? total : null, freePct };
 }
 
 async function collectSwap(): Promise<{ used: number | null; total: number | null }> {
@@ -243,6 +261,7 @@ export async function collectPulse(): Promise {
         cpuPct: cpu,
         memUsedBytes: mem.used,
         memTotalBytes: mem.total,
+        memFreePct: mem.freePct,
         swapUsedBytes: swap.used,
         swapTotalBytes: swap.total,
         batteryPct: battery.pct,
diff --git a/src/dev-dashboard/lib/system/history-db.test.ts b/src/dev-dashboard/lib/system/history-db.test.ts
index eea1d035c5..fb75b8c06c 100644
--- a/src/dev-dashboard/lib/system/history-db.test.ts
+++ b/src/dev-dashboard/lib/system/history-db.test.ts
@@ -1,5 +1,5 @@
 import { afterEach, beforeEach, describe, expect, test } from "bun:test";
-import { PulseHistoryDb } from "./history-db";
+import { downsamplePoints, PulseHistoryDb } from "./history-db";
 
 describe("PulseHistoryDb", () => {
     let db: PulseHistoryDb;
@@ -12,6 +12,20 @@ describe("PulseHistoryDb", () => {
         db.close();
     });
 
+    test("downsamplePoints averages buckets", () => {
+        const points = [
+            { ts: "2026-01-01T00:00:00.000Z", value: 10 },
+            { ts: "2026-01-01T00:00:05.000Z", value: 20 },
+            { ts: "2026-01-01T00:00:10.000Z", value: 30 },
+            { ts: "2026-01-01T00:00:15.000Z", value: 40 },
+        ];
+
+        expect(downsamplePoints(points, 2)).toEqual([
+            { ts: "2026-01-01T00:00:05.000Z", value: 15 },
+            { ts: "2026-01-01T00:00:15.000Z", value: 35 },
+        ]);
+    });
+
     test("record then series returns ascending points within window", () => {
         db.record("cpu", 12.5);
         db.record("cpu", 30);
@@ -37,6 +51,16 @@ describe("PulseHistoryDb", () => {
         expect(db.series("cpu", 60 * 24 * 7).length).toBe(1);
     });
 
+    test("series downsamples long ranges", () => {
+        for (let i = 0; i < 1000; i++) {
+            db.recordAt("cpu", i, new Date(Date.now() - (999 - i) * 1000).toISOString());
+        }
+
+        const points = db.series("cpu", 60 * 24, 360);
+        expect(points.length).toBeLessThanOrEqual(360);
+        expect(points.length).toBeGreaterThan(0);
+    });
+
     test("public ip cache respects freshness", () => {
         expect(db.getPublicIp(1000)).toBeNull();
         db.setPublicIp("1.2.3.4");
diff --git a/src/dev-dashboard/lib/system/history-db.ts b/src/dev-dashboard/lib/system/history-db.ts
index 97807f65fd..1696cfb827 100644
--- a/src/dev-dashboard/lib/system/history-db.ts
+++ b/src/dev-dashboard/lib/system/history-db.ts
@@ -5,6 +5,26 @@ import { dirname, join } from "node:path";
 import type { PulsePoint } from "./types";
 
 const DEFAULT_DB_PATH = join(homedir(), ".genesis-tools", "dev-dashboard", "pulse.db");
+export const MAX_SERIES_POINTS = 360;
+
+export function downsamplePoints(points: PulsePoint[], maxPoints: number = MAX_SERIES_POINTS): PulsePoint[] {
+    if (points.length <= maxPoints || maxPoints < 1) {
+        return points;
+    }
+
+    const bucketSize = Math.ceil(points.length / maxPoints);
+    const result: PulsePoint[] = [];
+
+    for (let i = 0; i < points.length; i += bucketSize) {
+        const bucket = points.slice(i, i + bucketSize);
+        const avg = bucket.reduce((sum, point) => sum + point.value, 0) / bucket.length;
+        const mid = bucket[Math.floor(bucket.length / 2)];
+
+        result.push({ ts: mid.ts, value: Math.round(avg * 100) / 100 });
+    }
+
+    return result;
+}
 
 interface PointRow {
     ts: string;
@@ -44,12 +64,15 @@ export class PulseHistoryDb {
         this.db.prepare("INSERT INTO pulse_points (metric, ts, value) VALUES (?, ?, ?)").run(metric, ts, value);
     }
 
-    series(metric: string, minutes: number): PulsePoint[] {
+    series(metric: string, minutes: number, maxPoints: number = MAX_SERIES_POINTS): PulsePoint[] {
         const cutoff = new Date(Date.now() - minutes * 60_000).toISOString();
         const rows = this.db
             .prepare("SELECT ts, value FROM pulse_points WHERE metric = ? AND ts >= ? ORDER BY ts ASC")
             .all(metric, cutoff) as PointRow[];
-        return rows.map((r) => ({ ts: r.ts, value: r.value }));
+        return downsamplePoints(
+            rows.map((r) => ({ ts: r.ts, value: r.value })),
+            maxPoints
+        );
     }
 
     pruneOlderThan(hours: number): number {
diff --git a/src/dev-dashboard/lib/system/poller.ts b/src/dev-dashboard/lib/system/poller.ts
index e2a57f3919..9be4a655d7 100644
--- a/src/dev-dashboard/lib/system/poller.ts
+++ b/src/dev-dashboard/lib/system/poller.ts
@@ -73,8 +73,8 @@ async function tick(): Promise {
             history.record("cpu", snapshot.cpuPct);
         }
 
-        if (snapshot.memUsedBytes !== null && snapshot.memTotalBytes) {
-            history.record("mem", (snapshot.memUsedBytes / snapshot.memTotalBytes) * 100);
+        if (snapshot.memFreePct !== null) {
+            history.record("mem_free", snapshot.memFreePct);
         }
 
         if (snapshot.swapUsedBytes !== null && snapshot.swapTotalBytes) {
diff --git a/src/dev-dashboard/lib/system/types.ts b/src/dev-dashboard/lib/system/types.ts
index 20ceee210b..ced9370160 100644
--- a/src/dev-dashboard/lib/system/types.ts
+++ b/src/dev-dashboard/lib/system/types.ts
@@ -8,6 +8,8 @@ export interface PulseSnapshot {
     cpuPct: number | null;
     memUsedBytes: number | null;
     memTotalBytes: number | null;
+    /** macOS system-wide free memory % from `memory_pressure` (matches Activity Monitor pressure). */
+    memFreePct: number | null;
     swapUsedBytes: number | null;
     swapTotalBytes: number | null;
     batteryPct: number | null;
diff --git a/src/dev-dashboard/ui/src/components/CmuxSessionList.tsx b/src/dev-dashboard/ui/src/components/CmuxSessionList.tsx
index 6c79941196..9abeedd28d 100644
--- a/src/dev-dashboard/ui/src/components/CmuxSessionList.tsx
+++ b/src/dev-dashboard/ui/src/components/CmuxSessionList.tsx
@@ -232,7 +232,7 @@ export function CmuxSessionList({ snapshot }: Props) {
                     )}
                 >
                     {activePane ? (
-                        
+
diff --git a/src/dev-dashboard/ui/src/components/LiveSseIndicator.tsx b/src/dev-dashboard/ui/src/components/LiveSseIndicator.tsx new file mode 100644 index 0000000000..ee1ec10b05 --- /dev/null +++ b/src/dev-dashboard/ui/src/components/LiveSseIndicator.tsx @@ -0,0 +1,26 @@ +interface LiveSseIndicatorProps { + live: boolean; + count: number; +} + +export function LiveSseIndicator({ live, count }: LiveSseIndicatorProps) { + return ( + + + {live ? "live (SSE)" : "disconnected"} + · + {count} shown + + ); +} diff --git a/src/dev-dashboard/ui/src/components/MobileKeyBar.tsx b/src/dev-dashboard/ui/src/components/MobileKeyBar.tsx new file mode 100644 index 0000000000..7a50472c2f --- /dev/null +++ b/src/dev-dashboard/ui/src/components/MobileKeyBar.tsx @@ -0,0 +1,84 @@ +import { ArrowDown, ArrowLeft, ArrowRight, ArrowUp, ChevronsDown, ChevronsUp } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { IframeKey } from "@/lib/iframe-keys"; + +interface MobileKeyBarProps { + onKey: (key: IframeKey) => void; + onScroll: (lines: number) => void; + /** Tweak how many lines a PgUp/PgDn tap scrolls. Defaults to 10. */ + scrollStep?: number; +} + +interface KeySpec { + label: string; + aria: string; + icon?: React.ReactNode; + action: () => void; + accent?: boolean; +} + +/** + * Tiny on-screen key bar for mobile ttyd. Sticks just above the OS keyboard + * using window.visualViewport (the only API iOS Safari actually honours), + * and falls back to bottom-of-viewport when no soft keyboard is open. + */ +export function MobileKeyBar({ onKey, onScroll, scrollStep = 10 }: MobileKeyBarProps) { + const [bottomOffset, setBottomOffset] = useState(0); + + useEffect(() => { + const vv = window.visualViewport; + if (!vv) { + return; + } + + const sync = () => { + // visualViewport.height shrinks when the OS keyboard rises; the + // gap between layout viewport and visual viewport is the keyboard. + const layoutH = window.innerHeight; + const visualH = vv.height + vv.offsetTop; + const keyboard = Math.max(0, layoutH - visualH); + setBottomOffset(keyboard); + }; + + sync(); + vv.addEventListener("resize", sync); + vv.addEventListener("scroll", sync); + + return () => { + vv.removeEventListener("resize", sync); + vv.removeEventListener("scroll", sync); + }; + }, []); + + const keys: KeySpec[] = [ + { label: "Esc", aria: "Escape", action: () => onKey("Escape"), accent: true }, + { label: "Tab", aria: "Tab", action: () => onKey("Tab") }, + { label: "←", aria: "Arrow left", icon: , action: () => onKey("ArrowLeft") }, + { label: "↑", aria: "Arrow up", icon: , action: () => onKey("ArrowUp") }, + { label: "↓", aria: "Arrow down", icon: , action: () => onKey("ArrowDown") }, + { label: "→", aria: "Arrow right", icon: , action: () => onKey("ArrowRight") }, + { label: "PgUp", aria: "Scroll up", icon: , action: () => onScroll(-scrollStep) }, + { label: "PgDn", aria: "Scroll down", icon: , action: () => onScroll(scrollStep) }, + ]; + + return ( +
+ {keys.map((k) => ( + + ))} +
+ ); +} diff --git a/src/dev-dashboard/ui/src/components/QaClockProvider.tsx b/src/dev-dashboard/ui/src/components/QaClockProvider.tsx new file mode 100644 index 0000000000..56e6da1d07 --- /dev/null +++ b/src/dev-dashboard/ui/src/components/QaClockProvider.tsx @@ -0,0 +1,22 @@ +import { createContext, type ReactNode, useContext, useEffect, useState } from "react"; + +const TICK_MS = 1_000; + +const QaClockContext = createContext(Date.now()); + +export function QaClockProvider({ children }: { children: ReactNode }) { + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const timer = setInterval(() => setNow(Date.now()), TICK_MS); + return () => clearInterval(timer); + }, []); + + return {children}; +} + +function useQaClock(): number { + return useContext(QaClockContext); +} + +export { useQaClock }; diff --git a/src/dev-dashboard/ui/src/components/QaRecencyTime.tsx b/src/dev-dashboard/ui/src/components/QaRecencyTime.tsx new file mode 100644 index 0000000000..b171dde455 --- /dev/null +++ b/src/dev-dashboard/ui/src/components/QaRecencyTime.tsx @@ -0,0 +1,25 @@ +import { resolveQaRecency } from "@app/dev-dashboard/lib/qa-recency"; +import { formatClock } from "@app/utils/format"; +import { memo } from "react"; +import { useQaClock } from "@/components/QaClockProvider"; + +interface QaRecencyTimeProps { + ts: number; +} + +export const QaRecencyTime = memo(function QaRecencyTime({ ts }: QaRecencyTimeProps) { + const now = useQaClock(); + const when = new Date(ts); + const { tier, relative } = resolveQaRecency(ts, now); + const absolute = formatClock(when, { date: "short", seconds: true }); + + return ( + + {relative} + · + + + ); +}); diff --git a/src/dev-dashboard/ui/src/components/QaSectionHeading.tsx b/src/dev-dashboard/ui/src/components/QaSectionHeading.tsx new file mode 100644 index 0000000000..4a8dc46c86 --- /dev/null +++ b/src/dev-dashboard/ui/src/components/QaSectionHeading.tsx @@ -0,0 +1,12 @@ +interface QaSectionHeadingProps { + label: "Question" | "Answer"; +} + +export function QaSectionHeading({ label }: QaSectionHeadingProps) { + return ( +
+ +

{label}

+
+ ); +} diff --git a/src/dev-dashboard/ui/src/lib/iframe-keys.ts b/src/dev-dashboard/ui/src/lib/iframe-keys.ts new file mode 100644 index 0000000000..515969693a --- /dev/null +++ b/src/dev-dashboard/ui/src/lib/iframe-keys.ts @@ -0,0 +1,148 @@ +/** + * Inject keystrokes / scroll commands into an embedded ttyd iframe. + * + * ttyd uses xterm.js under the hood with a hidden `.xterm-helper-textarea` + * that captures real keyboard input. To simulate Esc/Tab/Arrow/PageUp from + * a virtual mobile keybar in the parent page we: + * 1. resolve the iframe's helper textarea (same-origin via the front-proxy), + * 2. focus it so the resulting keydown lands on the active terminal, + * 3. dispatch a synthetic KeyboardEvent — xterm.js doesn't check + * `event.isTrusted`, so the standard handler picks it up and writes + * the right ESC sequence to the websocket. + * + * For scrollback we prefer xterm.js's own `scrollLines()` when ttyd exposes + * the terminal instance globally (faster + jank-free), and fall back to + * Shift+PageUp/PageDown keydowns which xterm.js binds by default. + */ + +export type IframeKey = "Escape" | "Tab" | "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" | "PageUp" | "PageDown"; + +const KEY_TABLE: Record = { + Escape: { key: "Escape", code: "Escape", keyCode: 27 }, + Tab: { key: "Tab", code: "Tab", keyCode: 9 }, + ArrowUp: { key: "ArrowUp", code: "ArrowUp", keyCode: 38 }, + ArrowDown: { key: "ArrowDown", code: "ArrowDown", keyCode: 40 }, + ArrowLeft: { key: "ArrowLeft", code: "ArrowLeft", keyCode: 37 }, + ArrowRight: { key: "ArrowRight", code: "ArrowRight", keyCode: 39 }, + PageUp: { key: "PageUp", code: "PageUp", keyCode: 33 }, + PageDown: { key: "PageDown", code: "PageDown", keyCode: 34 }, +}; + +interface XtermTerminal { + scrollLines?: (amount: number) => void; + focus?: () => void; +} + +interface XtermWindow extends Window { + term?: XtermTerminal; +} + +function getHelperTextarea(iframe: HTMLIFrameElement): HTMLTextAreaElement | null { + try { + return iframe.contentDocument?.querySelector(".xterm-helper-textarea") ?? null; + } catch { + return null; + } +} + +function getXtermInstance(iframe: HTMLIFrameElement): XtermTerminal | null { + try { + const w = iframe.contentWindow as XtermWindow | null; + return w?.term ?? null; + } catch { + return null; + } +} + +function dispatchKey(target: HTMLElement, key: IframeKey, shiftKey = false): boolean { + const spec = KEY_TABLE[key]; + target.focus(); + const event = new KeyboardEvent("keydown", { + key: spec.key, + code: spec.code, + keyCode: spec.keyCode, + which: spec.keyCode, + shiftKey, + bubbles: true, + cancelable: true, + }); + return target.dispatchEvent(event); +} + +export function sendKeyToIframe(iframe: HTMLIFrameElement | null, key: IframeKey): boolean { + if (!iframe) { + return false; + } + + const textarea = getHelperTextarea(iframe); + if (!textarea) { + return false; + } + + return dispatchKey(textarea, key); +} + +/** + * Scrolls the terminal's scrollback buffer. Positive amount = down, negative = up. + * + * xterm.js's reliable scroll inputs are (in priority order): + * 1. the public `term.scrollLines(±n)` API if ttyd exposes the Terminal, + * 2. a synthetic WheelEvent on `.xterm-viewport` — xterm.js binds wheel + * and converts deltaY into scrollback movement, + * 3. direct scrollTop manipulation as a belt-and-braces nudge. + * + * What does NOT work: PageUp/PageDown keydowns (xterm.js doesn't bind those + * by default — host apps add them, and ttyd doesn't). Earlier versions of + * this file shipped that approach and it was a no-op on ttyd. + */ +export function scrollIframeTerminal(iframe: HTMLIFrameElement | null, amount: number): boolean { + if (!iframe || amount === 0) { + return false; + } + + const term = getXtermInstance(iframe); + if (term?.scrollLines) { + term.scrollLines(amount); + return true; + } + + const viewport = getXtermViewport(iframe); + if (!viewport) { + return false; + } + + const lineHeight = estimateLineHeight(viewport); + const deltaY = amount * lineHeight; + + viewport.dispatchEvent( + new WheelEvent("wheel", { + deltaY, + deltaMode: 0, // pixels + bubbles: true, + cancelable: true, + }) + ); + + // Some xterm.js renderers detach visual rows from the scrollable DOM, so + // belt-and-braces also nudge scrollTop; harmless if the wheel already won. + viewport.scrollTop = Math.max(0, viewport.scrollTop + deltaY); + return true; +} + +function getXtermViewport(iframe: HTMLIFrameElement): HTMLElement | null { + try { + return iframe.contentDocument?.querySelector(".xterm-viewport") ?? null; + } catch { + return null; + } +} + +function estimateLineHeight(viewport: HTMLElement): number { + const rowEl = viewport.parentElement?.querySelector(".xterm-rows > div"); + const measured = rowEl?.getBoundingClientRect().height; + return measured && measured > 4 ? measured : 17; +} + +export function findIframeByTitle(title: string): HTMLIFrameElement | null { + return document.querySelector(`iframe[title="${title}"]`); +} diff --git a/src/dev-dashboard/ui/src/routes/index.tsx b/src/dev-dashboard/ui/src/routes/index.tsx index f78745a755..9b65e4414b 100644 --- a/src/dev-dashboard/ui/src/routes/index.tsx +++ b/src/dev-dashboard/ui/src/routes/index.tsx @@ -17,6 +17,7 @@ interface PulseSnapshot { cpuPct: number | null; memUsedBytes: number | null; memTotalBytes: number | null; + memFreePct: number | null; swapUsedBytes: number | null; swapTotalBytes: number | null; batteryPct: number | null; @@ -87,7 +88,7 @@ export function IndexRoute() { const snap = useQuery({ queryKey: ["pulse", "snap"], queryFn: () => fetchJson("/api/system/pulse"), - refetchInterval: 2000, + refetchInterval: 5000, }); const [rangeMinutes, setRangeMinutes] = useState(HISTORY_RANGES[0].minutes); @@ -99,9 +100,9 @@ export function IndexRoute() { }); const memHistory = useQuery({ - queryKey: ["pulse", "history", "mem", rangeMinutes], - queryFn: () => fetchJson(`/api/system/pulse/history?metric=mem&minutes=${rangeMinutes}`), - refetchInterval: 10000, + queryKey: ["pulse", "history", "mem_free", rangeMinutes], + queryFn: () => fetchJson(`/api/system/pulse/history?metric=mem_free&minutes=${rangeMinutes}`), + refetchInterval: rangeMinutes >= 1440 ? 60000 : 10000, }); const weather = useQuery({ @@ -128,8 +129,16 @@ export function IndexRoute() { - +
3; +const QaCard = memo(function QaCard({ + entry, + unread, + onSeen, +}: { + entry: QaRow; + unread: boolean; + onSeen: (id: string) => void; +}) { + const [open, setOpen] = useState(true); + const truncated = isQaAnswerTruncated(entry.answerMd); + const answerHtml = open || !truncated ? entry.answerHtml : entry.answerHtmlPreview; + + const handleMarkRead = useCallback(() => { + if (!unread) { + return; + } + + onSeen(entry.id); + }, [entry.id, onSeen, unread]); return ( -
+
{ + if (!unread) { + return; + } + + if (ev.key === "Enter" || ev.key === " ") { + ev.preventDefault(); + onSeen(entry.id); + } + }} + >
+ new {entry.project} · {entry.branch ?? "-"} {entry.tag} - {when} + +
+ +
+ {entry.question}
-
❯ {entry.question}
-
-                {open ? entry.answerMd : preview}
-            
+ +
{truncated ? (
); -} +}); export function QaRoute() { const logQuery = useQuery({ queryKey: ["qa-log"], queryFn: fetchQaLog, retry: false }); const [live, setLive] = useState([]); const [sseDown, setSseDown] = useState(false); + const [seenIds, setSeenIds] = useState>(() => new Set()); const seen = useRef>(new Set()); + const markedReadRef = useRef>(new Set()); + const pendingReadIds = useRef>(new Set()); + const readFlushTimer = useRef | undefined>(undefined); + const readApiDisabledRef = useRef(false); + + const flushReadIds = useCallback(() => { + readFlushTimer.current = undefined; + + if (readApiDisabledRef.current) { + pendingReadIds.current.clear(); + return; + } + + const ids = [...pendingReadIds.current]; + pendingReadIds.current.clear(); + + if (ids.length === 0) { + return; + } + + void fetch("/api/qa/read", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: SafeJSON.stringify({ ids }), + }) + .then((res) => { + if (res.status === 404) { + readApiDisabledRef.current = true; + } + }) + .catch(() => { + /* best-effort — unread styling is client-side until next load */ + }); + }, []); + + const markSeen = useCallback( + (id: string) => { + if (markedReadRef.current.has(id)) { + return; + } + + markedReadRef.current.add(id); + setSeenIds((prev) => { + if (prev.has(id)) { + return prev; + } + + const next = new Set(prev); + next.add(id); + return next; + }); + pendingReadIds.current.add(id); + + if (readFlushTimer.current) { + return; + } + + readFlushTimer.current = setTimeout(flushReadIds, READ_PERSIST_DEBOUNCE_MS); + }, + [flushReadIds] + ); + + useEffect(() => { + return () => { + if (readFlushTimer.current) { + clearTimeout(readFlushTimer.current); + } + + flushReadIds(); + }; + }, [flushReadIds]); + + useEffect(() => { + if (!logQuery.data) { + return; + } + + setSeenIds((prev) => { + const next = new Set(prev); + let changed = false; + + for (const row of logQuery.data) { + if (row.readAt != null) { + markedReadRef.current.add(row.id); + + if (!next.has(row.id)) { + next.add(row.id); + changed = true; + } + } + } + + return changed ? next : prev; + }); + }, [logQuery.data]); useEffect(() => { const es = new EventSource("/api/qa/stream"); @@ -187,8 +327,13 @@ export function QaRoute() { /* ignore malformed frame */ } }; - // Without this the indicator stays green forever on a dropped stream (t8). - es.onerror = () => setSseDown(true); + es.onerror = () => { + // EventSource auto-reconnects while CONNECTING — only show disconnected when + // the browser has given up (CLOSED). Avoid flicker on transient proxy blips. + if (es.readyState === EventSource.CLOSED) { + setSseDown(true); + } + }; return () => es.close(); }, []); @@ -211,13 +356,7 @@ export function QaRoute() {

Q&A

- - {sseDown ? "○ disconnected" : "● live (SSE)"} · {all.length} shown - +
{logQuery.isLoading ? ( @@ -227,11 +366,13 @@ export function QaRoute() { No questions recorded yet.
) : ( -
- {all.map((entry) => ( - - ))} -
+ +
+ {all.map((entry) => ( + + ))} +
+
)}
); diff --git a/src/dev-dashboard/ui/src/routes/ttyd.tsx b/src/dev-dashboard/ui/src/routes/ttyd.tsx index b8b33da157..1309b48e21 100644 --- a/src/dev-dashboard/ui/src/routes/ttyd.tsx +++ b/src/dev-dashboard/ui/src/routes/ttyd.tsx @@ -4,11 +4,13 @@ import { useEffect, useState } from "react"; import { Mosaic, type MosaicNode, MosaicWindow } from "react-mosaic-component"; import "react-mosaic-component/react-mosaic-component.css"; import { Button } from "@ui/components/button"; +import { MobileKeyBar } from "@/components/MobileKeyBar"; import { TtydFrame } from "@/components/TtydFrame"; import { TtydPane } from "@/components/TtydPane"; import { MobileTerminalShell } from "@/components/terminal-shell/MobileTerminalShell"; import { useLayoutMode } from "@/hooks/useLayoutMode"; import { ttydApi } from "@/lib/api"; +import { findIframeByTitle, scrollIframeTerminal, sendKeyToIframe } from "@/lib/iframe-keys"; import { buildBalancedMosaicLayout, flattenMosaicLeaves, reconcileMosaicLayout } from "@/lib/mosaic-layout"; import { buildTtydTabs } from "@/lib/terminal-tabs"; @@ -140,6 +142,12 @@ export function TtydRoute() {
)} + {isMobile && active ? ( + sendKeyToIframe(findIframeByTitle(`ttyd-${active}`), key)} + onScroll={(lines) => scrollIframeTerminal(findIframeByTitle(`ttyd-${active}`), lines)} + /> + ) : null} ); } diff --git a/src/dev-dashboard/ui/src/slate-grid.css b/src/dev-dashboard/ui/src/slate-grid.css index f49d32a1f6..3bdb101c07 100644 --- a/src/dev-dashboard/ui/src/slate-grid.css +++ b/src/dev-dashboard/ui/src/slate-grid.css @@ -9,6 +9,8 @@ --dd-text-primary: #e6edf3; --dd-text-secondary: #8b96a0; --dd-text-muted: #5b6670; + --dd-danger: #f87171; + --dd-accent-glow: rgba(52, 211, 153, 0.35); } .dd-grid-bg { @@ -31,6 +33,110 @@ color: transparent; } +.dd-qa-section-heading { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.dd-qa-section-heading-bar { + flex: none; + width: 2px; + height: 0.875rem; + border-radius: 9999px; + background: var(--dd-accent-gradient); + box-shadow: 0 0 8px var(--dd-accent-glow); +} + +.dd-qa-section-body { + margin-left: 0.625rem; + padding-left: 0.75rem; + border-left: 1px solid var(--dd-border); +} + +.dd-qa-card--unread { + border-color: rgba(52, 211, 153, 0.28); + background: + linear-gradient(180deg, rgba(52, 211, 153, 0.07), transparent 55%), + var(--dd-bg-panel); + box-shadow: 0 0 0 1px rgba(52, 211, 153, 0.06); +} + +.dd-qa-card--clickable { + cursor: pointer; +} + +.dd-qa-card--clickable:focus-visible { + outline: 2px solid rgba(52, 211, 153, 0.45); + outline-offset: 2px; +} + +.dd-qa-card--unread .dd-qa-unread-badge { + display: inline-flex; +} + +.dd-qa-unread-badge { + display: none; + align-items: center; + border-radius: 9999px; + border: 1px solid rgba(52, 211, 153, 0.35); + background: rgba(52, 211, 153, 0.1); + color: #6ee7b7; + font-size: 10px; + font-weight: 600; + letter-spacing: 0.06em; + padding: 1px 7px; + text-transform: uppercase; +} + +.dd-qa-recency { + font-weight: 600; + letter-spacing: 0.01em; +} + +.dd-qa-recency--hot { + color: #34d399; + text-shadow: 0 0 12px rgba(52, 211, 153, 0.65); + animation: dd-qa-recency-pulse 1.4s ease-in-out infinite; +} + +.dd-qa-recency--fresh { + color: #2dd4bf; +} + +.dd-qa-recency--recent { + color: #22d3ee; +} + +.dd-qa-recency--warm { + color: #38bdf8; +} + +.dd-qa-recency--cool { + color: #7dd3fc; +} + +.dd-qa-recency--muted { + color: var(--dd-text-secondary); + font-weight: 500; +} + +.dd-qa-recency--stale { + color: var(--dd-text-muted); + font-weight: 400; +} + +@keyframes dd-qa-recency-pulse { + 0%, + 100% { + opacity: 1; + } + + 50% { + opacity: 0.72; + } +} + .dd-mosaic .mosaic-window { background: var(--dd-bg-panel); border: 1px solid var(--dd-border); @@ -124,12 +230,16 @@ border-radius: 8px; background: #050607; color: #aab4bd; - padding: 10px; + padding: 10px calc(10px + env(safe-area-inset-right, 0px)) + calc(10px + env(safe-area-inset-bottom, 0px)) 10px; font-family: "JetBrains Mono", source-code-pro, Menlo, Monaco, Consolas, monospace; font-size: 11px; line-height: 1.55; text-shadow: 0 0 10px rgba(52, 211, 153, 0.05); white-space: pre-wrap; + /* Native momentum scroll on iOS + don't yank parent scroll. */ + -webkit-overflow-scrolling: touch; + overscroll-behavior: contain; } .dd-terminal-line { @@ -517,3 +627,79 @@ background: #3c4a44; box-shadow: none; } + +/* Mobile virtual keys bar — floats just above the OS keyboard. + Inline `bottom` is driven by visualViewport in MobileKeyBar.tsx. */ +.dd-keybar { + position: fixed; + left: 0; + right: 0; + z-index: 60; + display: flex; + gap: 6px; + padding: 6px 8px calc(6px + env(safe-area-inset-bottom, 0px)); + background: + linear-gradient(180deg, rgba(16, 19, 22, 0.92), rgba(8, 10, 12, 0.96)); + border-top: 1px solid var(--dd-border); + box-shadow: 0 -10px 24px rgba(0, 0, 0, 0.55); + backdrop-filter: blur(10px); + -webkit-backdrop-filter: blur(10px); + overflow-x: auto; + scrollbar-width: none; + font-family: "JetBrains Mono", source-code-pro, Menlo, Monaco, Consolas, monospace; + /* Mobile only — desktop never sees this. */ + pointer-events: auto; +} + +.dd-keybar::-webkit-scrollbar { + display: none; +} + +.dd-keybar-key { + flex: 1 0 auto; + min-width: 44px; + height: 38px; + display: inline-flex; + align-items: center; + justify-content: center; + padding: 0 10px; + border: 1px solid var(--dd-border); + border-radius: 8px; + background: #0f1316; + color: var(--dd-text-primary); + font-size: 12px; + line-height: 1; + letter-spacing: 0.04em; + cursor: pointer; + user-select: none; + -webkit-tap-highlight-color: transparent; + transition: + background 120ms ease, + border-color 120ms ease, + transform 80ms ease, + box-shadow 120ms ease; +} + +.dd-keybar-key:hover { + border-color: #2a3338; + background: #141a1f; +} + +.dd-keybar-key:active { + transform: translateY(1px); + background: rgba(52, 211, 153, 0.08); + border-color: rgba(52, 211, 153, 0.4); + box-shadow: inset 0 0 0 1px rgba(52, 211, 153, 0.2); +} + +.dd-keybar-key.is-accent { + color: #baf7dc; + border-color: rgba(52, 211, 153, 0.45); + background: rgba(52, 211, 153, 0.08); + box-shadow: inset 0 -1px 0 rgba(52, 211, 153, 0.3); +} + +.dd-keybar-label { + text-transform: uppercase; + font-weight: 600; +} diff --git a/src/dev-dashboard/ui/src/styles.css b/src/dev-dashboard/ui/src/styles.css index 01c1011f9c..ceeff599cb 100644 --- a/src/dev-dashboard/ui/src/styles.css +++ b/src/dev-dashboard/ui/src/styles.css @@ -1,6 +1,7 @@ @import "tailwindcss"; @import "tw-animate-css"; @import "@ui/theme/styles.css"; +@import "highlight.js/styles/atom-one-dark.css"; @import "./slate-grid.css"; @source "../../../utils/ui"; diff --git a/src/dev-dashboard/ui/vite-middleware.ts b/src/dev-dashboard/ui/vite-middleware.ts index 3e147386e6..ab4c606790 100644 --- a/src/dev-dashboard/ui/vite-middleware.ts +++ b/src/dev-dashboard/ui/vite-middleware.ts @@ -27,6 +27,7 @@ import { } from "@app/dev-dashboard/lib/obsidian/publish"; import { listVault, readNote } from "@app/dev-dashboard/lib/obsidian/reader"; import { renderSharePage } from "@app/dev-dashboard/lib/obsidian/share-template"; +import { enrichQaEntry } from "@app/dev-dashboard/lib/qa-render"; import { createQaStream, todayLogFile } from "@app/dev-dashboard/lib/qa-sse"; import { configureRetention, getCachedPulse, getSeries, startPulsePolling } from "@app/dev-dashboard/lib/system/poller"; import { addTodo, completeTodo, deleteTodo, listTodos } from "@app/dev-dashboard/lib/todos/service"; @@ -34,7 +35,7 @@ import { killTtyd, listTtyd, renameTtyd, spawnTtyd } from "@app/dev-dashboard/li import { fetchWeather } from "@app/dev-dashboard/lib/weather/client"; import { logger } from "@app/logger"; import { defaultDbPath } from "@app/question/commands/log"; -import { openReadModel, queryEntries } from "@app/question/lib/read-model"; +import { markEntriesRead, openReadModel, queryEntries } from "@app/question/lib/read-model"; import { getAudioLibrary } from "@app/utils/audio/library"; import { resolveSoundBuffer } from "@app/utils/audio/runner.server"; import { SafeJSON } from "@app/utils/json"; @@ -353,7 +354,7 @@ export function attachDevDashboardMiddleware(middlewares: Connect.Server): void unread: url.searchParams.get("unread") === "1", limit: Number.parseInt(url.searchParams.get("limit") ?? "100", 10), }); - sendJson(res, 200, { entries: rows }); + sendJson(res, 200, { entries: rows.map((row) => enrichQaEntry(row)) }); } catch (err) { sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); } finally { @@ -363,6 +364,23 @@ export function attachDevDashboardMiddleware(middlewares: Connect.Server): void return; } + if (req.method === "POST" && url.pathname === "/api/qa/read") { + let db: ReturnType | undefined; + try { + const body = await readJson<{ ids?: string[] }>(req); + const ids = body.ids?.filter((id) => typeof id === "string" && id.length > 0) ?? []; + db = openReadModel(defaultDbPath()); + const updated = markEntriesRead(db, ids); + sendJson(res, 200, { ok: true, updated }); + } catch (err) { + sendJson(res, 500, { error: err instanceof Error ? err.message : String(err) }); + } finally { + db?.close(); + } + + return; + } + if (req.method === "GET" && url.pathname === "/api/qa/audio-library") { sendJson(res, 200, getAudioLibrary()); return; @@ -425,9 +443,9 @@ export function attachDevDashboardMiddleware(middlewares: Connect.Server): void }); res.write(": qa stream open\n\n"); const stream = createQaStream(todayLogFile(), (entry) => { - res.write(`data: ${SafeJSON.stringify(entry)}\n\n`); + res.write(`data: ${SafeJSON.stringify(enrichQaEntry(entry))}\n\n`); }); - const keepAlive = setInterval(() => res.write(": ping\n\n"), 25000); + const keepAlive = setInterval(() => res.write(": ping\n\n"), 12_000); const shutdown = (): void => { clearInterval(keepAlive); stream.close(); diff --git a/src/question/commands/record.ts b/src/question/commands/record.ts index a099bc2fe4..b424d69d35 100644 --- a/src/question/commands/record.ts +++ b/src/question/commands/record.ts @@ -6,6 +6,7 @@ import type { QaTag } from "../lib/types"; export function registerRecordCommand(program: Command): void { program .command("record") + .alias("answer") .description("Record a Q→A entry (used by the question_answer MCP tool / scripts)") .requiredOption("--q ", "the question") .option("--a ", "the answer (markdown)") diff --git a/src/question/lib/read-model.test.ts b/src/question/lib/read-model.test.ts index 8e230cd47f..79c3fcb33c 100644 --- a/src/question/lib/read-model.test.ts +++ b/src/question/lib/read-model.test.ts @@ -3,7 +3,7 @@ import { mkdtempSync } from "node:fs"; import { tmpdir } from "node:os"; import { join } from "node:path"; import { appendEntry } from "./log-store"; -import { openReadModel, queryEntries } from "./read-model"; +import { markEntriesRead, openReadModel, queryEntries } from "./read-model"; import type { QaEntry } from "./types"; function e(id: string, over: Partial = {}): QaEntry { @@ -54,4 +54,17 @@ describe("read-model", () => { expect(queryEntries(db, { logBase, project: "Alpha" }).length).toBe(1); expect(queryEntries(db, { logBase, unread: true }).length).toBe(2); }); + + it("marks entries read without touching already-read rows", () => { + const logBase = mkdtempSync(join(tmpdir(), "qa-log-")); + const dbPath = join(mkdtempSync(join(tmpdir(), "qa-db-")), "qa.db"); + appendEntry(e("r1"), logBase); + appendEntry(e("r2"), logBase); + const db = openReadModel(dbPath); + queryEntries(db, { logBase }); + + expect(markEntriesRead(db, ["r1", "r2"], { logBase })).toBe(2); + expect(queryEntries(db, { logBase, unread: true }).length).toBe(0); + expect(markEntriesRead(db, ["r1"], { logBase })).toBe(0); + }); }); diff --git a/src/question/lib/read-model.ts b/src/question/lib/read-model.ts index e415bdbef3..3737dc84e7 100644 --- a/src/question/lib/read-model.ts +++ b/src/question/lib/read-model.ts @@ -163,3 +163,23 @@ export function queryEntries(db: Database, opts: QueryOpts = {}): QaRow[] { readAt: r.read_at as number | null, })); } + +export function markEntriesRead(db: Database, ids: string[], opts: Pick = {}): number { + if (ids.length === 0) { + return 0; + } + + catchUp(db, opts.logBase); + const now = Date.now(); + const stmt = db.prepare("UPDATE entries SET read_at = ? WHERE id = ? AND read_at IS NULL"); + let updated = 0; + + const tx = db.transaction((rowIds: string[]) => { + for (const id of rowIds) { + updated += stmt.run(now, id).changes; + } + }); + tx(ids); + + return updated; +} diff --git a/src/shops/commands/ui.ts b/src/shops/commands/ui.ts index 5f09bb1216..d096292754 100644 --- a/src/shops/commands/ui.ts +++ b/src/shops/commands/ui.ts @@ -1,23 +1,50 @@ -import { launchShopsDashboard } from "@app/shops/lib/ui-launcher"; +import { existsSync } from "node:fs"; +import { resolve } from "node:path"; +import { buildViteDevCmd, defineDashboardApp } from "@app/utils/DashboardApp"; +import { PROJECT_ROOT } from "@app/utils/paths"; import type { Command } from "commander"; -export function registerUiCommand(program: Command): void { - program - .command("ui") - .alias("dashboard") - .description("Launch the Shops dashboard web UI on http://localhost:3073") - .action(async () => { - try { - const exitCode = await launchShopsDashboard(); - if (exitCode !== 0) { - process.stderr.write(`\n× Vite exited with code ${exitCode}\n`); - } +const UI_DIR = resolve(import.meta.dirname, "..", "ui"); +const CONFIG_PATH = resolve(UI_DIR, "vite.config.ts"); +const VITE_ENTRY = resolve(PROJECT_ROOT, "node_modules", "vite", "bin", "vite.js"); + +function preflight(): { ok: boolean; error?: string } { + if (!existsSync(VITE_ENTRY)) { + return { + ok: false, + error: `Could not find vite at ${VITE_ENTRY}. Run "bun install" in ${PROJECT_ROOT} first.`, + }; + } + if (!existsSync(CONFIG_PATH)) { + return { ok: false, error: `Vite config missing: ${CONFIG_PATH}` }; + } + return { ok: true }; +} - process.exit(exitCode); - } catch (err) { - const msg = err instanceof Error ? err.message : String(err); - process.stderr.write(`× ${msg}\n`); - process.exit(1); - } - }); +export const shopsUiApp = defineDashboardApp({ + type: "ui", + key: "shops", + name: "Shops CZ", + description: "Launch the Shops dashboard web UI", + commandName: "ui", + aliases: ["dashboard"], + spawn: { + cmd: buildViteDevCmd({ configPath: CONFIG_PATH, strictPort: true }), + cwd: PROJECT_ROOT, + env: { SHOPS_PROJECT_CWD: process.cwd() }, + }, + preflight: async () => { + const check = preflight(); + if (check.ok) { + return { warnings: [] }; + } + return { warnings: [{ service: "shops", error: check.error ?? "preflight failed" }] }; + }, + readiness: { kind: "http", path: "/" }, + openBrowser: { enabled: true }, + launchd: { available: true }, +}); + +export function registerUiCommand(program: Command): void { + program.addCommand(shopsUiApp.commanderCommand); } diff --git a/src/shops/lib/ui-launcher.ts b/src/shops/lib/ui-launcher.ts deleted file mode 100644 index a0ded83dd4..0000000000 --- a/src/shops/lib/ui-launcher.ts +++ /dev/null @@ -1,78 +0,0 @@ -import { existsSync } from "node:fs"; -import { resolve } from "node:path"; -import { PROJECT_ROOT } from "@app/utils/paths"; - -export interface UiLauncherPaths { - uiDir: string; - configPath: string; - viteEntry: string; -} - -export interface UiLaunchOptions { - /** URL the dashboard listens on; opened in a browser ~2s after vite starts. */ - url?: string; - /** Override paths for testing. */ - paths?: Partial; -} - -const DEFAULT_URL = "http://localhost:3073"; - -export function resolveUiPaths(uiDirOverride?: string): UiLauncherPaths { - const uiDir = uiDirOverride ?? resolve(import.meta.dirname, "..", "ui"); - return { - uiDir, - configPath: resolve(uiDir, "vite.config.ts"), - viteEntry: resolve(PROJECT_ROOT, "node_modules", "vite", "bin", "vite.js"), - }; -} - -/** - * Launch the Shops dashboard via vite, open the browser, wait for vite to exit. - * Returns the vite process exit code (0 on clean exit). Throws if prerequisites - * (vite binary, vite config) are missing. - */ -export async function launchShopsDashboard(opts: UiLaunchOptions = {}): Promise { - const paths = { ...resolveUiPaths(), ...opts.paths }; - const url = opts.url ?? DEFAULT_URL; - - if (!existsSync(paths.viteEntry)) { - throw new Error(`Could not find vite at ${paths.viteEntry}. Run "bun install" in ${PROJECT_ROOT} first.`); - } - - if (!existsSync(paths.configPath)) { - throw new Error(`Vite config missing: ${paths.configPath}`); - } - - process.stdout.write(`Starting Shops dashboard at ${url} ...\n`); - process.stdout.write("(first start can take a few seconds; output below comes from Vite)\n\n"); - - const child = Bun.spawn(["bun", "--bun", paths.viteEntry, "dev", "-c", paths.configPath, "--strictPort"], { - cwd: PROJECT_ROOT, - stdio: ["inherit", "inherit", "inherit"], - env: { ...process.env, SHOPS_PROJECT_CWD: process.cwd() }, - }); - - const openTimer = setTimeout(() => { - openBrowser(url); - }, 2000); - - try { - return await child.exited; - } finally { - clearTimeout(openTimer); - } -} - -function openBrowser(url: string): void { - if (process.platform === "darwin") { - Bun.spawn(["open", url], { stdio: ["ignore", "ignore", "ignore"] }).unref(); - return; - } - - if (process.platform === "win32") { - Bun.spawn(["cmd", "/c", "start", "", url], { stdio: ["ignore", "ignore", "ignore"] }).unref(); - return; - } - - Bun.spawn(["xdg-open", url], { stdio: ["ignore", "ignore", "ignore"] }).unref(); -} diff --git a/src/utils/DashboardApp/commander.ts b/src/utils/DashboardApp/commander.ts new file mode 100644 index 0000000000..b643ce1ff7 --- /dev/null +++ b/src/utils/DashboardApp/commander.ts @@ -0,0 +1,162 @@ +/** + * Build the commander subcommand tree for a DashboardApp. + * + * Verb hierarchy: + * + * [verb] — verb defaults to a smart "what should I do" + * up --foreground --port --force --no-open + * down --force + * restart (same flags as up) + * status + * attach --lines + * logs --lines + * install (only if launchd.available) + * uninstall (only if launchd.available) + * + * Global flag on every verb AND the no-verb default: + * -i, --interactive force the menu + */ +import { Command } from "commander"; +import { attach, down, install, type LifecycleContext, logs, printStatus, restart, uninstall, up } from "./lifecycle"; +import type { DashboardAppConfig } from "./types"; + +interface BuildOptions { + config: DashboardAppConfig; + ctx: LifecycleContext; +} + +interface UpFlags { + foreground?: boolean; + port?: string; + force?: boolean; + open?: boolean; + interactive?: boolean; +} + +function parsePort(value: string): number { + const parsed = Number.parseInt(value, 10); + + if (!Number.isFinite(parsed) || parsed <= 0 || parsed > 65_535) { + throw new Error(`Invalid --port value: ${value}`); + } + + return parsed; +} + +function parseLines(value: string): number { + const parsed = Number.parseInt(value, 10); + + if (!Number.isFinite(parsed) || parsed <= 0) { + throw new Error(`Invalid --lines value: ${value}`); + } + + return parsed; +} + +function toUpOptions(flags: UpFlags) { + return { + foreground: flags.foreground, + port: flags.port ? parsePort(flags.port) : undefined, + force: flags.force, + open: flags.open, + interactive: flags.interactive, + }; +} + +export function buildCommanderCommand({ config, ctx }: BuildOptions): Command { + const cmd = new Command(config.commandName).description(config.description); + for (const alias of config.aliases ?? []) { + cmd.alias(alias); + } + + // Bare command (no verb) — smart default. Options are intentionally NOT + // duplicated from the `up` subcommand because Commander v14 silently drops + // options on a subcommand when the same names exist on the parent action. + // For explicit control use `up --foreground` (etc); the bare command runs + // with defaults (background, open=true). + cmd.option("-i, --interactive", "force the interactive menu even when the action is unambiguous").action( + async (flags: { interactive?: boolean }) => { + await up(ctx, { + interactive: flags.interactive, + open: config.type === "ui" ? (config.openBrowser?.enabled ?? true) : false, + }); + } + ); + + // `up` + cmd.command("up") + .description("Start (default: background). Use --foreground to block.") + .option("-f, --foreground", "run in the foreground (block until killed)") + .option("-p, --port ", "override the default port") + .option("--force", "kill any conflicting process before starting") + .option("--no-open", "do not auto-open the browser (UI apps only)") + .option("-i, --interactive", "force the menu") + .action(async (flags: UpFlags) => { + await up(ctx, toUpOptions(flags)); + }); + + // `down` + cmd.command("down") + .alias("stop") + .description("Stop the running instance.") + .option("--no-force", "do NOT escalate to SIGKILL after grace period") + .action(async (flags: { force?: boolean }) => { + await down(ctx, { force: flags.force }); + }); + + // `restart` + cmd.command("restart") + .description("Stop and start fresh.") + .option("-f, --foreground", "run in the foreground (block until killed)") + .option("-p, --port ", "override the default port") + .option("--force", "kill any conflicting process before starting") + .option("--no-open", "do not auto-open the browser (UI apps only)") + .option("-i, --interactive", "force the menu") + .action(async (flags: UpFlags) => { + await restart(ctx, toUpOptions(flags)); + }); + + // `status` + cmd.command("status") + .description("Print status: pid, port, uptime, dependencies, launchd state.") + .action(async () => { + await printStatus(ctx); + }); + + // `attach` + cmd.command("attach") + .description("Tail the background log. Ctrl+C detaches the tail (the process keeps running).") + .option("-n, --lines ", "how many trailing log lines to print first", parseLines, 50) + .action(async (flags: { lines?: number }) => { + await attach(ctx, { lines: flags.lines }); + }); + + // `logs` + cmd.command("logs") + .description("Print the last N lines of the background log.") + .option("-n, --lines ", "lines to print", parseLines, 200) + .action(async (flags: { lines?: number }) => { + await logs(ctx, { lines: flags.lines }); + }); + + // `install` / `uninstall` only when the app opts into launchd. + if (config.launchd?.available) { + cmd.command("install") + .description("Register a launchd plist so this dashboard survives reboot and respawns on crash.") + .option("--force", "kill any conflicting process before installing") + .option("-p, --port ", "override the default port") + .action(async (flags: { force?: boolean; port?: string }) => { + await install(ctx, { + force: flags.force, + port: flags.port ? parsePort(flags.port) : undefined, + }); + }); + cmd.command("uninstall") + .description("Remove the launchd plist registered by `install`.") + .action(async () => { + await uninstall(ctx); + }); + } + + return cmd; +} diff --git a/src/utils/DashboardApp/detach.ts b/src/utils/DashboardApp/detach.ts new file mode 100644 index 0000000000..8ce1228c03 --- /dev/null +++ b/src/utils/DashboardApp/detach.ts @@ -0,0 +1,65 @@ +/** + * Background-detach a child process. + * + * Ported from src/dev-dashboard/index.ts:255-310 (the `restart` verb). + * Key invariants: + * - `detached: true` so the child gets its own session group; SIGHUP to the + * parent shell does NOT cascade. + * - `stdio: ["ignore", fdLog, fdLog]` redirects child output to a file fd we + * open ahead of time and close in the parent after spawn — the kernel keeps + * the dup'd fd alive for the child. + * - `child.unref()` so the parent's event loop is free to exit immediately. + * + * This module does NOT register the PID file — that's lifecycle.ts's job, so + * detach can be reused for tests that don't want the side effects. + */ +import { type ChildProcess, spawn } from "node:child_process"; +import { closeSync, openSync } from "node:fs"; + +export interface DetachOptions { + cmd: readonly string[]; + cwd?: string; + env?: Record; + logFile: string; +} + +export interface DetachResult { + pid: number; +} + +export function spawnDetached(opts: DetachOptions): DetachResult { + const logFd = openSync(opts.logFile, "a"); + let child: ChildProcess; + + try { + child = spawn(opts.cmd[0], opts.cmd.slice(1), { + cwd: opts.cwd, + env: { ...process.env, ...filterUndefined(opts.env) }, + detached: true, + stdio: ["ignore", logFd, logFd], + }); + } finally { + closeSync(logFd); // child holds its own dup'd fd now + } + + child.unref(); + + if (!child.pid) { + throw new Error("Failed to spawn detached process — no PID returned"); + } + + return { pid: child.pid }; +} + +function filterUndefined(env: Record | undefined): Record { + if (!env) { + return {}; + } + const out: Record = {}; + for (const [k, v] of Object.entries(env)) { + if (v !== undefined) { + out[k] = v; + } + } + return out; +} diff --git a/src/utils/DashboardApp/index.ts b/src/utils/DashboardApp/index.ts new file mode 100644 index 0000000000..51af608e20 --- /dev/null +++ b/src/utils/DashboardApp/index.ts @@ -0,0 +1,124 @@ +/** + * Public API for the DashboardApp factory. + * + * Usage from a tool: + * + * import { defineDashboardApp } from "@app/utils/DashboardApp"; + * + * const app = defineDashboardApp({ + * type: "ui", + * key: "clarity", + * commandName: "ui", + * description: "Launch the Clarity dashboard", + * spawn: { cmd: buildViteDevCmd({ configPath, strictPort: true }), cwd: PROJECT_ROOT }, + * bindHost: "127.0.0.1", // default; use "0.0.0.0" for LAN/tunnel (see dev-dashboard) + * readiness: { kind: "http" }, + * openBrowser: { enabled: true }, + * launchd: { available: true }, + * }); + * + * parentCommand.addCommand(app.commanderCommand); + * + * See the DashboardApp design notes in the repo docs for the full design. + */ +import { DASHBOARDS } from "@app/utils/ui/dashboards"; +import { buildCommanderCommand } from "./commander"; +import { + attach as attachLifecycle, + buildLifecycleContext, + down as downLifecycle, + install as installLifecycle, + logs as logsLifecycle, + restart as restartLifecycle, + status as statusLifecycle, + uninstall as uninstallLifecycle, + up as upLifecycle, +} from "./lifecycle"; +import { logFilePath, pidFilePath } from "./pidFile"; +import type { + AttachOptions, + DashboardApp, + DashboardAppConfig, + DownOptions, + DownResult, + InstallOptions, + StatusResult, + UpOptions, + UpResult, +} from "./types"; + +export type { + AttachOptions, + DashboardApp, + DashboardAppConfig, + DashboardAppType, + DashboardBindHost, + DashboardDependency, + DependencyPolicy, + DependencyStatus, + DownOptions, + DownResult, + InstallOptions, + PreflightWarning, + ReadinessProbe, + StatusResult, + UpOptions, + UpResult, +} from "./types"; +export type { ViteDevCmdOptions } from "./viteSpawn"; +export { buildViteDevCmd, DEFAULT_BIND_HOST, resolveViteEntry } from "./viteSpawn"; + +function resolvePort(config: DashboardAppConfig): number { + if (typeof config.port === "number") { + return config.port; + } + if (config.type === "ui") { + const entry = (DASHBOARDS as Record)[config.key]; + if (entry) { + return entry.port; + } + } + throw new Error( + `DashboardApp "${config.key}": no port resolved. Pass \`port\` explicitly or register the key in src/utils/ui/dashboards.ts.` + ); +} + +export function defineDashboardApp(config: DashboardAppConfig): DashboardApp { + const port = resolvePort(config); + const ctx = buildLifecycleContext(config, port); + const commanderCommand = buildCommanderCommand({ config, ctx }); + + const app: DashboardApp = { + config: Object.freeze({ ...config }), + port, + pidFile: pidFilePath(config.key), + logFile: logFilePath(config.key), + commanderCommand, + up(opts?: UpOptions): Promise { + return upLifecycle(ctx, opts); + }, + down(opts?: DownOptions): Promise { + return downLifecycle(ctx, opts); + }, + restart(opts?: UpOptions): Promise { + return restartLifecycle(ctx, opts); + }, + status(): Promise { + return statusLifecycle(ctx); + }, + attach(opts?: AttachOptions): Promise { + return attachLifecycle(ctx, opts); + }, + logs(opts?: { lines?: number }): Promise { + return logsLifecycle(ctx, opts); + }, + async install(opts?: InstallOptions): Promise { + await installLifecycle(ctx, opts); + }, + async uninstall(): Promise { + await uninstallLifecycle(ctx); + }, + }; + + return app; +} diff --git a/src/utils/DashboardApp/launchd.ts b/src/utils/DashboardApp/launchd.ts new file mode 100644 index 0000000000..dabbdb29c0 --- /dev/null +++ b/src/utils/DashboardApp/launchd.ts @@ -0,0 +1,249 @@ +/** + * Launchd integration for DashboardApp (macOS only, opt-in). + * + * Writes/removes a plist under ~/Library/LaunchAgents and runs `launchctl + * load|unload` to register the daemon with the per-user launchd. Matches the + * shape used by `src/youtube/lib/server/launchd.ts` but parameterized. + * + * The plist sets `KeepAlive: true` + `RunAtLoad: true` so the daemon survives + * reboots and respawns on crash. The DashboardApp `down` verb explicitly + * unloads the plist before SIGTERM so the user's intent to stop isn't + * defeated by launchd respawning the process immediately. + */ +import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join, resolve } from "node:path"; + +const LAUNCH_AGENTS_DIR = join(homedir(), "Library", "LaunchAgents"); + +export function defaultPlistLabel(key: string): string { + return `com.genesis-tools.${key}`; +} + +export function plistPath(label: string): string { + return join(LAUNCH_AGENTS_DIR, `${label}.plist`); +} + +export function isLaunchdInstalled(label: string): boolean { + if (process.platform !== "darwin") { + return false; + } + return existsSync(plistPath(label)); +} + +export interface LaunchdInstallOptions { + label: string; + command: readonly string[]; + cwd?: string; + env?: Record; + logFile: string; +} + +/** Resolve bare executable names and inject a launchd-safe PATH (no shell profile). */ +export function resolveCommandForLaunchd( + command: readonly string[], + cwd?: string +): { + command: string[]; + env: Record; +} { + const workdir = cwd ?? process.cwd(); + const resolved = [...command]; + const first = resolved[0]; + + if (first && !first.includes("/")) { + const abs = Bun.which(first) ?? (first === "bun" ? process.execPath : undefined); + + if (abs) { + resolved[0] = abs; + } + } + + for (let i = 1; i < resolved.length; i++) { + const arg = resolved[i]; + + if (!arg || arg.startsWith("-") || arg.startsWith("/")) { + continue; + } + + if (arg.includes("/")) { + resolved[i] = resolve(workdir, arg); + } + } + + const binDir = dirname(resolved[0] ?? "/usr/bin"); + const env: Record = { + HOME: homedir(), + PATH: `/usr/local/bin:/opt/homebrew/bin:${binDir}:/usr/bin:/bin:/usr/sbin:/sbin`, + }; + + return { command: resolved, env }; +} + +export async function bootoutLaunchd(label: string): Promise { + if (process.platform !== "darwin") { + return; + } + + const path = plistPath(label); + + if (!existsSync(path)) { + return; + } + + const uid = process.getuid?.(); + + if (uid !== undefined) { + await launchctl(["bootout", `gui/${uid}`, path]).catch(() => undefined); + } + + await launchctl(["unload", path]).catch(() => undefined); +} + +export async function installLaunchd(opts: LaunchdInstallOptions): Promise { + if (process.platform !== "darwin") { + throw new Error("Launchd integration is macOS-only."); + } + + writeLaunchdPlist(opts); + + const path = plistPath(opts.label); + + // Load it. Idempotent: if already loaded, unload first. + await launchctl(["unload", path]).catch(() => undefined); + const { exitCode, stderr } = await launchctl(["load", path]); + + if (exitCode !== 0) { + throw new Error( + `launchctl load ${path} failed with exit code ${exitCode}${stderr.trim() ? `: ${stderr.trim()}` : ""}` + ); + } + + await kickstartLaunchd(opts.label); +} + +/** Rewrite plist on disk — used when refreshing env/cmd/throttle without full reinstall. */ +export function writeLaunchdPlist(opts: LaunchdInstallOptions): void { + if (process.platform !== "darwin") { + throw new Error("Launchd integration is macOS-only."); + } + + const path = plistPath(opts.label); + const { command: resolvedCommand, env: baseEnv } = resolveCommandForLaunchd(opts.command, opts.cwd); + const mergedEnv: Record = { ...baseEnv }; + + for (const [key, value] of Object.entries(opts.env ?? {})) { + if (value !== undefined) { + mergedEnv[key] = value; + } + } + + const programArgs = resolvedCommand.map((arg) => ` ${escapeXml(arg)}`).join("\n"); + const envEntries = Object.entries(mergedEnv) + .map(([key, value]) => ` ${escapeXml(key)}\n ${escapeXml(value)}`) + .join("\n"); + const cwdBlock = opts.cwd ? ` WorkingDirectory\n ${escapeXml(opts.cwd)}\n` : ""; + + const plist = ` + + + + Label + ${escapeXml(opts.label)} + ProgramArguments + +${programArgs} + +${cwdBlock} RunAtLoad + + KeepAlive + + ThrottleInterval + 2 + StandardOutPath + ${escapeXml(opts.logFile)} + StandardErrorPath + ${escapeXml(opts.logFile)} + EnvironmentVariables + +${envEntries} + + + +`; + + mkdirSync(LAUNCH_AGENTS_DIR, { recursive: true }); + writeFileSync(path, plist); +} + +export async function refreshLaunchd(opts: LaunchdInstallOptions): Promise { + if (process.platform !== "darwin") { + throw new Error("Launchd integration is macOS-only."); + } + + writeLaunchdPlist(opts); + + const path = plistPath(opts.label); + await launchctl(["load", path]).catch(() => undefined); + await kickstartLaunchd(opts.label); +} + +export async function kickstartLaunchd(label: string): Promise { + if (process.platform !== "darwin") { + return; + } + + const uid = process.getuid?.(); + + if (uid === undefined) { + return; + } + + await launchctl(["kickstart", "-k", `gui/${uid}/${label}`]).catch(() => undefined); +} + +/** Load plist if needed and kickstart — faster than rewriting the plist on restart. */ +export async function startLaunchd(label: string): Promise { + if (process.platform !== "darwin") { + throw new Error("Launchd integration is macOS-only."); + } + + const path = plistPath(label); + + if (!existsSync(path)) { + throw new Error(`launchd plist missing: ${path}`); + } + + const loaded = await launchctl(["load", path]); + + if (loaded.exitCode !== 0) { + throw new Error( + `launchctl load ${path} failed with exit code ${loaded.exitCode}${loaded.stderr.trim() ? `: ${loaded.stderr.trim()}` : ""}` + ); + } + + await kickstartLaunchd(label); +} + +export async function uninstallLaunchd(label: string): Promise { + if (process.platform !== "darwin") { + return; + } + + const path = plistPath(label); + if (existsSync(path)) { + await launchctl(["unload", path]).catch(() => undefined); + unlinkSync(path); + } +} + +async function launchctl(args: readonly string[]): Promise<{ exitCode: number; stderr: string }> { + const proc = Bun.spawn(["launchctl", ...args], { stdout: "pipe", stderr: "pipe" }); + const stderr = await new Response(proc.stderr).text(); + const exitCode = await proc.exited; + return { exitCode, stderr }; +} + +function escapeXml(s: string): string { + return s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} diff --git a/src/utils/DashboardApp/lifecycle.browser.test.ts b/src/utils/DashboardApp/lifecycle.browser.test.ts new file mode 100644 index 0000000000..ac76fa839d --- /dev/null +++ b/src/utils/DashboardApp/lifecycle.browser.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, test } from "bun:test"; +import { shouldOpenBrowser } from "./lifecycle"; +import type { DashboardAppConfig } from "./types"; + +function uiConfig(enabled: boolean): DashboardAppConfig { + return { + type: "ui", + key: "test", + description: "test", + commandName: "ui", + openBrowser: { enabled }, + spawn: { cmd: ["true"] }, + }; +} + +describe("shouldOpenBrowser", () => { + test("honors openBrowser.enabled when commander defaults open to true", () => { + expect(shouldOpenBrowser(uiConfig(false), { open: true })).toBe(false); + expect(shouldOpenBrowser(uiConfig(true), { open: true })).toBe(true); + }); + + test("--no-open suppresses even when app enables auto-open", () => { + expect(shouldOpenBrowser(uiConfig(true), { open: false })).toBe(false); + }); + + test("defaults to false when openBrowser is unset", () => { + const config: DashboardAppConfig = { + type: "ui", + key: "test", + description: "test", + commandName: "ui", + spawn: { cmd: ["true"] }, + }; + + expect(shouldOpenBrowser(config, { open: true })).toBe(false); + }); +}); diff --git a/src/utils/DashboardApp/lifecycle.ts b/src/utils/DashboardApp/lifecycle.ts new file mode 100644 index 0000000000..4314103423 --- /dev/null +++ b/src/utils/DashboardApp/lifecycle.ts @@ -0,0 +1,802 @@ +/** + * DashboardApp lifecycle orchestration — the `up` / `down` / `restart` / + * `status` / `attach` / `logs` / `install` / `uninstall` operations. + * + * Each operation is a free function so it can be tested in isolation against + * a synthetic DashboardApp instance. `commander.ts` wires verbs to these + * functions; the imperative `DashboardApp` API on `types.ts` does the same. + */ +import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; +import { logger, out } from "@app/logger"; +import { Browser } from "@app/utils/browser"; +import { suggestCommand } from "@app/utils/cli"; +import { getPortOwner } from "@app/utils/network"; +import { spawnDashboard } from "@app/utils/process/spawnDashboard"; +import { stripAnsi } from "@app/utils/string"; +import { spawnDetached } from "./detach"; +import { + bootoutLaunchd, + defaultPlistLabel, + installLaunchd, + isLaunchdInstalled, + refreshLaunchd, + uninstallLaunchd, +} from "./launchd"; +import { printDevServerBanner, readLogTail, resetLogFile } from "./logSession"; +import { + describeConflict, + promptDependencyStart, + promptForeignMenu, + promptLaunchdInstall, + promptMineMenu, +} from "./menu"; +import { clearPid, logFilePath, pidFilePath, pidFileStartTime, readPid, writePid } from "./pidFile"; +import { checkPortConflict, killPortOwner, waitForPortFree } from "./portConflict"; +import { readPreferences, writePreferences } from "./preferences"; +import { waitForReady, waitForUrlReady } from "./readiness"; +import type { + DashboardAppConfig, + DependencyStatus, + DownOptions, + DownResult, + InstallOptions, + PreflightWarning, + StatusResult, + UpOptions, + UpResult, +} from "./types"; +import { DEFAULT_BIND_HOST } from "./viteSpawn"; + +export interface LifecycleContext { + config: DashboardAppConfig; + port: number; + pidFile: string; + logFile: string; + plistLabel: string; +} + +export function buildLifecycleContext(config: DashboardAppConfig, resolvedPort: number): LifecycleContext { + return { + config, + port: resolvedPort, + pidFile: pidFilePath(config.key), + logFile: logFilePath(config.key), + plistLabel: config.launchd?.label ?? defaultPlistLabel(config.key), + }; +} + +function spawnEnv(config: DashboardAppConfig): Record { + return { + ...config.spawn.env, + ...(config.type === "ui" + ? { + FORCE_COLOR: "1", + BROWSER: "none", + DASHBOARD_BIND_HOST: config.bindHost ?? DEFAULT_BIND_HOST, + } + : {}), + }; +} + +export async function up(ctx: LifecycleContext, opts: UpOptions = {}): Promise { + const { config } = ctx; + const port = opts.port ?? ctx.port; + + await runPreflight(ctx); + await resolveDependencies(ctx, opts, "up"); + + // 3. Port conflict check — reclaim stale orphans, prompt on foreign holders. + const portReady = await preparePort(ctx, port, opts); + + if (!portReady.proceed) { + return portReady.result; + } + + // 4. Launchd first-run prompt (TTY only, opt-in apps only). + if ( + config.launchd?.available && + !opts.foreground && + !opts.skipInstallPrompt && + !readPreferences(config.key).launchdInstalled && + !readPreferences(config.key).launchdPromptDismissed + ) { + const wantInstall = await promptLaunchdInstall(config.key); + + if (wantInstall === true) { + return finishLaunchdStart(ctx, port, opts); + } + + if (wantInstall === false) { + writePreferences(config.key, { launchdPromptDismissed: true }); + } + } + + // If launchd is already installed, prefer launchd over a duplicate manual spawn. + if (config.launchd?.available && !opts.foreground && isLaunchdInstalled(ctx.plistLabel)) { + return finishLaunchdStart(ctx, port, { ...opts, skipInstallPrompt: true }); + } + + // 5. Spawn — foreground OR background. + const mode: "foreground" | "background" = opts.foreground ? "foreground" : "background"; + + if (mode === "foreground") { + // spawnDashboard blocks until the child exits and handles signals + orphan-detection. + writePid(config.key, process.pid); + try { + const exitCode = await spawnDashboard({ + cmd: [...config.spawn.cmd], + cwd: config.spawn.cwd, + env: { + ...spawnEnv(config), + ...(shouldOpenBrowser(config, opts) ? { DASHBOARD_OPEN_BROWSER: "1" } : {}), + }, + }); + clearPid(config.key); + process.exit(exitCode); + } catch (err) { + clearPid(config.key); + throw err; + } + } + + // Background: detached spawn with stdio → logfile. + resetLogFile(config.key); + const { pid } = spawnDetached({ + cmd: [...config.spawn.cmd], + cwd: config.spawn.cwd, + env: spawnEnv(config), + logFile: ctx.logFile, + }); + writePid(config.key, pid); + + const readiness = await waitForReady(config.readiness, { port, logFile: ctx.logFile }); + if (!readiness.ready) { + out.warn( + `Readiness check failed: ${readiness.detail ?? "(no detail)"}\n Started anyway — tail the log: tools ${config.key} ${config.commandName} attach` + ); + } else { + out.log.success( + `${config.name ?? config.key} ready on http://localhost:${port} (pid ${pid})\n logs → ${ctx.logFile}` + ); + + if (config.type === "ui") { + await Bun.sleep(400); + printDevServerBanner(ctx.logFile, port, { bindHost: config.bindHost }); + } + } + + // Browser-open (UI only) — wait until the URL actually serves (not 502 gateway). + if (config.type === "ui" && shouldOpenBrowser(config, opts)) { + await openBrowserWhenReady(config, port); + } + + return { started: true, port, mode, pid, logPath: ctx.logFile }; +} + +type PreparePortResult = { proceed: true } | { proceed: false; result: UpResult }; + +async function preparePort(ctx: LifecycleContext, port: number, opts: UpOptions): Promise { + const { config } = ctx; + const conflict = await checkPortConflict(config.key, port); + logger.debug({ conflict: describeConflict(conflict) }, `[${config.key}] port conflict check`); + + if (conflict.state === "free") { + return { proceed: true }; + } + + if (conflict.state === "mine") { + if (opts.force || opts.replaceRunning) { + out.log.step(`Stopping running instance (pid ${conflict.pid}) before start`); + await down(ctx, { force: true }); + await waitForPortFree(port, 5_000, { killIfHeld: true, dashboardKey: config.key }); + return { proceed: true }; + } + + const result = await handleMineMenu(ctx, conflict.pid, opts, port); + return { proceed: false, result }; + } + + if (conflict.state === "stale") { + out.log.step(`Reclaiming port ${port} from stale pid ${conflict.owner.pid} (${conflict.owner.command})`); + await killPortOwner(conflict.owner); + clearPid(config.key); + + const freed = await waitForPortFree(port, 5_000, { + killIfHeld: true, + expectOwnerPid: conflict.owner.pid, + }); + + if (!freed) { + out.error(`Port ${port} still held after reclaiming stale pid ${conflict.owner.pid}`); + return { + proceed: false, + result: { started: false, port, mode: opts.foreground ? "foreground" : "background" }, + }; + } + + return { proceed: true }; + } + + if (opts.force && conflict.owner) { + out.log.step(`Killing port owner pid ${conflict.owner.pid} (${conflict.owner.command})`); + await killPortOwner(conflict.owner); + clearPid(config.key); + await waitForPortFree(port, 5_000, { + killIfHeld: true, + expectOwnerPid: conflict.owner.pid, + sameUserOnly: conflict.owner.sameUser, + }); + return { proceed: true }; + } + + const owner = conflict.owner; + const ownerDesc = owner ? `pid ${owner.pid} (${owner.command})` : "(unknown owner)"; + out.error(`Port ${port} is held by ${ownerDesc}.`); + + if (owner) { + const choice = await promptForeignMenu(port, owner.pid, owner.command, owner.sameUser); + + if (choice === "kill-and-up") { + await killPortOwner(owner); + clearPid(config.key); + await waitForPortFree(port, 5_000, { + killIfHeld: true, + expectOwnerPid: owner.pid, + sameUserOnly: owner.sameUser, + }); + return { proceed: true }; + } + + if (choice === null) { + out.info(` Use --force to kill the owner and start: ${suggestCommand("tools", { add: ["--force"] })}`); + } + } + + return { + proceed: false, + result: { started: false, port, mode: opts.foreground ? "foreground" : "background" }, + }; +} + +async function finishLaunchdStart(ctx: LifecycleContext, port: number, opts: UpOptions): Promise { + const { config } = ctx; + const newlyInstalled = !isLaunchdInstalled(ctx.plistLabel); + + resetLogFile(config.key); + + if (newlyInstalled) { + await installLaunchd({ + label: ctx.plistLabel, + command: config.spawn.cmd, + cwd: config.spawn.cwd, + env: spawnEnv(config), + logFile: ctx.logFile, + }); + writePreferences(config.key, { launchdInstalled: true, launchdPromptDismissed: false }); + out.log.success(`Launchd plist installed at ~/Library/LaunchAgents/${ctx.plistLabel}.plist`); + } else { + const portHeld = await getPortOwner(port); + + if (portHeld) { + out.log.step(`Restarting launchd agent ${ctx.plistLabel}…`); + await bootoutLaunchd(ctx.plistLabel).catch((err) => { + logger.warn({ err }, `[${config.key}] launchd bootout failed`); + }); + out.log.step(`Waiting for port ${port} to free…`); + await waitForPortFree(port, 5_000, { killIfHeld: true, dashboardKey: config.key }); + } + + out.log.step(`Starting launchd agent ${ctx.plistLabel}…`); + await refreshLaunchd({ + label: ctx.plistLabel, + command: config.spawn.cmd, + cwd: config.spawn.cwd, + env: spawnEnv(config), + logFile: ctx.logFile, + }); + } + + out.log.step(`Waiting for ${config.name ?? config.key} to respond on :${port}…`); + const ok = await waitForReady(config.readiness, { port, logFile: ctx.logFile }); + const owner = ok.ready ? await getPortOwner(port) : null; + const pid = owner?.pid ?? readPid(config.key) ?? undefined; + + if (owner?.pid) { + writePid(config.key, owner.pid); + } + + if (ok.ready) { + out.log.success( + `${config.name ?? config.key} ready on http://localhost:${port}${pid ? ` (pid ${pid})` : ""} · launchd\n logs → ${ctx.logFile}` + ); + + if (config.type === "ui") { + await Bun.sleep(400); + printDevServerBanner(ctx.logFile, port, { bindHost: config.bindHost }); + } + + if (config.type === "ui" && shouldOpenBrowser(config, opts)) { + await openBrowserWhenReady(config, port); + } + } else { + out.warn( + `Launchd agent did not become ready: ${ok.detail ?? "unknown"}\n Check: launchctl print gui/$UID/${ctx.plistLabel}\n Log: ${ctx.logFile}` + ); + } + + return { + started: ok.ready, + port, + mode: "background", + pid, + logPath: ctx.logFile, + }; +} + +async function handleMineMenu( + ctx: LifecycleContext, + pid: number, + opts: UpOptions, + effectivePort: number +): Promise { + const { config } = ctx; + const port = effectivePort; + const choice = await promptMineMenu(port, pid, { canOpen: config.type === "ui" }); + + if (choice === null) { + // Non-TTY: print the verbs and exit. + out.info( + `Already running (pid ${pid} on :${port}). Try one of:\n` + + ` tools ${config.commandName} restart\n` + + ` tools ${config.commandName} attach\n` + + ` tools ${config.commandName} status\n` + + ` tools ${config.commandName} down` + ); + return { started: false, port, mode: opts.foreground ? "foreground" : "background" }; + } + + if (choice === "restart") { + await down(ctx, {}); + return up(ctx, { ...opts, interactive: false, open: opts.open ?? config.openBrowser?.enabled }); + } + if (choice === "down") { + await down(ctx, {}); + return { started: false, port, mode: "background" }; + } + if (choice === "attach") { + await attach(ctx, {}); + return { started: false, port, mode: "background", pid }; + } + if (choice === "status") { + await printStatus(ctx); + return { started: false, port, mode: "background", pid }; + } + if (choice === "open") { + if (config.type === "ui") { + await openBrowserWhenReady(config, port); + } + return { started: false, port, mode: "background", pid }; + } + return { started: false, port, mode: "background" }; +} + +export async function down(ctx: LifecycleContext, opts: DownOptions = {}): Promise { + const { config } = ctx; + const portOwner = await getPortOwner(ctx.port); + const filePid = readPid(config.key); + const targetPid = portOwner?.pid ?? filePid; + const launchdManaged = Boolean(config.launchd?.available && isLaunchdInstalled(ctx.plistLabel)); + let didBootout = false; + + if (!targetPid && !launchdManaged) { + out.info(`${config.name ?? config.key} is not running.`); + clearPid(config.key); + return { stopped: false }; + } + + const aliveBeforeStop = targetPid !== null && isProcessAlive(targetPid); + + if (launchdManaged) { + out.log.step(`Stopping launchd agent ${ctx.plistLabel}…`); + await bootoutLaunchd(ctx.plistLabel).catch((err) => { + logger.warn({ err }, `[${config.key}] launchd bootout failed`); + }); + didBootout = true; + } + + const pid = (await getPortOwner(ctx.port))?.pid ?? targetPid; + + if (!pid) { + clearPid(config.key); + out.log.success(`${config.name ?? config.key} stopped${didBootout ? " (launchd agent unloaded)" : ""}.`); + return { stopped: true }; + } + + if (isProcessAlive(pid)) { + try { + process.kill(pid, "SIGTERM"); + } catch (err) { + logger.warn({ err, pid }, `[${config.key}] SIGTERM failed`); + } + + out.log.step(`Waiting for ${config.name ?? config.key} (pid ${pid}) to exit…`); + const force = opts.force ?? true; + const gracePeriodMs = 5_000; + const deadline = Date.now() + gracePeriodMs; + + while (Date.now() < deadline) { + if (!isProcessAlive(pid)) { + break; + } + + await Bun.sleep(200); + } + + if (isProcessAlive(pid) && force) { + try { + process.kill(pid, "SIGKILL"); + } catch (err) { + logger.warn({ err, pid }, `[${config.key}] SIGKILL failed`); + } + + await Bun.sleep(500); + } + + if (isProcessAlive(pid)) { + out.error(`Failed to stop pid ${pid}; it may need manual cleanup.`); + return { stopped: false, pid }; + } + } + + if (await getPortOwner(ctx.port)) { + out.log.step(`Waiting for port ${ctx.port} to free…`); + await waitForPortFree(ctx.port, 2_000, { killIfHeld: true, dashboardKey: config.key }); + } + + clearPid(config.key); + + if (didBootout) { + out.log.success(`${config.name ?? config.key} stopped (pid ${pid})`); + } else if (!aliveBeforeStop) { + out.log.success(`${config.name ?? config.key} stopped (pid ${pid} was not running; cleaned up stale state)`); + } else { + out.log.success(`${config.name ?? config.key} stopped (pid ${pid})`); + } + + return { stopped: true, pid }; +} + +export async function restart(ctx: LifecycleContext, opts: UpOptions = {}): Promise { + await down(ctx, {}); + return up(ctx, opts); +} + +export async function status(ctx: LifecycleContext): Promise { + const { config, port } = ctx; + let pid = readPid(config.key); + let running = pid !== null && isProcessAlive(pid); + + if (!running) { + const owner = await getPortOwner(port); + + if (owner?.pid && isProcessAlive(owner.pid)) { + pid = owner.pid; + running = true; + writePid(config.key, owner.pid); + } + } + + const startTime = pid ? pidFileStartTime(config.key) : null; + + const dependencies: DependencyStatus[] = []; + for (const dep of config.dependencies ?? []) { + const depStatus = await dep.app.status(); + dependencies.push({ + key: depStatus.key, + running: depStatus.running, + pid: depStatus.pid, + port: depStatus.port, + }); + } + + let preflightWarnings: PreflightWarning[] = []; + if (config.preflight) { + try { + const { warnings } = await config.preflight(); + preflightWarnings = warnings; + } catch (err) { + logger.debug({ err }, `[${config.key}] preflight threw during status`); + } + } + + return { + key: config.key, + type: config.type, + running, + pid: pid ?? undefined, + port, + uptimeMs: pid && startTime ? Date.now() - startTime.getTime() : undefined, + logPath: ctx.logFile, + launchdAvailable: Boolean(config.launchd?.available), + launchdInstalled: config.launchd?.available ? isLaunchdInstalled(ctx.plistLabel) : false, + dependencies, + preflightWarnings, + }; +} + +export async function printStatus(ctx: LifecycleContext): Promise { + const s = await status(ctx); + const lines: string[] = []; + lines.push(`${s.key} (${s.type}): ${s.running ? `running · pid ${s.pid}` : "not running"}`); + lines.push(` port: ${s.port}`); + if (s.running && s.uptimeMs) { + lines.push(` uptime: ${formatDuration(s.uptimeMs)}`); + } + if (s.launchdAvailable) { + lines.push(` launchd: ${s.launchdInstalled ? "installed" : "not installed"}`); + } + lines.push(` log: ${s.logPath}`); + for (const dep of s.dependencies) { + lines.push(` dep ${dep.key}: ${dep.running ? `running · pid ${dep.pid}` : "not running"} (port ${dep.port})`); + } + for (const w of s.preflightWarnings) { + lines.push(` ⚠ ${w.service}: ${w.error}${w.fix ? ` (fix: ${w.fix})` : ""}`); + } + out.println(lines.join("\n")); +} + +function formatDuration(ms: number): string { + if (ms < 1_000) { + return `${ms}ms`; + } + if (ms < 60_000) { + return `${(ms / 1_000).toFixed(1)}s`; + } + if (ms < 3_600_000) { + return `${(ms / 60_000).toFixed(1)}m`; + } + return `${(ms / 3_600_000).toFixed(1)}h`; +} + +export async function attach(ctx: LifecycleContext, opts: { lines?: number } = {}): Promise { + const { logFile } = ctx; + if (!existsSync(logFile)) { + out.info(`No log file at ${logFile} — nothing to attach to.`); + return; + } + + const tail = readLogTail(logFile, opts.lines ?? 50, true); + const isTty = Boolean(process.stdout.isTTY); + out.print(isTty ? tail : stripAnsi(tail)); + + if (!tail.endsWith("\n")) { + out.print("\n"); + } + + out.info(`\n--- Attached (Ctrl+C to detach the tail; the process keeps running) ---`); + + let pos = statSync(logFile).size; + + let interrupted = false; + const handler = () => { + interrupted = true; + }; + process.on("SIGINT", handler); + + try { + while (!interrupted) { + await Bun.sleep(150); + if (!existsSync(logFile)) { + continue; + } + const size = statSync(logFile).size; + if (size <= pos) { + continue; + } + const fd = openSync(logFile, "r"); + try { + const buf = Buffer.alloc(size - pos); + const read = readSync(fd, buf, 0, buf.length, pos); + pos += read; + const chunk = buf.subarray(0, read).toString(); + out.print(isTty ? chunk : stripAnsi(chunk)); + } finally { + closeSync(fd); + } + } + } finally { + process.off("SIGINT", handler); + } +} + +export async function logs(ctx: LifecycleContext, opts: { lines?: number; sessionOnly?: boolean } = {}): Promise { + const { logFile } = ctx; + + if (!existsSync(logFile)) { + out.info(`No log file at ${logFile}.`); + return; + } + + const sessionOnly = opts.sessionOnly ?? true; + const tail = readLogTail(logFile, opts.lines ?? 200, sessionOnly); + const isTty = Boolean(process.stdout.isTTY); + out.print(isTty ? tail : stripAnsi(tail)); + + if (tail.length > 0 && !tail.endsWith("\n")) { + out.print("\n"); + } +} + +export function shouldOpenBrowser(config: DashboardAppConfig, opts: UpOptions): boolean { + if (config.type !== "ui") { + return false; + } + + if (opts.open === false) { + return false; + } + + // Commander's `--no-open` negatable flag defaults `open` to true; per-app + // `openBrowser.enabled` is the source of truth (dev-dashboard: false). + return config.openBrowser?.enabled ?? false; +} + +async function openBrowserWhenReady(config: DashboardAppConfig, port: number): Promise { + const browserUrl = config.openBrowser?.url ? config.openBrowser.url(port) : `http://localhost:${port}`; + const ready = await waitForUrlReady(browserUrl, 20_000); + + if (!ready.ready) { + logger.warn({ browserUrl, detail: ready.detail }, `[${config.key}] browser open skipped — URL not ready`); + out.warn( + `Skipping browser open — page not ready yet (${ready.detail ?? "timeout"})\n Open manually: ${browserUrl}` + ); + return; + } + + await Browser.open(browserUrl).catch((err) => { + logger.warn({ err }, `[${config.key}] browser open failed`); + }); +} + +export async function install(ctx: LifecycleContext, opts: InstallOptions = {}): Promise { + const { config } = ctx; + const port = opts.port ?? ctx.port; + + if (!config.launchd?.available) { + throw new Error(`${config.key} does not opt into launchd integration.`); + } + + if (process.platform !== "darwin") { + throw new Error("Launchd integration is macOS-only."); + } + + await runPreflight(ctx); + await resolveDependencies(ctx, { ...opts, open: false, skipInstallPrompt: true, replaceRunning: true }, "install"); + + const portReady = await preparePort(ctx, port, { + force: opts.force, + replaceRunning: true, + open: false, + skipInstallPrompt: true, + }); + + if (!portReady.proceed) { + throw new Error(`${config.name ?? config.key}: port ${port} is still in use.`); + } + + const result = await finishLaunchdStart(ctx, port, { open: false, skipInstallPrompt: true }); + + if (!result.started) { + throw new Error( + `${config.name ?? config.key} launchd install failed — not ready on :${port}. See ${ctx.logFile}` + ); + } +} + +async function runPreflight(ctx: LifecycleContext): Promise { + const { config } = ctx; + + if (!config.preflight) { + return; + } + + try { + const { warnings } = await config.preflight(); + + for (const w of warnings) { + logger.warn({ service: w.service, fix: w.fix }, w.error); + out.warn(`${w.service}: ${w.error}${w.fix ? `\n Fix: ${w.fix}` : ""}`); + } + } catch (err) { + logger.warn({ err }, `[${config.key}] preflight threw`); + } +} + +type StartMode = "up" | "install"; + +async function resolveDependencies(ctx: LifecycleContext, opts: UpOptions, mode: StartMode): Promise { + const { config } = ctx; + + for (const dep of config.dependencies ?? []) { + const depStatus = await dep.app.status(); + + if (depStatus.running) { + continue; + } + + const startDep = async () => { + if (mode === "install" && dep.app.config.launchd?.available) { + await dep.app.install({ force: opts.force }); + return; + } + + await dep.app.up({ + open: false, + force: opts.force, + skipInstallPrompt: true, + replaceRunning: opts.replaceRunning, + }); + }; + + if (dep.policy === "auto") { + out.log.step(`${mode === "install" ? "Installing" : "Starting"} dependency ${dep.app.config.key}...`); + await startDep(); + continue; + } + + if (dep.policy === "warn") { + out.warn(`Dependency ${dep.app.config.key} is not running.`); + continue; + } + + if (mode === "install") { + out.log.step(`Installing dependency ${dep.app.config.key}...`); + await startDep(); + continue; + } + + const choice = await promptDependencyStart(dep.app.config.key, config.key); + + if (choice === "abort") { + throw new Error(`Aborted: dependency ${dep.app.config.key} is required for ${config.key}`); + } + + if (choice === "start") { + await startDep(); + } else if (choice === null) { + out.warn( + `Dependency ${dep.app.config.key} is not running. Run \`tools ${dep.app.config.commandName} up\` on the dependency to start it.` + ); + } + } +} + +export async function uninstall(ctx: LifecycleContext): Promise { + await bootoutLaunchd(ctx.plistLabel).catch((err) => { + logger.warn({ err }, `[${ctx.config.key}] launchd bootout failed`); + }); + + const owner = await getPortOwner(ctx.port); + + if (owner?.pid && isProcessAlive(owner.pid)) { + await killPortOwner(owner); + } + + clearPid(ctx.config.key); + await uninstallLaunchd(ctx.plistLabel); + writePreferences(ctx.config.key, { launchdInstalled: false }); + out.log.success(`Launchd plist removed.`); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (err) { + logger.debug({ err, pid }, "process liveness probe failed"); + return false; + } +} + +// Re-export the imperative interface assembled by `index.ts`. +export type { DashboardApp } from "./types"; diff --git a/src/utils/DashboardApp/logSession.ts b/src/utils/DashboardApp/logSession.ts new file mode 100644 index 0000000000..9924c14e98 --- /dev/null +++ b/src/utils/DashboardApp/logSession.ts @@ -0,0 +1,113 @@ +import { closeSync, existsSync, openSync, readFileSync, readSync, statSync, writeFileSync } from "node:fs"; +import { out } from "@app/logger"; +import { getLocalIpv4 } from "@app/utils/network"; +import { stripAnsi } from "@app/utils/string"; +import pc from "picocolors"; +import { ensureLogFile } from "./pidFile"; +import type { DashboardBindHost } from "./types"; + +export const LOG_SESSION_MARKER = "--- DashboardApp session "; + +export function resetLogFile(key: string): string { + const file = ensureLogFile(key); + const marker = `${LOG_SESSION_MARKER}${new Date().toISOString()} ---\n`; + writeFileSync(file, marker); + return file; +} + +/** Byte offset of the most recent session marker, or 0 when none. */ +export function currentLogSessionOffset(logFile: string): number { + if (!existsSync(logFile)) { + return 0; + } + + const content = readFileSync(logFile, "utf-8"); + const markerIndex = content.lastIndexOf(LOG_SESSION_MARKER); + + if (markerIndex < 0) { + return 0; + } + + return markerIndex; +} + +export function readLogTail(logFile: string, lines: number, sessionOnly = true): string { + if (!existsSync(logFile)) { + return ""; + } + + const lineCount = Number.isFinite(lines) && lines > 0 ? Math.floor(lines) : 50; + const start = sessionOnly ? currentLogSessionOffset(logFile) : 0; + const size = statSync(logFile).size; + + if (size <= start) { + return ""; + } + + const fd = openSync(logFile, "r"); + try { + const buf = Buffer.alloc(size - start); + const read = readSync(fd, buf, 0, buf.length, start); + const text = buf.subarray(0, read).toString(); + const allLines = text.split("\n"); + return allLines.slice(-lineCount).join("\n"); + } finally { + closeSync(fd); + } +} + +const BANNER_LINE = + /(?:VITE v[\d.]+|➜\s+Local:|➜\s+Network:|Local:\s+https?:\/\/|Network:\s+https?:\/\/|ready in \d+)/i; + +/** Print the Vite/dev-server banner from the current log session to the terminal. */ +export function printDevServerBanner( + logFile: string, + port: number, + opts: { color?: boolean; bindHost?: DashboardBindHost } = {} +): void { + const color = opts.color ?? Boolean(process.stdout.isTTY); + const tail = readLogTail(logFile, 40, true); + const bannerLines: string[] = []; + + for (const line of tail.split("\n")) { + const trimmed = line.trim(); + + if (!trimmed) { + continue; + } + + if (BANNER_LINE.test(stripAnsi(trimmed))) { + bannerLines.push(trimmed); + } + } + + const hasNetworkLine = bannerLines.some((line) => /network:/i.test(stripAnsi(line))); + const bindHost = opts.bindHost ?? "127.0.0.1"; + + if (bannerLines.length === 0) { + const local = bindHost === "0.0.0.0" ? `http://localhost:${port}/` : `http://127.0.0.1:${port}/`; + out.print( + `\n ${color ? pc.cyan("➜") : "➜"} ${color ? pc.bold("Local:") : "Local:"} ${color ? pc.cyan(local) : local}\n` + ); + + if (bindHost === "0.0.0.0") { + const network = `http://${getLocalIpv4()}:${port}/`; + out.print( + ` ${color ? pc.cyan("➜") : "➜"} ${color ? pc.bold("Network:") : "Network:"} ${color ? pc.cyan(network) : network}\n` + ); + } + + return; + } + + const rendered = bannerLines.map((line) => (color ? line : stripAnsi(line))); + + if (bindHost === "0.0.0.0" && !hasNetworkLine) { + const network = `http://${getLocalIpv4()}:${port}/`; + rendered.push( + ` ${color ? pc.cyan("➜") : "➜"} ${color ? pc.bold("Network:") : "Network:"} ${color ? pc.cyan(network) : network}` + ); + } + + out.print(`\n${rendered.join("\n")}\n`); +} diff --git a/src/utils/DashboardApp/menu.ts b/src/utils/DashboardApp/menu.ts new file mode 100644 index 0000000000..0b1ac5905f --- /dev/null +++ b/src/utils/DashboardApp/menu.ts @@ -0,0 +1,134 @@ +/** + * Interactive menus for DashboardApp conflict / first-run prompts. + * + * All menus check `isInteractive()` first; non-TTY callers receive a sentinel + * (`null` typically) and use `suggestCommand` to print the equivalent verb + * invocation instead. Lifecycle wiring is in lifecycle.ts. + */ +import { isInteractive } from "@app/utils/cli"; +import * as p from "@app/utils/prompts/p"; +import type { PortConflict } from "./portConflict"; + +export type MineMenuChoice = "restart" | "down" | "attach" | "status" | "open" | "abort"; + +export async function promptMineMenu( + port: number, + pid: number, + opts?: { canOpen?: boolean } +): Promise { + if (!isInteractive()) { + return null; + } + + const options = [ + ...(opts?.canOpen ? [{ value: "open" as const, label: "Open in browser" }] : []), + { value: "restart", label: "Restart (stop and start fresh)" }, + { value: "attach", label: "Attach to its background log" }, + { value: "status", label: "Show status" }, + { value: "down", label: "Stop it" }, + { value: "abort", label: "Abort" }, + ]; + + const picked = await p.select({ + message: `Already running (pid ${pid} on :${port}). What now?`, + options, + }); + + if (p.isCancel(picked)) { + return "abort"; + } + + return picked as MineMenuChoice; +} + +export type ForeignMenuChoice = "kill-and-up" | "abort"; + +export async function promptForeignMenu( + port: number, + ownerPid: number, + ownerCommand: string, + sameUser: boolean +): Promise { + if (!isInteractive()) { + return null; + } + + const message = sameUser + ? `Port ${port} is held by pid ${ownerPid} (${ownerCommand}). Kill it and start fresh?` + : `Port ${port} is held by pid ${ownerPid} (${ownerCommand}) — owned by a different user; you may not have permission to kill it.`; + + const picked = await p.select({ + message, + options: sameUser + ? [ + { value: "kill-and-up", label: "Kill the owner and start" }, + { value: "abort", label: "Abort" }, + ] + : [{ value: "abort", label: "Abort" }], + }); + + if (p.isCancel(picked)) { + return "abort"; + } + + return picked as ForeignMenuChoice; +} + +export async function promptLaunchdInstall(key: string): Promise { + if (!isInteractive()) { + return null; + } + + const picked = await p.confirm({ + message: `Install ${key} as a launchd agent so it survives reboots and restarts on crash?`, + initialValue: false, + }); + + if (p.isCancel(picked)) { + return null; + } + + return Boolean(picked); +} + +export type DependencyMenuChoice = "start" | "skip" | "abort"; + +export async function promptDependencyStart(depKey: string, parentKey: string): Promise { + if (!isInteractive()) { + return null; + } + + const picked = await p.select({ + message: `${parentKey} depends on ${depKey}, which isn't running.`, + options: [ + { value: "start", label: `Start ${depKey} first` }, + { value: "skip", label: `Skip — start ${parentKey} anyway` }, + ], + }); + + if (p.isCancel(picked)) { + return "abort"; + } + + return picked as DependencyMenuChoice; +} + +export function describeConflict(conflict: PortConflict): string { + if (conflict.state === "free") { + return "free"; + } + + if (conflict.state === "mine") { + return `mine (pid ${conflict.pid})`; + } + + if (conflict.state === "stale") { + return `stale (pid ${conflict.owner.pid} ${conflict.owner.command})`; + } + + if (conflict.owner) { + return `foreign (pid ${conflict.owner.pid} ${conflict.owner.command})`; + } + + return "foreign (unknown owner)"; +} diff --git a/src/utils/DashboardApp/pidFile.ts b/src/utils/DashboardApp/pidFile.ts new file mode 100644 index 0000000000..32f1f188b2 --- /dev/null +++ b/src/utils/DashboardApp/pidFile.ts @@ -0,0 +1,109 @@ +/** + * PID file lifecycle for DashboardApp. + * + * Layout (per design decision §3 in the plan): + * PID: ~/.genesis-tools/dashboards/.pid + * Log: ~/.genesis-tools/logs/.bg.log + * + * Pattern ported from src/youtube/lib/server/daemon.ts. We keep DashboardApp's + * pidFile module self-contained instead of importing youtube/lib so the + * dependency graph stays one-way (youtube → DashboardApp, never back). + */ +import { existsSync, mkdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import { homedir } from "node:os"; +import { dirname, join } from "node:path"; + +const DASHBOARDS_DIR = join(homedir(), ".genesis-tools", "dashboards"); +const LOGS_DIR = join(homedir(), ".genesis-tools", "logs"); + +export function pidFilePath(key: string): string { + return join(DASHBOARDS_DIR, `${key}.pid`); +} + +export function logFilePath(key: string): string { + return join(LOGS_DIR, `${key}.bg.log`); +} + +export function configFilePath(key: string): string { + return join(DASHBOARDS_DIR, `${key}.config.json`); +} + +function ensureDir(file: string): void { + const dir = dirname(file); + if (!existsSync(dir)) { + mkdirSync(dir, { recursive: true }); + } +} + +export function writePid(key: string, pid: number): void { + const file = pidFilePath(key); + ensureDir(file); + writeFileSync(file, String(pid)); +} + +export function readPidRaw(key: string): number | null { + const file = pidFilePath(key); + + if (!existsSync(file)) { + return null; + } + + const raw = readFileSync(file, "utf-8").trim(); + const pid = Number.parseInt(raw, 10); + + if (Number.isNaN(pid) || pid <= 0) { + return null; + } + + return pid; +} + +/** + * Returns the PID written for this dashboard if (a) the file exists and (b) + * the PID is alive. Returns null otherwise — the file is left in place (caller + * decides whether to clear stale entries via `clearPid`). + */ +export function readPid(key: string): number | null { + const pid = readPidRaw(key); + + if (pid === null) { + return null; + } + + try { + process.kill(pid, 0); + return pid; + } catch (err) { + if (process.platform === "win32" && err instanceof Error && "code" in err) { + return (err as { code?: string }).code === "EPERM" ? pid : null; + } + + return null; + } +} + +export function clearPid(key: string): void { + const file = pidFilePath(key); + if (existsSync(file)) { + unlinkSync(file); + } +} + +/** + * Best-effort start time for a running pid, from the PID file's mtime. Returns + * null when the file is missing. + */ +export function pidFileStartTime(key: string): Date | null { + const file = pidFilePath(key); + if (!existsSync(file)) { + return null; + } + + return statSync(file).mtime; +} + +export function ensureLogFile(key: string): string { + const file = logFilePath(key); + ensureDir(file); + return file; +} diff --git a/src/utils/DashboardApp/portConflict.test.ts b/src/utils/DashboardApp/portConflict.test.ts new file mode 100644 index 0000000000..17c1732915 --- /dev/null +++ b/src/utils/DashboardApp/portConflict.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, test } from "bun:test"; +import { resolve } from "node:path"; +import { resolveCommandForLaunchd } from "./launchd"; +import { canKillPortOwner } from "./portConflict"; + +describe("resolveCommandForLaunchd", () => { + test("resolves bare bun and relative script paths", () => { + const projectRoot = resolve(import.meta.dirname, "../../.."); + const bunPath = Bun.which("bun") ?? process.execPath; + const { command, env } = resolveCommandForLaunchd( + ["bun", "run", "src/youtube/lib/server/index.ts"], + projectRoot + ); + + expect(command[0]).toBe(bunPath); + expect(command[2]).toBe(resolve(projectRoot, "src/youtube/lib/server/index.ts")); + expect(env.PATH).toContain("/usr/bin"); + expect(env.HOME).toBeTruthy(); + }); +}); + +describe("canKillPortOwner", () => { + const owner = { pid: 42, command: "bun", sameUser: true }; + + test("rejects foreign-user owners when sameUserOnly", async () => { + const ok = await canKillPortOwner(3000, { ...owner, sameUser: false }, { sameUserOnly: true }); + expect(ok).toBe(false); + }); + + test("rejects when pid changed since shutdown started", async () => { + const ok = await canKillPortOwner(3000, owner, { expectOwnerPid: 99 }); + expect(ok).toBe(false); + }); + + test("allows same-user owner with matching expectOwnerPid", async () => { + const ok = await canKillPortOwner(3000, owner, { expectOwnerPid: 42 }); + expect(ok).toBe(true); + }); +}); diff --git a/src/utils/DashboardApp/portConflict.ts b/src/utils/DashboardApp/portConflict.ts new file mode 100644 index 0000000000..5f465ffe21 --- /dev/null +++ b/src/utils/DashboardApp/portConflict.ts @@ -0,0 +1,199 @@ +/** + * Port conflict resolution for DashboardApp. + * + * Three distinct conflict states: + * + * - **free** — port is available. Caller proceeds with up. + * - **mine** — port is held by THIS dashboard's PID (matches `pidFile`). Caller + * offers restart/down/attach/status. + * - **stale** — port is held by a same-user process but the pid file is missing + * or points to a dead/different pid (typical orphan from a prior manual `up`). + * Callers reclaim automatically before launchd install or background spawn. + * - **foreign** — port is held by someone else. Caller errors with the owning + * PID + cmdline; in TTY can offer kill+up; non-TTY suggests `--force`. + */ +import { logger } from "@app/logger"; +import { getPortOwner, type PortOwner } from "@app/utils/network"; +import { defaultPlistLabel, isLaunchdInstalled } from "./launchd"; +import { readPid, readPidRaw } from "./pidFile"; +import { readPreferences } from "./preferences"; + +export type PortConflict = + | { state: "free" } + | { state: "mine"; pid: number; owner: PortOwner | null } + | { state: "stale"; owner: PortOwner } + | { state: "foreign"; owner: PortOwner | null }; + +/** + * Source of truth for port occupancy: lsof. `isPortInUse` is unreliable here + * because it probes a specific host/family (default `127.0.0.1` IPv4 only) and + * misses Vite, which listens on `*:port` IPv6 dual-stack. lsof finds any + * listener regardless of family. + */ +export async function checkPortConflict(key: string, port: number): Promise { + const owner = await getPortOwner(port); + + if (!owner) { + return { state: "free" }; + } + + const ourPid = readPid(key); + const rawPid = readPidRaw(key); + + if (owner.sameUser && isOwnedPortHolder(key, owner, ourPid)) { + return { state: "mine", pid: owner.pid, owner }; + } + + const label = defaultPlistLabel(key); + const hasOwnershipSignal = + rawPid !== null || (isLaunchdInstalled(label) && (readPreferences(key).launchdInstalled ?? false)); + + if (owner.sameUser && hasOwnershipSignal) { + return { state: "stale", owner }; + } + + return { state: "foreign", owner }; +} + +function isOwnedPortHolder(key: string, owner: PortOwner, filePid: number | null): boolean { + if (filePid && owner.pid === filePid) { + return true; + } + + if (!owner.sameUser) { + return false; + } + + const label = defaultPlistLabel(key); + + return isLaunchdInstalled(label) && (readPreferences(key).launchdInstalled ?? false); +} + +export interface WaitForPortFreeOptions { + /** After `timeoutMs`, escalate to SIGTERM→SIGKILL when the port is still held. Default false. */ + killIfHeld?: boolean; + /** When killing, require `owner.sameUser`. Default true. */ + sameUserOnly?: boolean; + /** Only kill when the listener is still this pid (avoids killing a new binder). */ + expectOwnerPid?: number; + /** + * Only kill when `checkPortConflict` reports `mine` or `stale` for this dashboard. + * Use after stopping our own launchd agent / orphan reclaim — never on arbitrary ports. + */ + dashboardKey?: string; +} + +const POST_KILL_WAIT_MS = 2_000; + +export async function waitForPortFree( + port: number, + timeoutMs: number, + opts: WaitForPortFreeOptions = {} +): Promise { + const deadline = Date.now() + timeoutMs; + + while (Date.now() < deadline) { + if (!(await getPortOwner(port))) { + return true; + } + + await Bun.sleep(150); + } + + if (!opts.killIfHeld) { + return !(await getPortOwner(port)); + } + + const owner = await getPortOwner(port); + + if (!owner) { + return true; + } + + if (!(await canKillPortOwner(port, owner, opts))) { + return false; + } + + logger.warn({ port, pid: owner.pid, command: owner.command }, "port still held after graceful wait; forcing kill"); + await killPortOwner(owner, { force: true }); + + const afterKillDeadline = Date.now() + POST_KILL_WAIT_MS; + + while (Date.now() < afterKillDeadline) { + if (!(await getPortOwner(port))) { + return true; + } + + await Bun.sleep(150); + } + + return false; +} + +export async function canKillPortOwner( + port: number, + owner: PortOwner, + opts: Pick +): Promise { + const sameUserOnly = opts.sameUserOnly ?? true; + + if (sameUserOnly && !owner.sameUser) { + return false; + } + + if (opts.expectOwnerPid !== undefined && owner.pid !== opts.expectOwnerPid) { + return false; + } + + if (opts.dashboardKey) { + const conflict = await checkPortConflict(opts.dashboardKey, port); + + if (conflict.state !== "mine" && conflict.state !== "stale") { + return false; + } + } + + return true; +} + +export async function killPortOwner(owner: PortOwner, opts: { force?: boolean } = {}): Promise { + const force = opts.force ?? true; + + try { + process.kill(owner.pid, "SIGTERM"); + } catch (error) { + logger.debug({ pid: owner.pid, signal: "SIGTERM", error }, "failed to signal process"); + } + + const graceDeadline = Date.now() + 5_000; + + while (Date.now() < graceDeadline) { + if (!isProcessAlive(owner.pid)) { + return; + } + + await Bun.sleep(200); + } + + if (!force) { + return; + } + + try { + process.kill(owner.pid, "SIGKILL"); + } catch (error) { + logger.debug({ pid: owner.pid, signal: "SIGKILL", error }, "failed to force-kill process"); + } + + await Bun.sleep(500); +} + +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + logger.debug({ pid, error }, "process liveness probe failed"); + return false; + } +} diff --git a/src/utils/DashboardApp/preferences.ts b/src/utils/DashboardApp/preferences.ts new file mode 100644 index 0000000000..30f5270d1d --- /dev/null +++ b/src/utils/DashboardApp/preferences.ts @@ -0,0 +1,46 @@ +/** + * Per-dashboard preferences stored at ~/.genesis-tools/dashboards/.config.json. + * + * Used to remember the user's answer to "Install as launchd agent?" so we + * don't pester on every `up`. Schema is intentionally minimal; extend as new + * persistent decisions surface. + */ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { dirname } from "node:path"; +import { logger } from "@app/logger"; +import { SafeJSON } from "@app/utils/json"; +import { configFilePath } from "./pidFile"; + +export interface DashboardPreferences { + /** Did the user dismiss the launchd first-run prompt? */ + launchdPromptDismissed?: boolean; + /** Did we successfully install the launchd plist for this app? */ + launchdInstalled?: boolean; +} + +export function readPreferences(key: string): DashboardPreferences { + const file = configFilePath(key); + if (!existsSync(file)) { + return {}; + } + + try { + const raw = readFileSync(file, "utf-8"); + const parsed = SafeJSON.parse(raw); + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as DashboardPreferences; + } + } catch (err) { + logger.debug({ err, key, file }, "failed to parse dashboard preferences"); + } + + return {}; +} + +export function writePreferences(key: string, prefs: DashboardPreferences): void { + const file = configFilePath(key); + mkdirSync(dirname(file), { recursive: true }); + const existing = readPreferences(key); + const merged: DashboardPreferences = { ...existing, ...prefs }; + writeFileSync(file, `${SafeJSON.stringify(merged, null, 2)}\n`); +} diff --git a/src/utils/DashboardApp/readiness.test.ts b/src/utils/DashboardApp/readiness.test.ts new file mode 100644 index 0000000000..ed4ab2be05 --- /dev/null +++ b/src/utils/DashboardApp/readiness.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, test } from "bun:test"; +import { isHttpServingStatus } from "./readiness"; + +describe("isHttpServingStatus", () => { + test("accepts 2xx–4xx", () => { + expect(isHttpServingStatus(200)).toBe(true); + expect(isHttpServingStatus(404)).toBe(true); + }); + + test("rejects gateway-unavailable statuses", () => { + expect(isHttpServingStatus(502)).toBe(false); + expect(isHttpServingStatus(503)).toBe(false); + expect(isHttpServingStatus(504)).toBe(false); + }); +}); diff --git a/src/utils/DashboardApp/readiness.ts b/src/utils/DashboardApp/readiness.ts new file mode 100644 index 0000000000..54b481d7b8 --- /dev/null +++ b/src/utils/DashboardApp/readiness.ts @@ -0,0 +1,138 @@ +/** + * Readiness probes — wait until the spawned child is ready to serve. + * + * Three probe kinds match the `ReadinessProbe` union in `types.ts`: + * - **http** — fetch(`http://localhost:`) until a serving response + * (2xx–4xx; 502/503/504 gateway statuses keep polling — front-proxy can bind + * before Vite/ttyd upstream is up). + * - **log** — tail the bg log until the regex matches. + * - **port** — wait for the TCP port to bind (simplest, used as fallback). + * + * Each takes a deadline; on timeout the function returns `{ ready: false }` + * and the caller decides whether to detach the child anyway (dev-dashboard + * does this on `restart` after 30s — see plan §Decisions). + */ +import { closeSync, existsSync, openSync, readSync, statSync } from "node:fs"; +import { logger } from "@app/logger"; +import { isPortInUse } from "@app/utils/network"; +import { stripAnsi } from "@app/utils/string"; +import type { ReadinessProbe } from "./types"; + +const DEFAULT_TIMEOUT_MS = 30_000; +const POLL_INTERVAL_MS = 250; + +/** Gateway statuses: TCP listener is up but upstream is not serving yet. */ +const GATEWAY_UNAVAILABLE = new Set([502, 503, 504]); + +export function isHttpServingStatus(status: number): boolean { + return status > 0 && !GATEWAY_UNAVAILABLE.has(status); +} + +export interface ReadinessResult { + ready: boolean; + /** What we observed last (helpful when ready:false). */ + detail?: string; +} + +export async function waitForReady( + probe: ReadinessProbe | undefined, + args: { port: number; logFile: string } +): Promise { + if (!probe) { + // Default = wait for the TCP port to bind. + return waitForPort({ kind: "port" }, args.port); + } + + switch (probe.kind) { + case "http": + return waitForHttp(probe, args.port); + case "log": + return waitForLog(probe, args.logFile); + case "port": + return waitForPort(probe, args.port); + } +} + +async function waitForPort(probe: { kind: "port"; timeoutMs?: number }, port: number): Promise { + const deadline = Date.now() + (probe.timeoutMs ?? DEFAULT_TIMEOUT_MS); + while (Date.now() < deadline) { + if (await isPortInUse(port)) { + return { ready: true }; + } + await Bun.sleep(POLL_INTERVAL_MS); + } + return { ready: false, detail: `port ${port} still free after ${probe.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` }; +} + +async function waitForHttp( + probe: { kind: "http"; path?: string; timeoutMs?: number }, + port: number +): Promise { + const url = `http://localhost:${port}${probe.path ?? "/"}`; + return waitForUrlReady(url, probe.timeoutMs ?? DEFAULT_TIMEOUT_MS); +} + +export async function waitForUrlReady(url: string, timeoutMs = DEFAULT_TIMEOUT_MS): Promise { + const deadline = Date.now() + timeoutMs; + let lastStatus: number | undefined; + let lastError: unknown; + + while (Date.now() < deadline) { + try { + const res = await fetch(url, { redirect: "manual", signal: AbortSignal.timeout(2_000) }); + lastStatus = res.status; + + // 4xx still means the app is serving; 502/503/504 means proxy-without-upstream. + if (isHttpServingStatus(res.status)) { + return { ready: true, detail: `http ${res.status}` }; + } + } catch (err) { + lastError = err; + logger.debug({ err, url }, "http readiness probe failed"); + } + await Bun.sleep(POLL_INTERVAL_MS); + } + + const statusSuffix = lastStatus !== undefined ? ` (last status ${lastStatus})` : ""; + const errorSuffix = + lastError !== undefined + ? ` (last error: ${lastError instanceof Error ? lastError.message : String(lastError)})` + : ""; + return { ready: false, detail: `http ${url} did not respond in ${timeoutMs}ms${statusSuffix}${errorSuffix}` }; +} + +async function waitForLog( + probe: { kind: "log"; regex: RegExp; timeoutMs?: number }, + logFile: string +): Promise { + const deadline = Date.now() + (probe.timeoutMs ?? DEFAULT_TIMEOUT_MS); + let pos = 0; + let acc = ""; + + while (Date.now() < deadline) { + if (existsSync(logFile)) { + const size = statSync(logFile).size; + if (size > pos) { + const fd = openSync(logFile, "r"); + try { + const buf = Buffer.alloc(size - pos); + const read = readSync(fd, buf, 0, buf.length, pos); + pos += read; + acc += stripAnsi(buf.subarray(0, read).toString()); + if (acc.length > 8_000) { + acc = acc.slice(-4_000); + } + probe.regex.lastIndex = 0; + if (probe.regex.test(acc)) { + return { ready: true, detail: `log matched ${probe.regex}` }; + } + } finally { + closeSync(fd); + } + } + } + await Bun.sleep(POLL_INTERVAL_MS); + } + + return { ready: false, detail: `log marker ${probe.regex} not seen in ${probe.timeoutMs ?? DEFAULT_TIMEOUT_MS}ms` }; +} diff --git a/src/utils/DashboardApp/types.ts b/src/utils/DashboardApp/types.ts new file mode 100644 index 0000000000..ab63f35fb6 --- /dev/null +++ b/src/utils/DashboardApp/types.ts @@ -0,0 +1,186 @@ +/** + * Shared types for the DashboardApp factory. + * + * `DashboardApp` is the runtime object returned by `defineDashboardApp`. The + * `DashboardAppConfig` is what callers pass in. Every dashboard CLI in the + * repo is being migrated to consume this contract so port handling, lifecycle + * verbs, readiness probes, and launchd integration are all done once. + * + * See the DashboardApp design notes in the repo docs for design context. + */ +import type { Command } from "commander"; + +export type DashboardAppType = "ui" | "server"; + +/** Vite / dev-server bind address. Default `127.0.0.1` (localhost only). */ +export type DashboardBindHost = "127.0.0.1" | "0.0.0.0"; + +export type ReadinessProbe = + | { kind: "http"; path?: string; timeoutMs?: number } + | { kind: "log"; regex: RegExp; timeoutMs?: number } + | { kind: "port"; timeoutMs?: number }; + +export type DependencyPolicy = + /** Silently start the dep before us. */ + | "auto" + /** Prompt the user (TTY); on non-TTY, warn and proceed without starting. */ + | "prompt" + /** Print a warning if the dep is down; never auto-start. */ + | "warn"; + +export interface DashboardDependency { + /** Late-bound to avoid circular factory references. */ + app: DashboardApp; + policy: DependencyPolicy; +} + +export interface PreflightWarning { + service: string; + error: string; + fix?: string; +} + +export interface DashboardAppConfig { + type: DashboardAppType; + + /** Stable id. Used for PID/log paths and (for type:"ui") to look up port from DASHBOARDS registry. */ + key: string; + + /** Display name for status output. Defaults to `key`. */ + name?: string; + + /** Description shown in --help. */ + description: string; + + /** CLI subcommand name (e.g. "ui", "dashboard", "server"). */ + commandName: string; + + /** Optional CLI aliases for the subcommand. */ + aliases?: string[]; + + /** Port. For type:"ui" defaults to DASHBOARDS[key].port; for type:"server" required. */ + port?: number; + + /** + * Dev-server bind address (UI dashboards). Default `127.0.0.1`. + * Use `0.0.0.0` when the dashboard must be reachable on LAN or via a tunnel + * (e.g. dev-dashboard + cloudflared). + */ + bindHost?: DashboardBindHost; + + /** Spawn instructions for the child process. */ + spawn: { + cmd: readonly string[]; + cwd?: string; + env?: Record; + }; + + /** Optional preflight checks. Soft warnings; surfaced in `status` and on `up`. */ + preflight?: () => Promise<{ warnings: PreflightWarning[] }>; + + /** Other DashboardApps this one needs. */ + dependencies?: DashboardDependency[]; + + /** How to know the process is ready before browser-open / dependency wait completes. */ + readiness?: ReadinessProbe; + + /** Browser-open policy (effective only for type:"ui"). */ + openBrowser?: { + enabled: boolean; + url?: (port: number) => string; + }; + + /** Launchd opt-in. */ + launchd?: { + available: boolean; + /** Plist label. Default `com.genesis-tools.`. */ + label?: string; + }; +} + +export interface UpOptions { + foreground?: boolean; + port?: number; + force?: boolean; + open?: boolean; + /** Suppress the launchd first-run prompt for this invocation. */ + skipInstallPrompt?: boolean; + /** When true, forces the interactive menu even on unambiguous actions. */ + interactive?: boolean; + /** When true, an already-running instance on our port is stopped without a menu (used by `install`). */ + replaceRunning?: boolean; +} + +export interface InstallOptions { + force?: boolean; + port?: number; +} + +export interface UpResult { + started: boolean; + pid?: number; + port: number; + /** "foreground" blocks; "background" returns immediately after readiness. */ + mode: "foreground" | "background"; + logPath?: string; +} + +export interface DownOptions { + /** Escalate to SIGKILL after grace period (default true). */ + force?: boolean; +} + +export interface DownResult { + stopped: boolean; + pid?: number; +} + +export interface DependencyStatus { + key: string; + running: boolean; + pid?: number; + port: number; +} + +export interface StatusResult { + key: string; + type: DashboardAppType; + running: boolean; + pid?: number; + port: number; + /** Uptime in milliseconds (only set when running and we know start time). */ + uptimeMs?: number; + logPath: string; + launchdAvailable: boolean; + launchdInstalled: boolean; + dependencies: DependencyStatus[]; + preflightWarnings: PreflightWarning[]; +} + +export interface AttachOptions { + /** Tail last N lines on attach (default 50). */ + lines?: number; +} + +export interface DashboardApp { + /** The config the app was created with (frozen). */ + readonly config: DashboardAppConfig; + /** Resolved port (from config.port or DASHBOARDS[key].port). */ + readonly port: number; + /** Where the PID file lives. */ + readonly pidFile: string; + /** Where the background log file lives. */ + readonly logFile: string; + + /** Plug into the parent commander: `parent.addCommand(app.commanderCommand)`. */ + readonly commanderCommand: Command; + + up(opts?: UpOptions): Promise; + down(opts?: DownOptions): Promise; + restart(opts?: UpOptions): Promise; + status(): Promise; + attach(opts?: AttachOptions): Promise; + logs(opts?: { lines?: number }): Promise; + install(opts?: InstallOptions): Promise; + uninstall(): Promise; +} diff --git a/src/utils/DashboardApp/viteSpawn.test.ts b/src/utils/DashboardApp/viteSpawn.test.ts new file mode 100644 index 0000000000..157edc6363 --- /dev/null +++ b/src/utils/DashboardApp/viteSpawn.test.ts @@ -0,0 +1,22 @@ +import { describe, expect, test } from "bun:test"; +import { buildViteDevCmd, DEFAULT_BIND_HOST } from "./viteSpawn"; + +describe("buildViteDevCmd", () => { + test("defaults to localhost bind", () => { + const cmd = buildViteDevCmd({ configPath: "/tmp/vite.config.ts", strictPort: true }); + + expect(cmd).toContain("--host"); + expect(cmd).toContain(DEFAULT_BIND_HOST); + expect(cmd).toContain("--strictPort"); + }); + + test("supports all-interfaces bind", () => { + const cmd = buildViteDevCmd({ + configPath: "/tmp/vite.config.ts", + bindHost: "0.0.0.0", + port: 3071, + }); + + expect(cmd).toEqual(expect.arrayContaining(["--host", "0.0.0.0", "--port", "3071"])); + }); +}); diff --git a/src/utils/DashboardApp/viteSpawn.ts b/src/utils/DashboardApp/viteSpawn.ts new file mode 100644 index 0000000000..9a3732ef62 --- /dev/null +++ b/src/utils/DashboardApp/viteSpawn.ts @@ -0,0 +1,34 @@ +import { resolve } from "node:path"; +import { PROJECT_ROOT } from "@app/utils/paths"; +import type { DashboardBindHost } from "./types"; + +export const DEFAULT_BIND_HOST: DashboardBindHost = "127.0.0.1"; + +export function resolveViteEntry(): string { + return resolve(PROJECT_ROOT, "node_modules", "vite", "bin", "vite.js"); +} + +export interface ViteDevCmdOptions { + configPath: string; + port?: number; + strictPort?: boolean; + bindHost?: DashboardBindHost; + viteEntry?: string; +} + +/** Standard `vite dev` argv for DashboardApp UI tools. */ +export function buildViteDevCmd(opts: ViteDevCmdOptions): string[] { + const entry = opts.viteEntry ?? resolveViteEntry(); + const bindHost = opts.bindHost ?? DEFAULT_BIND_HOST; + const cmd: string[] = ["bun", "--bun", entry, "dev", "-c", opts.configPath, "--host", bindHost]; + + if (opts.port !== undefined) { + cmd.push("--port", String(opts.port)); + } + + if (opts.strictPort) { + cmd.push("--strictPort"); + } + + return cmd; +} diff --git a/src/utils/network.ts b/src/utils/network.ts index b7996a1eab..ef2ed9d2d0 100644 --- a/src/utils/network.ts +++ b/src/utils/network.ts @@ -1,5 +1,6 @@ import { createServer } from "node:net"; -import { networkInterfaces } from "node:os"; +import { networkInterfaces, userInfo } from "node:os"; +import { logger } from "@app/logger"; /** * Returns the first non-internal IPv4 address (LAN IP). @@ -45,3 +46,81 @@ export async function isPortInUse(port: number, host = "127.0.0.1"): Promise { + if (process.platform === "win32") { + return null; + } + + try { + // /usr/sbin/lsof on macOS; some sandboxed PATHs don't include /usr/sbin. + const lsofBinary = process.platform === "darwin" ? "/usr/sbin/lsof" : "lsof"; + const lsofProc = Bun.spawn([lsofBinary, "-nP", `-iTCP:${port}`, "-sTCP:LISTEN", "-t"], { + stdout: "pipe", + stderr: "pipe", + }); + const lsofOut = await new Response(lsofProc.stdout).text(); + const lsofExit = await lsofProc.exited; + + if (lsofExit !== 0) { + logger.debug({ port, exitCode: lsofExit }, "lsof returned non-zero exit"); + return null; + } + + const pid = Number.parseInt( + lsofOut + .split("\n") + .map((line) => line.trim()) + .find((line) => line.length > 0) ?? "", + 10 + ); + if (Number.isNaN(pid) || pid <= 0) { + return null; + } + + const psProc = Bun.spawn(["ps", "-p", String(pid), "-o", "command=,uid="], { + stdout: "pipe", + stderr: "pipe", + }); + const psOut = (await new Response(psProc.stdout).text()).trim(); + const psExit = await psProc.exited; + + if (psExit !== 0) { + logger.debug({ port, pid, exitCode: psExit }, "ps returned non-zero exit"); + return { pid, command: "(unknown)", sameUser: false }; + } + + if (!psOut) { + return { pid, command: "(unknown)", sameUser: false }; + } + + // `ps -p N -o command=,uid=` prints " " — uid is the last token. + const lastSpace = psOut.lastIndexOf(" "); + let command = psOut; + let uid = Number.NaN; + if (lastSpace > 0) { + command = psOut.slice(0, lastSpace).trim(); + uid = Number.parseInt(psOut.slice(lastSpace + 1).trim(), 10); + } + + const myUid = userInfo().uid; + + return { pid, command, sameUser: !Number.isNaN(uid) && uid === myUid }; + } catch (err) { + logger.debug({ err, port }, "getPortOwner failed"); + return null; + } +} diff --git a/src/utils/process/spawnDashboard.ts b/src/utils/process/spawnDashboard.ts new file mode 100644 index 0000000000..fc1c6b3ae6 --- /dev/null +++ b/src/utils/process/spawnDashboard.ts @@ -0,0 +1,102 @@ +/** + * Spawn a long-running child (typically `vite dev`) for a dashboard CLI launcher, + * with three protections the naive `Bun.spawn` + `await proc.exited` pattern lacks: + * + * 1. **Signal forwarding.** SIGHUP/SIGINT/SIGTERM/SIGQUIT received by the parent + * are forwarded to the child so Vite gets a chance to clean up its sockets + * before being killed. + * 2. **Orphan auto-exit.** macOS does not propagate parent death to children + * (no `PR_SET_PDEATHSIG`). When the launching shell dies, the child gets + * reparented to launchd (PPID=1) and lives forever — that is exactly how the + * 2-day reas zombies came to exist. We poll `process.ppid`; the moment it + * flips to 1 we kill the Vite child and exit ourselves. + * 3. **Force-exit after grace.** If the child ignores SIGTERM, escalate to + * SIGKILL after `gracePeriodMs` so we cannot leak. + * + * Returns the child's exit code so callers can `process.exit(code)` themselves + * — we deliberately do NOT call `process.exit` here so callers can run cleanup. + */ +import { logger } from "@app/logger"; + +export interface SpawnDashboardOptions { + /** Argv. First element is the executable. */ + cmd: readonly string[]; + /** Working directory. Defaults to `process.cwd()`. */ + cwd?: string; + /** Extra env vars merged onto `process.env`. */ + env?: Record; + /** How often to check whether we have been reparented. Default 2000ms. */ + orphanPollMs?: number; + /** How long to wait after SIGTERM before SIGKILL. Default 5000ms. */ + gracePeriodMs?: number; +} + +export async function spawnDashboard(opts: SpawnDashboardOptions): Promise { + const { cmd, cwd, env, orphanPollMs = 2000, gracePeriodMs = 5000 } = opts; + + const child = Bun.spawn([...cmd], { + cwd, + stdio: ["inherit", "inherit", "inherit"], + env: { ...process.env, ...env }, + }); + + let exiting = false; + + const escalateKill = (signal: NodeJS.Signals | number = "SIGTERM"): void => { + if (exiting) { + return; + } + + exiting = true; + try { + child.kill(signal); + } catch (err) { + logger.debug({ err }, "[spawnDashboard] child.kill threw"); + } + + setTimeout(() => { + if (!child.killed) { + try { + child.kill("SIGKILL"); + } catch (err) { + logger.debug({ err }, "[spawnDashboard] SIGKILL escalation threw"); + } + } + }, gracePeriodMs).unref(); + }; + + const handleSignal = (signal: NodeJS.Signals): void => { + logger.debug({ signal }, "[spawnDashboard] received signal, forwarding to child"); + escalateKill(signal); + }; + + const signalsToForward: readonly NodeJS.Signals[] = ["SIGHUP", "SIGINT", "SIGTERM", "SIGQUIT"]; + const signalHandlers = new Map void>(); + + for (const sig of signalsToForward) { + const handler = () => { + handleSignal(sig); + }; + signalHandlers.set(sig, handler); + process.on(sig, handler); + } + + const orphanTimer = setInterval(() => { + if (process.ppid === 1) { + logger.warn( + "[spawnDashboard] reparented to launchd (parent shell died) — killing vite + exiting to avoid orphan" + ); + escalateKill("SIGTERM"); + } + }, orphanPollMs); + orphanTimer.unref(); + + const exitCode = await child.exited; + clearInterval(orphanTimer); + + for (const [sig, handler] of signalHandlers) { + process.off(sig, handler); + } + + return exitCode; +} diff --git a/src/utils/ui/dashboards.ts b/src/utils/ui/dashboards.ts index a1739231db..0dfa0b6447 100644 --- a/src/utils/ui/dashboards.ts +++ b/src/utils/ui/dashboards.ts @@ -28,6 +28,8 @@ export interface DashboardEntry { readonly description: string; /** Default localhost port the dev server binds. Must be unique. */ readonly port: number; + /** Dev-server bind address. Default 127.0.0.1 when omitted. */ + readonly bindHost?: "127.0.0.1" | "0.0.0.0"; /** * Whether the dev server passes `--strictPort` (a port clash is then a * hard crash rather than an auto-increment). Relevant to conflict risk. @@ -72,6 +74,7 @@ export const DASHBOARDS = { name: "Dev Dashboard", description: "Obsidian vault + ttyd terminal + cmux multiplexer.", port: 3042, + bindHost: "0.0.0.0", strictPort: true, launch: "tools dev-dashboard", portOverride: { env: "DEV_DASHBOARD_PUBLIC_PORT" }, diff --git a/src/utils/ui/vite.base.ts b/src/utils/ui/vite.base.ts index a9ffa544fd..53ad5e8e9d 100644 --- a/src/utils/ui/vite.base.ts +++ b/src/utils/ui/vite.base.ts @@ -1,5 +1,6 @@ +import { createHash } from "node:crypto"; import { existsSync } from "node:fs"; -import { isAbsolute, relative, resolve, sep } from "node:path"; +import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path"; import tailwindcss from "@tailwindcss/vite"; import { tanstackStart } from "@tanstack/react-start/plugin/vite"; import viteReact from "@vitejs/plugin-react"; @@ -122,10 +123,15 @@ function deriveWatchDirs(root: string, appDir: string, extraDirs: string[]): str rootRelativeToApp !== ".." && !rootRelativeToApp.startsWith(`..${sep}`) ) { - const toolName = rootRelativeToApp.split(/[\\/]/)[0]; + const withoutUi = rootRelativeToApp.replace(/[\\/]ui$/, ""); + const withoutAppsWeb = withoutUi.replace(/[\\/]apps[\\/]web$/, ""); + const toolRel = + withoutAppsWeb !== withoutUi || withoutUi !== rootRelativeToApp + ? withoutAppsWeb + : rootRelativeToApp.split(/[\\/]/)[0]; - if (toolName) { - const toolDir = resolve(appDir, toolName); + if (toolRel) { + const toolDir = resolve(appDir, toolRel); if (toolDir !== root && existsSync(toolDir)) { dirs.push(toolDir); @@ -161,6 +167,54 @@ function deriveWatchDirs(root: string, appDir: string, extraDirs: string[]): str return dirs; } +/** + * Find the monorepo git root, not the first `.git` walking up from `start`. + * + * Some dashboards (e.g. `src/claude-history-dashboard`) still carry a nested + * `.git` from their scaffold template. Stopping at the first match parked their + * Vite cache at `/node_modules/.vite-cache/root`, colliding with + * other tools and causing 504 Outdated Optimize Dep / duplicate React crashes. + */ +function resolveGitRoot(start: string): string { + let gitRoot = start; + let dir = start; + + while (dir !== dirname(dir)) { + if (existsSync(join(dir, ".git"))) { + gitRoot = dir; + } + + dir = dirname(dir); + } + + return gitRoot; +} + +/** + * Per-dashboard cacheDir under `/node_modules/.vite-cache//`. + * + * Default Vite cacheDir is `/node_modules/.vite`, which from + * `src//ui` resolves to the repo-root `node_modules/.vite` for every + * dashboard — so running ≥2 in parallel makes their optimize-deps versioners + * race, rewriting the same `?v=` files and breaking already-open tabs + * with 504 "Outdated Optimize Dep" + "Failed to fetch dynamically imported + * module". Isolating per dashboard prevents the collision. + * + * **Anchored on `.git`, not the closest `node_modules`.** Some tools (e.g. + * `src/shops`) have their own nested `package.json` + `node_modules`. If we + * picked that as the cache home, Vite would resolve `react` from the nested + * tree while the rest of the deps come from the root — duplicate-React + * "Invalid hook call". Pinning the cache to the repo root guarantees a single + * resolution chain. + */ +function resolveDashboardCacheDir(root: string): string { + const gitRoot = resolveGitRoot(root); + const relativeRoot = relative(gitRoot, root) || "root"; + const slug = createHash("sha1").update(relativeRoot).digest("hex").slice(0, 12); + + return join(gitRoot, "node_modules", ".vite-cache", slug); +} + export function createDashboardViteConfig({ root, port, @@ -193,6 +247,7 @@ export function createDashboardViteConfig({ return defineConfig({ root, + cacheDir: resolveDashboardCacheDir(root), plugins: [...corePlugins, ...extraPlugins], server: { port, @@ -223,6 +278,12 @@ export function createDashboardViteConfig({ "bun", "@tanstack/react-start-client", "@tanstack/start-client-core", + // Terminal-only packages pulled in by @tanstack/devtools-vite (chalk). + // Vite 8's rolldown optimizer resolves supports-color to browser.js, + // which lacks createSupportsColor — exclude the whole chain. + "chalk", + "supports-hyperlinks", + "supports-color", ...(overrideOptimizeDeps?.exclude ?? []), ], include: [ @@ -238,6 +299,23 @@ export function createDashboardViteConfig({ ssr: { external: ["node:async_hooks", "bun"], }, + ...(tanstackStartOptions !== false + ? { + environments: { + ssr: { + optimizeDeps: { + include: [ + "react", + "react-dom", + "react-dom/server", + "react/jsx-runtime", + "react/jsx-dev-runtime", + ], + }, + }, + }, + } + : {}), ...rest, }) as UserConfig; } diff --git a/src/youtube/commands/server.ts b/src/youtube/commands/server.ts index c4de700e30..cfd7af31db 100644 --- a/src/youtube/commands/server.ts +++ b/src/youtube/commands/server.ts @@ -1,13 +1,6 @@ -import { registerServerInstall } from "@app/youtube/lib/server/cli/install"; -import { registerServerStart } from "@app/youtube/lib/server/cli/start"; -import { registerServerStatus } from "@app/youtube/lib/server/cli/status"; -import { registerServerStop } from "@app/youtube/lib/server/cli/stop"; +import { youtubeServerApp } from "@app/youtube/lib/server/app"; import type { Command } from "commander"; export function registerServerCommand(program: Command): void { - const cmd = program.command("server").description("Run the YouTube API server (HTTP + WebSocket)"); - registerServerStart(cmd); - registerServerStop(cmd); - registerServerStatus(cmd); - registerServerInstall(cmd); + program.addCommand(youtubeServerApp.commanderCommand); } diff --git a/src/youtube/commands/ui.ts b/src/youtube/commands/ui.ts index 503bf53f73..c1dd995768 100644 --- a/src/youtube/commands/ui.ts +++ b/src/youtube/commands/ui.ts @@ -1,61 +1,66 @@ import { resolve } from "node:path"; +import { buildViteDevCmd, defineDashboardApp } from "@app/utils/DashboardApp"; import { PROJECT_ROOT } from "@app/utils/paths"; import { DASHBOARDS } from "@app/utils/ui/dashboards"; import { getYoutube } from "@app/youtube/commands/_shared/ensure-pipeline"; +import { youtubeServerApp } from "@app/youtube/lib/server/app"; import type { Command } from "commander"; -export function registerUiCommand(program: Command): void { - const cmd = program.command("ui").description("Launch the YouTube web UI"); - - cmd.command("start", { isDefault: true }) - .description("Start the YouTube web UI dev server") - .option( - "--port ", - `Vite dev server port (default ${DASHBOARDS.youtube.port})`, - (value) => Number.parseInt(value, 10), - DASHBOARDS.youtube.port - ) - .option("--api-url ", "Override the API base URL written to server.json on first run") - .action(async (opts: { port: number; apiUrl?: string }) => { - const yt = await getYoutube(); - if (opts.apiUrl) { - await yt.config.update({ apiBaseUrl: opts.apiUrl, firstRunComplete: true }); - } - - const uiDir = resolve(import.meta.dirname, "..", "ui"); - const proc = Bun.spawn( - [ - "bun", - "--bun", - "vite", - "dev", - "-c", - resolve(uiDir, "vite.config.ts"), - "--port", - String(opts.port), - "--strictPort", - ], - { - cwd: PROJECT_ROOT, - stdio: ["inherit", "inherit", "inherit"], - env: { ...process.env, YOUTUBE_PROJECT_CWD: process.cwd() }, - } - ); - setTimeout(() => openBrowser(`http://localhost:${opts.port}`), 2000); - await proc.exited; - }); -} +const UI_DIR = resolve(import.meta.dirname, "..", "ui"); +const CONFIG_PATH = resolve(UI_DIR, "vite.config.ts"); + +const API_URL_OPTION = "--api-url "; +const API_URL_DESC = "Override the API base URL written to server.json on first run"; -function openBrowser(url: string): void { - if (process.platform === "darwin") { - Bun.spawn(["open", url]); - return; +export const youtubeUiApp = defineDashboardApp({ + type: "ui", + key: "youtube", + name: "YouTube AI", + description: "Launch the YouTube AI web UI", + commandName: "ui", + spawn: { + cmd: buildViteDevCmd({ + configPath: CONFIG_PATH, + port: DASHBOARDS.youtube.port, + strictPort: true, + }), + cwd: PROJECT_ROOT, + env: { YOUTUBE_PROJECT_CWD: process.cwd() }, + }, + dependencies: [{ app: youtubeServerApp, policy: "prompt" }], + readiness: { kind: "http", path: "/", timeoutMs: 90_000 }, + openBrowser: { enabled: true }, + launchd: { available: true }, +}); + +async function applyApiUrlOverride(command: Command): Promise { + const opts = command.opts() as { apiUrl?: string }; + + if (opts.apiUrl) { + const yt = await getYoutube(); + await yt.config.update({ apiBaseUrl: opts.apiUrl, firstRunComplete: true }); } +} + +function wireApiUrlOption(command: Command): void { + command.option(API_URL_OPTION, API_URL_DESC); + command.hook("preAction", async (thisCommand) => { + await applyApiUrlOverride(thisCommand); + }); +} + +export function registerUiCommand(program: Command): void { + const cmd = youtubeUiApp.commanderCommand; + + wireApiUrlOption(cmd); + + for (const name of ["up", "restart"]) { + const sub = cmd.commands.find((c) => c.name() === name); - if (process.platform === "win32") { - Bun.spawn(["cmd", "/c", "start", url]); - return; + if (sub) { + wireApiUrlOption(sub); + } } - Bun.spawn(["xdg-open", url]); + program.addCommand(cmd); } diff --git a/src/youtube/lib/server/app.ts b/src/youtube/lib/server/app.ts new file mode 100644 index 0000000000..b6cbe11cb5 --- /dev/null +++ b/src/youtube/lib/server/app.ts @@ -0,0 +1,20 @@ +import { resolve } from "node:path"; +import { defineDashboardApp } from "@app/utils/DashboardApp"; +import { PROJECT_ROOT } from "@app/utils/paths"; + +const SERVER_ENTRY = resolve(PROJECT_ROOT, "src/youtube/lib/server/index.ts"); + +export const youtubeServerApp = defineDashboardApp({ + type: "server", + key: "youtube-server", + name: "YouTube AI API server", + description: "Run the YouTube AI background API server", + commandName: "server", + port: 9876, + spawn: { + cmd: ["bun", "run", SERVER_ENTRY], + cwd: PROJECT_ROOT, + }, + readiness: { kind: "http", path: "/api/v1/jobs?limit=1" }, + launchd: { available: true, label: "com.genesis-tools.youtube-server" }, +}); diff --git a/src/youtube/lib/server/cli/install.ts b/src/youtube/lib/server/cli/install.ts deleted file mode 100644 index 97289d8ebb..0000000000 --- a/src/youtube/lib/server/cli/install.ts +++ /dev/null @@ -1,35 +0,0 @@ -import { installLaunchd, isLaunchdInstalled } from "@app/youtube/lib/server/launchd"; -import * as p from "@clack/prompts"; -import type { Command } from "commander"; - -interface InstallOpts { - port?: number; -} - -export function registerServerInstall(parent: Command): void { - parent - .command("install") - .description("Install server as a launchd agent (macOS) — runs at login") - .option("--port ", "Port to expose (default 9876)", (value) => Number.parseInt(value, 10)) - .addHelpText( - "after", - "\nExamples:\n $ tools youtube server install\n $ tools youtube server install --port 9999\n" - ) - .action(async (opts: InstallOpts) => { - if (process.platform !== "darwin") { - p.log.error("server install is only supported on macOS (launchd)."); - process.exitCode = 1; - return; - } - - if (isLaunchdInstalled()) { - p.log.info("Server is already installed in launchd."); - return; - } - - await installLaunchd({ port: opts.port ?? 9876 }); - p.log.success( - `Installed launchd agent on port ${opts.port ?? 9876}. Run \`tools youtube server status\` to verify.` - ); - }); -} diff --git a/src/youtube/lib/server/cli/start.ts b/src/youtube/lib/server/cli/start.ts deleted file mode 100644 index 88f8943a2f..0000000000 --- a/src/youtube/lib/server/cli/start.ts +++ /dev/null @@ -1,57 +0,0 @@ -import { startServer } from "@app/youtube/lib/server"; -import { readPid } from "@app/youtube/lib/server/daemon"; -import { installLaunchd, isLaunchdInstalled } from "@app/youtube/lib/server/launchd"; -import * as p from "@clack/prompts"; -import type { Command } from "commander"; -import pc from "picocolors"; - -interface StartOpts { - port?: number; - background?: boolean; -} - -export function registerServerStart(parent: Command): void { - parent - .command("start") - .description("Start the YouTube API server (foreground by default)") - .option("--port ", "Port (defaults to apiPort from server.json or 9876)", (value) => - Number.parseInt(value, 10) - ) - .option("--background", "Daemonise via launchd (macOS only)") - .addHelpText( - "after", - "\nExamples:\n $ tools youtube server start\n $ tools youtube server start --port 9999\n $ tools youtube server start --background\n" - ) - .action(async (opts: StartOpts) => { - const existing = readPid(); - - if (existing) { - p.log.warn(`Server already running (PID ${existing}). Run \`tools youtube server stop\` first.`); - process.exitCode = 1; - return; - } - - if (opts.background) { - if (process.platform !== "darwin") { - p.log.error("--background is only supported on macOS (launchd)."); - process.exitCode = 1; - return; - } - - if (isLaunchdInstalled()) { - p.log.info("Server is already installed in launchd."); - return; - } - - await installLaunchd({ port: opts.port ?? 9876 }); - p.log.success( - `Installed launchd agent on port ${opts.port ?? 9876}. Use \`tools youtube server stop --uninstall\` to remove.` - ); - return; - } - - const handle = await startServer({ port: opts.port, daemon: true }); - p.log.success(`Server listening on http://localhost:${handle.port} ${pc.dim("(Ctrl+C to stop)")}`); - await new Promise(() => undefined); - }); -} diff --git a/src/youtube/lib/server/cli/status.ts b/src/youtube/lib/server/cli/status.ts deleted file mode 100644 index 3f32f1df4f..0000000000 --- a/src/youtube/lib/server/cli/status.ts +++ /dev/null @@ -1,28 +0,0 @@ -import { renderOrEmit } from "@app/youtube/commands/_shared/render"; -import { readPid } from "@app/youtube/lib/server/daemon"; -import { isLaunchdInstalled } from "@app/youtube/lib/server/launchd"; -import { readPortFile } from "@app/youtube/lib/server/port-file"; -import type { Command } from "commander"; -import pc from "picocolors"; - -export function registerServerStatus(parent: Command): void { - parent - .command("status") - .description("Show server status (PID, port, launchd state)") - .action(async (_: unknown, cmd: Command) => { - const pid = readPid(); - const port = readPortFile(); - const launchd = isLaunchdInstalled(); - - const lines: string[] = []; - lines.push(pid ? pc.green(`running · PID ${pid}`) : pc.dim("not running")); - lines.push(port !== null ? `port: ${port}` : pc.dim("port: —")); - lines.push(launchd ? pc.green("launchd: installed") : pc.dim("launchd: not installed")); - - await renderOrEmit({ - text: lines.join("\n"), - json: { running: pid !== null, pid, port, launchdInstalled: launchd }, - flags: cmd.optsWithGlobals(), - }); - }); -} diff --git a/src/youtube/lib/server/cli/stop.ts b/src/youtube/lib/server/cli/stop.ts deleted file mode 100644 index 59da609eca..0000000000 --- a/src/youtube/lib/server/cli/stop.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { clearPid, readPid } from "@app/youtube/lib/server/daemon"; -import { isLaunchdInstalled, uninstallLaunchd } from "@app/youtube/lib/server/launchd"; -import { clearPortFile } from "@app/youtube/lib/server/port-file"; -import * as p from "@clack/prompts"; -import type { Command } from "commander"; - -interface StopOpts { - uninstall?: boolean; -} - -export function registerServerStop(parent: Command): void { - parent - .command("stop") - .description("Stop the running server (and optionally uninstall the launchd agent)") - .option("--uninstall", "Also uninstall the launchd agent (macOS) — permanent stop") - .addHelpText("after", "\nExamples:\n $ tools youtube server stop\n $ tools youtube server stop --uninstall\n") - .action(async (opts: StopOpts) => { - if (opts.uninstall && isLaunchdInstalled()) { - await uninstallLaunchd(); - p.log.success("Server uninstalled from launchd."); - } - - const pid = readPid(); - - if (!pid) { - if (!opts.uninstall) { - p.log.info("Server is not running."); - } - clearPortFile(); - return; - } - - try { - process.kill(pid, "SIGTERM"); - p.log.success(`Sent SIGTERM to PID ${pid}.`); - } catch (error) { - p.log.warn(`Could not signal PID ${pid}: ${error instanceof Error ? error.message : String(error)}`); - clearPid(); - clearPortFile(); - } - }); -} diff --git a/src/youtube/ui/vite.config.ts b/src/youtube/ui/vite.config.ts index 6f7f7d61e6..431dd4c1bc 100644 --- a/src/youtube/ui/vite.config.ts +++ b/src/youtube/ui/vite.config.ts @@ -1,7 +1,7 @@ import { resolve } from "node:path"; -import { DASHBOARDS } from "@app/utils/ui/dashboards"; -import { createDashboardViteConfig } from "@app/utils/ui/vite.base"; import { tanstackRouter } from "@tanstack/router-plugin/vite"; +import { DASHBOARDS } from "../../utils/ui/dashboards"; +import { createDashboardViteConfig } from "../../utils/ui/vite.base"; import { youtubeConfigPlugin } from "./vite.plugins/config-middleware"; const root = resolve(import.meta.dirname); diff --git a/src/youtube/ui/vite.plugins/config-middleware.ts b/src/youtube/ui/vite.plugins/config-middleware.ts index 652393cdad..ec0d61384e 100644 --- a/src/youtube/ui/vite.plugins/config-middleware.ts +++ b/src/youtube/ui/vite.plugins/config-middleware.ts @@ -1,7 +1,7 @@ -import { SafeJSON } from "@app/utils/json"; -import { YoutubeConfig } from "@app/youtube/lib/config"; -import type { YoutubeConfigPatch } from "@app/youtube/lib/config.api.types"; import type { Plugin } from "vite"; +import { SafeJSON } from "../../../utils/json"; +import { YoutubeConfig } from "../../lib/config"; +import type { YoutubeConfigPatch } from "../../lib/config.api.types"; export function youtubeConfigPlugin(): Plugin { return { diff --git a/tools b/tools index 9a9db928a5..918947d9e4 100755 --- a/tools +++ b/tools @@ -213,8 +213,61 @@ async function spawnAndCaptureStderr(args: string[], workspaceRoot: string): Pro } }); - child.on("error", reject); + // Orphan detection: macOS does not propagate parent death (no + // PR_SET_PDEATHSIG). If the launching shell dies, both we and our child + // get reparented to launchd (PPID=1) — without this poll, a long-lived + // dashboard (`tools --dashboard`) survives invisibly and burns + // CPU/holds DB locks for days. Detect reparent → kill child → exit. + const orphanTimer = setInterval(() => { + if (process.ppid === 1) { + try { + child.kill("SIGTERM"); + } catch (err) { + logger.debug({ err }, "[tools] orphan SIGTERM threw"); + } + setTimeout(() => { + if (!child.killed) { + try { + child.kill("SIGKILL"); + } catch (err) { + logger.debug({ err }, "[tools] orphan SIGKILL threw"); + } + } + }, 5000).unref(); + } + }, 2000); + orphanTimer.unref(); + + // Forward standard signals to the child so it can clean up before exit. + const forward = (sig: NodeJS.Signals) => () => { + try { + child.kill(sig); + } catch (err) { + logger.debug({ err, sig }, "[tools] signal forward threw"); + } + }; + const signals: readonly NodeJS.Signals[] = ["SIGHUP", "SIGINT", "SIGTERM", "SIGQUIT"]; + const signalHandlers = new Map void>(); + + for (const sig of signals) { + const handler = forward(sig); + signalHandlers.set(sig, handler); + process.on(sig, handler); + } + + const cleanup = () => { + clearInterval(orphanTimer); + for (const [sig, handler] of signalHandlers) { + process.off(sig, handler); + } + }; + + child.on("error", (err) => { + cleanup(); + reject(err); + }); child.on("close", (code, signal) => { + cleanup(); if (signal) { resolvePromise(128 + (signalToNumber(signal) ?? 0)); return;