diff --git a/.gitignore b/.gitignore index 20f29a70a0..840bbde99b 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,8 @@ apps/benchmark/data # AI planning files (root-level only, not docs/plans/) /plans .cursor/plans +.claude/ +.planning/ # Local demo/prototype directories studio-demo/ diff --git a/.planning/REQUIREMENTS.md b/.planning/REQUIREMENTS.md index f39a865ac2..4dcc75c650 100644 --- a/.planning/REQUIREMENTS.md +++ b/.planning/REQUIREMENTS.md @@ -20,43 +20,43 @@ ### Deco Blocks Plugin (`packages/mesh-plugin-deco-blocks/`) -- [ ] **BLK-01**: plugin-deco-blocks scans a folder and returns all block definitions (name, props schema, file path) -- [ ] **BLK-02**: plugin-deco-blocks scans a folder and returns all loader definitions (name, props schema, return type) -- [ ] **BLK-03**: plugin-deco-blocks defines DECO_BLOCKS_BINDING — the binding a connection must implement to be treated as a deco site -- [ ] **BLK-04**: plugin-deco-blocks provides `isDecoSite(connection)` binding checker usable by other plugins and flows -- [ ] **BLK-05**: plugin-deco-blocks ships with the canonical BLOCKS_FRAMEWORK.md spec as a package asset -- [ ] **BLK-06**: plugin-deco-blocks includes the Claude skill for implementing deco blocks (`.claude/commands/deco/blocks-framework.md`) +- [x] **BLK-01**: plugin-deco-blocks scans a folder and returns all block definitions (name, props schema, file path) +- [x] **BLK-02**: plugin-deco-blocks scans a folder and returns all loader definitions (name, props schema, return type) +- [x] **BLK-03**: plugin-deco-blocks defines DECO_BLOCKS_BINDING — the binding a connection must implement to be treated as a deco site +- [x] **BLK-04**: plugin-deco-blocks provides `isDecoSite(connection)` binding checker usable by other plugins and flows +- [x] **BLK-05**: plugin-deco-blocks ships with the canonical BLOCKS_FRAMEWORK.md spec as a package asset +- [x] **BLK-06**: plugin-deco-blocks includes the Claude skill for implementing deco blocks (`.claude/commands/deco/blocks-framework.md`) ### Site Editor Plugin (`packages/mesh-plugin-site-editor/`) -- [ ] **EDT-01**: User can view and navigate all pages in a deco site project -- [ ] **EDT-02**: User can create, rename, and delete pages -- [ ] **EDT-03**: User can view all available blocks and their prop schemas -- [ ] **EDT-04**: User can view all available loaders and their prop schemas -- [ ] **EDT-05**: User can open the visual composer for any page -- [ ] **EDT-06**: User can add, remove, and reorder sections on a page via drag-and-drop -- [ ] **EDT-07**: User can edit section props via auto-generated form (RJSF) -- [ ] **EDT-08**: User can bind a loader to a section prop -- [ ] **EDT-09**: User can preview the page live in an iframe with edit/interact mode toggle -- [ ] **EDT-10**: User can undo and redo changes in the composer -- [ ] **EDT-11**: User sees pending changes (sections added/modified/deleted vs git HEAD) with diff badges — powered by bash git calls via local-dev -- [ ] **EDT-12**: User can commit pending changes from Mesh UI with a Claude-generated commit message — via bash git commit -- [ ] **EDT-13**: User can view git history for the current page with commit list and diff preview — via bash git log/show -- [ ] **EDT-14**: User can revert to a previous commit with a confirmation dialog — via bash git checkout -- [ ] **EDT-15**: Site editor activates automatically when the project connection implements DECO_BLOCKS_BINDING +- [x] **EDT-01**: User can view and navigate all pages in a deco site project +- [x] **EDT-02**: User can create, rename, and delete pages +- [x] **EDT-03**: User can view all available blocks and their prop schemas +- [x] **EDT-04**: User can view all available loaders and their prop schemas +- [x] **EDT-05**: User can open the visual composer for any page +- [x] **EDT-06**: User can add, remove, and reorder sections on a page via drag-and-drop +- [x] **EDT-07**: User can edit section props via auto-generated form (RJSF) +- [x] **EDT-08**: User can bind a loader to a section prop +- [x] **EDT-09**: User can preview the page live in an iframe with edit/interact mode toggle +- [x] **EDT-10**: User can undo and redo changes in the composer +- [x] **EDT-11**: User sees pending changes (sections added/modified/deleted vs git HEAD) with diff badges — powered by bash git calls via local-dev +- [x] **EDT-12**: User can commit pending changes from Mesh UI with a Claude-generated commit message — via bash git commit +- [x] **EDT-13**: User can view git history for the current page with commit list and diff preview — via bash git log/show +- [x] **EDT-14**: User can revert to a previous commit with a confirmation dialog — via bash git checkout +- [x] **EDT-15**: Site editor activates automatically when the project connection implements DECO_BLOCKS_BINDING > **Note:** EDT-11 through EDT-14 (git UX) activate only when the connection also exposes the bash tool. No direct dependency on local-dev package — capability-checked at runtime. ### `deco link` command (`packages/cli/`) -- [ ] **LNK-01**: Developer can run `deco link ./my-folder` to register a local project folder with a running Mesh instance -- [ ] **LNK-02**: `deco link` starts a local-dev daemon for the given folder (or connects to an already-running one) -- [ ] **LNK-03**: `deco link` creates (or reuses) a Connection in Mesh pointing at the local-dev daemon -- [ ] **LNK-04**: `deco link` creates (or reuses) a Project in Mesh wired to that Connection -- [ ] **LNK-05**: If the folder is a deco site (`.deco/` present), `deco link` auto-enables the site-editor plugin on the project -- [ ] **LNK-06**: `deco link` opens the browser to the project URL in Mesh, already logged in -- [ ] **LNK-07**: `deco link` keeps running as a daemon — when Ctrl+C is pressed, local-dev shuts down cleanly -- [ ] **LNK-08**: `deco link` is designed for both local Mesh (v1.3) and remote Mesh via tunnel (v1.4) — the Mesh URL is configurable +- [x] **LNK-01**: Developer can run `deco link ./my-folder` to register a local project folder with a running Mesh instance +- [x] **LNK-02**: `deco link` starts a local-dev daemon for the given folder (or connects to an already-running one) +- [x] **LNK-03**: `deco link` creates (or reuses) a Connection in Mesh pointing at the local-dev daemon +- [x] **LNK-04**: `deco link` creates (or reuses) a Project in Mesh wired to that Connection +- [x] **LNK-05**: If the folder is a deco site (`.deco/` present), `deco link` auto-enables the site-editor plugin on the project +- [x] **LNK-06**: `deco link` opens the browser to the project URL in Mesh, already logged in +- [x] **LNK-07**: `deco link` keeps running as a daemon — when Ctrl+C is pressed, local-dev shuts down cleanly +- [x] **LNK-08**: `deco link` is designed for both local Mesh (v1.3) and remote Mesh via tunnel (v1.4) — the Mesh URL is configurable > **Note:** deco-cli (`packages/cli`) already exists with login support. `deco link` is a new command added to it. The CLI is the portable piece; Mesh can be local or remote. @@ -97,35 +97,35 @@ | LDV-05 | Phase 15 | Pending | | LDV-06 | Phase 15 | Pending | | LDV-07 | Phase 15 | Pending | -| BLK-01 | Phase 16 | Pending | -| BLK-02 | Phase 16 | Pending | -| BLK-03 | Phase 16 | Pending | -| BLK-04 | Phase 16 | Pending | -| BLK-05 | Phase 16 | Pending | -| BLK-06 | Phase 16 | Pending | -| EDT-01 | Phase 17 | Pending | -| EDT-02 | Phase 17 | Pending | -| EDT-03 | Phase 17 | Pending | -| EDT-04 | Phase 17 | Pending | -| EDT-05 | Phase 17 | Pending | -| EDT-06 | Phase 17 | Pending | -| EDT-07 | Phase 17 | Pending | -| EDT-08 | Phase 17 | Pending | -| EDT-09 | Phase 17 | Pending | -| EDT-10 | Phase 17 | Pending | -| EDT-11 | Phase 17 | Pending | -| EDT-12 | Phase 17 | Pending | -| EDT-13 | Phase 17 | Pending | -| EDT-14 | Phase 17 | Pending | -| EDT-15 | Phase 17 | Pending | -| LNK-01 | Phase 18 | Pending | -| LNK-02 | Phase 18 | Pending | -| LNK-03 | Phase 18 | Pending | -| LNK-04 | Phase 18 | Pending | -| LNK-05 | Phase 18 | Pending | -| LNK-06 | Phase 18 | Pending | -| LNK-07 | Phase 18 | Pending | -| LNK-08 | Phase 18 | Pending | +| BLK-01 | Phase 16 | Complete | +| BLK-02 | Phase 16 | Complete | +| BLK-03 | Phase 16 | Complete | +| BLK-04 | Phase 16 | Complete | +| BLK-05 | Phase 16 | Complete | +| BLK-06 | Phase 16 | Complete | +| EDT-01 | Phase 17 | Complete | +| EDT-02 | Phase 17 | Complete | +| EDT-03 | Phase 17 | Complete | +| EDT-04 | Phase 17 | Complete | +| EDT-05 | Phase 17 | Complete | +| EDT-06 | Phase 17 | Complete | +| EDT-07 | Phase 17 | Complete | +| EDT-08 | Phase 17 | Complete | +| EDT-09 | Phase 17 | Complete | +| EDT-10 | Phase 17 | Complete | +| EDT-11 | Phase 17 | Complete | +| EDT-12 | Phase 17 | Complete | +| EDT-13 | Phase 17 | Complete | +| EDT-14 | Phase 17 | Complete | +| EDT-15 | Phase 17 | Complete | +| LNK-01 | Phase 18 | Complete | +| LNK-02 | Phase 18 | Complete | +| LNK-03 | Phase 18 | Complete | +| LNK-04 | Phase 18 | Complete | +| LNK-05 | Phase 18 | Complete | +| LNK-06 | Phase 18 | Complete | +| LNK-07 | Phase 18 | Complete | +| LNK-08 | Phase 18 | Complete | **Coverage:** - v1.3 requirements: 36 total (7 LDV + 6 BLK + 15 EDT + 8 LNK) diff --git a/.planning/ROADMAP.md b/.planning/ROADMAP.md index ea2d52a7f9..db60764cb2 100644 --- a/.planning/ROADMAP.md +++ b/.planning/ROADMAP.md @@ -36,10 +36,10 @@ Git site binding tools, pending changes UI, commit dialog with Claude-generated ## Phases -- [ ] **Phase 15: local-dev daemon** - MCP server for local filesystem, object storage, git, and dev server management -- [ ] **Phase 16: plugin-deco-blocks** - Standalone deco blocks framework: scanners, DECO_BLOCKS_BINDING, Claude skill -- [ ] **Phase 17: site-editor plugin** - Full site editor UI with visual composer and git UX -- [ ] **Phase 18: deco link command** - `deco link ./folder` in packages/cli connects local project to Mesh +- [x] **Phase 15: local-dev daemon** - MCP server for local filesystem, object storage, git, and dev server management (completed 2026-02-20, on feat/local-dev-daemon branch) +- [x] **Phase 16: plugin-deco-blocks** - Standalone deco blocks framework: scanners, DECO_BLOCKS_BINDING, Claude skill (completed 2026-02-21) +- [x] **Phase 17: site-editor plugin** - Full site editor UI with visual composer and git UX (completed 2026-02-21) +- [x] **Phase 18: deco link command** - `deco link ./folder` in packages/cli connects local project to Mesh (completed 2026-02-22) ## Phase Details @@ -78,7 +78,15 @@ Git site binding tools, pending changes UI, commit dialog with Claude-generated 3. User can preview the page live in an iframe and toggle between edit mode and interact mode 4. User sees pending changes (additions, edits, deletions vs git HEAD) with diff badges, and can commit them from the UI with a Claude-generated commit message 5. User can view the git history for a page, see a diff preview per commit, and revert to any previous commit with a confirmation dialog -**Plans**: TBD +**Plans**: 6 plans + +Plans: +- [ ] 17-01-PLAN.md — Package scaffold + plugin shell (package.json, shared.ts, ClientPlugin, ServerPlugin stub) +- [ ] 17-02-PLAN.md — Data layer: page-api, block-api, git-api, query-keys +- [ ] 17-03-PLAN.md — TDD: useUndoRedo hook (tested) + useIframeBridge hook +- [ ] 17-04-PLAN.md — Pages UI: pages list, create/rename/delete modals, plugin router +- [ ] 17-05-PLAN.md — Composer: section list DnD, RJSF prop editor, loader drawer, preview iframe, undo/redo +- [ ] 17-06-PLAN.md — Git UX footer, commit message server route, apps/mesh plugin registration ### Phase 18: deco link command **Goal**: A developer can run `deco link ./my-folder` (from the existing deco-cli) and immediately see their local project in a running Mesh instance — browser opens, project ready, no manual wiring @@ -90,7 +98,11 @@ Git site binding tools, pending changes UI, commit dialog with Claude-generated 3. Running the same command again on an existing setup reuses the existing Connection and Project — nothing is duplicated 4. Pressing Ctrl+C shuts down local-dev cleanly — the project goes offline in Mesh 5. The Mesh URL is configurable so the same `deco link` command can target a remote Mesh instance (tunnel wiring deferred to v1.4, but the config surface is ready) -**Plans**: TBD +**Plans**: 2 plans + +Plans: +- [ ] 18-01-PLAN.md — Foundation libs: mesh-url, mesh-auth, mesh-client, local-dev-manager +- [ ] 18-02-PLAN.md — Link command: orchestration, Connection + Project creation, site-editor auto-enable, browser open, idempotency, clean shutdown > **Amended 2026-02-20:** Replaced `npx @decocms/mesh ./folder` with `deco link` in packages/cli (deco-cli). CLI is the portable piece — Mesh can be local or remote. Auto-setup (admin/admin) remains needed for local Mesh but lives in Mesh startup, not in the CLI. @@ -100,7 +112,7 @@ Git site binding tools, pending changes UI, commit dialog with Claude-generated | Phase | Milestone | Plans Complete | Status | Completed | |-------|-----------|----------------|--------|-----------| -| 15. local-dev daemon | v1.3 | 0/? | Not started | - | -| 16. plugin-deco-blocks | v1.3 | 0/? | Not started | - | -| 17. site-editor plugin | v1.3 | 0/? | Not started | - | -| 18. deco link command | v1.3 | 0/? | Not started | - | +| 15. local-dev daemon | v1.3 | 5/5 | Complete | 2026-02-20 | +| 16. plugin-deco-blocks | 4/4 | Complete | 2026-02-21 | - | +| 17. site-editor plugin | 6/6 | Complete | 2026-02-21 | - | +| 18. deco link command | 2/2 | Complete | 2026-02-22 | - | diff --git a/.planning/STATE.md b/.planning/STATE.md index 89c4ff1193..f652eef79e 100644 --- a/.planning/STATE.md +++ b/.planning/STATE.md @@ -5,33 +5,43 @@ See: .planning/PROJECT.md (updated 2026-02-20) **Core value:** Developers can connect any MCP server to Mesh and get auth, routing, observability, and a visual site editor for Deco sites. -**Current focus:** Milestone v1.3 — Phase 15: local-dev daemon (ready to plan) +**Current focus:** Milestone v1.3 — Phase 18: deco-link-command (executing) ## Current Position -Phase: 15 of 18 (local-dev daemon) -Plan: — of — (not yet planned) -Status: Ready to plan -Last activity: 2026-02-20 — Roadmap created, v1.3 phases 15–18 defined +Phase: 18 of 18 (deco-link-command) +Plan: 2 of 2 complete +Status: Complete +Last activity: 2026-02-22 — Plan 18-02 complete: deco link command — meshLinkCommand with full orchestration (Connection + Project creation, site-editor auto-enable, idempotency, SIGINT teardown) -Progress: [░░░░░░░░░░] 0% +Progress: [██████████] 100% ## Performance Metrics **Velocity:** -- Total plans completed: 0 -- Average duration: — -- Total execution time: — +- Total plans completed: 2 +- Average duration: 2 min +- Total execution time: 4 min **By Phase:** | Phase | Plans | Total | Avg/Plan | |-------|-------|-------|----------| -| - | - | - | - | +| 16-plugin-deco-blocks | 2 | 4 min | 2 min | -**Recent Trend:** No data yet +**Recent Trend:** Phase 16 plans 1-2 each executed in 2 min *Updated after each plan completion* +| Phase 16 P03 | 2 | 2 tasks | 3 files | +| Phase 16-plugin-deco-blocks P04 | 4 | 2 tasks | 4 files | +| Phase 17-site-editor-plugin P01 | 2 | 2 tasks | 7 files | +| Phase 17-site-editor-plugin P02 | 3 | 2 tasks | 4 files | +| Phase 17-site-editor-plugin P03 | 2 | 2 tasks | 3 files | +| Phase 17-site-editor-plugin P04 | 3 | 2 tasks | 5 files | +| Phase 17-site-editor-plugin P05 | 3 | 2 tasks | 8 files | +| Phase 17-site-editor-plugin P06 | 3 | 2 tasks | 8 files | +| Phase 18-deco-link-command P01 | 3 | 2 tasks | 4 files | +| Phase 18 P02 | 3 | 2 tasks | 2 files | ## Accumulated Context @@ -50,6 +60,34 @@ Recent decisions affecting current work: - Bash tool is unrestricted, scoped to project folder — like Claude Code's bash - deco-cli (packages/cli) already exists with login; `deco link` is a new command added to it - Browser auto-opens on `deco link` (best DX, confirmed) +- z.record(z.string(), z.unknown()) required for Zod v4 — two-arg form, single-arg deprecated +- DECO_BLOCKS_BINDING lives in packages/bindings/ not in the plugin — enables site-editor import without plugin dependency +- [Phase 16-plugin-deco-blocks]: Skills placed at packages/mesh-plugin-deco-blocks/.claude/commands/deco/ satisfying BLK-06 in-package requirement +- [Phase 16-plugin-deco-blocks]: RootlessError (not NoRootTypeError) is the actual error class in ts-json-schema-generator — caught for Props-not-named-Props fallback +- [Phase 16-plugin-deco-blocks]: index.ts re-exports DECO_BLOCKS_BINDING for consumers who don't want to import @decocms/bindings directly +- [Phase 16-plugin-deco-blocks]: extractReturnTypeSchema handles T[] return types by stripping suffix, generating element schema, wrapping in array schema +- [Phase 17-site-editor-plugin]: Plugin uses ClientPlugin for binding-filtered activation — tab hides automatically for projects without the binding +- [Phase 17-site-editor-plugin]: server/index.ts stub with empty routes() — extended in plan 17-06 with commit-message route +- [Phase 17-site-editor-plugin]: registerPluginRoutes([]) in plan 01 setup — routes wired in plan 17-04 when TanStack Router setup exists +- [Phase 17-site-editor-plugin]: GenericToolCaller separate from TypedToolCaller — filesystem/bash tools not in DECO_BLOCKS_BINDING +- [Phase 17-site-editor-plugin]: listPages handles both { entries: [...] } and bare array response shapes defensively +- [Phase 17-site-editor-plugin]: hasBashTool gates git UI at runtime by checking connection.tools array for bash +- [Phase 17-site-editor-plugin]: Page/BlockInstance types defined inline in use-iframe-bridge.ts pending plan 17-02 page-api.ts completion +- [Phase 17-site-editor-plugin]: undoRedoReducer exported as named export to enable direct (non-renderHook) testing +- [Phase 17-site-editor-plugin]: @deco/ui imports require .tsx extension — bundler moduleResolution doesn't auto-resolve for workspace packages +- [Phase 17-site-editor-plugin]: usePluginContext uses typeof DECO_BLOCKS_BINDING (runtime value) as generic, not DecoBlocksBinding type alias +- [Phase 17-site-editor-plugin]: page-composer.tsx stub created in plan 17-04 to satisfy TS lazy import resolution in router.ts +- [Phase 17-site-editor-plugin]: Module-level keyboard store (_undoFn/_redoFn + kbStore singleton) with useSyncExternalStore — avoids useEffect ban for Cmd+Z/Cmd+Shift+Z keyboard shortcuts +- [Phase 17-site-editor-plugin]: typedCaller cast pattern — toolCaller cast to TypedToolCaller for block/loader tools and GenericToolCaller for filesystem tools +- [Phase 17-site-editor-plugin]: resetTrackerRef inline ref pattern — { current: '' } created in render body to detect pageId changes and call useUndoRedo.reset() without useEffect +- [Phase 17-site-editor-plugin]: FooterBar returns null when hasBashTool() is false — entire git UX absent for non-bash connections +- [Phase 17-site-editor-plugin]: Server /commit-message returns { message: "" } (not error) when ANTHROPIC_API_KEY is absent — graceful degradation to manual entry +- [Phase 18-deco-link-command]: System keychain storage via execSync platform CLIs (security/secret-tool) with ~/.deco_mesh_tokens.json fallback — no native addon +- [Phase 18-deco-link-command]: startLocalDev() returns null when daemon already running — null signals no child to manage +- [Phase 18-deco-link-command]: getOrganizationId() tries /api/auth/get-session then /api/auth/organization/list for resilience +- [Phase 18-deco-link-command]: OAuth callback supports both cookie-based sessions and token query param strategy +- [Phase 18]: Browser opens to plain project URL — no auto-login token per CONTEXT.md locked decision +- [Phase 18]: isDecoSite() evaluated before writeLinkState so .deco detection is pre-create (no false positives) ### Pending Todos @@ -63,6 +101,6 @@ None yet. ## Session Continuity -Last session: 2026-02-20 -Stopped at: Roadmap written, all 35 v1.3 requirements mapped to phases 15–18 +Last session: 2026-02-22 +Stopped at: Completed 18-02-PLAN.md — deco link command (mesh link mode + CLI registration) Resume file: None diff --git a/.planning/phases/18-deco-link-command/18-01-SUMMARY.md b/.planning/phases/18-deco-link-command/18-01-SUMMARY.md new file mode 100644 index 0000000000..55e0b182e3 --- /dev/null +++ b/.planning/phases/18-deco-link-command/18-01-SUMMARY.md @@ -0,0 +1,124 @@ +--- +phase: 18-deco-link-command +plan: 01 +subsystem: cli +tags: [cli, mcp, better-auth, oauth, keychain, child-process, streamable-http] + +# Dependency graph +requires: + - phase: 15-local-dev-daemon + provides: mcp-local-dev binary with /_ready endpoint on port 3456 + - phase: 17-site-editor-plugin + provides: Mesh instance with /mcp/self and /api/auth/* endpoints +provides: + - mesh-url.ts: Mesh URL resolution (localhost probe + cloud fallback) + - mesh-auth.ts: Better Auth browser OAuth flow + system keychain token storage per Mesh URL + - mesh-client.ts: MCP Client factory for /mcp/self + organization ID retrieval + - local-dev-manager.ts: local-dev daemon probe/spawn/stop lifecycle +affects: + - 18-02-PLAN.md: link command imports all four modules from this plan + +# Tech tracking +tech-stack: + added: [] + patterns: + - "System keychain storage via platform CLI (security/secret-tool) with ~/.deco_mesh_tokens.json fallback" + - "Browser OAuth callback server pattern: listen(0) for random port, open browser to /login?cli&redirectTo=callback" + - "StreamableHTTPClientTransport with Bearer auth header for /mcp/self" + - "Probe-first pattern: check /_ready before spawning daemon, return null if already alive" + +key-files: + created: + - packages/cli/src/lib/mesh-url.ts + - packages/cli/src/lib/mesh-auth.ts + - packages/cli/src/lib/mesh-client.ts + - packages/cli/src/lib/local-dev-manager.ts + modified: [] + +key-decisions: + - "System keychain via execSync platform CLIs (security/secret-tool/cmdkey) — no native addon dependency" + - "Windows uses file-based fallback for token storage since cmdkey read is limited" + - "startLocalDev() returns null when daemon already running — null signals no child to manage" + - "getOrganizationId() tries /api/auth/get-session activeOrganizationId first, falls back to /api/auth/organization/list" + - "OAuth callback extracts session cookies or query token param — supports both Mesh callback strategies" + +patterns-established: + - "Pattern 1: Keychain-first storage — try execSync platform CLI, catch all errors, fall back to chmod-600 JSON file" + - "Pattern 2: Probe-then-spawn — always check if daemon alive before spawning to support already-running state" + - "Pattern 3: Random-port callback server — server.listen(0) assigns OS-chosen port for OAuth callback" + +requirements-completed: + - LNK-02 + - LNK-08 + +# Metrics +duration: 3min +completed: 2026-02-22 +--- + +# Phase 18 Plan 01: deco link Foundation Modules Summary + +**Four CLI lib modules for Mesh URL resolution, Better Auth browser OAuth with keychain storage, MCP /mcp/self client, and mcp-local-dev daemon lifecycle management** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-02-22T01:30:59Z +- **Completed:** 2026-02-22T01:33:38Z +- **Tasks:** 2 +- **Files modified:** 4 + +## Accomplishments + +- Mesh URL resolver probes localhost:3000/health with 1s timeout, falls back to studio.decocms.com, respects --mesh-url override — no caching per user decision +- Mesh auth module implements browser OAuth flow (local callback server on random port), creates persistent API key via /api/auth/api-key/create, stores per-Mesh-URL tokens in system keychain (security/secret-tool) with file fallback; 120s auth timeout +- MCP client factory creates StreamableHTTPClientTransport to /mcp/self with Bearer auth; callMeshTool() extracts and JSON-parses text content; getOrganizationId() resolves active org from session or org list +- local-dev manager: probeLocalDev() hits /_ready with 500ms AbortSignal timeout; startLocalDev() spawns mcp-local-dev and polls /_ready up to 10s; returns null if already running; stopLocalDev() sends SIGTERM + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Mesh URL resolver and auth module** - `2ddd6f200` (feat) +2. **Task 2: MCP client factory and local-dev manager** - `eab3f95cc` (feat) + +## Files Created/Modified + +- `packages/cli/src/lib/mesh-url.ts` - resolveMeshUrl() with localhost probe and cloud fallback +- `packages/cli/src/lib/mesh-auth.ts` - ensureMeshAuth(), readMeshToken(), saveMeshToken() with browser OAuth flow +- `packages/cli/src/lib/mesh-client.ts` - createMeshSelfClient(), callMeshTool(), getOrganizationId() +- `packages/cli/src/lib/local-dev-manager.ts` - probeLocalDev(), startLocalDev(), stopLocalDev() + +## Decisions Made + +- System keychain via `execSync` with platform CLIs (security on macOS, secret-tool on Linux, file fallback on Windows) — no native addon, no new dependencies +- Windows uses `~/.deco_mesh_tokens.json` file fallback since `cmdkey` read is unreliable for secrets +- `startLocalDev()` returns `ChildProcess | null` where null means daemon was already running — caller (link command) treats null as "no child to manage on shutdown" +- `getOrganizationId()` tries both `/api/auth/get-session` (Better Auth standard) and `/api/auth/organization/list` to be resilient to API shape differences +- OAuth callback handles both cookie-based sessions (standard redirect) and token query param (alternative strategy) to be robust against Mesh login page implementation details + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## User Setup Required + +None - no external service configuration required. + +## Next Phase Readiness + +All four foundation modules ready for Plan 02 (link command) to compose: +- `resolveMeshUrl` — URL detection with override support +- `ensureMeshAuth` — returns API key, handles first-run OAuth automatically +- `createMeshSelfClient` / `callMeshTool` — ready for COLLECTION_CONNECTIONS_CREATE, PROJECT_CREATE calls +- `probeLocalDev` / `startLocalDev` / `stopLocalDev` — daemon lifecycle ready + +No blockers. + +--- +*Phase: 18-deco-link-command* +*Completed: 2026-02-22* diff --git a/.planning/phases/18-deco-link-command/18-02-SUMMARY.md b/.planning/phases/18-deco-link-command/18-02-SUMMARY.md new file mode 100644 index 0000000000..0f37aa5624 --- /dev/null +++ b/.planning/phases/18-deco-link-command/18-02-SUMMARY.md @@ -0,0 +1,105 @@ +--- +phase: 18-deco-link-command +plan: 02 +subsystem: cli +tags: [cli, mcp, chalk, commander, deco-link, local-dev, site-editor] + +# Dependency graph +requires: + - phase: 18-01 + provides: mesh-url.ts, mesh-auth.ts, mesh-client.ts, local-dev-manager.ts +affects: [] +provides: + - packages/cli/src/commands/mesh/link.ts: deco link command orchestration + - packages/cli/src/commands.ts: Updated CLI with folder-aware link command + +# Tech tracking +tech-stack: + added: [] + patterns: + - "Folder argument branching: [folder] present -> Mesh mode, absent -> tunnel mode" + - "LinkStateSchema Zod v4 schema with safeParse for idempotency reads" + - "SIGINT/SIGTERM cleanup handler with shuttingDown guard to prevent double-exit" + - "isDecoSite() checks existsSync(.deco) before spawning to detect Deco site for site-editor auto-enable" + +key-files: + created: + - packages/cli/src/commands/mesh/link.ts + modified: + - packages/cli/src/commands.ts + +key-decisions: + - "Browser opens to plain project URL — no auto-login token per CONTEXT.md locked decision" + - "callMeshTool result cast to concrete types (connResult.item.id, projResult.project.id) to satisfy TypeScript strict mode" + - "isDecoSite() checks .deco dir existence BEFORE writeLinkState creates .deco/link.json — detection is pre-create so no false positive on new Deco sites" + - "slugify imported from ../../lib/slugify.js — no local copy per plan instruction" + - "chalk imported in commands.ts for consistent error formatting in folder-mode error handler" + +# Metrics +duration: 3min +completed: 2026-02-22 +--- + +# Phase 18 Plan 02: deco link Command Summary + +**`deco link ./my-folder` command that orchestrates Mesh URL resolution, Better Auth, local-dev daemon, Connection + Project creation, site-editor auto-enable, idempotency via `.deco/link.json`, browser open, and clean Ctrl+C teardown** + +## Performance + +- **Duration:** 3 min +- **Started:** 2026-02-22T01:35:55Z +- **Completed:** 2026-02-22T01:38:47Z +- **Tasks:** 2 +- **Files modified:** 2 + +## Accomplishments + +- Created `packages/cli/src/commands/mesh/link.ts` with full `meshLinkCommand` implementation +- Vercel-style CLI output: `step()` (green checkmark), `fail()` (red X), `info()` (cyan arrow) chalk helpers +- Full orchestration: Mesh URL resolution → Better Auth → local-dev startup → Connection creation → Project creation → site-editor binding → browser open → stay-alive loop +- Idempotency: reads `.deco/link.json` via LinkStateSchema.safeParse; reuses existing connectionId/projectId/projectSlug if valid +- Site-editor auto-enable: `isDecoSite()` detects `.deco/` folder before writeLinkState; calls PROJECT_PLUGIN_CONFIG_UPDATE when detected +- Clean shutdown: SIGINT/SIGTERM handlers call `stopLocalDev()` with `shuttingDown` guard; logs teardown steps +- Updated `commands.ts`: added `chalk` import, added `meshLinkCommand` import, added `[folder]` positional arg + `--mesh-url` option to `linkCmd`, branches to mesh mode when folder provided + +## Task Commits + +Each task was committed atomically: + +1. **Task 1: Implement deco link command** - `7af55a6f5` (feat) +2. **Task 2: Register link command in CLI** - `f7cfa329b` (feat) + +## Files Created/Modified + +- `packages/cli/src/commands/mesh/link.ts` — meshLinkCommand full orchestration (new) +- `packages/cli/src/commands.ts` — Updated linkCmd with [folder] arg, --mesh-url option, meshLinkCommand branch (modified) + +## Decisions Made + +- Browser opens to plain project URL — no auto-login URL token (per CONTEXT.md locked decision that overrides roadmap "already logged in" wording) +- `isDecoSite()` uses `existsSync(path.join(folder, ".deco"))` — evaluated before `writeLinkState` creates `.deco/link.json` to avoid false positives on new link operations +- `slugify` imported from existing `../../lib/slugify.js` — no local copy created per plan instruction +- TypeScript cast pattern: `callMeshTool` returns `unknown` so results cast to `{ item: { id: string } }` and `{ project: { id: string } }` for type safety +- `chalk` added to `commands.ts` to maintain consistent error output in the new folder-mode error handler + +## Deviations from Plan + +None - plan executed exactly as written. + +## Issues Encountered + +None. + +## Verification Checklist + +- [x] Full compilation: `bun run check` passes for all workspaces (deco-cli exits code 0) +- [x] meshLinkCommand imports and calls all four foundation libs from plan 01 (mesh-url, mesh-auth, mesh-client, local-dev-manager) +- [x] Idempotency: readLinkState/writeLinkState use `.deco/link.json` with Zod safeParse +- [x] Site-editor auto-enable: isDecoSite check + PROJECT_PLUGIN_CONFIG_UPDATE call present +- [x] Clean shutdown: SIGINT handler calls stopLocalDev +- [x] Vercel-style output: step/fail/info helpers with chalk, branded banner +- [x] `deco link ./folder` routes to meshLinkCommand, `deco link -p 8787` routes to existing tunnel + +--- +*Phase: 18-deco-link-command* +*Completed: 2026-02-22* diff --git a/apps/mesh/package.json b/apps/mesh/package.json index 36184b4111..f729e73392 100644 --- a/apps/mesh/package.json +++ b/apps/mesh/package.json @@ -115,6 +115,7 @@ "lucide-react": "^0.468.0", "marked": "^15.0.6", "mesh-plugin-object-storage": "workspace:*", + "mesh-plugin-preview": "workspace:*", "mesh-plugin-private-registry": "workspace:*", "farmrio-collection-reorder": "workspace:*", "mesh-plugin-reports": "workspace:*", diff --git a/apps/mesh/src/api/app.ts b/apps/mesh/src/api/app.ts index 05a620af2f..7206343fce 100644 --- a/apps/mesh/src/api/app.ts +++ b/apps/mesh/src/api/app.ts @@ -40,7 +40,9 @@ import oauthProxyRoutes, { } from "./routes/oauth-proxy"; import openaiCompatRoutes from "./routes/openai-compat"; import proxyRoutes from "./routes/proxy"; +import localDevDiscoverRoutes from "./routes/local-dev-discover"; import publicConfigRoutes from "./routes/public-config"; +import cliAuthRoutes from "./routes/cli-auth"; import selfRoutes from "./routes/self"; import { shouldSkipMeshContext, SYSTEM_PATHS } from "./utils/paths"; import { @@ -279,6 +281,9 @@ export async function createApp(options: CreateAppOptions = {}) { return await auth.handler(c.req.raw); }); + // CLI authentication (creates API key with org metadata server-side) + app.route("/api/cli", cliAuthRoutes); + // ============================================================================ // OAuth Proxy Routes (for proxying OAuth to origin MCP servers) // MUST be defined BEFORE the wildcard OAuth routes below @@ -558,6 +563,11 @@ export async function createApp(options: CreateAppOptions = {}) { }); }); + // Local-dev auto-discovery (dev only, requires auth via MeshContext) + if (process.env.NODE_ENV !== "production") { + app.route("/api/local-dev", localDevDiscoverRoutes); + } + // ============================================================================ // API Routes // ============================================================================ diff --git a/apps/mesh/src/api/routes/cli-auth.ts b/apps/mesh/src/api/routes/cli-auth.ts new file mode 100644 index 0000000000..310d7ffe42 --- /dev/null +++ b/apps/mesh/src/api/routes/cli-auth.ts @@ -0,0 +1,96 @@ +/** + * CLI Authentication Route + * + * Server-side API key creation for the CLI (deco link). + * Creates an API key with the user's organization embedded in metadata, + * which the client-side Better Auth endpoint cannot do. + * + * Route: POST /api/cli/auth + * Auth: Cookie-based session (from browser redirect) + * Returns: { key: string } + */ + +import { Hono } from "hono"; +import { auth } from "../../auth"; + +const app = new Hono(); + +app.post("/auth", async (c) => { + // Get session from cookies (sent by browser redirect) + const session = await auth.api.getSession({ + headers: c.req.raw.headers, + }); + + if (!session?.session || !session?.user) { + return c.json({ error: "Unauthorized" }, 401); + } + + // Get the user's active organization + const orgId = session.session.activeOrganizationId; + + if (!orgId) { + // Try to find the user's first organization + const memberships = await auth.api.listOrganizations({ + headers: c.req.raw.headers, + }); + + const firstOrg = Array.isArray(memberships) ? memberships[0] : null; + + if (!firstOrg) { + return c.json( + { + error: + "No organization found. Please create an organization in Mesh first.", + }, + 400, + ); + } + + // Set the active organization + await auth.api.setActiveOrganization({ + headers: c.req.raw.headers, + body: { organizationId: firstOrg.id }, + }); + + // Create API key with org metadata + const result = await auth.api.createApiKey({ + body: { + name: "deco-link-cli", + metadata: { + organization: { + id: firstOrg.id, + slug: firstOrg.slug, + name: firstOrg.name, + }, + }, + }, + headers: c.req.raw.headers, + }); + + return c.json({ key: result.key }); + } + + // Get org details for metadata + const org = await auth.api.getFullOrganization({ + headers: c.req.raw.headers, + query: { organizationId: orgId }, + }); + + const result = await auth.api.createApiKey({ + body: { + name: "deco-link-cli", + metadata: { + organization: { + id: orgId, + slug: org?.slug, + name: org?.name, + }, + }, + }, + headers: c.req.raw.headers, + }); + + return c.json({ key: result.key }); +}); + +export default app; diff --git a/apps/mesh/src/api/routes/local-dev-discover.ts b/apps/mesh/src/api/routes/local-dev-discover.ts new file mode 100644 index 0000000000..c10d672035 --- /dev/null +++ b/apps/mesh/src/api/routes/local-dev-discover.ts @@ -0,0 +1,343 @@ +/** + * Local-Dev Auto-Discovery Routes + * + * GET /discover — probe localhost ports for running local-dev daemons + * POST /add-project — create connection + project + bind object-storage plugin + * + * Also exports reconcileLocalDevConnection() for use by the MCP proxy + * to fix port drift on-the-fly when a connection is accessed. + */ + +import { Hono } from "hono"; +import { getUserId, requireAuth } from "../../core/mesh-context"; +import { fetchToolsFromMCP } from "../../tools/connection/fetch-tools"; +import { pickRandomCapybaraIcon } from "../../constants/capybara-icons"; +import type { Env } from "../env"; + +interface ReadyResponse { + ready: boolean; + version: string; + root: string; +} + +interface DiscoveredInstance { + port: number; + root: string; + version: string; +} + +const PORT_START = 4201; +const PORT_END = 4210; +const PROBE_TIMEOUT_MS = 500; + +// ---- Port drift reconciliation (used by proxy + discovery) ---- + +/** + * TTL cache for reconciliation results. Prevents probing all ports + * on every single MCP request. Keyed by connection ID. + */ +const reconcileCache = new Map(); +const RECONCILE_TTL_MS = 30_000; // 30 seconds + +/** + * Reconcile a local-dev connection's port. Called by the MCP proxy before + * proxying a request to ensure the connection_url points to the right daemon. + * + * For connections with metadata.localDevRoot: + * 1. Quick-check: is the stored port serving the expected root? + * 2. If yes, return as-is (cache hit path is instant) + * 3. If no, probe all ports to find the correct daemon and update the DB + * 4. If daemon not found on any port, return null (don't proxy to wrong project) + * + * Returns the (possibly updated) connection_url, or null if the daemon + * is offline. Non-local-dev connections pass through unchanged. + */ +export async function reconcileLocalDevConnection( + connection: { + id: string; + connection_url: string | null; + metadata: Record | null; + }, + storage: { + connections: { + update: ( + id: string, + data: { connection_url: string }, + ) => Promise; + }; + }, +): Promise<{ connection_url: string | null }> { + const meta = connection.metadata as { localDevRoot?: string } | null; + if (!meta?.localDevRoot || !connection.connection_url) { + return { connection_url: connection.connection_url }; + } + + // Check cache first + const cached = reconcileCache.get(connection.id); + if (cached && cached.expiresAt > Date.now()) { + // Empty string means daemon was confirmed offline + if (cached.url === "") { + return { connection_url: null }; + } + if (cached.url === connection.connection_url) { + return { connection_url: connection.connection_url }; + } + // Cache says URL should be different — return updated URL + return { connection_url: cached.url }; + } + + const expectedRoot = meta.localDevRoot; + const currentPortMatch = connection.connection_url.match( + /^https?:\/\/(?:localhost|127\.0\.0\.1):(\d+)/, + ); + const currentPort = currentPortMatch?.[1] + ? parseInt(currentPortMatch[1], 10) + : null; + + // Quick-check: is the current port serving the expected root? + if (currentPort) { + const instance = await probePort(currentPort); + if (instance?.root === expectedRoot) { + // Port is correct — cache and return + reconcileCache.set(connection.id, { + url: connection.connection_url, + expiresAt: Date.now() + RECONCILE_TTL_MS, + }); + return { connection_url: connection.connection_url }; + } + } + + // Port is wrong or unreachable — probe all ports to find the right daemon + const probes = []; + for (let port = PORT_START; port <= PORT_END; port++) { + probes.push(probePort(port)); + } + const results = await Promise.all(probes); + const correctInstance = results.find( + (r): r is DiscoveredInstance => r !== null && r.root === expectedRoot, + ); + + if (correctInstance) { + const newUrl = `http://localhost:${correctInstance.port}/mcp`; + if (newUrl !== connection.connection_url) { + console.log( + `[local-dev] Port drift detected for connection ${connection.id}: ` + + `${currentPort} → ${correctInstance.port}, updating connection_url`, + ); + await storage.connections.update(connection.id, { + connection_url: newUrl, + }); + } + reconcileCache.set(connection.id, { + url: newUrl, + expiresAt: Date.now() + RECONCILE_TTL_MS, + }); + return { connection_url: newUrl }; + } + + // Daemon not found on any port. Return null so the proxy refuses to + // connect rather than silently routing to the wrong project's daemon. + // Use a very short TTL so we re-probe quickly once the daemon starts. + reconcileCache.set(connection.id, { + url: "", + expiresAt: Date.now() + 2_000, // 2s — retry fast when offline + }); + return { connection_url: null }; +} + +const app = new Hono(); + +// ---- Discovery ---- + +app.get("/discover", async (c) => { + const meshContext = c.var.meshContext; + + if (!meshContext.auth.user?.id && !meshContext.auth.apiKey?.id) { + return c.json({ error: "Unauthorized" }, 401); + } + + const organizationId = meshContext.organization?.id; + if (!organizationId) { + return c.json({ instances: [] }); + } + + // Probe all ports in parallel + const probes = []; + for (let port = PORT_START; port <= PORT_END; port++) { + probes.push(probePort(port)); + } + const results = await Promise.all(probes); + + // Filter out nulls (ports that didn't respond) + const discovered = results.filter((r): r is DiscoveredInstance => r !== null); + + if (discovered.length === 0) { + return c.json({ instances: [] }); + } + + // Get existing connections and determine which discovered instances are already linked. + // New connections store metadata.localDevRoot (set by /add-project) — match by root. + // Legacy connections without it fall back to port matching. + const connections = + await meshContext.storage.connections.list(organizationId); + const linkedRoots = new Set(); + const legacyLinkedPorts = new Set(); + + for (const conn of connections) { + const meta = conn.metadata as { localDevRoot?: string } | null; + if (meta?.localDevRoot) { + linkedRoots.add(meta.localDevRoot); + + // Reconcile port drift for this connection + await reconcileLocalDevConnection(conn, meshContext.storage); + + continue; + } + if (conn.connection_url) { + const match = conn.connection_url.match( + /^https?:\/\/(?:localhost|127\.0\.0\.1):(\d+)/, + ); + if (match?.[1]) { + legacyLinkedPorts.add(parseInt(match[1], 10)); + } + } + } + + const unlinked = discovered.filter( + (inst) => !linkedRoots.has(inst.root) && !legacyLinkedPorts.has(inst.port), + ); + + return c.json({ instances: unlinked }); +}); + +// ---- Add project (server-side orchestration) ---- + +app.post("/add-project", async (c) => { + const ctx = c.var.meshContext; + requireAuth(ctx); + + const organizationId = ctx.organization?.id; + const userId = getUserId(ctx); + if (!organizationId || !userId) { + return c.json({ error: "Unauthorized" }, 401); + } + + const { port, root } = (await c.req.json()) as { + port: number; + root: string; + }; + if (port < 4201 || port > 4210) { + return c.json({ error: "Invalid port" }, 400); + } + const connectionUrl = `http://localhost:${port}/mcp`; + const name = root.replace(/\/+$/, "").split("/").pop() || root; + const slug = name + .toLowerCase() + .trim() + .replace(/[^a-z0-9\s-]/g, "") + .replace(/\s+/g, "-") + .replace(/-+/g, "-") + .replace(/^-|-$/g, ""); + + // 1. Fetch tools from the local-dev MCP server + let tools: Awaited> = null; + try { + tools = await fetchToolsFromMCP({ + id: `pending-${Date.now()}`, + title: name, + connection_type: "HTTP", + connection_url: connectionUrl, + }); + console.log( + `[local-dev] Fetched ${tools?.length ?? 0} tools from port ${port}`, + ); + } catch (err) { + console.error("[local-dev] Failed to fetch tools:", err); + } + + // 2. Create connection with fetched tools + const connection = await ctx.storage.connections.create({ + title: name, + connection_type: "HTTP", + connection_url: connectionUrl, + organization_id: organizationId, + created_by: userId, + tools: tools?.length ? tools : null, + metadata: { localDevRoot: root }, + }); + + // 3. Create project with object-storage and preview enabled + const project = await ctx.storage.projects.create({ + organizationId, + slug, + name, + description: `Local development project (${root})`, + enabledPlugins: ["object-storage", "preview"], + ui: { + banner: null, + bannerColor: "#10B981", + icon: null, + themeColor: "#10B981", + }, + }); + + // 4. Bind object-storage and preview plugins to the connection + await ctx.storage.projectPluginConfigs.upsert(project.id, "object-storage", { + connectionId: connection.id, + }); + await ctx.storage.projectPluginConfigs.upsert(project.id, "preview", { + connectionId: connection.id, + }); + + // 5. Create a Virtual MCP (agent) so the local-dev tools are available in chat + const virtualMcp = await ctx.storage.virtualMcps.create( + organizationId, + userId, + { + title: name, + description: `Local development agent for ${root}`, + icon: pickRandomCapybaraIcon(), + status: "active", + connections: [{ connection_id: connection.id }], + metadata: { + instructions: [ + "## Dev Server Preview", + "This project has a preview plugin that shows the dev server in an iframe.", + "The preview config is stored at `.deco/preview.json` with this format:", + '```json\n{ "command": "bun run dev", "port": 3000 }\n```', + "When the user asks to set up or configure the dev server preview:", + "1. Analyze package.json scripts and config files (vite.config.ts, next.config.js, etc.) to determine the correct command and port", + "2. Write the config to `.deco/preview.json` using the write_file tool", + "3. Tell the user to refresh the Preview panel in the sidebar", + "The command should use the project's package manager (check for bun.lockb, yarn.lock, pnpm-lock.yaml, or package-lock.json).", + ].join("\n"), + }, + }, + ); + + return c.json({ + project: { + id: project.id, + slug: project.slug, + name: project.name, + }, + connectionId: connection.id, + virtualMcpId: virtualMcp.id, + }); +}); + +async function probePort(port: number): Promise { + try { + const res = await fetch(`http://localhost:${port}/_ready`, { + signal: AbortSignal.timeout(PROBE_TIMEOUT_MS), + }); + if (!res.ok) return null; + const data = (await res.json()) as ReadyResponse; + if (!data.ready || !data.root) return null; + return { port, root: data.root, version: data.version }; + } catch { + return null; + } +} + +export default app; diff --git a/apps/mesh/src/api/routes/proxy.ts b/apps/mesh/src/api/routes/proxy.ts index 497c45fc83..171be05469 100644 --- a/apps/mesh/src/api/routes/proxy.ts +++ b/apps/mesh/src/api/routes/proxy.ts @@ -30,6 +30,7 @@ import { Context, Hono } from "hono"; import type { MeshContext } from "../../core/mesh-context"; import { handleAuthError } from "./oauth-proxy"; import { handleVirtualMcpRequest } from "./virtual-mcp"; +import { reconcileLocalDevConnection } from "./local-dev-discover"; // Define Hono variables type type Variables = { @@ -135,6 +136,17 @@ async function createMCPProxyDoNotUseDirectly( throw new Error(`Connection inactive: ${connection.status}`); } + // Reconcile local-dev port drift before connecting + const reconciled = await reconcileLocalDevConnection(connection, ctx.storage); + if (reconciled.connection_url === null) { + throw new Error( + "Local dev server is not running. Start it with `deco link` and try again.", + ); + } + if (reconciled.connection_url !== connection.connection_url) { + connection.connection_url = reconciled.connection_url; + } + // Create base client with auth + monitoring transports const baseClient = await clientFromConnection(connection, ctx, superUser); @@ -262,6 +274,20 @@ app.all("/:connectionId", async (c) => { throw new Error(`Connection inactive: ${connection.status}`); } + // Reconcile local-dev port drift before proxying + const reconciled = await reconcileLocalDevConnection( + connection, + ctx.storage, + ); + if (reconciled.connection_url === null) { + throw new Error( + "Local dev server is not running. Start it with `deco link` and try again.", + ); + } + if (reconciled.connection_url !== connection.connection_url) { + connection.connection_url = reconciled.connection_url; + } + // Create enhanced server directly (no need for bridge - server is used directly!) const server = await serverFromConnection(connection, ctx, false); @@ -331,6 +357,24 @@ app.all("/:connectionId/call-tool/:toolName", async (c) => { return c.json({ error: "Connection not found" }, 404); } + // Reconcile local-dev port drift before connecting + const reconciled = await reconcileLocalDevConnection( + connection, + ctx.storage, + ); + if (reconciled.connection_url === null) { + return c.json( + { + error: + "Local dev server is not running. Start it with `deco link` and try again.", + }, + 503, + ); + } + if (reconciled.connection_url !== connection.connection_url) { + connection.connection_url = reconciled.connection_url; + } + // Client pool manages lifecycle, no need for await using const client = await clientFromConnection(connection, ctx, false); const result = await client.callTool({ diff --git a/apps/mesh/src/api/utils/paths.ts b/apps/mesh/src/api/utils/paths.ts index fda504a467..d7eaa29700 100644 --- a/apps/mesh/src/api/utils/paths.ts +++ b/apps/mesh/src/api/utils/paths.ts @@ -83,6 +83,7 @@ export function shouldSkipMeshContext(path: string): boolean { return ( path === "/" || path.startsWith(PATH_PREFIXES.API_AUTH) || + path.startsWith("/api/cli/") || isSystemPath(path) || isStaticFilePath(path) ); diff --git a/apps/mesh/src/storage/ports.ts b/apps/mesh/src/storage/ports.ts index c80123c112..e917f3fa57 100644 --- a/apps/mesh/src/storage/ports.ts +++ b/apps/mesh/src/storage/ports.ts @@ -84,6 +84,7 @@ export interface ProjectPluginConfigStoragePort { }, ): Promise; delete(projectId: string, pluginId: string): Promise; + listByConnectionId(connectionId: string): Promise; } // ============================================================================ diff --git a/apps/mesh/src/storage/project-plugin-configs.ts b/apps/mesh/src/storage/project-plugin-configs.ts index 8f023fdbea..97d6aa7776 100644 --- a/apps/mesh/src/storage/project-plugin-configs.ts +++ b/apps/mesh/src/storage/project-plugin-configs.ts @@ -135,6 +135,17 @@ export class ProjectPluginConfigsStorage return (result.numDeletedRows ?? 0n) > 0n; } + async listByConnectionId( + connectionId: string, + ): Promise { + const rows = await this.db + .selectFrom("project_plugin_configs") + .selectAll() + .where("connection_id", "=", connectionId) + .execute(); + return rows.map((row) => this.parseRow(row)); + } + /** * Get bound connections for multiple projects (for list display) * Returns a map of project ID to array of connection summaries diff --git a/apps/mesh/src/tools/projects/delete.ts b/apps/mesh/src/tools/projects/delete.ts index 4cfe43264e..2f864e7660 100644 --- a/apps/mesh/src/tools/projects/delete.ts +++ b/apps/mesh/src/tools/projects/delete.ts @@ -47,6 +47,41 @@ export const PROJECT_DELETE = defineTool({ return { success: false, message: "Cannot delete the org-admin project" }; } + // Before deleting, clean up localhost connections and their Virtual MCPs + // Only delete connections that are exclusively used by this project + const pluginConfigs = + await ctx.storage.projectPluginConfigs.list(projectId); + for (const config of pluginConfigs) { + if (!config.connectionId) continue; + const conn = await ctx.storage.connections.findById(config.connectionId); + if ( + conn?.connection_url && + /^https?:\/\/(?:localhost|127\.0\.0\.1):/.test(conn.connection_url) + ) { + // Check if any other project references this connection + const allConfigs = + await ctx.storage.projectPluginConfigs.listByConnectionId(conn.id); + const otherProjectRefs = allConfigs.filter( + (c) => c.projectId !== projectId, + ); + if (otherProjectRefs.length > 0) { + // Another project uses this connection — skip deletion + continue; + } + + // Delete Virtual MCPs that use this connection + const virtualMcps = await ctx.storage.virtualMcps.listByConnectionId( + project.organizationId, + conn.id, + ); + for (const vmcp of virtualMcps) { + await ctx.storage.virtualMcps.delete(vmcp.id); + } + // Delete the connection itself + await ctx.storage.connections.delete(conn.id); + } + } + const success = await ctx.storage.projects.delete(projectId); return { success }; }, diff --git a/apps/mesh/src/web/components/git-panel.tsx b/apps/mesh/src/web/components/git-panel.tsx new file mode 100644 index 0000000000..92a56d4d14 --- /dev/null +++ b/apps/mesh/src/web/components/git-panel.tsx @@ -0,0 +1,940 @@ +import type { Client } from "@modelcontextprotocol/sdk/client/index.js"; +import { + useChatBridge, + useMCPClientOptional, + useProjectContext, + Locator, +} from "@decocms/mesh-sdk"; +import { useModelConnections } from "@/web/hooks/collections/use-llm"; +import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query"; +import { useConnectionWatch } from "@/web/hooks/use-connection-watch"; +import { useState } from "react"; +import { useNavigate } from "@tanstack/react-router"; +import { + gitBranch, + gitBranchList, + gitCheckoutBranch, + gitStatus, + gitDiff, + gitLog, + gitCommit, + gitCheckoutNewBranch, + type ChangedFile, +} from "@/web/lib/git-api"; +import { Button } from "@deco/ui/components/button.tsx"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@deco/ui/components/command.tsx"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@deco/ui/components/popover.tsx"; +import { Textarea } from "@deco/ui/components/textarea.tsx"; +import { Badge } from "@deco/ui/components/badge.tsx"; +import { + AlertTriangle, + Check, + GitBranch01, + GitCommit as GitCommitIcon, + Loading01, + File06, + Plus, + Minus, + Edit04, + RefreshCw01, + ChevronDown, + ChevronRight, + Stars01, +} from "@untitledui/icons"; + +const GIT_QUERY_KEYS = { + branch: (connId: string) => ["git", "branch", connId] as const, + branchList: (connId: string) => ["git", "branchList", connId] as const, + status: (connId: string) => ["git", "status", connId] as const, + diff: (connId: string) => ["git", "diff", connId] as const, + log: (connId: string) => ["git", "log", connId] as const, +}; + +// ============================================================================ +// AI Helpers +// ============================================================================ + +/** Keywords to match cheap/fast models, in priority order */ +const FAST_MODEL_KEYWORDS = [ + "haiku", + "gemini-2.5-flash-lite", + "gemini-2.5-flash", + "gemini-3-flash", + "flash-lite", + "ministral", +]; + +/** + * Call LLM_DO_GENERATE on a model connection's MCP client. + */ +async function llmGenerate( + modelClient: Client, + modelId: string, + systemPrompt: string, + userMessage: string, +): Promise { + const timeout = new Promise((_, reject) => + setTimeout(() => reject(new Error("LLM generation timed out")), 15_000), + ); + const result = await Promise.race([ + modelClient.callTool({ + name: "LLM_DO_GENERATE", + arguments: { + modelId, + callOptions: { + prompt: [ + { role: "system", content: systemPrompt }, + { role: "user", content: [{ type: "text", text: userMessage }] }, + ], + maxOutputTokens: 500, + temperature: 0, + providerOptions: { + openrouter: { reasoning: { exclude: true } }, + }, + }, + }, + }), + timeout, + ]); + + // structuredContent follows LanguageModelGenerateOutputSchema: + // { content: [{ type: "text", text: "..." }, ...], finishReason, usage } + const structured = result.structuredContent as + | { content?: { type: string; text?: string }[] } + | undefined; + if (structured?.content) { + const textPart = structured.content.find((p) => p.type === "text"); + if (textPart?.text) return textPart.text.trim(); + } + + // Fallback: parse from MCP text content envelope + if (result.content && Array.isArray(result.content)) { + const textEntry = result.content.find( + (c: { type: string }) => c.type === "text", + ); + if (textEntry) { + try { + const parsed = JSON.parse((textEntry as { text: string }).text); + // Could be the full output schema wrapped in text + const inner = parsed.content ?? parsed; + if (Array.isArray(inner)) { + const tp = inner.find((p: { type: string }) => p.type === "text"); + if (tp?.text) return tp.text.trim(); + } + if (parsed.text) return parsed.text.trim(); + } catch { + return (textEntry as { text: string }).text.trim(); + } + } + } + return ""; +} + +/** + * List available models from the model connection and pick the cheapest fast one. + */ +async function pickFastModel(modelClient: Client): Promise { + try { + const result = await modelClient.callTool({ + name: "COLLECTION_LLM_LIST", + arguments: {}, + }); + const structured = result.structuredContent as + | { items?: { id: string }[] } + | undefined; + const items = structured?.items ?? []; + const ids = items.map((m) => m.id); + + for (const keyword of FAST_MODEL_KEYWORDS) { + const match = ids.find((id) => id.toLowerCase().includes(keyword)); + if (match) return match; + } + // Fallback to first available model + return ids[0] ?? null; + } catch { + return null; + } +} + +const COMMIT_MESSAGE_PROMPT = `You generate concise git commit messages. Given a diff and list of changed files, write a single-line commit message following conventional commits format (e.g. "feat: ...", "fix: ...", "chore: ..."). No explanation, just the message. Max 72 characters.`; + +/** + * Hook to get the model connection's MCP client for AI features. + */ +function useModelClient(): { + modelClient: Client | null; + modelId: string | null; +} { + const { org } = useProjectContext(); + const modelConnections = useModelConnections(); + const firstModelConn = modelConnections[0]; + + const modelClient = useMCPClientOptional({ + connectionId: firstModelConn?.id, + orgId: org.id, + }); + + // We'll pick the model lazily — we need the model list + // For now, return client + null modelId (resolved at call time) + return { modelClient, modelId: null }; +} + +// ============================================================================ +// Status helpers +// ============================================================================ + +function statusIcon(status: ChangedFile["status"]) { + switch (status) { + case "M": + return ; + case "A": + case "?": + return ; + case "D": + return ; + default: + return ; + } +} + +function statusLabel(status: ChangedFile["status"]) { + switch (status) { + case "M": + return "Modified"; + case "A": + return "Added"; + case "D": + return "Deleted"; + case "?": + return "Untracked"; + case "R": + return "Renamed"; + default: + return status; + } +} + +// ============================================================================ +// Branch Section +// ============================================================================ + +function BranchSection({ + client, + connectionId, + watcher, +}: { + client: Client; + connectionId: string; + watcher: { pause: () => void; resume: () => void }; +}) { + const queryClient = useQueryClient(); + + const { data: branch, isLoading } = useQuery({ + queryKey: GIT_QUERY_KEYS.branch(connectionId), + queryFn: () => gitBranch(client), + staleTime: 10_000, + }); + + const { data: branches = [] } = useQuery({ + queryKey: GIT_QUERY_KEYS.branchList(connectionId), + queryFn: () => gitBranchList(client), + staleTime: 30_000, + }); + + const invalidateAll = () => { + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.branch(connectionId), + }); + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.branchList(connectionId), + }); + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.status(connectionId), + }); + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.log(connectionId), + }); + }; + + const switchBranch = useMutation({ + mutationFn: async (name: string) => { + watcher.pause(); + const result = await gitCheckoutBranch(client, name); + if (result.exitCode !== 0) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + "Failed to switch branch", + ); + } + return result; + }, + onSettled: () => { + setTimeout(() => watcher.resume(), 1000); + invalidateAll(); + }, + }); + + const createBranch = useMutation({ + mutationFn: async (name: string) => { + watcher.pause(); + const result = await gitCheckoutNewBranch(client, name); + if (result.exitCode !== 0) { + throw new Error( + result.stderr.trim() || + result.stdout.trim() || + "Failed to create branch", + ); + } + return result; + }, + onSettled: () => { + setTimeout(() => watcher.resume(), 1000); + invalidateAll(); + }, + }); + + const isMain = branch === "main" || branch === "master"; + const [open, setOpen] = useState(false); + const [searchValue, setSearchValue] = useState(""); + + if (isLoading) { + return ( +
+ + Loading branch... +
+ ); + } + + // Determine if search value could be a new branch name (no exact match) + const trimmedSearch = searchValue.trim(); + const isNewBranch = + trimmedSearch.length > 0 && + !branches.some((b) => b.toLowerCase() === trimmedSearch.toLowerCase()); + + return ( +
+
+ + + + + + + + + No branches found. + + {branches.map((b) => ( + { + if (b !== branch) { + switchBranch.mutate(b); + } + setOpen(false); + setSearchValue(""); + }} + > + + {b} + {b === branch && ( + + )} + + ))} + + {isNewBranch && ( + <> + + + { + createBranch.mutate(trimmedSearch); + setOpen(false); + setSearchValue(""); + }} + > + + + Create {trimmedSearch} + + + + + )} + + + + + {isMain && ( + + + main + + )} +
+ + {(switchBranch.isError || createBranch.isError) && ( +
+

+ + {switchBranch.isError + ? "Cannot switch branch" + : "Cannot create branch"} +

+
+            {(switchBranch.error ?? createBranch.error)?.message}
+          
+
+ )} + + {isMain && ( +
+

+ You're on the main branch +

+

+ Create a new branch before committing changes. +

+
+ )} +
+ ); +} + +// ============================================================================ +// Changed Files List +// ============================================================================ + +function ChangedFilesList({ + client, + connectionId, + onFileClick, +}: { + client: Client; + connectionId: string; + onFileClick?: (filePath: string) => void; +}) { + const [showDiff, setShowDiff] = useState(false); + + const { data: files = [], isLoading } = useQuery({ + queryKey: GIT_QUERY_KEYS.status(connectionId), + queryFn: () => gitStatus(client), + staleTime: 10_000, + }); + + const { data: diff } = useQuery({ + queryKey: GIT_QUERY_KEYS.diff(connectionId), + queryFn: () => gitDiff(client), + enabled: showDiff && files.length > 0, + staleTime: 5000, + }); + + if (isLoading) { + return ( +
+ + Checking for changes... +
+ ); + } + + if (files.length === 0) { + return ( +
+ + No uncommitted changes +
+ ); + } + + return ( +
+ +
+ {files.map((file) => ( + + ))} +
+ + {showDiff && diff && ( +
+          {diff}
+        
+ )} +
+ ); +} + +// ============================================================================ +// Commit Form +// ============================================================================ + +function CommitForm({ + client, + connectionId, + modelClient, + watcher, + onCommitted, +}: { + client: Client; + connectionId: string; + modelClient: Client | null; + watcher: { pause: () => void; resume: () => void }; + onCommitted?: () => void; +}) { + const queryClient = useQueryClient(); + const chatBridge = useChatBridge(); + const [message, setMessage] = useState(""); + + const { data: files = [] } = useQuery({ + queryKey: GIT_QUERY_KEYS.status(connectionId), + queryFn: () => gitStatus(client), + staleTime: 10_000, + }); + + const { data: branch } = useQuery({ + queryKey: GIT_QUERY_KEYS.branch(connectionId), + queryFn: () => gitBranch(client), + staleTime: 10_000, + }); + + const invalidateAfterCommit = () => { + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.status(connectionId), + }); + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.log(connectionId), + }); + queryClient.invalidateQueries({ + queryKey: GIT_QUERY_KEYS.diff(connectionId), + }); + }; + + const commit = useMutation({ + mutationFn: async (msg: string) => { + watcher.pause(); + const result = await gitCommit(client, msg); + if (result.exitCode !== 0) { + const errorMsg = + result.stderr.trim() || result.stdout.trim() || "Commit failed"; + throw new Error(errorMsg); + } + return result; + }, + onSuccess: () => { + setMessage(""); + // Cancel in-flight refetches (e.g. from the topbar watcher reacting to + // pre-commit hook file writes) so they don't overwrite the optimistic data. + queryClient.cancelQueries({ + queryKey: GIT_QUERY_KEYS.status(connectionId), + }); + queryClient.cancelQueries({ + queryKey: GIT_QUERY_KEYS.diff(connectionId), + }); + // Optimistically clear status so the topbar button updates instantly + queryClient.setQueryData(GIT_QUERY_KEYS.status(connectionId), []); + queryClient.setQueryData(GIT_QUERY_KEYS.diff(connectionId), ""); + onCommitted?.(); + }, + onSettled: () => { + // Resume watcher after a delay to let filesystem settle, then + // invalidate so the real status is fetched once things are stable. + setTimeout(() => { + watcher.resume(); + invalidateAfterCommit(); + }, 1000); + }, + }); + + const generateMessage = useMutation({ + mutationFn: async () => { + if (!modelClient || files.length === 0) return ""; + const modelId = await pickFastModel(modelClient); + if (!modelId) return ""; + + const diff = await gitDiff(client); + const fileList = files.map((f) => `${f.status} ${f.path}`).join("\n"); + const context = `Changed files:\n${fileList}\n\nDiff (truncated):\n${diff.slice(0, 3000)}`; + + const result = await llmGenerate( + modelClient, + modelId, + COMMIT_MESSAGE_PROMPT, + context, + ); + return result ? result.replace(/^["']|["']$/g, "") : ""; + }, + onSuccess: (msg) => { + if (msg) setMessage(msg); + }, + }); + + const isMain = branch === "main" || branch === "master"; + const canCommit = files.length > 0 && message.trim() && !isMain; + const hasChanges = files.length > 0; + + return ( +
+
+